Skip to content

perf(core,plugin-auth): an authenticated request resolves its caller's grants once, not twice - #22441

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-2634-prehandler-grants-once
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-2634-prehandler-grants-once

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Refs objectstack-ai/cloud#2634 (item 2: the pre-handler lever)

Clause-②: no

What this changes

An authenticated request that resolves identity through resolveAuthzContext with a better-auth session read resolved the caller's grants twice, with the same arguments and no write in between:

  1. Inside the session read. resolveAuthzContext calls the transport's getSession; against plugin-auth that is better-auth's getSession, whose customSession hook resolves the grants for the payload's positions[] / isPlatformAdmin (packages/plugins/plugin-auth/src/auth-manager.ts:4079 on main).
  2. Step 2 of the resolver. resolveAuthzContext then calls resolveUserAuthzGrants again for the request envelope (packages/core/src/security/resolve-authz-context.ts:428 on main).

Both call sites were confirmed on an instrumented request (async stacks below), not only by reading. Each resolution is 8 tenant-DB reads for a caller in an organization: sys_user, sys_member (own and peers), sys_user_position, sys_user_permission_set, sys_position, sys_position_permission_set and sys_permission_set.

The fix is a request-scoped grants memo (packages/core/src/security/request-grants-memo.ts):

  • resolveAuthzContext runs its whole body, the getSession call included, inside an AsyncLocalStorage scope. The scope is closed when the call settles. No envelope survives the request, and a continuation the request started reads afresh once it has settled.
  • resolveUserAuthzGrants consults the scope first. A completed resolution with the same arguments (user, tenant, seed email, seed permissions, spelled as the cross-request cache spells its key, with the engine as the outer key) is served as a clone. It is served only while a fresh read would agree with it:
    • No write has started, been executed at the driver, or is in flight on the engine since that resolution opened. Two signals are read at the open (before its first read) and again at the lookup:

      • the engine's write epoch, which the engine bumps when a write starts, and for non-write reasons;
      • a write observer: a middleware the memo registers on first sight of an engine. It counts each write that enters it, and, in a finally around next(), each write whose driver step has settled. The driver step runs inside that next(), so a write cannot be executed at the driver without moving the counters.

      An entry is served only when the epoch and both counters read what they read at the open and nothing is inside the observer. The observer sees statement execution, not commit visibility: a write inside an engine.transaction() becomes visible at the driver COMMIT, outside every chain (see Acceptance notes).

    • The reading clock lies in [resolvedAt, nextValidityBoundary).

  • The memo declines in four cases: an engine without the write-epoch seam or registerMiddleware, a bypassGrantsCache caller, a resolution that threw (never stored), and any call outside a resolveAuthzContext scope. The scope that registers an engine's observer stores nothing for that engine, so that request reads twice. An engine without the epoch seam gets no observer at all.
  • plugin-auth's hook now passes the session's email as seedEmail, spelled exactly as resolveAuthzContext spells it for the same session, so the two calls ask for the same resolution. That seed reaches only the envelope's email, which the hook does not read. The hook's positions[] / isPlatformAdmin are unchanged, and platform-admin standing still compares the stored sys_user.email, never a seed (core §6b-config).

Why a memo-side observer, not a second engine-side bump.

  • The engine route would bump write again in a finally after executor(). That changes the pinned one-bump-per-write seam: objectql/src/write-epoch.test.ts pins "insert, update and delete each advance it exactly once". It would also double the cluster authz.invalidated hints, because authz-invalidation-bridge.ts publishes every non-remote bump. And it would still serve an entry while a write had committed at the driver but not yet settled.
  • The observer is core-only, follows the grants cache's own seam-1 pattern, and its started === completed check covers that last window. Every other writeEpoch reader is untouched.

Where this landed, and why here. The card's suggested file surface was the downstream agent route. Measurement put both resolutions in this repo: the dispatcher's resolveExecutionContext → core resolveAuthzContext → plugin-auth's hook, all before any route handler runs. Any downstream-side change would have been a workaround over a framework duplicate. So the fix sits at the producer, and the downstream repo gets it with its next framework pin bump.

Other doors. Every caller of resolveAuthzContext with a better-auth getSession gets the same saving through the same function. That covers the runtime dispatcher (agent chat, Ask, data routes, metadata, everything behind resolveRequestScope), the REST server, the settings, storage, datasource-admin, sharing and marketplace-install routes, and the downstream env-settings routes.

Where the saving does not apply (the request reads twice, as before; always the safe direction):

  • a request that meets a concurrent write on its engine;
  • a request whose session read itself writes: the first request on a fresh auth instance generates its signing key, and with an idle timeout configured enforceSessionControls stamps sys_session.last_activity_at about once a minute per session;
  • the first resolveAuthzContext call on an engine.

Equivalence: the same decision, per caller class

The security floor: the grant set a request is authorised with must be the same decision as before, the dedupe is scoped to one request, and no check is skipped or loosened.

  • Per caller class (resolve-authz-context.request-grants-memo.test.ts, CALLER_CLASSES at :285): platform administrator via the unscoped admin_full_access grant (single posture) and via the declared administrator email (isolated posture), organization owner, admin and member, a non-member whose claimed organization is dropped (walled) or stands (single), an API-key principal with scopes and a stamped organization (x-api-key), and an anonymous request. For each class, the envelope a request resolves with the memo serving step 2 is deep-equal to the envelope step 2 resolves on its own, and the read multiset equals one resolution's. Each class also asserts its own expected posture, positions or scopes, so two equally wrong envelopes cannot pass.
  • With the real hook (session-grants-resolved-once.test.ts): a real better-auth getSession over the shared memory engine double, which the test drives through the write epoch and a middleware chain the way the engine runs them. Org member, owner, platform operator, removed member with a stale claim (dropped exactly as before), and anonymous each resolve to an envelope deep-equal to the same request with the memo declined (epoch seam removed, which is the pre-memo path), with one resolution's grant reads instead of two.
  • Writes in flight.
    • :589 opens the hook's resolution after a revocation has bumped the epoch but before it lands, then lands it before step 2. Step 2 reads afresh and the envelope equals the post-write baseline.
    • :622 holds the write in flight across step 2; step 2 reads afresh.
    • :638 covers a write that starts and lands in between.
    • :660 covers a non-write epoch bump.
    • :677 covers a write that starts during the first resolution's reads.
  • Isolation.
    • :457: two interleaved requests from different callers, with both session reads committed before either step 2, keep their own grants.
    • :492: a revocation between two requests with no epoch bump is seen by the second request.
    • :510: a continuation started inside a request reads afresh after it settles.
    • :535: a session read against a second engine serves nothing to the first engine's step 2.
    • :552: a nested resolveAuthzContext serves nothing to the outer step 2.
  • Clock. A step-2 clock one hour after the hook's, inside the validity window, is served: 8 reads, and the envelope equals the baseline at T0 + 1 h (:691). A boundary between the two clocks, or a step-2 clock earlier than the resolution, reads afresh (:701).
  • No aliasing: the session payload's arrays and the envelope's are distinct objects (:753).

Measurement: round trips before the handler, hosted composition

Rig: the downstream hosted HTTP composition (artifact kernel factory with the hosted forced requires, kernel manager, cloud kernel resolver, the objectos host slate, REST and dispatcher over Hono). The tenant driver is a TursoDriver on the remote face, over a @libsql/client wrapped so that every execute / batch / transaction op counts as one round trip. The handler entry is a wrapper on the env kernel's agent-chat route; the model is a memory adapter. The framework is at the downstream pin 56bf27af, unpatched for before, with this PR's code files at 870b4297 applied for after; the downstream checkout is b7d13034. Every request is POST /api/v1/ai/agents/build/chat, stream on, status 200.

request before after
T1 founder, first AI turn on the kernel 31 31
T2 founder, warm 23 15
T3 founder, warm (+2 sys_job_queue, background) 25 17
T4 member, warm 23 15
T5 member, warm 23 15
  • Warm T2 per table, before: sys_user 3, sys_member 4, and 2 each for the five grant-only tables.
  • Warm T2 per table, after: sys_user 2, sys_member 2, and 1 each for the five grant-only tables.
  • Unchanged: ai_messages 1, sys_session 2, sys_jwks 2, sys_setting 1.
  • So 23 − 16 + 8 = 15.
  • T1 is unchanged by design. That request registers the write observer, and its session read writes the JWT signing key.
  • The real ObjectQL engine took the observer through registerMiddleware, and the warm path met no concurrent write.
  • Call sites, from the async stacks of the instrumented T2.
    • Before, sys_user_position was read twice. The first read came from tryFind ← resolveUserAuthzGrants ← auth-manager.ts:4079 ← better-auth custom-session ← resolve-execution-context.ts:160 (getSession) ← resolve-authz-context.ts:401 (resolveAuthzContext). The second came from tryFind ← resolveUserAuthzGrants ← resolve-authz-context.ts:428, with the same dispatcher frames below (http-dispatcher.ts:1008 / :619 / :2640).
    • After, the hook's read is the only one.
    • Line numbers are at 56bf27af.
  • Wall clock: not a staging reading. The rig has no network, so its millisecond delta mostly measures the probe's own per-round-trip cost. On a hosted plane the saving is 8 round trips × that plane's tenant-DB RTT, which this rig cannot measure.

Tests

Final head da29c616 (round 2 changed comment and changeset text only; the code and tests are those of 870b4297).

  • @objectstack/core
    • build + typecheck exit 0; the test layer holds its debt at 4 files / 4 errors, unchanged;
    • local suite: 84 files, 2,258 passed, the memo pin's 26 tests included;
    • test:repo: 3 files, 48 passed.
  • @objectstack/plugin-auth
    • build + typecheck exit 0; debt 10 files / 94 errors, unchanged;
    • suite: 133 files, 2,689 passed, 10 skipped.
  • Consumers of resolveAuthzContext (at 606c3340, whose code equals the head's; 870b4297 reorders assertions in one core test only):
    • @objectstack/runtime full suite: 346 files, 5,579 passed, 19 skipped;
    • the files that call resolveAuthzContext / resolveExecutionContext: plugin-security 2 files, 126 passed; plugin-sharing 1, 28; service-datasource 1, 29; service-storage 1, 26; rest 7 files, 216 passed.
  • Ablations. Each ran through scripts/ablation-replace.mjs in wrap mode against the committed memo file (HEAD blob 9e08f71d). Every leg's anchor went 1 → 0, the blob changed, the restore blob equals the HEAD blob, and git diff HEAD was empty. Over the 26 memo pins:
    • A6, landing guard reverted to the epoch-only guard of 0b07e024: 2 red / 24 green. :589 reds with expected [ 'org_member', 'auditor', … ] to not include 'auditor': the request is authorised with the revoked position, which is the review's interleaving. :622 reds with expected 8 to be 16. With the guard: green.
    • A0, memo off: 15 red / 11 green, as predicted. The red ones are the 7 session classes' read counts, the count pin, observer registration, interleaved, nothing-outlives, nested, the positive clock, bypass and clones.
    • A1, key dropped (constant key): 1 red, the walled non-member: its dropped-claim re-resolution is served the claimed organization's envelope.
    • A2, entries shared across requests and the scope never closed: 3 red (nothing-outlives, continuation, nested).
    • A3, A1 + A2: 5 red, the cross-caller test among them.
    • A4, epoch comparison dropped: 1 red (the non-write bump).
    • A5, clock window dropped: 1 red (the boundary).
    • A7, registering scope stores anyway: 1 red (observer registration).
  • Gates. node scripts/pm/dispatch-gates.mjs --commands derived 68 families at 870b4297. All 68 exited 0, recorded per command with its exit code and reconciled with --ran: 68 run, 0 NOT-MEASURED (a DERIVED zero).
    • check:dual-build-cjs-loads first exited 3 (no dist/ yet in this worktree). It exited 0 after check:type-check-debt's re-measure had built the packages.
    • The derivation warns that the tree is 9 commits behind origin/main da159f74, with ci.yml, partition-test-shards.mjs and sdui-manifest.record.json changed there. No upstream commit touches core, plugin-auth or objectql.
  • Round 2, text-only, at da29c616.
    • The diff from 870b4297 touches only request-grants-memo.ts (+17/−2, every added and removed line inside the module's JSDoc block) and the changeset (1 line). The two versions of the .ts file produce byte-identical comment-stripped transpileModule output (sha256 prefix 9c90b60a9eba1fdd both).
    • @objectstack/core typecheck exit 0; the memo pin, 26 passed.
    • All exit 0: check:nul-bytes, check-comment-mask-adoption (and its --self-test), check-comment-mask-corpus (8,517 files, 0 disagree), check-changeset-no-major, check-empty-changeset, check-adr-0087-registration, check:doc-authoring and check:issue-citations.
    • Derived gate list unchanged (68).
  • Repo-wide lint: CI's.

Acceptance notes

  • What the observer cannot see, stated in the module doc. The engine snapshots its middleware list when a write starts, so a write that began before the observer was registered never passes it. The registering request stores nothing, which leaves one case open: a write that began before an engine's first resolveAuthzContext, still in flight when a later request's resolution opens, landing before that request's step 2. Writes from another process are read as of the first resolution, a few milliseconds earlier than the second used to read them. Closing the first case needs the engine to report in-flight writes, which is a public-surface change to WriteEpoch and out of this patch.

  • Transactional COMMIT, stated in the module doc. A grant-table write executed on an engine.transaction() passes the observer per statement, so the counters move and balance. Its rows become visible to other connections only at the driver COMMIT, which runs outside every middleware chain. If that COMMIT lands between the first resolution and step 2 (one commit round trip after the transaction's last statement), step 2 is served the pre-commit envelope.

    • Reachable on driver-sql deployments: SCIM through the better-auth adapter, and REST /batch.
    • Not reachable on the Turso remote face, which declares transactionsUnsupported and opens no transaction.
    • The consequence is the out-of-process one: that request is authorised as of its first resolution, and the next request reads fresh.
    • The optional engine-side closure, an objectql epoch bump after an owned transaction's commit, is out of scope here.
  • Other duplicates. A few session reads still resolve grants outside resolveAuthzContext, so this memo does not reach them; this is a code reading, not measured on the agent-chat path:

    • HttpDispatcher.enforceAuthGate re-reads the session when an auth-gate feature is active;
    • enforceProjectMembership re-reads it when membership enforcement is on;
    • the current-user permissions endpoint (plugin-hono-server/src/current-user-endpoints.ts:430) reads a session and then resolves grants with different seeds.

    None of them ran on the measured hosted request. No owner.

  • Read twice in the handler. The localization setting is still read twice per AI request, once by the execution context and once by the agent route's turn time zone. That is inside the handler and already on the downstream card. No owner here.


Generated by Claude Code

claude added 5 commits October 9, 2026 07:11
…sion principal's grants once

The session read inside resolveAuthzContext runs plugin-auth's customSession
hook, which resolves the principal's grants for the payload's positions[];
the resolver then resolved the same grants again with the same arguments.
A request-scoped memo (AsyncLocalStorage, closed when the call settles) now
serves the second resolution the first one's envelope, keyed by user,
tenant and seeds, guarded by the engine write epoch and the next validity
boundary. The hook passes the session email as its seed so both calls ask
for the same resolution.

Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH
Co-authored-by: Claude <noreply@anthropic.com>
…count, isolation and freshness

Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH
Co-authored-by: Claude <noreply@anthropic.com>
…t resolve grants once per warm request

Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/plugin-auth, touching 24 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 440bed63e731117bc194166fea3eec3d923ad2c6.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 34 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 440bed63e731117bc194166fea3eec3d923ad2c6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 76575da92df0c1f35ccc87c6535072aed23e7ba7 — the merge of head da29c616426e02fb511a44573acc46dffe5987b1 into base 440bed63e731117bc194166fea3eec3d923ad2c6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 76575da92df0c1f35ccc87c6535072aed23e7ba7 && git checkout 76575da92df0c1f35ccc87c6535072aed23e7ba7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 440bed63e731117bc194166fea3eec3d923ad2c6 da29c616426e02fb511a44573acc46dffe5987b1 && git checkout -B drift-repro 440bed63e731117bc194166fea3eec3d923ad2c6 && git merge --no-ff da29c616426e02fb511a44573acc46dffe5987b1

node scripts/docs-audit/affected-docs.mjs --json 440bed63e731117bc194166fea3eec3d923ad2c6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 440bed63e731117bc194166fea3eec3d923ad2c6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0b07e024d3483e630611d387f82438b1ed520305
Local-runs: none

Isolated review at the contract-review tier, owed by the cross-lane rule (the claiming seat repo:cloud#1 follows this objectstack PR to MERGED). Inputs: the diff, cloud#2634 and the head's check-runs. Recorded 2026-10-09T08:30Z.

① Derived judgments

  • BLOCKING: the epoch guard misses a write that is already in flight. ObjectQLEngine.executeWithMiddleware (packages/objectql/src/engine.ts ~5631-5647 on main) bumps writeEpoch at write start, before the middleware chain and the driver round trip. No bump happens after the write lands. The memo stamps epochAtOpen at the top of the hook's resolution and serves while epochAtOpen === epochNow.
    • Interleaving: W (delete sys_member, or update sys_user_permission_set / sys_position) bumps e→e+1 and is still in flight. R's hook resolution opens at e+1 and reads the pre-W rows. W lands. R's step 2 sees e+1, gets a hit, and authorises R with the revoked membership or position.
    • On main, step 2 re-reads after landing. That is a different authorization decision, which the PR body and the module doc (request-grants-memo.ts ~71-76) both deny.
    • The cross-request grants cache avoided this by bumping its own generation after next() (resolve-user-grants-cache.ts ~35-49, 203-225).
    • Neither test produces the interleaving. core.test.ts ~446-456 bumps during a find. auth.test.ts ~98-105 bumps before a write that lands on the next tick. Ablation A4 proves the guard exists, not that it covers in-flight writes.
  • Non-blocking, equivalence: the served envelope equals step 2's own for every traced caller class.
    • Session callers: the keys match, because null/undefined and absent/[] collapse, and ctx.email is the hook's spread value.
    • The seed email reaches only grants.email. sys_user is still read, and the declared-administrator anchor still compares the stored row.
    • nowMs-dependent outputs are constant on [resolvedAt, nextBoundary).
    • API-key and OAuth-synthetic callers never run the hook. Bypass callers decline. Anonymous callers return before step 2.
  • Non-blocking, isolation: sound by construction (WeakMap keyed by ql; each call has its own AsyncLocalStorage.run; a closed scope declines), but two-engine and nested-call cases are unpinned.
  • Non-blocking, saving scope: enforceSessionControls runs after the hook and writes sys_session.last_activity_at about once a minute per session when an idle timeout is configured. Step 2 then declines, which fails toward a fresh read, so this costs performance only. The "every warm request" claim needs scoping.
  • Non-blocking, tests: CALLER_CLASSES has no API-key or OAuth-synthetic class, though the PR body says it covers one. No positive clock-window case is pinned: a step-2 clock later than the hook's but inside the window, which is the case every production request takes.

② Semver level

patch for @objectstack/core and @objectstack/plugin-auth is consistent with the diff. request-grants-memo.ts is not exported from either @objectstack/core entry, so there is no new public surface, and Clause-②: no holds.

③ Boundary flags

  • The PR states authorization decisions are unchanged. That boundary claim is not met (see the blocking item).
  • Required fix: guard on a post-landing signal. Either an engine-side bump of write in a finally after executor() settles, or a memo-side post-completion counter: a middleware that increments in a finally around next(), which the memo compares instead of, or in addition to, the raw epoch.
  • Required pin: an engine double whose delete bumps the epoch, then awaits a deferred before mutating. Open the hook's resolution between the bump and the landing, and release the landing before step 2. Expect 16 reads and an envelope deep-equal to a post-write baseline.
  • The module doc and the PR body must stop claiming the in-flight case is covered.

Implemented-by: claude/issue-2634-prehandler-grants-once
Reviewed-by: session_01WVbr5J6u8BHh8EyFtcWciH

VERDICT: FAIL

claude added 3 commits October 9, 2026 08:46
…flight since the first resolution opened

The engine bumps its write epoch when a write STARTS, so an epoch-only guard
served an envelope read while an already-bumped revocation was in flight,
after that revocation landed. The memo now also registers a write observer
on the engine (a middleware counting writes into the chain and, in a finally
around next(), out of it) and serves only when the epoch and both counters
read what they read at the open with nothing in flight. The scope that
registers an engine's observer stores nothing for it.

Pins: bump-then-deferred-landing, in flight at step 2, two engines, a nested
resolveAuthzContext, an API-key caller class, the positive clock window.

Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH
Co-authored-by: Claude <noreply@anthropic.com>
…e that carries the write epoch

Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 870b42979c885b2a2852add8071abdb8e49fbd39
Local-runs: none

Round 2 of the isolated review at the contract-review tier, owed by the cross-lane rule (the claiming seat repo:cloud#1 follows this PR to MERGED). Inputs: the diff and its delta from 0b07e024, the rewritten PR body, cloud#2634, the round-1 record 6077362013, and the head's check-runs. Recorded 2026-10-09T09:38Z.

① Derived judgments

  • Round-1 blocking finding: CLOSED. The memo-side write observer is traced against the real ObjectQLEngine.executeWithMiddleware:

    • the epoch bumps first, then the chain is snapshotted at write start;
    • registerMiddleware appends, so the observer is innermost to every boot-registered middleware;
    • insert, update, delete and insertMany each reach the chain unconditionally, with the driver step inside next();
    • every outer middleware checked calls next();
    • no plugin writes grant tables around the chain.

    The guard serves only when the epoch, started and completed all equal their open values and started === completed. A write landing after the open, one still in flight, or one starting after the open all decline. Pins :589 (the round-1 interleaving) and :622 (still in flight) cover it. Ablation A6 (epoch-only guard) turns :589 red with the revoked position authorised.

  • Non-blocking, required for accuracy: one unstated residual of the same class.

    • A grant-table write inside engine.transaction() settles the observer per statement but lands at the driver COMMIT, which is outside every chain.
    • It is reachable on driver-sql deployments through SCIM flows (objectql-adapter.ts ~788-810) and REST /batch (protocol.ts ~7633). It is not reachable on the hosted Turso remote face, which declares transactionsUnsupported.
    • The window is one COMMIT round trip: the same mechanism and size as the out-of-process residual the PR already accepts.
    • The module doc's categorical "a write cannot land without first moving started" (request-grants-memo.ts ~62-64) and its stated-residual list (~86-99) are inaccurate for this case. They must say "cannot be executed at the driver" and list the transactional-COMMIT residual. The PR body's acceptance notes must say the same.
  • Non-blocking: the stated pre-observer residual is real but boot-shaped (kernel boot writes are awaited inside start()), and acceptable as stated.

  • Non-blocking: observer side effects are safe.

    • finally rethrows unchanged.
    • Reads skip.
    • Unknown operations count as writes, which errs in the safe direction.
    • Lifetime is a WeakMap keyed by the engine.
    • Cost is one async frame per write.
    • Unlike the cross-request cache, the memo has no off switch. That is not needed for correctness.
  • Non-blocking: equivalence and isolation hold under the "registering scope stores nothing" rule. All six round-1 non-blocking items are addressed: the API-key class, two-engine, nested call, the positive clock window, the saving scope, and the module doc and PR body.

② Semver level

patch for @objectstack/core and @objectstack/plugin-auth. request-grants-memo.ts is still not exported from either @objectstack/core entry, so Clause-②: no holds.

③ Boundary flags

  • The boundary claim that the authorization decision is unchanged now holds for every in-process non-transactional write. The two remaining residuals (transactional COMMIT and pre-observer) have the same next-request-reads-fresh consequence as the accepted out-of-process case.

  • Optional closures, not required here:

    • an objectql-side epoch bump after an owned transaction's commit;
    • a one-function core export that registers the observer at engine construction.

    Both are author calls. Neither is a condition of this verdict.

  • This verdict is conditional on Test Core (1/6), which was still in_progress at review time, finishing green.

Implemented-by: claude/issue-2634-prehandler-grants-once
Reviewed-by: session_01WVbr5J6u8BHh8EyFtcWciH

VERDICT: PASS

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: da29c616426e02fb511a44573acc46dffe5987b1
Local-runs: none

Round 3 of the isolated review at the contract-review tier: a delta check of 870b4297..da29c616 against the round-2 record 6078361755. Inputs are the diff, the rewritten PR body and the head's check-runs. Recorded 2026-10-09T09:47Z.

① Derived judgments

  • The delta is prose-only. Two files changed: the changeset (one sentence) and request-grants-memo.ts (+18/−3). Every changed line of the .ts file is a JSDoc line inside the module doc block, which ends before the first import. The executable body, from the first import on, hashes identically on both heads (sha256 a20a2b65f90c0fca…). Every round-2 code finding carries over unchanged.
  • The round-2 accuracy requirement is met in all three places:
    • The module doc now says a write cannot be executed at the driver without moving the counters, and that the observer sees statement execution, not commit visibility.
    • The module doc's residual list carries the transactional-COMMIT case: reachable on driver-sql (SCIM through the better-auth adapter, REST /batch), not on the Turso remote face, with the out-of-process consequence. The engine-side closure is named as out of scope.
    • The changeset sentence and the PR body's acceptance notes say the same.
  • Non-blocking, wording. The bullet heading at ~51 ("landed, or is landing") and the consequence sentence at ~70-72 ("lands after it") are loose. They sit inside the bullet whose body states the corrected definition and points to the residual, so they do not reinstate the false categorical guarantee. "executed at the driver" would make both exact; that can ride on any later touch.
  • Non-blocking, precision. The transactional window runs from the transaction's last write statement to its commit. That is slightly more than one round trip when the callback reads after its last write. The consequence statement is unaffected.

② Semver level

Unchanged: patch for @objectstack/core and @objectstack/plugin-auth. Nothing newly exported, so Clause-②: no holds.

③ Boundary flags

  • Unchanged from round 2. The decision is unchanged for every in-process non-transactional write. The transactional-COMMIT and pre-observer residuals are stated, and they have the out-of-process consequence.
  • This verdict is conditional on the check-runs that were still running on da29c616 at review time finishing green: all six Test Core shards, the three Dogfood Regression Gate shards, Dogfood Verify CLI, Temporal Conformance, the three Type Check gates, Lint & Repo Gates, Check Changeset and the issue-claim guard. The PR is not readied before they are.

Implemented-by: claude/issue-2634-prehandler-grants-once
Reviewed-by: session_01WVbr5J6u8BHh8EyFtcWciH

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI note from the seat driving this PR (repo:cloud#1, session session_01WVbr5J6u8BHh8EyFtcWciH) · 2026-10-09T10:04Z

Failing check: Test Core (1/6) (and its aggregate Test Core) on da29c616, job 113761803267, step "Run this shard's tests".

The failing test: @objectstack/cli test/package-install-local-jobs.integration.test.ts failed with PORT DRIFT on 'os start': this harness asked for port 37913 and the child BOUND port 37914. Every other file in the shard passed: 371 of 372 files, 4950 tests.

Not this PR's, for three reasons:

  • The harness's own message calls it "a HOST race, not a verdict about the code under test": something on the runner took the port between the probe and the child's listen.
  • This PR touches only packages/core and packages/plugins/plugin-auth, not packages/cli.
  • da29c616 differs from 870b4297 only in comments. Its executable code is byte-identical (contract review 6078498883), and all six Test Core shards were green on 870b4297.

What I'm doing:

  • Re-running the failed job once.
  • Not relaxing or skipping the test.
  • If the re-run fails the same way, I will treat it as real and investigate it.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants