Skip to content

Commit da29c61

Browse files
committed
docs(core): the grants memo's observer sees statement execution, not commit visibility; state the transactional residual
Comment and changeset text only. A write inside engine.transaction() is executed through the observer but becomes visible at the driver COMMIT, outside every middleware chain: on driver-sql, a request whose step 2 falls in that one commit round trip is authorised as of its first resolution and the next request reads fresh, the same answer as an out-of-process write. Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH Co-authored-by: Claude <noreply@anthropic.com>
1 parent 870b429 commit da29c61

2 files changed

Lines changed: 18 additions & 3 deletions

File tree

‎.changeset/request-scoped-grants-memo.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Clause-②: no
1212
`resolveAuthzContext` now runs inside a request-scoped grants memo. A resolution that already completed inside the same call, with the same user, organization and seeds, is served to the next caller that asks for exactly that resolution, so the hook's resolution serves the resolver's own. The decision a request is authorised with is unchanged:
1313

1414
- The memo lives for one `resolveAuthzContext` call (an `AsyncLocalStorage` scope, closed when the call settles). Nothing is cached across requests; the cross-request grants cache keeps its own default-off switch, `OS_AUTHZ_GRANTS_CACHE_TTL_MS`.
15-
- An entry is served only when a fresh read would agree with it. No write may have started, landed or still be in flight on the engine since the first resolution opened: the engine's write epoch (bumped when a write starts) and a write observer the memo registers as an engine middleware (counting writes into the chain and, after the driver step, out of it) must both read what they read at the open, with nothing inside the chain. No grant validity boundary may lie between the two clocks. The organization must be the same (the session arm's dropped-claim re-resolution is its own entry). A `bypassGrantsCache` caller is never served, a failed resolution is never stored, and an engine without the write-epoch seam or `registerMiddleware` declines entirely.
15+
- An entry is served only when a fresh read would agree with it. No write may have started, been executed at the driver, or still be in flight on the engine since the first resolution opened: the engine's write epoch (bumped when a write starts) and a write observer the memo registers as an engine middleware (counting writes into the chain and, after the driver step, out of it) must both read what they read at the open, with nothing inside the chain. The observer sees statement execution, not commit visibility. A write inside an `engine.transaction()` becomes visible only at its COMMIT, outside every chain. On driver-sql (not on the Turso remote face, which opens no transaction), a request whose step 2 falls inside that one commit round trip is authorised as of its first resolution, and the next request reads fresh — the same answer as a write from another process. No grant validity boundary may lie between the two clocks. The organization must be the same (the session arm's dropped-claim re-resolution is its own entry). A `bypassGrantsCache` caller is never served, a failed resolution is never stored, and an engine without the write-epoch seam or `registerMiddleware` declines entirely.
1616
- `plugin-auth`'s hook now passes the session's email as its seed email, exactly as `resolveAuthzContext` does for the same session, so the two calls ask for the same resolution. The seed reaches only the envelope's `email`, which the hook does not read: the payload's `positions[]` and `isPlatformAdmin` are unchanged, and platform-admin standing still compares the stored `sys_user.email`, never a seed.
1717

1818
Where the saving does not apply, the request reads the grants twice, as before — always the safe direction:

‎packages/core/src/security/request-grants-memo.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,11 @@
6060
* first sight of an engine, counting every write that enters it and,
6161
* in a `finally` around `next()`, every write whose driver step has
6262
* settled. The driver step runs INSIDE that `next()`, so a write cannot
63-
* land without first moving `started` and cannot finish without moving
64-
* `completed`.
63+
* be EXECUTED at the driver without first moving `started`, and cannot
64+
* finish executing without moving `completed`. The observer sees
65+
* statement execution, not commit visibility: on an
66+
* `engine.transaction()` the rows become visible to other connections
67+
* only at the driver COMMIT, outside every chain (residuals below).
6568
* An entry is served only when the epoch, `started` and `completed` all read
6669
* what they read at the open AND no write is inside the observer
6770
* (`started === completed`). So a write that started before the open and
@@ -98,6 +101,18 @@
98101
* them — the same answer a write committing just after the second read always
99102
* got.
100103
*
104+
* The same holds for a write executed on an `engine.transaction()` whose
105+
* COMMIT lands between the first resolution and step 2. Its statements pass
106+
* the observer, which settles per statement, but the rows become visible only
107+
* at the driver commit, which runs outside every middleware chain. The window
108+
* is that one commit round trip after the transaction's last statement. It is
109+
* reachable on driver-sql deployments (SCIM through the better-auth adapter,
110+
* REST `/batch`), and not on the Turso remote face, which declares
111+
* `transactionsUnsupported` and so opens no transaction. The answer is the
112+
* out-of-process one: that request is authorised as of the first resolution,
113+
* and the next request reads fresh. Closing it engine-side (an objectql epoch
114+
* bump after an owned transaction's commit) is out of scope here.
115+
*
101116
* The cost: on an engine with a concurrent write, step 2 reads afresh. That
102117
* includes a session read that writes inside the request (the first request on
103118
* a fresh auth instance generates its signing key; `enforceSessionControls`

0 commit comments

Comments
 (0)