Skip to content

feat(engine,core,cluster): the authorization-cache invalidation substrate — engine-seam write epoch, authz.invalidated channel, boot-time posture statement (#11968) - #12652

Merged
os-warren merged 5 commits into
mainfrom
claude/issue-11968-authz-cache-substrate
Aug 27, 2026
Merged

feat(engine,core,cluster): the authorization-cache invalidation substrate — engine-seam write epoch, authz.invalidated channel, boot-time posture statement (#11968)#12652
os-warren merged 5 commits into
mainfrom
claude/issue-11968-authz-cache-substrate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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.


⚠️ Provenance of the CODE — this branch resumes a killed seat

The previous dev seat was killed by a container restart. Its 14 uncommitted files were committed and pushed as 4ea453ae55 by 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:

KEPTAll 14 files of 4ea453ae55, after reading each one against the ruling and re-verifying its seams on current origin/main. The implementation is unchanged except for the five repairs listed below. git reset and 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.
RE-DERIVEDThe verification, all of it — it did not exist. Build, five package suites, three new test files (56 cases), two ablations with written predictions, the 54-gate union, repo-wide lint. Plus the five repairs the verification found: a limit-blind stub driver, a missing vitest source alias, three as any query bags, and two ledger drifts.
DISCARDEDNothing. No file, and no part of any file, was reverted.
ADDEDThe changeset, the three test files, service-cluster/vitest.config.ts, the OS_AUTHZ_GRANTS_CACHE_TTL_MS row in the canonical env table, and one deliberate change of direction in runtime.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 for AUTHZ_INVALIDATED_CHANNEL cannot 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 own publish docblock, 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 and authz-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 debug and swallowed, the publish is never awaited by the writer, and the epoch has already advanced locally before any of it runs. Pinned in authz-invalidation-bridge.test.ts"a lost hint costs latency, never correctness".

⚠️ Recorded, deliberately not repaired here: IPubSub's own interface docblock in @objectstack/spec/contracts still 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 a packages/spec change 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 at kernel:ready in 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, and disabled is 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: Runtime registers the memory cluster driver by default, so "is a cluster service registered?" answers yes on the shipped default while the bus fans out to nobody. in-process is therefore a distinct AuthzInvalidationBusState, read from isInProcessClusterDriver in split-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

With the substrate landed and no cache consumers yet, runtime behaviour is unchanged; the posture line appears exactly when a cache flag is on without a bus, and not otherwise.

"Appears" alone passes for an implementation that always prints. Both arms are asserted, at both levels:

  • The decisionpackages/core/src/security/authz-cache-posture.test.ts, including an enumerated ttl × bus matrix (3 × 3) so "exactly when" is measured rather than described.
  • The bootpackages/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 above debug.
  • The unchanged halfpackages/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" asserted kernel.use was not called at all. It now asserts exactly one plugin is registered and that it is the authz posture bridge. AuthzClusterBridgePlugin is registered outside the cluster !== false branch on purpose: cluster: false is 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 inherited

claimmeasured
plugin-security carries a private writeEpoch to generalisesecurity-plugin.ts on origin/main: private writeEpoch = 0 (:906), bumped at :1058 and :1463, read at :4454/:4458
the engine seam sees every writeexecuteWithMiddleware has 7 call sites — find, findOne, insert, update, delete, count, aggregate. All three write verbs route through it
plugin-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 narrow
IPubSub is the shipped cross-node channelpackages/spec/src/contracts/cluster-service.ts:78; subscribe returns an Unsubscribe, publish returns Promise<void>
service-cluster may import @objectstack/core as a value"@objectstack/core": "workspace:*" in its dependencies, not a dev/peer dep

Premise 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.invalidated on 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, 0 a real path, deployment config never a settings row) is what readAuthzGrantsCacheTtlMs implements. Nothing to escalate.

Clause ②

  • Contract accept/reject behaviour changed?No. No schema, no parse, no refusal. Nothing that parsed before is rejected now and nothing rejected before is accepted; the posture statement is a log line, not a gate.
  • Public surface widened?Yes — additively, and packages/spec is untouched. Precisely: @objectstack/core gains 1 const + 3 functions + 8 types; @objectstack/objectql gains 3 functions + 1 const + 4 types, plus three public members on ObjectQL (writeEpoch, attachAuthzInvalidationPubSub, detachAuthzInvalidationPubSub); @objectstack/service-cluster gains AuthzClusterBridgePlugin and isInProcessClusterDriver. No existing signature changed, nothing removed or renamed. ⛔ The authz.invalidated payload is deliberately not a packages/spec contract 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 --repo assertion 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 a tail. ⚠️os-verify-lock.sh printed VERDICT command-exit 0 over 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 root check: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 .mdx row 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):

suitefilestestsexit
@objectstack/objectql24142270
@objectstack/runtime19628790
@objectstack/plugin-security8515540
@objectstack/core4010090
@objectstack/service-cluster4660

typecheck green for @objectstack/objectql, @objectstack/plugin-security, @objectstack/runtime (script names echoed in each log — a --filter matching 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 -cF counts read before any result, and restored under trap … EXIT INT TERM. Restoration verified more strongly than an empty diff: git diff --exit-code on 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 anchor 1 → 0, injected marker 0 → 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 drive WriteEpoch directly.

A2 — the "and not otherwise" arm. Make ttl = 0 fall through the disabled branch — an implementation that states a posture always. On disk: removed anchor 1 → 0, injected marker 0 → 1. Predicted RED, 6 failed / 16 passed of 22, naming all six, and naming why ttl=0 bus=bridged -> quiet must not move (the bridged posture speaks at info, and that matrix row asserts on warn), 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 frame ObjectQL.requireFindOnePredicate src/engine.ts:8595:11 — a src frame with src line 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.

  1. 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.
  2. check:objectql-double-limit, red. The new stub driver's find was limit-blind. Repaired by applying the bound after the filter, by presence.
  3. check:test-source-alias, red.service-cluster's test reached @objectstack/core through the workspace link, i.e. dist/. Repaired with an anchored alias config, not by widening the registry — the dangerous direction here is quiet: a dist merely behind runs the posture tests green against the decision function that used to ship.
  4. check:query-options-erasure, red. Three as any query bags on calls the engine already types. Repaired by deleting the casts, not by raising the ratchet.
  5. Two shrink-only ledger drifts (service-cluster DEBT +1, objectql TEST_DEBT +3), both from the new tests. Repaired at the source — registerObject requires an owning package id, and the attach mock needed typed parameters for its call tuple. ⛔ Neither ledger was raised.

One informational note, attributed by measurement

check:type-check-debt reports @objectstack/runtime TEST_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 only runtime.test.ts swapped 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

minor for @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). patch for @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_MS is 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/contracts states "At-least-once delivery", which contradicts cluster.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 in authz-invalidation-channel.ts so nobody resolves it in the wrong direction; correcting the docblock is a packages/spec change for the spec seat.

⚠️ Dedup search channel, declared: this session's REST endpoints answer HTTP 403 (sessions are bound to their configured repositories for /search/issues, and GitHub access is not enabled for this session for 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

claudeand others added 4 commits August 26, 2026 18:07
…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.
…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
@github-actions

github-actionsBot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/core, @objectstack/objectql, @objectstack/plugin-security, @objectstack/runtime, @objectstack/service-cluster, touching 44 documentable anchor(s). ⚠️3 changed file(s) yielded no anchor (packages/objectql/src/index.ts, packages/services/service-cluster/src/index.ts, packages/services/service-cluster/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/approvals.mdx(via nodeId (symbol))
  • content/docs/automation/flows.mdx(via nodeId (symbol))
  • content/docs/concepts/metadata-lifecycle.mdx(via originNode (symbol))
  • content/docs/kernel/cluster.mdx(via nodeId (symbol), originNode (symbol))
  • content/docs/permissions/field-level-security.mdx(via SecurityPlugin (symbol))
  • content/docs/permissions/index.mdx(via SecurityPlugin (symbol))
  • content/docs/plugins/packages.mdx(via SecurityPlugin (symbol))
  • content/docs/ui/forms.mdx(via SecurityPlugin (symbol))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via SecurityPlugin (symbol))
  • content/docs/releases/v17.mdx(via nodeId (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/objectql/src/index.ts, packages/services/service-cluster/src/index.ts, packages/services/service-cluster/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 64 pages)
  • 21 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 — 51 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 68bf4efc6ac3e18385496432012eaf9aba09cc39packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0cdc0e3bdb80ba160ad7dfe915a8499cb32cee65 — the merge of head 998986f05c49c4038b1ee3fbe57476c39bad1d58 into base 68bf4efc6ac3e18385496432012eaf9aba09cc39, 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 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

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 27, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — held as draft, needs:contract-review applied, one question for the maintainer

This is a strong report and I am not sending it back. Holding it at draft for two reasons that are both about who gets to decide, not about quality.

Clause ② — surface widening, so contract-review tier

The report states it precisely rather than waving at it: accept/reject does not move (nothing that parsed before is refused, nothing refused before is accepted), but the public surface widens additively across three packages@objectstack/core gains 2 consts, 3 functions and 8 types; @objectstack/objectql gains 5 named exports, 4 types and 3 public members on ObjectQL; @objectstack/service-cluster gains 2. packages/spec is deliberately untouched, and the authz.invalidated payload is deliberately not made a spec contract type because #11633 s5 reserves that shape for the spec seat. That restraint is the right call and is why this is a label, not a rejection.

needs:contract-review is the compensating control for that tier. Applied.

For contrast, and to keep the rule honest: I did not apply it to #12641 tonight. That card changes observable behaviour on two published packages, but its accept set does not move and its diff contains zero added-or-removed export lines — measured, not assumed. My clause ② triggers on an accept/reject change or a surface widening. #12641 has neither, so labelling it "to be safe" would have been the rule bending to my nerves rather than to evidence.

The question I will not answer on the maintainer's behalf

Runtime now registers AuthzClusterBridgePluginunconditionally, including under cluster: false, which changed an existing pin from "kernel.use is not called at all" to "exactly one plugin, the authz posture bridge".

The report's argument for this is sound on its own terms — a missing bus is the loudest case the posture check has, so skipping registration exactly there would put the statement's absence precisely where the missing bus is, which is #4785's shape — and the plugin is inert on the shipped default. I am not disputing it.

But it means cluster: false no longer means "this package contributes nothing", and it got there by changing an assertion that someone previously wrote down on purpose. Whether the posture statement belongs inside service-cluster at all is a shape decision about package boundaries, and that is the maintainer's to make, not a PM's and not a dev's. The report says so itself and files it as "stated rather than decided" — which is exactly right, and is why it reaches the decision box instead of the merge queue.

Two other things I want visible rather than buried in a green wall:

  • Four gates went red mid-task and were repaired at the source, never by raising a ratchet — a limit-blind stub driver, a missing vitest source alias that would have run the posture tests against dist/, three as any query bags, and two shrink-only ledger drifts closed by giving registerObject its package id and typing the mock's parameters. The repair is the finding, and reporting it that way is worth more than the greens.
  • One gate refused rather than failed: check:type-check-debt exited on "@objectstack/core has a type entry point OLDER than its own sources". The report classifies that as not measured, not red, and rebuilt before re-running. That distinction is the whole of [finding] Every verify-lock ledger row written before #12288 records outcome=command-exit — for batched runs that word is the defect, and the rc beside it is the lie #12365, and it was drawn correctly without being prompted.

Also noted: the dedup search for #12651 again had to route through MCP because this session's REST endpoints answer 403. That is the third seat to declare it today.

Next step is the maintainer's, not mine: rule on the cluster: false registration shape, and this comes off draft and into the queue unchanged if the answer is "keep it".


Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red — root-caused. check:adr-anchors, and it is this PR's, not infrastructure

CI (run 33036279632) is success. The red is Lint & Type Check (run 33036279292), job Lint & Repo Gates, and the failure is precise:

✗ check-adr-anchors --self-test — 2 failure(s) of 74 assertion(s)
• live-citations-are-green-today
• citation-ablation-without-allowlist-is-red
ADR-0127 is cited by 2 file(s) but names no record under docs/adr/ —
content/docs/deployment/environment-variables.mdx
packages/core/src/security/index.ts

Measured, with a firing control rather than taken from the log:

proberesult
ADR records on main130, highest is 0126-packaged-metadata-customization-model.mdno 0127
ADR-0127 hits on main0 files
ADR-0127 hits on this branch2 files
control — ADR-0049 resolvesdocs/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 numberADR-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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Follow-up commit 998986f05c — CI red repaired (check:adr-anchors)

Narrow citation fix only. No behaviour change, no new exports, no changeset (prose in 2 files; the branch's existing changeset already covers the env-var row).

The failureLint & Type Check run 33036279292, job Lint & Repo Gates: check-adr-anchors --self-test 2 failures of 74 assertions (live-citations-are-green-today, citation-ablation-without-allowlist-is-red). ADR-0127 was cited by 2 files but names no record under docs/adr/ (records top out at 0126). Verified the branch was the sole source: 0 hits on origin/main, 2 on this branch. Control ADR-0049 resolves, so gate and probe path are sound.

The repair — the gate's own remedy (b), "cite the number that exists": dropped the ADR token, kept #11633, which resolves today.

  • content/docs/deployment/environment-variables.mdx(ADR-0127 / #11633) becomes (#11633).
  • packages/core/src/security/index.ts — the phrase was ADR-0127-shaped, so the shape is now named outright ("TTL-bounded and invalidated over a best-effort cross-node channel") instead of pointed at an unshipped record. Surrounding prose unchanged.

⛔ Not remedy (a): docs/adr/** is a governed surface, maintainer hand-merge only. The pending ADR-0127 in draft PR #12519 was not touched and is not treated as a ruling.
⛔ No allowlist entry — that would baseline the finding.

Verification, all at final head 998986f05c (exit codes captured before any pipe; verdicts quoted from each gate's own output):

  • Reproduced first on the unmodified branch: command-exit 1, exactly the 2 named assertions.
  • After repair: ✓ check-adr-anchors --self-test: 74 assertions and check-adr-anchors: OK (52 anchored file(s) … 29470 citation(s) across 3705 file(s) resolve). Same 74 denominator — no assertion was removed.
  • Ablation, direction and exact count written to disk before mutating: predicted RED, "2 failure(s) of 74 assertion(s)", detail reading "cited by 1 file(s)" (CI saw 2) because the gate counts dangling numbers, not citing files. Observed exactly that. Mutation proven on disk with anchored grep -cF 0 to 1 before any result was read; restored under trap … EXIT INT TERM, restore verified by a 0-byte git diff and a 0-line git status --porcelain (never git checkout HEAD -- .).
  • All 33 gates of the derived union that match via these two files: green. Three initially exited 1 as explicit prerequisite refusals, not findings (spec check:docs needed gen:schema; check:doc-security-posture needed @objectstack/lint built; check:skill-examples needed @objectstack/client-react built) — each said so in its own words and refused rather than reporting a false green. All three green after building. gen:schema produced no tracked-file delta.
  • Plus check:nul-bytes green.

⚠️check:adr-anchors is NOT in the derived gate union — this is why it reached CI

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives 54 gates over this branch's 21-path change set, and check:adr-anchors is absent from all 54 — still absent at this head. Both files carrying the dangling citation are in the change set, so the derivation does not associate a citation-bearing file with the citation gate. That is evidence for #12514 and was deliberately not fixed here.

This also explains the body's "54 gate families — all green" above: it was true of the derived union and still missed a red gate.

Left undone, deliberately

  • The body's verification section is now stale — it pins its results to head 756786cc2e and claims all-green, which the adr-anchors red already contradicted. Not rewritten: the stored body bytes are already HTML-entity-encoded, and round-tripping them through an edit would double-encode. Flagging for the maintainer rather than risking the body.
  • dispatch-gates.mjs reports a STALE TREE warning: this branch is at least 26 commits behind origin/main and 4 gate-defining scripts changed in that range. Not merged main in — the PR is held for a maintainer ruling, and merging would widen a narrow fix. None of the 4 stale files relate to adr-anchors or docs.

PR left draft, not enqueued, no auto-merge, labels untouched.

Generated by Claude Code


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

authz caching substrate: engine-seam write epoch (generalise plugin-security's writeEpoch) + authz.invalidated channel + boot-time posture statement

3 participants

@os-warren@os-litant@claude