Uh oh!
There was an error while loading. Please reload this page.
feat(engine,core,cluster): the authorization-cache invalidation substrate — engine-seam write epoch, authz.invalidated channel, boot-time posture statement (#11968) - #12652
Conversation
…tart NOT REVIEWED, NOT VERIFIED. The dispatch that wrote this was killed by a container restart before it committed, pushed or ran any gate. This commit exists only so the work survives; the resuming dev is expected to inspect it, not to trust it. Contents as found on disk: 14 modified/added files across packages/core, packages/objectql and packages/plugins/plugin-security. No gate was run against it, no test was run, no ablation exists, and neither of the ruling's two hard requirements (the TTL-is-the-contract note at the channel, and the boot-time posture statement) has been checked for presence.
…thz-cache-substrate
…gine seam and the lost-hint contract Verification the recovered WIP commit did not carry. Three new test files and one existing pin updated in a deliberate direction: - `write-epoch.test.ts` — the seam covers the three write verbs and no read verb, advances even when a middleware refuses the write, advances for an object no middleware is registered for (the "seam with holes" regression), and — the card's own acceptance criterion — a fresh engine has ZERO epoch subscribers, so the substrate publishes nothing while there are no consumers. - `authz-invalidation-bridge.test.ts` — a lost hint costs latency, never correctness: a rejecting publish, a synchronously throwing publish and a missing logger all leave the epoch already advanced and the write untouched. Loopback suppression and the no-echo-of-remote rule are pinned too. - `authz-cluster-bridge-plugin.test.ts` — the posture statement where it actually happens. Loud arm: no cluster service, an in-process driver, a remote driver with no engine seam, a failed attach, a malformed TTL. Silent arm: the shipped default attaches nothing and says nothing above debug. - `runtime.test.ts` — `cluster: false` now registers exactly one plugin, the authz posture bridge, where it previously registered none. The direction is the point: a missing bus is the loudest case the posture check has. Plus the changeset and the `OS_AUTHZ_GRANTS_CACHE_TTL_MS` row in the canonical environment-variable table, stated honestly as a knob no cache consumes yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
- `check:objectql-double-limit`: the stub driver's `find` now applies the caller's bound AFTER the filter, by presence. A double that silently drops `limit` answers a different question than the engine asked. - `check:test-source-alias`: `@objectstack/service-cluster` gains a `vitest.config.ts` anchoring `@objectstack/core` to source. The plugin under test resolves the posture decision through that package, and unaliased the workspace link reads `dist/` — a stale build would run the posture tests green against the decision function that used to ship. - `check:query-options-erasure`: the query bags on these calls were already typed by the engine's signatures; the `as any` casts were noise and are gone. And the two ledger drifts the same tests moved, both repaired at the source rather than by raising a shrink-only ratchet: `registerObject` requires an owning package id, and the `attach` mock needed typed parameters for its call tuple to carry the node id the assertion reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
📓 Docs Drift CheckThis PR changes 5 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 51 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 0cdc0e3bdb80ba160ad7dfe915a8499cb32cee65 && git checkout 0cdc0e3bdb80ba160ad7dfe915a8499cb32cee65
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 68bf4efc6ac3e18385496432012eaf9aba09cc39 998986f05c49c4038b1ee3fbe57476c39bad1d58 && git checkout -B drift-repro 68bf4efc6ac3e18385496432012eaf9aba09cc39 && git merge --no-ff 998986f05c49c4038b1ee3fbe57476c39bad1d58
node scripts/docs-audit/affected-docs.mjs --json 68bf4efc6ac3e18385496432012eaf9aba09cc39
|
os-warren
commented
Aug 27, 2026
PM review — held as draft, |
os-warren
commented
Aug 27, 2026
CI red — root-caused. |
| probe | result |
|---|---|
ADR records on main | 130, highest is 0126-packaged-metadata-customization-model.md — no 0127 |
ADR-0127 hits on main | 0 files |
ADR-0127 hits on this branch | 2 files |
control — ADR-0049 resolves | docs/adr/0049-no-unenforced-security-properties.md ✓ |
So this branch is the sole source of the dangling citation. Not a pre-existing condition, not a flake, not a base-branch failure.
What happened, and it is a subtle one
The report handled ADR-0127 carefully in one respect and slipped in another. It says:
"adr0127Conflict": "NO CONFLICT. PR 12519 (draft, docs/adr/**) not touched and not treated as a ruling."
Refusing to treat a draft governed-surface PR as a ruling is exactly right. But the code then cites its number — ADR-0127-shaped … in packages/core/src/security/index.ts:181, and (ADR-0127 / #11633) in the env-vars row — as though the record were shipped. A citation is a promise that the decision is readable at the other end; #12519 is still open and draft, so today it resolves to nothing.
The gate names the second-order cost better than I would: "whoever later writes a real ADR-0127 retroactively falsifies all 2 of those citations at once" (#6634, where one number accumulated 77).
Remedy — the gate's option (b)
(a) writing docs/adr/0127-*.md is not available: docs/adr/** is a governed surface, maintainer hand-merge only. Neither I nor a dev may author there, and it must never be flipped ready or enqueued. (c) sibling-repo form does not apply.
So: drop the ADR-0127 token from both sites and cite #11633 alone, which exists and is readable today. If #12519 later lands the record, restoring the ADR reference is a one-line follow-up — that direction is cheap, whereas shipping a squat on 0127 is not.
Dispatching that fix. It is two files and touches no governed surface.
One standing-issue instance worth recording
The dev derived and ran 54 gate families green at the final head, and check:adr-anchors was not among them — the path derivation could not name it. That is the fourth instance of #12514 I have logged today, and it fits the generalisation already posted there: a name registered into a shared namespace has a fan-out into artifacts the path derivation does not follow. Here the "shared namespace" is the ADR number space and the unfollowed artifact is docs/adr/.
This does not change the hold: #12652 remains draft with needs:contract-review, awaiting the maintainer's ruling on the cluster: false registration shape. Green CI is a precondition for that conversation, not a substitute for it.
Generated by Claude Code
`check:adr-anchors` was red on this branch: ADR-0127 is cited by 2 files but names no record under `docs/adr/` (records top out at 0126). A citation is a promise the decision is readable at the other end, and an unshipped number is also a squat — whoever writes the real ADR-0127 would retroactively falsify both citations at once. Takes the gate's remedy (b), "cite the number that exists": keep `#11633`, which resolves today, and drop the ADR token. In `security/index.ts` the phrase was `ADR-0127-shaped`, so the shape is now named outright — TTL-bounded, invalidated over a best-effort cross-node channel — rather than pointed at. Prose is otherwise unchanged; no behaviour changes. Not remedy (a): `docs/adr/**` is maintainer hand-merge only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
os-warren
commented
Aug 27, 2026
Follow-up commit |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11968
The substrate step (§10.3) of the accepted #11633 cross-request caching design. It ships the invalidation machinery once, before leg B (#11967, the grants cache), so that card does not carry it. ⛔ No cache consumers. Nothing here caches anything, and no cache was added "so the substrate has a user".
Provenance of the rules this obeys — design document #11633 comment 5394942824 (§2, §3, §10.3); maintainer acceptance #11633 comment 5404681591, 2026-08-25, verbatim 「接受你的建议,继续」, Fork 2 → B.
The previous dev seat was killed by a container restart. Its 14 uncommitted files were committed and pushed as
4ea453ae55by the PM with the message saying plainly what it was: NOT REVIEWED, NOT VERIFIED — no gate run, no test run, no ablation, and neither of the ruling's two hard requirements checked for presence. Nobody had vouched for any of it. Stated per file, because "suggests" is not a measurement:4ea453ae55, after reading each one against the ruling and re-verifying its seams on currentorigin/main. The implementation is unchanged except for the five repairs listed below.git resetand re-derive was on the table and was not taken: the recovered code satisfies both hard requirements (measured, below), builds, and its shape is the one §2.1/§3 argue for.limit-blind stub driver, a missing vitest source alias, threeas anyquery bags, and two ledger drifts.service-cluster/vitest.config.ts, theOS_AUTHZ_GRANTS_CACHE_TTL_MSrow in the canonical env table, and one deliberate change of direction inruntime.test.ts(below).⭐ The two hard requirements — measured, not assumed
1. The TTL is the correctness contract; the bus only narrows the typical staleness window. — PRESENT.
The statement lives in
packages/core/src/security/authz-invalidation-channel.ts, the module that declares the channel constant — so a consumer reaching forAUTHZ_INVALIDATED_CHANNELcannot import it without the rule being in the file they opened. It is not a design-doc reference and not a changeset note. It says a missed message is expected, cites the three measured sources (cluster.mdx§4.2 "No shipped driver provides this yet", the redis driver's ownpublishdocblock, and the memory driver's no-cross-process note), and states the consequence in the form a misuse would violate: a consumer that would be incorrect if a message were lost is misusing this channel. It is restated at the two other places a reader can arrive from —ObjectQL.attachAuthzInvalidationPubSub's docblock andauthz-invalidation-bridge.ts.It is also structural, not only prose, and that half is pinned: a publish failure (rejecting or synchronously throwing) is logged at
debugand swallowed, the publish is never awaited by the writer, and the epoch has already advanced locally before any of it runs. Pinned inauthz-invalidation-bridge.test.ts› "a lost hint costs latency, never correctness".IPubSub's own interface docblock in@objectstack/spec/contractsstill says "At-least-once delivery", which no shipped driver provides.cluster.mdx§4.2 and the redis driver are the measured statements and are the ones this substrate follows. Repairing that docblock is apackages/specchange and is out of this card's scope — see Out-of-scope findings.2. The boot-time posture statement is non-optional. — PRESENT.
resolveAuthzCachePosture(core) decides;AuthzClusterBridgePlugin(service-cluster) runs it atkernel:readyin the default composition. Enabled cache + no bus ⇒warn, every boot, naming the window, the reason, the remedy, and that it is not an error. Three postures, anddisabledis silent on purpose: a courtesy line on every default boot is how the loud line stops being loud.The case that would otherwise slip through is handled as its own state:
Runtimeregisters the memory cluster driver by default, so "is aclusterservice registered?" answers yes on the shipped default while the bus fans out to nobody.in-processis therefore a distinctAuthzInvalidationBusState, read fromisInProcessClusterDriverinsplit-brain-guard.ts(the module that already owns that fact) rather than re-decided, and it is treated as no bus.The acceptance criterion, pinned on both arms
"Appears" alone passes for an implementation that always prints. Both arms are asserted, at both levels:
packages/core/src/security/authz-cache-posture.test.ts, including an enumeratedttl × busmatrix (3 × 3) so "exactly when" is measured rather than described.packages/services/service-cluster/src/authz-cluster-bridge-plugin.test.ts. A perfect decision function reached with the wrong input is silent in exactly the deployment the ruling made this non-optional for, so which bus state a real composition resolves to is pinned separately: no cluster service, in-process driver, remote driver with no engine seam, failed attach, malformed TTL — and the silent arm, where the shipped default attaches nothing and says nothing abovedebug.packages/objectql/src/write-epoch.test.ts› "with no consumers, the substrate is inert": a freshly-initialised engine has zero epoch subscribers, so a write advances a counter nobody reads. Nothing is published, nothing is invalidated. That is the entire observable delta.One existing pin changed direction, deliberately
runtime.test.ts› "skips cluster auto-registration when cluster:false" assertedkernel.usewas not called at all. It now asserts exactly one plugin is registered and that it is the authz posture bridge.AuthzClusterBridgePluginis registered outside thecluster !== falsebranch on purpose:cluster: falseis not a reason to skip the posture check, it is the loudest case the check has. Skipping it there would put the statement's absence exactly where the missing bus is — #4785's shape. The plugin roster changed; the observable behaviour did not.Premise check on current
origin/main— re-verified by symbol, nothing inheritedplugin-securitycarries a privatewriteEpochto generalisesecurity-plugin.tsonorigin/main:private writeEpoch = 0(:906), bumped at :1058 and :1463, read at :4454/:4458executeWithMiddlewarehas 7 call sites —find,findOne,insert,update,delete,count,aggregate. All three write verbs route through itplugin-security's middleware is global, so the covered set is identicalql.registerMiddleware(fn)at :1482 with no{ object }— the covered set does not widen or narrowIPubSubis the shipped cross-node channelpackages/spec/src/contracts/cluster-service.ts:78;subscribereturns anUnsubscribe,publishreturnsPromise<void>service-clustermay import@objectstack/coreas a value"@objectstack/core": "workspace:*"in itsdependencies, not a dev/peer depPremise still valid — nothing in the card or the dispatch was falsified.
ADR-0127 (draft PR #12519) — read, no conflict
Not touched (governed surface) and not treated as a ruling. Its D4 is the same rule this PR implements, in the ruling's own words: "The TTL is the correctness contract;
authz.invalidatedon the existing at-most-once bus narrows the typical window only, with a non-optional boot-time posture statement."D2 (engine seam, never a call-site list) and D3 (coarse invalidation is the baseline) match §2.1/§2.2 and Fork 1 → A. D6 (off by default,0a real path, deployment config never a settings row) is whatreadAuthzGrantsCacheTtlMsimplements. Nothing to escalate.Clause ②
packages/specis untouched. Precisely:@objectstack/coregains 1 const + 3 functions + 8 types;@objectstack/objectqlgains 3 functions + 1 const + 4 types, plus three public members onObjectQL(writeEpoch,attachAuthzInvalidationPubSub,detachAuthzInvalidationPubSub);@objectstack/service-clustergainsAuthzClusterBridgePluginandisInProcessClusterDriver. No existing signature changed, nothing removed or renamed. ⛔ Theauthz.invalidatedpayload is deliberately not apackages/speccontract type: Design: cross-request caching for the authenticated request path (tranche 2 of #10757) — write-invalidation-first, short-TTL fallback, configurable staleness window #11633 §5 reserves a declared shape for the invalidation event to the spec seat and does not pre-commit it.Verification
Gate union derived on the real change set with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(the--repoassertion held against this checkout's origin; the script took its own change set from the merge base — 21 paths), re-derived after the changeset and the docs row existed, since both pull in families the first derivation could not name.All results below are from runs at the final head⚠️
756786cc2e. Exit codes captured before any pipe (cmd > log 2>&1; e=$?) — never$?after atail.os-verify-lock.shprintedVERDICT command-exit 0over an inner non-zero on the first test batch of this task (#12365, #12518); every number here is the inner command's own.54 gate families — all green, including the four the derivation could not name that were run by hand because this diff adds exported names:
@objectstack/speccheck:docs,check:api-surface,check:export-origins, plus rootcheck:published-readme-exports. Also run whole:check:type-check-debt --re-measure(OK — 31 ledger entries re-measured, 1687 raw tsc errors total, none above its recorded number),check:engine-double-contract,check:where-matcher,check:i18n,check:i18n-stale-fill,check:nul-bytes, the six changeset families, and the eleven docs families the.mdxrow pulled in.Repo-wide lint, not narrowed:
node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config→ exit 0, clean, 62s under the shared lock. No narrowing was claimed and none was needed.Tests (per-package, inner exits):
@objectstack/objectql@objectstack/runtime@objectstack/plugin-security@objectstack/core@objectstack/service-clustertypecheckgreen for@objectstack/objectql,@objectstack/plugin-security,@objectstack/runtime(script names echoed in each log — a--filtermatching no script exits 0 having measured nothing). Full workspace closure built first (turbo run build --filter=./packages/* --filter=./packages/*/*, 70/70).Ablations — direction and exact count predicted in writing first
Both mutations were proved on disk with anchored
grep -cFcounts read before any result, and restored undertrap … EXIT INT TERM. Restoration verified more strongly than an empty diff:git diff --exit-codeon the single mutated path and both anchors measured back to their original counts.A1 — the engine seam. Disable the epoch bump at the head of
executeWithMiddleware. On disk: removed anchor1 → 0, injected marker0 → 1,this.writeEpoch.bump('write');1 → 0. Predicted RED, 4 failed / 20 passed of 24, naming all four. Observed: exactly those four. Named in the prediction as deliberate non-movers: "no read verb advances it" passes vacuously (0 before, 0 after) and is therefore not the seam's evidence; the six counter-contract cases and all eleven bridge cases driveWriteEpochdirectly.A2 — the "and not otherwise" arm. Make
ttl = 0fall through thedisabledbranch — an implementation that states a posture always. On disk: removed anchor1 → 0, injected marker0 → 1. Predicted RED, 6 failed / 16 passed of 22, naming all six, and naming whyttl=0 bus=bridged -> quietmust not move (the bridged posture speaks atinfo, and that matrix row asserts onwarn), so the count is 3+2+1 and not 3+3+1. Observed: exactly those six, with the bridged row green.Neither ablation needed a rebuild, and that is a measurement rather than an assumption: both subjects are imported by relative path within their own package, which vitest resolves to
src/. Evidence: an earlier failing run in this task printed the frameObjectQL.requireFindOnePredicate src/engine.ts:8595:11— asrcframe withsrcline numbers.What the verification actually caught
None of this was visible before the tests were run — recorded because "the shape suggests it was being built" was the entire prior warrant for this code.
runtime.test.ts× 2, red. The unconditional plugin registration broke two existing pins. Resolved in the direction the ruling requires (above), not by moving the registration.check:objectql-double-limit, red. The new stub driver'sfindwaslimit-blind. Repaired by applying the bound after the filter, by presence.check:test-source-alias, red.service-cluster's test reached@objectstack/corethrough the workspace link, i.e.dist/. Repaired with an anchored alias config, not by widening the registry — the dangerous direction here is quiet: adistmerely behind runs the posture tests green against the decision function that used to ship.check:query-options-erasure, red. Threeas anyquery bags on calls the engine already types. Repaired by deleting the casts, not by raising the ratchet.service-clusterDEBT +1,objectqlTEST_DEBT +3), both from the new tests. Repaired at the source —registerObjectrequires an owning package id, and theattachmock needed typed parameters for its call tuple. ⛔ Neither ledger was raised.One informational note, attributed by measurement
check:type-check-debtreports@objectstack/runtimeTEST_DEBT recorded at 227 while tsc measures 226 — a 1-unit surplus in a shrink-only ratchet. Not this PR's: measured both ways, with onlyruntime.test.tsswapped between the two runs, the test-inclusive count is 226 before and 226 after this branch's edit. Pre-existing, already tracked by #6376, and deliberately not repaired here — lowering the ledger is an unrelated bookkeeping edit on a gate script.Changeset
minorfor@objectstack/objectql,@objectstack/core,@objectstack/service-cluster(each gains public exports) and@objectstack/runtime(the default composition gains a plugin and a new deployment variable becomes live).patchfor@objectstack/plugin-security: no API change and no behaviour change — its memo now reads the engine's epoch when the wired engine exposes one and keeps its private counter otherwise, over an identical covered set.OS_AUTHZ_GRANTS_CACHE_TTL_MSis registered in the canonical table (content/docs/deployment/environment-variables.mdx, §5 of the design) and the row says plainly that no cache reads it yet — today a non-zero value does exactly one thing: make the boot state its posture.Out-of-scope findings
Filed as #12651, unassigned, and not repaired here:
IPubSub's interface docblock in@objectstack/spec/contractsstates "At-least-once delivery", which contradictscluster.mdx§4.2 ("No shipped driver provides this yet") and every shipped driver. The wrong statement is the one on the interface, i.e. the one a consumer reads at the call site, and it invites designing for duplicates when the real hazard is loss. This branch records the contradiction inline inauthz-invalidation-channel.tsso nobody resolves it in the wrong direction; correcting the docblock is apackages/specchange for the spec seat.sessions are bound to their configured repositoriesfor/search/issues, andGitHub access is not enabled for this sessionfor the repo-scoped issue list), so the pre-file search ran through the MCP search instead of the REST list the standing rule prefers. One call, zero results, plus a local grep of the two contradicting sources.Generated by Claude Code