feat(core): authz grants cache — #11633 leg B: coarse invalidation, default off, expiry-boundary rule, bypass list - #13415
feat(core): authz grants cache — #11633 leg B: coarse invalidation, default off, expiry-boundary rule, bypass list#13415os-elon wants to merge 3 commits into
Conversation
…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
📓 Docs Drift CheckThis PR changes 3 package(s): 40 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 34 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
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-reviewre-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 byOS_AUTHZ_GRANTS_CACHE_TTL_MS.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.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 measuredsys_sessiontrap.sys_sessionstays out of the watched set — pinned with a control showing the session-activity write DID advance the engine epoch while the cache survived. Asys_memberwrite retires the organization's entries (coarse retires everything, which contains the ruled behaviour) — pinned as: user X'sorg_user_idschanges when user Y's row is written.min(ttl, nextBoundary);nextGrantValidityBoundary(new, ingrant-validity.ts, sharingisGrantActive's parser) scans exactly the rows the validity predicate was applied to — including currently-inactive rows, because a futurevalid_fromis a flip the timer must catch too. Pinned in both directions with an injected clock and a one-hour TTL.plugin-security/src/explain-engine.tsbuildContextForUser(now line 473→481 region) andservice-automation/src/plugin.tssetUserGrantsResolver(was :812, now :869 region) passbypassGrantsCache: truewith the ruling's reason preserved in comments. Bypass reads nothing from and writes nothing into the cache — both halves pinned.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_GRANTSmoves the ADR-0095 posture rung, and seeded email/ai_seatsuppress thesys_userread (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 inplugin-security/src/explain-engine.test.ts. The batch-equivalence harness (recording double + 11-fixture matrix) moved toresolve-authz-context.batch-equivalence.testkit.tsso 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 withassertEngineUpdateDispatch/assertEngineDeleteDispatch(metadata-core — the non-cycle edge) and the RETAINED ledger records it.⭐ 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.
state.gen += 1;sites inresolve-user-grants-cache.tsreplaced 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 blob8a3875a....git checkout HEAD -- $ABS_PATH(absolute path, trap on EXIT/INT/TERM), thengit diff HEADempty,git statusclean, blob hash back to8a3875a..., marker grep 0; rerun 28/28 green. Cycle run twice — once at the feature commit, once at the final headae0ec14b6.Verification (all at final head
ae0ec14b6unless noted)scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, derivation stamped atae0ec14b6after merging origin/main): 39 commands. 38 green.check:dual-build-cjs-loadsexits 3 = NOT MEASURED (its own text: prerequisite not met, full workspace dist required — CI runs it after the full build).check:skill-examplesinitially exited 1 for the same prerequisite class (client dists not built); afterturbo 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 +--writeledger row; final run green ("653 (file, verb) rows held by the RETAINED ledger").pnpm lint(eslint . --no-inline-config): exit 0 (at merge commit0f348d3a4; the two commits after it touch one test file + the ledger json, both relinted green in the final union's lint-family gates).ae0ec14b6; plugin-security 89 files / 1637 tests green and its three-programtypecheckgreen 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 (at0f348d3a4; untouched since).check:nul-bytesgreen.bypassGrantsCacheverified present in core's builtdist/index.d.ts(plugin-security resolves core unaliased, per theKNOWN_UNALIASED_TEST_IMPORTSledger), andservice-automation/src/plugin.tscompiles 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.tsis not among them), noservice-datasource/runtimemanifests (card #12943 — its optional-peers change arrived via the origin/main merge, untouched), noplugin-sharing/plugin-auth(PR #13397). Noskills/**, nocontent/docs/releases/**, nopm:*label or assignee changes.Generated by Claude Code