Skip to content

feat(core): authz grants cache — #11633 leg B: coarse invalidation, default off, expiry-boundary rule, bypass list - #13415

Draft
os-elon wants to merge 3 commits into
mainfrom
claude/issue-11971-grants-cache-leg-b
Draft

feat(core): authz grants cache — #11633 leg B: coarse invalidation, default off, expiry-boundary rule, bypass list#13415
os-elon wants to merge 3 commits into
mainfrom
claude/issue-11971-grants-cache-leg-b

Conversation

@os-elon

@os-elon os-elon commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Fixes #11971

Leg B of the accepted #11633 cross-request caching design (maintainer acceptance 2026-08-25, verbatim 「接受你的建议,继续」), built on the #11968 substrate (PR #12652). Clause-②: yes (authorization answers) — this PR is opened as a draft and parked for a CONTRACT_REVIEW_TIER reviewer; that is the expected outcome. The needs:contract-review re-attach now that a reviewable diff exists is left to the seat that owns that mechanism (per the 2026-08-28 #12887 ruling quoted on the card).

What ships

resolveUserAuthzGrants (packages/core) can cache its resolved envelope across requests, governed by OS_AUTHZ_GRANTS_CACHE_TTL_MS.

  • Fork 4 — default 0, cache OFF, off is a real path. With the shipped default the open call returns undefined with zero side effects: no engine middleware, no epoch subscription, no entry — pinned by asserting the engine double's middleware count and epoch listener count are both zero and the second resolution re-issues the identical query multiset.
  • Fork 1A — coarse invalidation, two seams. (1) A cache-owned engine middleware retires everything on any write to the watched set (sys_member, sys_user_position, sys_user_permission_set, sys_position, sys_position_permission_set, sys_permission_set, sys_user), bumping AFTER the write completes so an in-flight resolution stamps a pre-write generation and dies on arrival. (2) An engine write-epoch subscription retires wholesale on every non-write reason: metadata (a permission set can be DECLARED — plugin-security bumps the engine epoch for it), remote (peer hints carry no object; wholesale is the payload's stated contract), manual. The raw epoch alone is deliberately NOT consumed for local writes — it advances on writes to every object, which would revive the measured sys_session trap.
  • The ruled keying traps hold. sys_session stays out of the watched set — pinned with a control showing the session-activity write DID advance the engine epoch while the cache survived. A sys_member write retires the organization's entries (coarse retires everything, which contains the ruled behaviour) — pinned as: user X's org_user_ids changes when user Y's row is written.
  • Expiry-boundary rule. Entries expire at min(ttl, nextBoundary); nextGrantValidityBoundary (new, in grant-validity.ts, sharing isGrantActive's parser) scans exactly the rows the validity predicate was applied to — including currently-inactive rows, because a future valid_from is a flip the timer must catch too. Pinned in both directions with an injected clock and a one-hour TTL.
  • TTL is the correctness contract. The bus only narrows: pinned by a two-node pair with a bridge double (one hop, no clock advance) and without one (stale inside the TTL — the accepted window — and converged past it with no message ever delivered).
  • Ruled bypass list, re-derived by symbol (both files had moved off the ruling's line numbers): plugin-security/src/explain-engine.ts buildContextForUser (now line 473→481 region) and service-automation/src/plugin.ts setUserGrantsResolver (was :812, now :869 region) pass bypassGrantsCache: true with the ruling's reason preserved in comments. Bypass reads nothing from and writes nothing into the cache — both halves pinned.
  • Docs + changeset: the canonical env-vars table row drops its "no cache reads this value yet" caveat; changeset = core minor, plugin-security/service-automation patch.

One measured deviation from the design's lean (not from the ruling)

Design §4 B.2 leans toward caching the seedless envelope and re-applying seeds outside, conditioned on seeding being "a pure prepend, which must be pinned, not assumed". Measured before implementing: it is not a pure prepend. Seeds flow into derivations — a seed named in ORGANIZATION_ADMIN_GRANTS moves the ADR-0095 posture rung, and seeded email/ai_seat suppress the sys_user read (needsUserRow), so a seedless resolution issues a query the seeded path must not issue. Re-deriving those outside the resolver would be a second copy of authorization logic — the #10348 drift shape. So seeds are part of the cache key (the design's other named option), which keeps every cached answer bit-identical to its own uncached resolution by construction. Pin 5 carries the isolation, ordering, and suppressed-read halves.

Test plan (design §7: pins 1–7, 9 + the required ablation)

New suite packages/core/src/security/resolve-user-grants-cache.test.ts (28 tests) + pin 6 in plugin-security/src/explain-engine.test.ts. The batch-equivalence harness (recording double + 11-fixture matrix) moved to resolve-authz-context.batch-equivalence.testkit.ts so the identity pins reuse the fixtures without re-registering that suite; the goldens stay in the test file. The seam double's write verbs open with assertEngineUpdateDispatch/assertEngineDeleteDispatch (metadata-core — the non-cycle edge) and the RETAINED ledger records it.

  • Pin 1: identity over all 11 fixtures — cached deep-equals uncached including array order, AND the hit issues zero reads. Plus clone isolation (a caller mutating its answer cannot poison the next).
  • Pin 2: revoke and grant through the engine observed by the next resolution, no clock advance, TTL one hour — asserting the capability/posture at the end of the chain, never "the cache was cleared".
  • Pin 3: validity boundary, both directions, no write anywhere.
  • Pin 4: peer membership (B.3). Pin 5: seed isolation (B.2). Pin 6: explainer observes a revocation the cache provably still holds (control asserts the cached path IS stale first). Pin 7: zero-means-off (above). Pin 9: two-node bus/TTL pair.
  • Decline rule: a ql without the seams resolves uncached every time (never degrades to TTL-only).

⭐ Ablation of write-invalidation (required by the ruling) — invalidation is load-bearing

Declared direction before running: with both gen-bump seams removed and only the TTL/boundary expiry left, the read-after-write pins go RED; boundary/TTL/identity pins stay GREEN.

  • Mutation: both state.gen += 1; sites in resolve-user-grants-cache.ts replaced with a no-op marker. Proven on disk before reading any result: marker grep = 2, original-text grep = 0, git hash-object = 5257ceb... vs HEAD blob 8a3875a....
  • No rebuild needed for the ablated legs, and that was verified rather than assumed: the pins import the cache module by in-package relative path, so vitest resolves the mutated source directly (no dist in the resolution path; the RED result itself confirms the mutated code ran).
  • Result, exactly as declared — 5 RED / 23 GREEN: revoke-through-engine RED, grant-through-engine RED, metadata/manual retirement RED, peer-membership RED, bus-hop RED; TTL convergence, validity boundary, identity, off-path all GREEN.
  • Restore proven by state, not exit code: git checkout HEAD -- $ABS_PATH (absolute path, trap on EXIT/INT/TERM), then git diff HEAD empty, git status clean, blob hash back to 8a3875a..., marker grep 0; rerun 28/28 green. Cycle run twice — once at the feature commit, once at the final head ae0ec14b6.

Verification (all at final head ae0ec14b6 unless noted)

  • Gate family derived at edit time (scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, derivation stamped at ae0ec14b6 after merging origin/main): 39 commands. 38 green. check:dual-build-cjs-loads exits 3 = NOT MEASURED (its own text: prerequisite not met, full workspace dist required — CI runs it after the full build). check:skill-examples initially exited 1 for the same prerequisite class (client dists not built); after turbo run build --filter=@objectstack/client-react... it is a real green: "260 prose examples type-check across 3 surfaces".
  • check:engine-double-contract (self-derived, not in the path-derived list): initially red on the new double, fixed by pinning to the producer predicates + --write ledger row; final run green ("653 (file, verb) rows held by the RETAINED ledger").
  • Full-repo pnpm lint (eslint . --no-inline-config): exit 0 (at merge commit 0f348d3a4; the two commits after it touch one test file + the ledger json, both relinted green in the final union's lint-family gates).
  • Suites: core 44 files / 1088 tests green at ae0ec14b6; plugin-security 89 files / 1637 tests green and its three-program typecheck green with the new test file confirmed IN the tsc program via --listFiles (zero-residue regime of PR test(plugin-security): compile the 89 test files no tsc program read #13395 — nothing parked); service-automation 91 files / 1091 tests green (at 0f348d3a4; untouched since). check:nul-bytes green.
  • bypassGrantsCache verified present in core's built dist/index.d.ts (plugin-security resolves core unaliased, per the KNOWN_UNALIASED_TEST_IMPORTS ledger), and service-automation/src/plugin.ts compiles clean against it (0 tsc errors in that file; the package's pre-existing test-file noise is environmental and untouched).

Serial fences respected

No touches to permission-set-projection.ts / packaged-permission-set-restore-leg.test.ts (card #12020), none of PR #13371's eight files (service-automation/src/plugin.ts is not among them), no service-datasource/runtime manifests (card #12943 — its optional-peers change arrived via the origin/main merge, untouched), no plugin-sharing/plugin-auth (PR #13397). No skills/**, no content/docs/releases/**, no pm:* label or assignee changes.


Generated by Claude Code

claude added 3 commits August 30, 2026 10:05
…ation, TTL=0 default, expiry-boundary rule, ruled bypass list

The UserAuthzGrants envelope resolveUserAuthzGrants produces can now be
cached across requests, governed by OS_AUTHZ_GRANTS_CACHE_TTL_MS (default
0 = off, a real path with zero engine footprint). When enabled: coarse
invalidation on any engine write to a watched authorization object
(sys_session deliberately excluded — the measured keying trap), wholesale
retirement on non-write epoch reasons (metadata/remote/manual), entry
expiry at min(ttl, nextValidityBoundary) because ADR-0091 windows flip
with no write anywhere, seeds in the cache key (measured: seeding is NOT
a pure prepend — posture and the suppressed sys_user read both depend on
seeds), and clones served so callers cannot poison entries. The explain
engine and runAs:'user' automation runs take the ruled force-fresh path.

Pins: identity over the 11-fixture matrix (zero reads on a hit),
read-after-write revocation/grant with no clock advance, the sys_session
trap, validity boundaries in both directions, peer-membership coarse
retirement, seed isolation, TTL=0 bit-identity, and the two-node bus/TTL
convergence pair. The batch-equivalence harness moved to a .testkit.ts so
the identity pins reuse the fixtures without re-registering that suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi
…contract

check:engine-double-contract flagged makeSeamQl — its update()/delete()
accepted calls the real ObjectQL refuses. Both verbs now open with the
producer's own predicates (assertEngineUpdateDispatch /
assertEngineDeleteDispatch from @objectstack/metadata-core — the
non-cycle edge for a package objectql depends on), the pins spell their
writes legally (multi delete carries multi:true, the session-activity
update is by-id), and the RETAINED ledger records the new pinned double.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/plugin-security, @objectstack/service-automation, touching 93 documentable anchor(s).

40 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 c09451bf1e2602e58a8c58718ef168a644a034f6.

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

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 29 pages)
  • 12 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

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 c09451bf1e2602e58a8c58718ef168a644a034f6packageMentionDocs.

Which tree this was computed on

This run read content/docs from 42c5119b4434d905868ca96c2a992ca16193c466 — the merge of head ae0ec14b68cd63baa7dd8488a433b2af5ecfde9f into base c09451bf1e2602e58a8c58718ef168a644a034f6, 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 42c5119b4434d905868ca96c2a992ca16193c466 && git checkout 42c5119b4434d905868ca96c2a992ca16193c466
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c09451bf1e2602e58a8c58718ef168a644a034f6 ae0ec14b68cd63baa7dd8488a433b2af5ecfde9f && git checkout -B drift-repro c09451bf1e2602e58a8c58718ef168a644a034f6 && git merge --no-ff ae0ec14b68cd63baa7dd8488a433b2af5ecfde9f

node scripts/docs-audit/affected-docs.mjs --json c09451bf1e2602e58a8c58718ef168a644a034f6

⚠️ 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 c09451bf1e2602e58a8c58718ef168a644a034f6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

authz caching leg B: grants cache — coarse invalidation, default TTL=0 (off), expiry-boundary expiry, bypass list

2 participants