fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point - #14411

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation
Sep 2, 2026
Merged

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point#14411
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14179

emitMetadataMutation documents itself as "the ONE choke point every authoring surface funnels through", and since the #13331 fan-out landed it is also the metadata.mutated cluster publish point. Three live write paths never reached it. Each now emits after its write, with the same org scope the write received and the same singular type key the registry was written under — mirroring the emitting siblings rather than inventing an event shape.

Verified at e60660c3a (the branch head this description describes; the gate union below was run on that same commit).

The three doors

doorreached bybeforeafterpinned by
rollbackMetaItem, after its registry write-throughPOST /api/v1/meta/:type/:name/rollbackwrite-through, no emitemitMetadataMutation({ type: singularType, name, state: 'active', organizationId: orgId })new file, §2 "announces the restored body on the mutation choke point" + "folds a PLURAL request type…"
revertCommit — RESTORE limb, after the per-item write-throughPOST /packages/:id/commits/:commitId/revertwrite-through, no emitsame event with PLURAL_TO_SINGULAR[it.type] ?? it.type, state: 'active', per-item itemOrgIdnew file, §2 "RESTORE limb announces the pre-commit body it wrote back" + the mixed-batch case
revertCommit — SOFT-REMOVE limb, after repo.delete + restoreArtifactRegistryViewsame door, items the commit createddelete + heal, no emitsame key/scope, state: 'deleted', gated on a row actually having been removednew file, §2 "SOFT-REMOVE limb announces a deleted…" + §4's already-absent negative control
deleteMetaItem legacy raw-engine exit (the non-useRepoPath side)DELETE /api/v1/meta/:type/:name on a control-plane bootstrap kernelengine.delete + optional storage drop + heal, no emitmirrors the repository twin exactly: state: 'deleted', singularTypeForRepo, request.organizationId ?? nullnew file, §2 "announces the deletion it really performs" + "carries the org scope the delete predicate used"

Four call sites, three doors — revertCommit's two limbs are one door with two exits, exactly as the card's inventory counts it. this.emitMetadataMutation( call sites go 3 to 7; applyRegistryWriteThrough call sites stay at 5, so protocol.object-registry-write-through-spelling.test.ts's closed-caller-set pin is untouched and green.

Premise, re-measured

At the branch base 5c9e40ad9: git grep -n "applyRegistryWriteThrough(" -- packages/metadata-protocol/src/protocol.ts returns 6 hits, one of them the declaration at :13660 — FIVE call sites; the same grep for emitMetadataMutation( returns 4, one of them the declaration at :4962 — THREE call sites. The card's count holds.

One correction to the card's attribution of the fifth write-through: it sits inside applyRemoteMetadataMutation (the #13331 RECEIVE-side applier), not inside publishMetadataMutation (the publisher, which makes no write-through call at all). The substance is unchanged — it is the receive side, and it is deliberately not a door.

Reachability of the legacy delete exit — the NOT MEASURED item, now measured

The unblock comment recorded that it had not verified the branch's own docblock claim ("only reachable in control-plane bootstrap mode where environmentId is undefined"), noting correctly that useRepoPath does not itself test environmentId.

Measured: the claim holds, by a mechanism the docblock does not name. What confines the branch is the two-tier delete authorization above it, which runs only when environmentId !== undefined and refuses both limbs for exactly the types that would reach the legacy path:

  • artifact-backed: artifactBacked AND NOT overlayAllowed AND NOT legacyOverlayRemoval produces NOT_OVERRIDABLE 403
  • not artifact-backed: NOT artifactBacked AND NOT overlayAllowed AND NOT runtimeCreateAllowed produces NOT_CREATABLE 403

The #6960 carve-out is the only way past the first limb and it cannot apply: it reads supportsOverlay, and the useRepoPath === false set is exactly agent, api, capability, field, job — five of the registry's 27 entries, none of which carries supportsOverlay: true. Both 403s are driven end to end in §3, and the registry property itself is pinned there too, so a registry edit that opens the door has to come past this file.

So door 4's exposure is narrower than the comment feared: unreachable through the public deleteMetaItem on a normal boot. That is why its repair is pinned under bootstrap mode — which is where the defect is real, and where the registry every organization shares is the one being healed. The OS_METADATA_WRITABLE escape hatch only moves a type ONTO the repository (emitting) path, never onto this one; the harness clears its memoised cache so that cannot silently change what is measured.

The receive path stays silent — cited, not duplicated

Adding an emit inside applyRemoteMetadataMutation would re-broadcast every received event and ping-pong across replicas, which the loopback guard (own-messages only) cannot stop. That negative is already pinned in protocol.cluster-mutation-fanout.test.ts, in "the peer's onMetadataMutation listeners receive the remote event, once, after convergence": expect(writerSeen).toHaveLength(1), commented "the remote replay stays local to the receiving node (no echo)". A peer that re-emitted would re-publish, the writer would apply that peer-origin message past its own loopback guard, and that assertion would read 2. It is cited rather than re-built.

Alongside it, §5 of the new file carries three structural pins that make the same claim locally and cheaply: the emitMetadataMutation call-site count is 7, notifyMutationListenersLocal keeps exactly its two declared callers (the choke point and the receive path), and applyRemoteMetadataMutation's body contains notifyMutationListenersLocal and does not contain emitMetadataMutation.

The cluster half is inherited, not re-measured

emitMetadataMutation is two lines — the local fan-out, then publishMetadataMutation — so a door that reaches the choke point reaches the publisher. protocol.cluster-mutation-fanout.test.ts already proves that end to end over a two-replica pub/sub double (Arm B, "a runtime-authored object registers on the PEER after the writer's save", with Arm A as the no-bridge control). Combined with §5's call-site count, the new doors inherit the cluster leg structurally. Building a second cluster harness here would re-measure that file's subject rather than this card's, so it is not built.

Ablation — direction declared before the run, in the committed test header

Declared (commit 43640d6c6, before running): removing door 1's emit from protocol.ts turns RED both of §2's rollback cases and §5's call-site count, and leaves everything else GREEN — 3 RED / 15 GREEN of 18.

Measured, exactly that:

BEFORE: anchor=1 emit-call-sites=7
AFTER: anchor=0 emit-call-sites=6 blob=ba5fa53549caa6a282bb325a2ae93f62a47fcdc6
MUTATION CONFIRMED ON DISK (anchor 1->0, emit sites 7->6, blob differs from HEAD)
x announces the restored body on the mutation choke point
x folds a PLURAL request type to the singular the write-through registered under
x has exactly seven `emitMetadataMutation` call sites
Tests 3 failed | 15 passed (18)
RESTORE: blob=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 head=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 anchor=1 emits=7 diff-bytes=0
RESTORE CONFIRMED BYTE-EXACT (blob == HEAD blob, git diff HEAD empty, anchor 1, emits 7)

Mechanics: the fix was committed first, so the restore leg had a real restore point; the mutation ran under an EXIT INT TERM trap whose paths are absolute (seeded from git rev-parse --show-toplevel, not from a cwd); the mutation is proved by anchored counts of the removed text and a blob hash that differs from HEAD's, never by perl -i's exit code; the restore is proved by git checkout HEAD -- path followed by a blob-hash match against the HEAD blob AND an empty git diff HEAD, never by an exit code. The first attempt of this script refused to run — its precondition read anchor=0 because the bracketed issue tag was being read as a regex character class — and that refusal is the design working: a zero-match mutation would otherwise have produced a fully green "ablation".

No rebuild is involved and that is itself a measurement: the pin file imports the same-package relative ./protocol.js, so vitest compiles src/protocol.ts. An ablation that goes red with no build is what proves it; a dist-resolved one would have stayed green.

Deliberately unchanged

Files, and one beyond the dispatched surface

  • packages/metadata-protocol/src/protocol.ts — the three doors (four call sites), +60 lines, comments included.
  • packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts — new, 18 cases.
  • .changeset/recovery-doors-emit-metadata-mutation.md@objectstack/metadata-protocol: patch.
  • scripts/engine-double-contract.pinned.jsonnot on the dispatched file surface, declared here.check:engine-double-contract reds on a new pin file until the RETAINED ledger records it ("New pinned coverage is GOOD and nothing is wrong with your change — the ledger just has to learn about it, or it never protects this file"). Regenerated with that gate's own --write: 3 rows added, all naming the new test file, 0 lost, no other row touched. Reusing an existing double instead was not available — the file is new, so it has no double to override.

No existing sibling pin needed updating: the whole @objectstack/metadata-protocol suite is green unchanged (2125 passed), including the write-through-spelling call-site count, the cluster fan-out arms and the delete-rewrap envelope pins.

Gate union, run on e60660c3a

Derived on the real change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands after the last commit — 44 commands, 7 more than the dispatch's list because the ledger file above joined the diff (agent-test-spelling, bash32-floor, cli-command-ids, entry-guard, parse-guard, pnpm-filter-targets, watch-hint-literal; all seven PASS). Exit codes captured after a redirect, never through a pipe.

  • pnpm --filter @objectstack/metadata-protocol test — PASS, 154 files passed / 2 skipped, 2125 tests passed / 10 skipped.
  • pnpm --filter @objectstack/metadata-protocol typecheck — PASS (tsc --noEmit). The new pin file is inside that program: tsc --listFilesOnly lists it, 1 hit among 699 files.
  • pnpm lint — PASS over the whole repo in 68s (eslint . --no-inline-config), so no narrowing was needed and none is claimed.
  • pnpm check:nul-bytes — PASS (7859 files scanned), plus a manual control-character scan over the four changed paths: zero hits.
  • The 44-command union: 40 PASS, 4 NOT MEASURED, 0 findings.

The four NOT MEASURED are exit-3 PREREQUISITE-NOT-MET, recorded with the gates' own words and read as neither pass nor finding — every one needs an input a per-package local run does not have, and CI supplies all four:

gateits own verdict
check-test-completeness.mjs"pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED"
pm/check-half-states.mjsexit 3, an unread instrument (needs the GitHub board) — "never as a quiet board"
check:dual-build-cjs-loads"PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/… Run pnpm build first. This is NOT a pass"
check:type-check-debt"Build the closure first, exactly as lint.yml does… This is NOT a pass and NOT a finding"

Merge state

main landed 5 commits since this branch's base (5c9e40ad9 to 4485f7dad): CLI, lint, docs and three merge-driver scripts. None touches packages/metadata-protocol or packages/spec, so the AGENTS.md §10 re-check is scoped out rather than skipped, and the merge queue validates the merge commit itself. No other in-flight claude/issue-14* branch on origin touches protocol.ts (24 branches fetched and diffed against their merge-base at PR time), so the serial lane the dispatch declared is still clear.

Not this card

#13609 (a deleted datasource prolonging cluster-wide) is a different defect and is not addressed here: datasource carries allowOrgOverride: false, allowRuntimeCreate: true, so useRepoPath is true and it takes the repository exit, which has emitted since #2588. This branch changes nothing about it, and that card stays open on its own premise.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code


Generated by Claude Code

…ry doors (#14179)
rollbackMetaItem, revertCommit (both limbs) and deleteMetaItem's legacy
raw-engine exit mutate live state and never reached emitMetadataMutation —
the documented choke point and, since #13331, the cluster publish point. Each
now announces after its write, with the same org scope and singular type key
the write used. Row-absent exits stay silent; the cluster receive path is
untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… id that a default parameter swallows (#14179)
A default parameter fires on an explicitly passed `undefined`, so the harness's
bootstrap-mode cases were built on an environment boot and measured the
two-tier gate's 403 rather than the legacy exit. The factory now takes a named
mode and asserts the topology it constructed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…re running it (#14179)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… ledger (#14179)
`check:engine-double-contract` reds until the ledger learns about a file that
pins engine doubles, or the pin never protects it. Regenerated with
`--write`: 3 rows added, all naming the new test file, 0 lost.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 7 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx(via meta.rollbackItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/rollback, selected by route anchor /:type/:name/rollback), rollbackItem (sdk, the bare tail of client method meta.rollbackItem, bound to POST /api/v1/meta/:type/:name/rollback))
  • content/docs/kernel/contracts/metadata-service.mdx(via /:type/:name/rollback (route, bridged from symbol rollbackMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via deleteMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
What this run could not see
  • 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 — 8 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 41aa979295be112cc6c27fcd6cc6c5ac20c519d5packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2431d3164baf7f9401f96757f5f0beb6e663570 — the merge of head e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 into base 41aa979295be112cc6c27fcd6cc6c5ac20c519d5, 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 b2431d3164baf7f9401f96757f5f0beb6e663570 && git checkout b2431d3164baf7f9401f96757f5f0beb6e663570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 && git checkout -B drift-repro 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 && git merge --no-ff e60660c3a62281b5ef3edf397f1b2f50d4d3ec44
node scripts/docs-audit/affected-docs.mjs --json 41aa979295be112cc6c27fcd6cc6c5ac20c519d5

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

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14179 (comment 5504761434) → at 05:32Z every check run on head e60660c3a was completed with success or skipped (33 runs; Lint & Repo Gates finished 05:31Z), governed-surface test on the four changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


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/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point - #14411

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation
Sep 2, 2026
Merged

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point#14411
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14179

emitMetadataMutation documents itself as "the ONE choke point every authoring surface funnels through", and since the #13331 fan-out landed it is also the metadata.mutated cluster publish point. Three live write paths never reached it. Each now emits after its write, with the same org scope the write received and the same singular type key the registry was written under — mirroring the emitting siblings rather than inventing an event shape.

Verified at e60660c3a (the branch head this description describes; the gate union below was run on that same commit).

The three doors

doorreached bybeforeafterpinned by
rollbackMetaItem, after its registry write-throughPOST /api/v1/meta/:type/:name/rollbackwrite-through, no emitemitMetadataMutation({ type: singularType, name, state: 'active', organizationId: orgId })new file, §2 "announces the restored body on the mutation choke point" + "folds a PLURAL request type…"
revertCommit — RESTORE limb, after the per-item write-throughPOST /packages/:id/commits/:commitId/revertwrite-through, no emitsame event with PLURAL_TO_SINGULAR[it.type] ?? it.type, state: 'active', per-item itemOrgIdnew file, §2 "RESTORE limb announces the pre-commit body it wrote back" + the mixed-batch case
revertCommit — SOFT-REMOVE limb, after repo.delete + restoreArtifactRegistryViewsame door, items the commit createddelete + heal, no emitsame key/scope, state: 'deleted', gated on a row actually having been removednew file, §2 "SOFT-REMOVE limb announces a deleted…" + §4's already-absent negative control
deleteMetaItem legacy raw-engine exit (the non-useRepoPath side)DELETE /api/v1/meta/:type/:name on a control-plane bootstrap kernelengine.delete + optional storage drop + heal, no emitmirrors the repository twin exactly: state: 'deleted', singularTypeForRepo, request.organizationId ?? nullnew file, §2 "announces the deletion it really performs" + "carries the org scope the delete predicate used"

Four call sites, three doors — revertCommit's two limbs are one door with two exits, exactly as the card's inventory counts it. this.emitMetadataMutation( call sites go 3 to 7; applyRegistryWriteThrough call sites stay at 5, so protocol.object-registry-write-through-spelling.test.ts's closed-caller-set pin is untouched and green.

Premise, re-measured

At the branch base 5c9e40ad9: git grep -n "applyRegistryWriteThrough(" -- packages/metadata-protocol/src/protocol.ts returns 6 hits, one of them the declaration at :13660 — FIVE call sites; the same grep for emitMetadataMutation( returns 4, one of them the declaration at :4962 — THREE call sites. The card's count holds.

One correction to the card's attribution of the fifth write-through: it sits inside applyRemoteMetadataMutation (the #13331 RECEIVE-side applier), not inside publishMetadataMutation (the publisher, which makes no write-through call at all). The substance is unchanged — it is the receive side, and it is deliberately not a door.

Reachability of the legacy delete exit — the NOT MEASURED item, now measured

The unblock comment recorded that it had not verified the branch's own docblock claim ("only reachable in control-plane bootstrap mode where environmentId is undefined"), noting correctly that useRepoPath does not itself test environmentId.

Measured: the claim holds, by a mechanism the docblock does not name. What confines the branch is the two-tier delete authorization above it, which runs only when environmentId !== undefined and refuses both limbs for exactly the types that would reach the legacy path:

  • artifact-backed: artifactBacked AND NOT overlayAllowed AND NOT legacyOverlayRemoval produces NOT_OVERRIDABLE 403
  • not artifact-backed: NOT artifactBacked AND NOT overlayAllowed AND NOT runtimeCreateAllowed produces NOT_CREATABLE 403

The #6960 carve-out is the only way past the first limb and it cannot apply: it reads supportsOverlay, and the useRepoPath === false set is exactly agent, api, capability, field, job — five of the registry's 27 entries, none of which carries supportsOverlay: true. Both 403s are driven end to end in §3, and the registry property itself is pinned there too, so a registry edit that opens the door has to come past this file.

So door 4's exposure is narrower than the comment feared: unreachable through the public deleteMetaItem on a normal boot. That is why its repair is pinned under bootstrap mode — which is where the defect is real, and where the registry every organization shares is the one being healed. The OS_METADATA_WRITABLE escape hatch only moves a type ONTO the repository (emitting) path, never onto this one; the harness clears its memoised cache so that cannot silently change what is measured.

The receive path stays silent — cited, not duplicated

Adding an emit inside applyRemoteMetadataMutation would re-broadcast every received event and ping-pong across replicas, which the loopback guard (own-messages only) cannot stop. That negative is already pinned in protocol.cluster-mutation-fanout.test.ts, in "the peer's onMetadataMutation listeners receive the remote event, once, after convergence": expect(writerSeen).toHaveLength(1), commented "the remote replay stays local to the receiving node (no echo)". A peer that re-emitted would re-publish, the writer would apply that peer-origin message past its own loopback guard, and that assertion would read 2. It is cited rather than re-built.

Alongside it, §5 of the new file carries three structural pins that make the same claim locally and cheaply: the emitMetadataMutation call-site count is 7, notifyMutationListenersLocal keeps exactly its two declared callers (the choke point and the receive path), and applyRemoteMetadataMutation's body contains notifyMutationListenersLocal and does not contain emitMetadataMutation.

The cluster half is inherited, not re-measured

emitMetadataMutation is two lines — the local fan-out, then publishMetadataMutation — so a door that reaches the choke point reaches the publisher. protocol.cluster-mutation-fanout.test.ts already proves that end to end over a two-replica pub/sub double (Arm B, "a runtime-authored object registers on the PEER after the writer's save", with Arm A as the no-bridge control). Combined with §5's call-site count, the new doors inherit the cluster leg structurally. Building a second cluster harness here would re-measure that file's subject rather than this card's, so it is not built.

Ablation — direction declared before the run, in the committed test header

Declared (commit 43640d6c6, before running): removing door 1's emit from protocol.ts turns RED both of §2's rollback cases and §5's call-site count, and leaves everything else GREEN — 3 RED / 15 GREEN of 18.

Measured, exactly that:

BEFORE: anchor=1 emit-call-sites=7
AFTER: anchor=0 emit-call-sites=6 blob=ba5fa53549caa6a282bb325a2ae93f62a47fcdc6
MUTATION CONFIRMED ON DISK (anchor 1->0, emit sites 7->6, blob differs from HEAD)
x announces the restored body on the mutation choke point
x folds a PLURAL request type to the singular the write-through registered under
x has exactly seven `emitMetadataMutation` call sites
Tests 3 failed | 15 passed (18)
RESTORE: blob=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 head=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 anchor=1 emits=7 diff-bytes=0
RESTORE CONFIRMED BYTE-EXACT (blob == HEAD blob, git diff HEAD empty, anchor 1, emits 7)

Mechanics: the fix was committed first, so the restore leg had a real restore point; the mutation ran under an EXIT INT TERM trap whose paths are absolute (seeded from git rev-parse --show-toplevel, not from a cwd); the mutation is proved by anchored counts of the removed text and a blob hash that differs from HEAD's, never by perl -i's exit code; the restore is proved by git checkout HEAD -- path followed by a blob-hash match against the HEAD blob AND an empty git diff HEAD, never by an exit code. The first attempt of this script refused to run — its precondition read anchor=0 because the bracketed issue tag was being read as a regex character class — and that refusal is the design working: a zero-match mutation would otherwise have produced a fully green "ablation".

No rebuild is involved and that is itself a measurement: the pin file imports the same-package relative ./protocol.js, so vitest compiles src/protocol.ts. An ablation that goes red with no build is what proves it; a dist-resolved one would have stayed green.

Deliberately unchanged

Files, and one beyond the dispatched surface

  • packages/metadata-protocol/src/protocol.ts — the three doors (four call sites), +60 lines, comments included.
  • packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts — new, 18 cases.
  • .changeset/recovery-doors-emit-metadata-mutation.md@objectstack/metadata-protocol: patch.
  • scripts/engine-double-contract.pinned.jsonnot on the dispatched file surface, declared here.check:engine-double-contract reds on a new pin file until the RETAINED ledger records it ("New pinned coverage is GOOD and nothing is wrong with your change — the ledger just has to learn about it, or it never protects this file"). Regenerated with that gate's own --write: 3 rows added, all naming the new test file, 0 lost, no other row touched. Reusing an existing double instead was not available — the file is new, so it has no double to override.

No existing sibling pin needed updating: the whole @objectstack/metadata-protocol suite is green unchanged (2125 passed), including the write-through-spelling call-site count, the cluster fan-out arms and the delete-rewrap envelope pins.

Gate union, run on e60660c3a

Derived on the real change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands after the last commit — 44 commands, 7 more than the dispatch's list because the ledger file above joined the diff (agent-test-spelling, bash32-floor, cli-command-ids, entry-guard, parse-guard, pnpm-filter-targets, watch-hint-literal; all seven PASS). Exit codes captured after a redirect, never through a pipe.

  • pnpm --filter @objectstack/metadata-protocol test — PASS, 154 files passed / 2 skipped, 2125 tests passed / 10 skipped.
  • pnpm --filter @objectstack/metadata-protocol typecheck — PASS (tsc --noEmit). The new pin file is inside that program: tsc --listFilesOnly lists it, 1 hit among 699 files.
  • pnpm lint — PASS over the whole repo in 68s (eslint . --no-inline-config), so no narrowing was needed and none is claimed.
  • pnpm check:nul-bytes — PASS (7859 files scanned), plus a manual control-character scan over the four changed paths: zero hits.
  • The 44-command union: 40 PASS, 4 NOT MEASURED, 0 findings.

The four NOT MEASURED are exit-3 PREREQUISITE-NOT-MET, recorded with the gates' own words and read as neither pass nor finding — every one needs an input a per-package local run does not have, and CI supplies all four:

gateits own verdict
check-test-completeness.mjs"pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED"
pm/check-half-states.mjsexit 3, an unread instrument (needs the GitHub board) — "never as a quiet board"
check:dual-build-cjs-loads"PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/… Run pnpm build first. This is NOT a pass"
check:type-check-debt"Build the closure first, exactly as lint.yml does… This is NOT a pass and NOT a finding"

Merge state

main landed 5 commits since this branch's base (5c9e40ad9 to 4485f7dad): CLI, lint, docs and three merge-driver scripts. None touches packages/metadata-protocol or packages/spec, so the AGENTS.md §10 re-check is scoped out rather than skipped, and the merge queue validates the merge commit itself. No other in-flight claude/issue-14* branch on origin touches protocol.ts (24 branches fetched and diffed against their merge-base at PR time), so the serial lane the dispatch declared is still clear.

Not this card

#13609 (a deleted datasource prolonging cluster-wide) is a different defect and is not addressed here: datasource carries allowOrgOverride: false, allowRuntimeCreate: true, so useRepoPath is true and it takes the repository exit, which has emitted since #2588. This branch changes nothing about it, and that card stays open on its own premise.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code


Generated by Claude Code

…ry doors (#14179)
rollbackMetaItem, revertCommit (both limbs) and deleteMetaItem's legacy
raw-engine exit mutate live state and never reached emitMetadataMutation —
the documented choke point and, since #13331, the cluster publish point. Each
now announces after its write, with the same org scope and singular type key
the write used. Row-absent exits stay silent; the cluster receive path is
untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… id that a default parameter swallows (#14179)
A default parameter fires on an explicitly passed `undefined`, so the harness's
bootstrap-mode cases were built on an environment boot and measured the
two-tier gate's 403 rather than the legacy exit. The factory now takes a named
mode and asserts the topology it constructed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…re running it (#14179)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… ledger (#14179)
`check:engine-double-contract` reds until the ledger learns about a file that
pins engine doubles, or the pin never protects it. Regenerated with
`--write`: 3 rows added, all naming the new test file, 0 lost.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 7 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx(via meta.rollbackItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/rollback, selected by route anchor /:type/:name/rollback), rollbackItem (sdk, the bare tail of client method meta.rollbackItem, bound to POST /api/v1/meta/:type/:name/rollback))
  • content/docs/kernel/contracts/metadata-service.mdx(via /:type/:name/rollback (route, bridged from symbol rollbackMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via deleteMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
What this run could not see
  • 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 — 8 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 41aa979295be112cc6c27fcd6cc6c5ac20c519d5packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2431d3164baf7f9401f96757f5f0beb6e663570 — the merge of head e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 into base 41aa979295be112cc6c27fcd6cc6c5ac20c519d5, 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 b2431d3164baf7f9401f96757f5f0beb6e663570 && git checkout b2431d3164baf7f9401f96757f5f0beb6e663570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 && git checkout -B drift-repro 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 && git merge --no-ff e60660c3a62281b5ef3edf397f1b2f50d4d3ec44
node scripts/docs-audit/affected-docs.mjs --json 41aa979295be112cc6c27fcd6cc6c5ac20c519d5

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

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14179 (comment 5504761434) → at 05:32Z every check run on head e60660c3a was completed with success or skipped (33 runs; Lint & Repo Gates finished 05:31Z), governed-surface test on the four changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


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/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point - #14411

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation
Sep 2, 2026
Merged

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point#14411
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14179

emitMetadataMutation documents itself as "the ONE choke point every authoring surface funnels through", and since the #13331 fan-out landed it is also the metadata.mutated cluster publish point. Three live write paths never reached it. Each now emits after its write, with the same org scope the write received and the same singular type key the registry was written under — mirroring the emitting siblings rather than inventing an event shape.

Verified at e60660c3a (the branch head this description describes; the gate union below was run on that same commit).

The three doors

doorreached bybeforeafterpinned by
rollbackMetaItem, after its registry write-throughPOST /api/v1/meta/:type/:name/rollbackwrite-through, no emitemitMetadataMutation({ type: singularType, name, state: 'active', organizationId: orgId })new file, §2 "announces the restored body on the mutation choke point" + "folds a PLURAL request type…"
revertCommit — RESTORE limb, after the per-item write-throughPOST /packages/:id/commits/:commitId/revertwrite-through, no emitsame event with PLURAL_TO_SINGULAR[it.type] ?? it.type, state: 'active', per-item itemOrgIdnew file, §2 "RESTORE limb announces the pre-commit body it wrote back" + the mixed-batch case
revertCommit — SOFT-REMOVE limb, after repo.delete + restoreArtifactRegistryViewsame door, items the commit createddelete + heal, no emitsame key/scope, state: 'deleted', gated on a row actually having been removednew file, §2 "SOFT-REMOVE limb announces a deleted…" + §4's already-absent negative control
deleteMetaItem legacy raw-engine exit (the non-useRepoPath side)DELETE /api/v1/meta/:type/:name on a control-plane bootstrap kernelengine.delete + optional storage drop + heal, no emitmirrors the repository twin exactly: state: 'deleted', singularTypeForRepo, request.organizationId ?? nullnew file, §2 "announces the deletion it really performs" + "carries the org scope the delete predicate used"

Four call sites, three doors — revertCommit's two limbs are one door with two exits, exactly as the card's inventory counts it. this.emitMetadataMutation( call sites go 3 to 7; applyRegistryWriteThrough call sites stay at 5, so protocol.object-registry-write-through-spelling.test.ts's closed-caller-set pin is untouched and green.

Premise, re-measured

At the branch base 5c9e40ad9: git grep -n "applyRegistryWriteThrough(" -- packages/metadata-protocol/src/protocol.ts returns 6 hits, one of them the declaration at :13660 — FIVE call sites; the same grep for emitMetadataMutation( returns 4, one of them the declaration at :4962 — THREE call sites. The card's count holds.

One correction to the card's attribution of the fifth write-through: it sits inside applyRemoteMetadataMutation (the #13331 RECEIVE-side applier), not inside publishMetadataMutation (the publisher, which makes no write-through call at all). The substance is unchanged — it is the receive side, and it is deliberately not a door.

Reachability of the legacy delete exit — the NOT MEASURED item, now measured

The unblock comment recorded that it had not verified the branch's own docblock claim ("only reachable in control-plane bootstrap mode where environmentId is undefined"), noting correctly that useRepoPath does not itself test environmentId.

Measured: the claim holds, by a mechanism the docblock does not name. What confines the branch is the two-tier delete authorization above it, which runs only when environmentId !== undefined and refuses both limbs for exactly the types that would reach the legacy path:

  • artifact-backed: artifactBacked AND NOT overlayAllowed AND NOT legacyOverlayRemoval produces NOT_OVERRIDABLE 403
  • not artifact-backed: NOT artifactBacked AND NOT overlayAllowed AND NOT runtimeCreateAllowed produces NOT_CREATABLE 403

The #6960 carve-out is the only way past the first limb and it cannot apply: it reads supportsOverlay, and the useRepoPath === false set is exactly agent, api, capability, field, job — five of the registry's 27 entries, none of which carries supportsOverlay: true. Both 403s are driven end to end in §3, and the registry property itself is pinned there too, so a registry edit that opens the door has to come past this file.

So door 4's exposure is narrower than the comment feared: unreachable through the public deleteMetaItem on a normal boot. That is why its repair is pinned under bootstrap mode — which is where the defect is real, and where the registry every organization shares is the one being healed. The OS_METADATA_WRITABLE escape hatch only moves a type ONTO the repository (emitting) path, never onto this one; the harness clears its memoised cache so that cannot silently change what is measured.

The receive path stays silent — cited, not duplicated

Adding an emit inside applyRemoteMetadataMutation would re-broadcast every received event and ping-pong across replicas, which the loopback guard (own-messages only) cannot stop. That negative is already pinned in protocol.cluster-mutation-fanout.test.ts, in "the peer's onMetadataMutation listeners receive the remote event, once, after convergence": expect(writerSeen).toHaveLength(1), commented "the remote replay stays local to the receiving node (no echo)". A peer that re-emitted would re-publish, the writer would apply that peer-origin message past its own loopback guard, and that assertion would read 2. It is cited rather than re-built.

Alongside it, §5 of the new file carries three structural pins that make the same claim locally and cheaply: the emitMetadataMutation call-site count is 7, notifyMutationListenersLocal keeps exactly its two declared callers (the choke point and the receive path), and applyRemoteMetadataMutation's body contains notifyMutationListenersLocal and does not contain emitMetadataMutation.

The cluster half is inherited, not re-measured

emitMetadataMutation is two lines — the local fan-out, then publishMetadataMutation — so a door that reaches the choke point reaches the publisher. protocol.cluster-mutation-fanout.test.ts already proves that end to end over a two-replica pub/sub double (Arm B, "a runtime-authored object registers on the PEER after the writer's save", with Arm A as the no-bridge control). Combined with §5's call-site count, the new doors inherit the cluster leg structurally. Building a second cluster harness here would re-measure that file's subject rather than this card's, so it is not built.

Ablation — direction declared before the run, in the committed test header

Declared (commit 43640d6c6, before running): removing door 1's emit from protocol.ts turns RED both of §2's rollback cases and §5's call-site count, and leaves everything else GREEN — 3 RED / 15 GREEN of 18.

Measured, exactly that:

BEFORE: anchor=1 emit-call-sites=7
AFTER: anchor=0 emit-call-sites=6 blob=ba5fa53549caa6a282bb325a2ae93f62a47fcdc6
MUTATION CONFIRMED ON DISK (anchor 1->0, emit sites 7->6, blob differs from HEAD)
x announces the restored body on the mutation choke point
x folds a PLURAL request type to the singular the write-through registered under
x has exactly seven `emitMetadataMutation` call sites
Tests 3 failed | 15 passed (18)
RESTORE: blob=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 head=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 anchor=1 emits=7 diff-bytes=0
RESTORE CONFIRMED BYTE-EXACT (blob == HEAD blob, git diff HEAD empty, anchor 1, emits 7)

Mechanics: the fix was committed first, so the restore leg had a real restore point; the mutation ran under an EXIT INT TERM trap whose paths are absolute (seeded from git rev-parse --show-toplevel, not from a cwd); the mutation is proved by anchored counts of the removed text and a blob hash that differs from HEAD's, never by perl -i's exit code; the restore is proved by git checkout HEAD -- path followed by a blob-hash match against the HEAD blob AND an empty git diff HEAD, never by an exit code. The first attempt of this script refused to run — its precondition read anchor=0 because the bracketed issue tag was being read as a regex character class — and that refusal is the design working: a zero-match mutation would otherwise have produced a fully green "ablation".

No rebuild is involved and that is itself a measurement: the pin file imports the same-package relative ./protocol.js, so vitest compiles src/protocol.ts. An ablation that goes red with no build is what proves it; a dist-resolved one would have stayed green.

Deliberately unchanged

Files, and one beyond the dispatched surface

  • packages/metadata-protocol/src/protocol.ts — the three doors (four call sites), +60 lines, comments included.
  • packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts — new, 18 cases.
  • .changeset/recovery-doors-emit-metadata-mutation.md@objectstack/metadata-protocol: patch.
  • scripts/engine-double-contract.pinned.jsonnot on the dispatched file surface, declared here.check:engine-double-contract reds on a new pin file until the RETAINED ledger records it ("New pinned coverage is GOOD and nothing is wrong with your change — the ledger just has to learn about it, or it never protects this file"). Regenerated with that gate's own --write: 3 rows added, all naming the new test file, 0 lost, no other row touched. Reusing an existing double instead was not available — the file is new, so it has no double to override.

No existing sibling pin needed updating: the whole @objectstack/metadata-protocol suite is green unchanged (2125 passed), including the write-through-spelling call-site count, the cluster fan-out arms and the delete-rewrap envelope pins.

Gate union, run on e60660c3a

Derived on the real change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands after the last commit — 44 commands, 7 more than the dispatch's list because the ledger file above joined the diff (agent-test-spelling, bash32-floor, cli-command-ids, entry-guard, parse-guard, pnpm-filter-targets, watch-hint-literal; all seven PASS). Exit codes captured after a redirect, never through a pipe.

  • pnpm --filter @objectstack/metadata-protocol test — PASS, 154 files passed / 2 skipped, 2125 tests passed / 10 skipped.
  • pnpm --filter @objectstack/metadata-protocol typecheck — PASS (tsc --noEmit). The new pin file is inside that program: tsc --listFilesOnly lists it, 1 hit among 699 files.
  • pnpm lint — PASS over the whole repo in 68s (eslint . --no-inline-config), so no narrowing was needed and none is claimed.
  • pnpm check:nul-bytes — PASS (7859 files scanned), plus a manual control-character scan over the four changed paths: zero hits.
  • The 44-command union: 40 PASS, 4 NOT MEASURED, 0 findings.

The four NOT MEASURED are exit-3 PREREQUISITE-NOT-MET, recorded with the gates' own words and read as neither pass nor finding — every one needs an input a per-package local run does not have, and CI supplies all four:

gateits own verdict
check-test-completeness.mjs"pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED"
pm/check-half-states.mjsexit 3, an unread instrument (needs the GitHub board) — "never as a quiet board"
check:dual-build-cjs-loads"PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/… Run pnpm build first. This is NOT a pass"
check:type-check-debt"Build the closure first, exactly as lint.yml does… This is NOT a pass and NOT a finding"

Merge state

main landed 5 commits since this branch's base (5c9e40ad9 to 4485f7dad): CLI, lint, docs and three merge-driver scripts. None touches packages/metadata-protocol or packages/spec, so the AGENTS.md §10 re-check is scoped out rather than skipped, and the merge queue validates the merge commit itself. No other in-flight claude/issue-14* branch on origin touches protocol.ts (24 branches fetched and diffed against their merge-base at PR time), so the serial lane the dispatch declared is still clear.

Not this card

#13609 (a deleted datasource prolonging cluster-wide) is a different defect and is not addressed here: datasource carries allowOrgOverride: false, allowRuntimeCreate: true, so useRepoPath is true and it takes the repository exit, which has emitted since #2588. This branch changes nothing about it, and that card stays open on its own premise.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code


Generated by Claude Code

…ry doors (#14179)
rollbackMetaItem, revertCommit (both limbs) and deleteMetaItem's legacy
raw-engine exit mutate live state and never reached emitMetadataMutation —
the documented choke point and, since #13331, the cluster publish point. Each
now announces after its write, with the same org scope and singular type key
the write used. Row-absent exits stay silent; the cluster receive path is
untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… id that a default parameter swallows (#14179)
A default parameter fires on an explicitly passed `undefined`, so the harness's
bootstrap-mode cases were built on an environment boot and measured the
two-tier gate's 403 rather than the legacy exit. The factory now takes a named
mode and asserts the topology it constructed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…re running it (#14179)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… ledger (#14179)
`check:engine-double-contract` reds until the ledger learns about a file that
pins engine doubles, or the pin never protects it. Regenerated with
`--write`: 3 rows added, all naming the new test file, 0 lost.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 7 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx(via meta.rollbackItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/rollback, selected by route anchor /:type/:name/rollback), rollbackItem (sdk, the bare tail of client method meta.rollbackItem, bound to POST /api/v1/meta/:type/:name/rollback))
  • content/docs/kernel/contracts/metadata-service.mdx(via /:type/:name/rollback (route, bridged from symbol rollbackMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via deleteMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
What this run could not see
  • 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 — 8 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 41aa979295be112cc6c27fcd6cc6c5ac20c519d5packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2431d3164baf7f9401f96757f5f0beb6e663570 — the merge of head e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 into base 41aa979295be112cc6c27fcd6cc6c5ac20c519d5, 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 b2431d3164baf7f9401f96757f5f0beb6e663570 && git checkout b2431d3164baf7f9401f96757f5f0beb6e663570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 && git checkout -B drift-repro 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 && git merge --no-ff e60660c3a62281b5ef3edf397f1b2f50d4d3ec44
node scripts/docs-audit/affected-docs.mjs --json 41aa979295be112cc6c27fcd6cc6c5ac20c519d5

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

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14179 (comment 5504761434) → at 05:32Z every check run on head e60660c3a was completed with success or skipped (33 runs; Lint & Repo Gates finished 05:31Z), governed-surface test on the four changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


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/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point - #14411

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation
Sep 2, 2026
Merged

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point#14411
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14179

emitMetadataMutation documents itself as "the ONE choke point every authoring surface funnels through", and since the #13331 fan-out landed it is also the metadata.mutated cluster publish point. Three live write paths never reached it. Each now emits after its write, with the same org scope the write received and the same singular type key the registry was written under — mirroring the emitting siblings rather than inventing an event shape.

Verified at e60660c3a (the branch head this description describes; the gate union below was run on that same commit).

The three doors

doorreached bybeforeafterpinned by
rollbackMetaItem, after its registry write-throughPOST /api/v1/meta/:type/:name/rollbackwrite-through, no emitemitMetadataMutation({ type: singularType, name, state: 'active', organizationId: orgId })new file, §2 "announces the restored body on the mutation choke point" + "folds a PLURAL request type…"
revertCommit — RESTORE limb, after the per-item write-throughPOST /packages/:id/commits/:commitId/revertwrite-through, no emitsame event with PLURAL_TO_SINGULAR[it.type] ?? it.type, state: 'active', per-item itemOrgIdnew file, §2 "RESTORE limb announces the pre-commit body it wrote back" + the mixed-batch case
revertCommit — SOFT-REMOVE limb, after repo.delete + restoreArtifactRegistryViewsame door, items the commit createddelete + heal, no emitsame key/scope, state: 'deleted', gated on a row actually having been removednew file, §2 "SOFT-REMOVE limb announces a deleted…" + §4's already-absent negative control
deleteMetaItem legacy raw-engine exit (the non-useRepoPath side)DELETE /api/v1/meta/:type/:name on a control-plane bootstrap kernelengine.delete + optional storage drop + heal, no emitmirrors the repository twin exactly: state: 'deleted', singularTypeForRepo, request.organizationId ?? nullnew file, §2 "announces the deletion it really performs" + "carries the org scope the delete predicate used"

Four call sites, three doors — revertCommit's two limbs are one door with two exits, exactly as the card's inventory counts it. this.emitMetadataMutation( call sites go 3 to 7; applyRegistryWriteThrough call sites stay at 5, so protocol.object-registry-write-through-spelling.test.ts's closed-caller-set pin is untouched and green.

Premise, re-measured

At the branch base 5c9e40ad9: git grep -n "applyRegistryWriteThrough(" -- packages/metadata-protocol/src/protocol.ts returns 6 hits, one of them the declaration at :13660 — FIVE call sites; the same grep for emitMetadataMutation( returns 4, one of them the declaration at :4962 — THREE call sites. The card's count holds.

One correction to the card's attribution of the fifth write-through: it sits inside applyRemoteMetadataMutation (the #13331 RECEIVE-side applier), not inside publishMetadataMutation (the publisher, which makes no write-through call at all). The substance is unchanged — it is the receive side, and it is deliberately not a door.

Reachability of the legacy delete exit — the NOT MEASURED item, now measured

The unblock comment recorded that it had not verified the branch's own docblock claim ("only reachable in control-plane bootstrap mode where environmentId is undefined"), noting correctly that useRepoPath does not itself test environmentId.

Measured: the claim holds, by a mechanism the docblock does not name. What confines the branch is the two-tier delete authorization above it, which runs only when environmentId !== undefined and refuses both limbs for exactly the types that would reach the legacy path:

  • artifact-backed: artifactBacked AND NOT overlayAllowed AND NOT legacyOverlayRemoval produces NOT_OVERRIDABLE 403
  • not artifact-backed: NOT artifactBacked AND NOT overlayAllowed AND NOT runtimeCreateAllowed produces NOT_CREATABLE 403

The #6960 carve-out is the only way past the first limb and it cannot apply: it reads supportsOverlay, and the useRepoPath === false set is exactly agent, api, capability, field, job — five of the registry's 27 entries, none of which carries supportsOverlay: true. Both 403s are driven end to end in §3, and the registry property itself is pinned there too, so a registry edit that opens the door has to come past this file.

So door 4's exposure is narrower than the comment feared: unreachable through the public deleteMetaItem on a normal boot. That is why its repair is pinned under bootstrap mode — which is where the defect is real, and where the registry every organization shares is the one being healed. The OS_METADATA_WRITABLE escape hatch only moves a type ONTO the repository (emitting) path, never onto this one; the harness clears its memoised cache so that cannot silently change what is measured.

The receive path stays silent — cited, not duplicated

Adding an emit inside applyRemoteMetadataMutation would re-broadcast every received event and ping-pong across replicas, which the loopback guard (own-messages only) cannot stop. That negative is already pinned in protocol.cluster-mutation-fanout.test.ts, in "the peer's onMetadataMutation listeners receive the remote event, once, after convergence": expect(writerSeen).toHaveLength(1), commented "the remote replay stays local to the receiving node (no echo)". A peer that re-emitted would re-publish, the writer would apply that peer-origin message past its own loopback guard, and that assertion would read 2. It is cited rather than re-built.

Alongside it, §5 of the new file carries three structural pins that make the same claim locally and cheaply: the emitMetadataMutation call-site count is 7, notifyMutationListenersLocal keeps exactly its two declared callers (the choke point and the receive path), and applyRemoteMetadataMutation's body contains notifyMutationListenersLocal and does not contain emitMetadataMutation.

The cluster half is inherited, not re-measured

emitMetadataMutation is two lines — the local fan-out, then publishMetadataMutation — so a door that reaches the choke point reaches the publisher. protocol.cluster-mutation-fanout.test.ts already proves that end to end over a two-replica pub/sub double (Arm B, "a runtime-authored object registers on the PEER after the writer's save", with Arm A as the no-bridge control). Combined with §5's call-site count, the new doors inherit the cluster leg structurally. Building a second cluster harness here would re-measure that file's subject rather than this card's, so it is not built.

Ablation — direction declared before the run, in the committed test header

Declared (commit 43640d6c6, before running): removing door 1's emit from protocol.ts turns RED both of §2's rollback cases and §5's call-site count, and leaves everything else GREEN — 3 RED / 15 GREEN of 18.

Measured, exactly that:

BEFORE: anchor=1 emit-call-sites=7
AFTER: anchor=0 emit-call-sites=6 blob=ba5fa53549caa6a282bb325a2ae93f62a47fcdc6
MUTATION CONFIRMED ON DISK (anchor 1->0, emit sites 7->6, blob differs from HEAD)
x announces the restored body on the mutation choke point
x folds a PLURAL request type to the singular the write-through registered under
x has exactly seven `emitMetadataMutation` call sites
Tests 3 failed | 15 passed (18)
RESTORE: blob=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 head=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 anchor=1 emits=7 diff-bytes=0
RESTORE CONFIRMED BYTE-EXACT (blob == HEAD blob, git diff HEAD empty, anchor 1, emits 7)

Mechanics: the fix was committed first, so the restore leg had a real restore point; the mutation ran under an EXIT INT TERM trap whose paths are absolute (seeded from git rev-parse --show-toplevel, not from a cwd); the mutation is proved by anchored counts of the removed text and a blob hash that differs from HEAD's, never by perl -i's exit code; the restore is proved by git checkout HEAD -- path followed by a blob-hash match against the HEAD blob AND an empty git diff HEAD, never by an exit code. The first attempt of this script refused to run — its precondition read anchor=0 because the bracketed issue tag was being read as a regex character class — and that refusal is the design working: a zero-match mutation would otherwise have produced a fully green "ablation".

No rebuild is involved and that is itself a measurement: the pin file imports the same-package relative ./protocol.js, so vitest compiles src/protocol.ts. An ablation that goes red with no build is what proves it; a dist-resolved one would have stayed green.

Deliberately unchanged

Files, and one beyond the dispatched surface

  • packages/metadata-protocol/src/protocol.ts — the three doors (four call sites), +60 lines, comments included.
  • packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts — new, 18 cases.
  • .changeset/recovery-doors-emit-metadata-mutation.md@objectstack/metadata-protocol: patch.
  • scripts/engine-double-contract.pinned.jsonnot on the dispatched file surface, declared here.check:engine-double-contract reds on a new pin file until the RETAINED ledger records it ("New pinned coverage is GOOD and nothing is wrong with your change — the ledger just has to learn about it, or it never protects this file"). Regenerated with that gate's own --write: 3 rows added, all naming the new test file, 0 lost, no other row touched. Reusing an existing double instead was not available — the file is new, so it has no double to override.

No existing sibling pin needed updating: the whole @objectstack/metadata-protocol suite is green unchanged (2125 passed), including the write-through-spelling call-site count, the cluster fan-out arms and the delete-rewrap envelope pins.

Gate union, run on e60660c3a

Derived on the real change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands after the last commit — 44 commands, 7 more than the dispatch's list because the ledger file above joined the diff (agent-test-spelling, bash32-floor, cli-command-ids, entry-guard, parse-guard, pnpm-filter-targets, watch-hint-literal; all seven PASS). Exit codes captured after a redirect, never through a pipe.

  • pnpm --filter @objectstack/metadata-protocol test — PASS, 154 files passed / 2 skipped, 2125 tests passed / 10 skipped.
  • pnpm --filter @objectstack/metadata-protocol typecheck — PASS (tsc --noEmit). The new pin file is inside that program: tsc --listFilesOnly lists it, 1 hit among 699 files.
  • pnpm lint — PASS over the whole repo in 68s (eslint . --no-inline-config), so no narrowing was needed and none is claimed.
  • pnpm check:nul-bytes — PASS (7859 files scanned), plus a manual control-character scan over the four changed paths: zero hits.
  • The 44-command union: 40 PASS, 4 NOT MEASURED, 0 findings.

The four NOT MEASURED are exit-3 PREREQUISITE-NOT-MET, recorded with the gates' own words and read as neither pass nor finding — every one needs an input a per-package local run does not have, and CI supplies all four:

gateits own verdict
check-test-completeness.mjs"pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED"
pm/check-half-states.mjsexit 3, an unread instrument (needs the GitHub board) — "never as a quiet board"
check:dual-build-cjs-loads"PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/… Run pnpm build first. This is NOT a pass"
check:type-check-debt"Build the closure first, exactly as lint.yml does… This is NOT a pass and NOT a finding"

Merge state

main landed 5 commits since this branch's base (5c9e40ad9 to 4485f7dad): CLI, lint, docs and three merge-driver scripts. None touches packages/metadata-protocol or packages/spec, so the AGENTS.md §10 re-check is scoped out rather than skipped, and the merge queue validates the merge commit itself. No other in-flight claude/issue-14* branch on origin touches protocol.ts (24 branches fetched and diffed against their merge-base at PR time), so the serial lane the dispatch declared is still clear.

Not this card

#13609 (a deleted datasource prolonging cluster-wide) is a different defect and is not addressed here: datasource carries allowOrgOverride: false, allowRuntimeCreate: true, so useRepoPath is true and it takes the repository exit, which has emitted since #2588. This branch changes nothing about it, and that card stays open on its own premise.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code


Generated by Claude Code

…ry doors (#14179)
rollbackMetaItem, revertCommit (both limbs) and deleteMetaItem's legacy
raw-engine exit mutate live state and never reached emitMetadataMutation —
the documented choke point and, since #13331, the cluster publish point. Each
now announces after its write, with the same org scope and singular type key
the write used. Row-absent exits stay silent; the cluster receive path is
untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… id that a default parameter swallows (#14179)
A default parameter fires on an explicitly passed `undefined`, so the harness's
bootstrap-mode cases were built on an environment boot and measured the
two-tier gate's 403 rather than the legacy exit. The factory now takes a named
mode and asserts the topology it constructed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…re running it (#14179)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… ledger (#14179)
`check:engine-double-contract` reds until the ledger learns about a file that
pins engine doubles, or the pin never protects it. Regenerated with
`--write`: 3 rows added, all naming the new test file, 0 lost.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 7 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx(via meta.rollbackItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/rollback, selected by route anchor /:type/:name/rollback), rollbackItem (sdk, the bare tail of client method meta.rollbackItem, bound to POST /api/v1/meta/:type/:name/rollback))
  • content/docs/kernel/contracts/metadata-service.mdx(via /:type/:name/rollback (route, bridged from symbol rollbackMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via deleteMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
What this run could not see
  • 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 — 8 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 41aa979295be112cc6c27fcd6cc6c5ac20c519d5packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2431d3164baf7f9401f96757f5f0beb6e663570 — the merge of head e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 into base 41aa979295be112cc6c27fcd6cc6c5ac20c519d5, 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 b2431d3164baf7f9401f96757f5f0beb6e663570 && git checkout b2431d3164baf7f9401f96757f5f0beb6e663570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 && git checkout -B drift-repro 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 && git merge --no-ff e60660c3a62281b5ef3edf397f1b2f50d4d3ec44
node scripts/docs-audit/affected-docs.mjs --json 41aa979295be112cc6c27fcd6cc6c5ac20c519d5

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

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14179 (comment 5504761434) → at 05:32Z every check run on head e60660c3a was completed with success or skipped (33 runs; Lint & Repo Gates finished 05:31Z), governed-surface test on the four changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


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/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point - #14411

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation
Sep 2, 2026
Merged

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point#14411
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14179

emitMetadataMutation documents itself as "the ONE choke point every authoring surface funnels through", and since the #13331 fan-out landed it is also the metadata.mutated cluster publish point. Three live write paths never reached it. Each now emits after its write, with the same org scope the write received and the same singular type key the registry was written under — mirroring the emitting siblings rather than inventing an event shape.

Verified at e60660c3a (the branch head this description describes; the gate union below was run on that same commit).

The three doors

doorreached bybeforeafterpinned by
rollbackMetaItem, after its registry write-throughPOST /api/v1/meta/:type/:name/rollbackwrite-through, no emitemitMetadataMutation({ type: singularType, name, state: 'active', organizationId: orgId })new file, §2 "announces the restored body on the mutation choke point" + "folds a PLURAL request type…"
revertCommit — RESTORE limb, after the per-item write-throughPOST /packages/:id/commits/:commitId/revertwrite-through, no emitsame event with PLURAL_TO_SINGULAR[it.type] ?? it.type, state: 'active', per-item itemOrgIdnew file, §2 "RESTORE limb announces the pre-commit body it wrote back" + the mixed-batch case
revertCommit — SOFT-REMOVE limb, after repo.delete + restoreArtifactRegistryViewsame door, items the commit createddelete + heal, no emitsame key/scope, state: 'deleted', gated on a row actually having been removednew file, §2 "SOFT-REMOVE limb announces a deleted…" + §4's already-absent negative control
deleteMetaItem legacy raw-engine exit (the non-useRepoPath side)DELETE /api/v1/meta/:type/:name on a control-plane bootstrap kernelengine.delete + optional storage drop + heal, no emitmirrors the repository twin exactly: state: 'deleted', singularTypeForRepo, request.organizationId ?? nullnew file, §2 "announces the deletion it really performs" + "carries the org scope the delete predicate used"

Four call sites, three doors — revertCommit's two limbs are one door with two exits, exactly as the card's inventory counts it. this.emitMetadataMutation( call sites go 3 to 7; applyRegistryWriteThrough call sites stay at 5, so protocol.object-registry-write-through-spelling.test.ts's closed-caller-set pin is untouched and green.

Premise, re-measured

At the branch base 5c9e40ad9: git grep -n "applyRegistryWriteThrough(" -- packages/metadata-protocol/src/protocol.ts returns 6 hits, one of them the declaration at :13660 — FIVE call sites; the same grep for emitMetadataMutation( returns 4, one of them the declaration at :4962 — THREE call sites. The card's count holds.

One correction to the card's attribution of the fifth write-through: it sits inside applyRemoteMetadataMutation (the #13331 RECEIVE-side applier), not inside publishMetadataMutation (the publisher, which makes no write-through call at all). The substance is unchanged — it is the receive side, and it is deliberately not a door.

Reachability of the legacy delete exit — the NOT MEASURED item, now measured

The unblock comment recorded that it had not verified the branch's own docblock claim ("only reachable in control-plane bootstrap mode where environmentId is undefined"), noting correctly that useRepoPath does not itself test environmentId.

Measured: the claim holds, by a mechanism the docblock does not name. What confines the branch is the two-tier delete authorization above it, which runs only when environmentId !== undefined and refuses both limbs for exactly the types that would reach the legacy path:

  • artifact-backed: artifactBacked AND NOT overlayAllowed AND NOT legacyOverlayRemoval produces NOT_OVERRIDABLE 403
  • not artifact-backed: NOT artifactBacked AND NOT overlayAllowed AND NOT runtimeCreateAllowed produces NOT_CREATABLE 403

The #6960 carve-out is the only way past the first limb and it cannot apply: it reads supportsOverlay, and the useRepoPath === false set is exactly agent, api, capability, field, job — five of the registry's 27 entries, none of which carries supportsOverlay: true. Both 403s are driven end to end in §3, and the registry property itself is pinned there too, so a registry edit that opens the door has to come past this file.

So door 4's exposure is narrower than the comment feared: unreachable through the public deleteMetaItem on a normal boot. That is why its repair is pinned under bootstrap mode — which is where the defect is real, and where the registry every organization shares is the one being healed. The OS_METADATA_WRITABLE escape hatch only moves a type ONTO the repository (emitting) path, never onto this one; the harness clears its memoised cache so that cannot silently change what is measured.

The receive path stays silent — cited, not duplicated

Adding an emit inside applyRemoteMetadataMutation would re-broadcast every received event and ping-pong across replicas, which the loopback guard (own-messages only) cannot stop. That negative is already pinned in protocol.cluster-mutation-fanout.test.ts, in "the peer's onMetadataMutation listeners receive the remote event, once, after convergence": expect(writerSeen).toHaveLength(1), commented "the remote replay stays local to the receiving node (no echo)". A peer that re-emitted would re-publish, the writer would apply that peer-origin message past its own loopback guard, and that assertion would read 2. It is cited rather than re-built.

Alongside it, §5 of the new file carries three structural pins that make the same claim locally and cheaply: the emitMetadataMutation call-site count is 7, notifyMutationListenersLocal keeps exactly its two declared callers (the choke point and the receive path), and applyRemoteMetadataMutation's body contains notifyMutationListenersLocal and does not contain emitMetadataMutation.

The cluster half is inherited, not re-measured

emitMetadataMutation is two lines — the local fan-out, then publishMetadataMutation — so a door that reaches the choke point reaches the publisher. protocol.cluster-mutation-fanout.test.ts already proves that end to end over a two-replica pub/sub double (Arm B, "a runtime-authored object registers on the PEER after the writer's save", with Arm A as the no-bridge control). Combined with §5's call-site count, the new doors inherit the cluster leg structurally. Building a second cluster harness here would re-measure that file's subject rather than this card's, so it is not built.

Ablation — direction declared before the run, in the committed test header

Declared (commit 43640d6c6, before running): removing door 1's emit from protocol.ts turns RED both of §2's rollback cases and §5's call-site count, and leaves everything else GREEN — 3 RED / 15 GREEN of 18.

Measured, exactly that:

BEFORE: anchor=1 emit-call-sites=7
AFTER: anchor=0 emit-call-sites=6 blob=ba5fa53549caa6a282bb325a2ae93f62a47fcdc6
MUTATION CONFIRMED ON DISK (anchor 1->0, emit sites 7->6, blob differs from HEAD)
x announces the restored body on the mutation choke point
x folds a PLURAL request type to the singular the write-through registered under
x has exactly seven `emitMetadataMutation` call sites
Tests 3 failed | 15 passed (18)
RESTORE: blob=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 head=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 anchor=1 emits=7 diff-bytes=0
RESTORE CONFIRMED BYTE-EXACT (blob == HEAD blob, git diff HEAD empty, anchor 1, emits 7)

Mechanics: the fix was committed first, so the restore leg had a real restore point; the mutation ran under an EXIT INT TERM trap whose paths are absolute (seeded from git rev-parse --show-toplevel, not from a cwd); the mutation is proved by anchored counts of the removed text and a blob hash that differs from HEAD's, never by perl -i's exit code; the restore is proved by git checkout HEAD -- path followed by a blob-hash match against the HEAD blob AND an empty git diff HEAD, never by an exit code. The first attempt of this script refused to run — its precondition read anchor=0 because the bracketed issue tag was being read as a regex character class — and that refusal is the design working: a zero-match mutation would otherwise have produced a fully green "ablation".

No rebuild is involved and that is itself a measurement: the pin file imports the same-package relative ./protocol.js, so vitest compiles src/protocol.ts. An ablation that goes red with no build is what proves it; a dist-resolved one would have stayed green.

Deliberately unchanged

Files, and one beyond the dispatched surface

  • packages/metadata-protocol/src/protocol.ts — the three doors (four call sites), +60 lines, comments included.
  • packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts — new, 18 cases.
  • .changeset/recovery-doors-emit-metadata-mutation.md@objectstack/metadata-protocol: patch.
  • scripts/engine-double-contract.pinned.jsonnot on the dispatched file surface, declared here.check:engine-double-contract reds on a new pin file until the RETAINED ledger records it ("New pinned coverage is GOOD and nothing is wrong with your change — the ledger just has to learn about it, or it never protects this file"). Regenerated with that gate's own --write: 3 rows added, all naming the new test file, 0 lost, no other row touched. Reusing an existing double instead was not available — the file is new, so it has no double to override.

No existing sibling pin needed updating: the whole @objectstack/metadata-protocol suite is green unchanged (2125 passed), including the write-through-spelling call-site count, the cluster fan-out arms and the delete-rewrap envelope pins.

Gate union, run on e60660c3a

Derived on the real change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands after the last commit — 44 commands, 7 more than the dispatch's list because the ledger file above joined the diff (agent-test-spelling, bash32-floor, cli-command-ids, entry-guard, parse-guard, pnpm-filter-targets, watch-hint-literal; all seven PASS). Exit codes captured after a redirect, never through a pipe.

  • pnpm --filter @objectstack/metadata-protocol test — PASS, 154 files passed / 2 skipped, 2125 tests passed / 10 skipped.
  • pnpm --filter @objectstack/metadata-protocol typecheck — PASS (tsc --noEmit). The new pin file is inside that program: tsc --listFilesOnly lists it, 1 hit among 699 files.
  • pnpm lint — PASS over the whole repo in 68s (eslint . --no-inline-config), so no narrowing was needed and none is claimed.
  • pnpm check:nul-bytes — PASS (7859 files scanned), plus a manual control-character scan over the four changed paths: zero hits.
  • The 44-command union: 40 PASS, 4 NOT MEASURED, 0 findings.

The four NOT MEASURED are exit-3 PREREQUISITE-NOT-MET, recorded with the gates' own words and read as neither pass nor finding — every one needs an input a per-package local run does not have, and CI supplies all four:

gateits own verdict
check-test-completeness.mjs"pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED"
pm/check-half-states.mjsexit 3, an unread instrument (needs the GitHub board) — "never as a quiet board"
check:dual-build-cjs-loads"PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/… Run pnpm build first. This is NOT a pass"
check:type-check-debt"Build the closure first, exactly as lint.yml does… This is NOT a pass and NOT a finding"

Merge state

main landed 5 commits since this branch's base (5c9e40ad9 to 4485f7dad): CLI, lint, docs and three merge-driver scripts. None touches packages/metadata-protocol or packages/spec, so the AGENTS.md §10 re-check is scoped out rather than skipped, and the merge queue validates the merge commit itself. No other in-flight claude/issue-14* branch on origin touches protocol.ts (24 branches fetched and diffed against their merge-base at PR time), so the serial lane the dispatch declared is still clear.

Not this card

#13609 (a deleted datasource prolonging cluster-wide) is a different defect and is not addressed here: datasource carries allowOrgOverride: false, allowRuntimeCreate: true, so useRepoPath is true and it takes the repository exit, which has emitted since #2588. This branch changes nothing about it, and that card stays open on its own premise.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code


Generated by Claude Code

…ry doors (#14179)
rollbackMetaItem, revertCommit (both limbs) and deleteMetaItem's legacy
raw-engine exit mutate live state and never reached emitMetadataMutation —
the documented choke point and, since #13331, the cluster publish point. Each
now announces after its write, with the same org scope and singular type key
the write used. Row-absent exits stay silent; the cluster receive path is
untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… id that a default parameter swallows (#14179)
A default parameter fires on an explicitly passed `undefined`, so the harness's
bootstrap-mode cases were built on an environment boot and measured the
two-tier gate's 403 rather than the legacy exit. The factory now takes a named
mode and asserts the topology it constructed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…re running it (#14179)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… ledger (#14179)
`check:engine-double-contract` reds until the ledger learns about a file that
pins engine doubles, or the pin never protects it. Regenerated with
`--write`: 3 rows added, all naming the new test file, 0 lost.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 7 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx(via meta.rollbackItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/rollback, selected by route anchor /:type/:name/rollback), rollbackItem (sdk, the bare tail of client method meta.rollbackItem, bound to POST /api/v1/meta/:type/:name/rollback))
  • content/docs/kernel/contracts/metadata-service.mdx(via /:type/:name/rollback (route, bridged from symbol rollbackMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via deleteMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
What this run could not see
  • 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 — 8 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 41aa979295be112cc6c27fcd6cc6c5ac20c519d5packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2431d3164baf7f9401f96757f5f0beb6e663570 — the merge of head e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 into base 41aa979295be112cc6c27fcd6cc6c5ac20c519d5, 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 b2431d3164baf7f9401f96757f5f0beb6e663570 && git checkout b2431d3164baf7f9401f96757f5f0beb6e663570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 && git checkout -B drift-repro 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 && git merge --no-ff e60660c3a62281b5ef3edf397f1b2f50d4d3ec44
node scripts/docs-audit/affected-docs.mjs --json 41aa979295be112cc6c27fcd6cc6c5ac20c519d5

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

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14179 (comment 5504761434) → at 05:32Z every check run on head e60660c3a was completed with success or skipped (33 runs; Lint & Repo Gates finished 05:31Z), governed-surface test on the four changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


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/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point - #14411

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation
Sep 2, 2026
Merged

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point#14411
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14179

emitMetadataMutation documents itself as "the ONE choke point every authoring surface funnels through", and since the #13331 fan-out landed it is also the metadata.mutated cluster publish point. Three live write paths never reached it. Each now emits after its write, with the same org scope the write received and the same singular type key the registry was written under — mirroring the emitting siblings rather than inventing an event shape.

Verified at e60660c3a (the branch head this description describes; the gate union below was run on that same commit).

The three doors

doorreached bybeforeafterpinned by
rollbackMetaItem, after its registry write-throughPOST /api/v1/meta/:type/:name/rollbackwrite-through, no emitemitMetadataMutation({ type: singularType, name, state: 'active', organizationId: orgId })new file, §2 "announces the restored body on the mutation choke point" + "folds a PLURAL request type…"
revertCommit — RESTORE limb, after the per-item write-throughPOST /packages/:id/commits/:commitId/revertwrite-through, no emitsame event with PLURAL_TO_SINGULAR[it.type] ?? it.type, state: 'active', per-item itemOrgIdnew file, §2 "RESTORE limb announces the pre-commit body it wrote back" + the mixed-batch case
revertCommit — SOFT-REMOVE limb, after repo.delete + restoreArtifactRegistryViewsame door, items the commit createddelete + heal, no emitsame key/scope, state: 'deleted', gated on a row actually having been removednew file, §2 "SOFT-REMOVE limb announces a deleted…" + §4's already-absent negative control
deleteMetaItem legacy raw-engine exit (the non-useRepoPath side)DELETE /api/v1/meta/:type/:name on a control-plane bootstrap kernelengine.delete + optional storage drop + heal, no emitmirrors the repository twin exactly: state: 'deleted', singularTypeForRepo, request.organizationId ?? nullnew file, §2 "announces the deletion it really performs" + "carries the org scope the delete predicate used"

Four call sites, three doors — revertCommit's two limbs are one door with two exits, exactly as the card's inventory counts it. this.emitMetadataMutation( call sites go 3 to 7; applyRegistryWriteThrough call sites stay at 5, so protocol.object-registry-write-through-spelling.test.ts's closed-caller-set pin is untouched and green.

Premise, re-measured

At the branch base 5c9e40ad9: git grep -n "applyRegistryWriteThrough(" -- packages/metadata-protocol/src/protocol.ts returns 6 hits, one of them the declaration at :13660 — FIVE call sites; the same grep for emitMetadataMutation( returns 4, one of them the declaration at :4962 — THREE call sites. The card's count holds.

One correction to the card's attribution of the fifth write-through: it sits inside applyRemoteMetadataMutation (the #13331 RECEIVE-side applier), not inside publishMetadataMutation (the publisher, which makes no write-through call at all). The substance is unchanged — it is the receive side, and it is deliberately not a door.

Reachability of the legacy delete exit — the NOT MEASURED item, now measured

The unblock comment recorded that it had not verified the branch's own docblock claim ("only reachable in control-plane bootstrap mode where environmentId is undefined"), noting correctly that useRepoPath does not itself test environmentId.

Measured: the claim holds, by a mechanism the docblock does not name. What confines the branch is the two-tier delete authorization above it, which runs only when environmentId !== undefined and refuses both limbs for exactly the types that would reach the legacy path:

  • artifact-backed: artifactBacked AND NOT overlayAllowed AND NOT legacyOverlayRemoval produces NOT_OVERRIDABLE 403
  • not artifact-backed: NOT artifactBacked AND NOT overlayAllowed AND NOT runtimeCreateAllowed produces NOT_CREATABLE 403

The #6960 carve-out is the only way past the first limb and it cannot apply: it reads supportsOverlay, and the useRepoPath === false set is exactly agent, api, capability, field, job — five of the registry's 27 entries, none of which carries supportsOverlay: true. Both 403s are driven end to end in §3, and the registry property itself is pinned there too, so a registry edit that opens the door has to come past this file.

So door 4's exposure is narrower than the comment feared: unreachable through the public deleteMetaItem on a normal boot. That is why its repair is pinned under bootstrap mode — which is where the defect is real, and where the registry every organization shares is the one being healed. The OS_METADATA_WRITABLE escape hatch only moves a type ONTO the repository (emitting) path, never onto this one; the harness clears its memoised cache so that cannot silently change what is measured.

The receive path stays silent — cited, not duplicated

Adding an emit inside applyRemoteMetadataMutation would re-broadcast every received event and ping-pong across replicas, which the loopback guard (own-messages only) cannot stop. That negative is already pinned in protocol.cluster-mutation-fanout.test.ts, in "the peer's onMetadataMutation listeners receive the remote event, once, after convergence": expect(writerSeen).toHaveLength(1), commented "the remote replay stays local to the receiving node (no echo)". A peer that re-emitted would re-publish, the writer would apply that peer-origin message past its own loopback guard, and that assertion would read 2. It is cited rather than re-built.

Alongside it, §5 of the new file carries three structural pins that make the same claim locally and cheaply: the emitMetadataMutation call-site count is 7, notifyMutationListenersLocal keeps exactly its two declared callers (the choke point and the receive path), and applyRemoteMetadataMutation's body contains notifyMutationListenersLocal and does not contain emitMetadataMutation.

The cluster half is inherited, not re-measured

emitMetadataMutation is two lines — the local fan-out, then publishMetadataMutation — so a door that reaches the choke point reaches the publisher. protocol.cluster-mutation-fanout.test.ts already proves that end to end over a two-replica pub/sub double (Arm B, "a runtime-authored object registers on the PEER after the writer's save", with Arm A as the no-bridge control). Combined with §5's call-site count, the new doors inherit the cluster leg structurally. Building a second cluster harness here would re-measure that file's subject rather than this card's, so it is not built.

Ablation — direction declared before the run, in the committed test header

Declared (commit 43640d6c6, before running): removing door 1's emit from protocol.ts turns RED both of §2's rollback cases and §5's call-site count, and leaves everything else GREEN — 3 RED / 15 GREEN of 18.

Measured, exactly that:

BEFORE: anchor=1 emit-call-sites=7
AFTER: anchor=0 emit-call-sites=6 blob=ba5fa53549caa6a282bb325a2ae93f62a47fcdc6
MUTATION CONFIRMED ON DISK (anchor 1->0, emit sites 7->6, blob differs from HEAD)
x announces the restored body on the mutation choke point
x folds a PLURAL request type to the singular the write-through registered under
x has exactly seven `emitMetadataMutation` call sites
Tests 3 failed | 15 passed (18)
RESTORE: blob=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 head=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 anchor=1 emits=7 diff-bytes=0
RESTORE CONFIRMED BYTE-EXACT (blob == HEAD blob, git diff HEAD empty, anchor 1, emits 7)

Mechanics: the fix was committed first, so the restore leg had a real restore point; the mutation ran under an EXIT INT TERM trap whose paths are absolute (seeded from git rev-parse --show-toplevel, not from a cwd); the mutation is proved by anchored counts of the removed text and a blob hash that differs from HEAD's, never by perl -i's exit code; the restore is proved by git checkout HEAD -- path followed by a blob-hash match against the HEAD blob AND an empty git diff HEAD, never by an exit code. The first attempt of this script refused to run — its precondition read anchor=0 because the bracketed issue tag was being read as a regex character class — and that refusal is the design working: a zero-match mutation would otherwise have produced a fully green "ablation".

No rebuild is involved and that is itself a measurement: the pin file imports the same-package relative ./protocol.js, so vitest compiles src/protocol.ts. An ablation that goes red with no build is what proves it; a dist-resolved one would have stayed green.

Deliberately unchanged

Files, and one beyond the dispatched surface

  • packages/metadata-protocol/src/protocol.ts — the three doors (four call sites), +60 lines, comments included.
  • packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts — new, 18 cases.
  • .changeset/recovery-doors-emit-metadata-mutation.md@objectstack/metadata-protocol: patch.
  • scripts/engine-double-contract.pinned.jsonnot on the dispatched file surface, declared here.check:engine-double-contract reds on a new pin file until the RETAINED ledger records it ("New pinned coverage is GOOD and nothing is wrong with your change — the ledger just has to learn about it, or it never protects this file"). Regenerated with that gate's own --write: 3 rows added, all naming the new test file, 0 lost, no other row touched. Reusing an existing double instead was not available — the file is new, so it has no double to override.

No existing sibling pin needed updating: the whole @objectstack/metadata-protocol suite is green unchanged (2125 passed), including the write-through-spelling call-site count, the cluster fan-out arms and the delete-rewrap envelope pins.

Gate union, run on e60660c3a

Derived on the real change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands after the last commit — 44 commands, 7 more than the dispatch's list because the ledger file above joined the diff (agent-test-spelling, bash32-floor, cli-command-ids, entry-guard, parse-guard, pnpm-filter-targets, watch-hint-literal; all seven PASS). Exit codes captured after a redirect, never through a pipe.

  • pnpm --filter @objectstack/metadata-protocol test — PASS, 154 files passed / 2 skipped, 2125 tests passed / 10 skipped.
  • pnpm --filter @objectstack/metadata-protocol typecheck — PASS (tsc --noEmit). The new pin file is inside that program: tsc --listFilesOnly lists it, 1 hit among 699 files.
  • pnpm lint — PASS over the whole repo in 68s (eslint . --no-inline-config), so no narrowing was needed and none is claimed.
  • pnpm check:nul-bytes — PASS (7859 files scanned), plus a manual control-character scan over the four changed paths: zero hits.
  • The 44-command union: 40 PASS, 4 NOT MEASURED, 0 findings.

The four NOT MEASURED are exit-3 PREREQUISITE-NOT-MET, recorded with the gates' own words and read as neither pass nor finding — every one needs an input a per-package local run does not have, and CI supplies all four:

gateits own verdict
check-test-completeness.mjs"pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED"
pm/check-half-states.mjsexit 3, an unread instrument (needs the GitHub board) — "never as a quiet board"
check:dual-build-cjs-loads"PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/… Run pnpm build first. This is NOT a pass"
check:type-check-debt"Build the closure first, exactly as lint.yml does… This is NOT a pass and NOT a finding"

Merge state

main landed 5 commits since this branch's base (5c9e40ad9 to 4485f7dad): CLI, lint, docs and three merge-driver scripts. None touches packages/metadata-protocol or packages/spec, so the AGENTS.md §10 re-check is scoped out rather than skipped, and the merge queue validates the merge commit itself. No other in-flight claude/issue-14* branch on origin touches protocol.ts (24 branches fetched and diffed against their merge-base at PR time), so the serial lane the dispatch declared is still clear.

Not this card

#13609 (a deleted datasource prolonging cluster-wide) is a different defect and is not addressed here: datasource carries allowOrgOverride: false, allowRuntimeCreate: true, so useRepoPath is true and it takes the repository exit, which has emitted since #2588. This branch changes nothing about it, and that card stays open on its own premise.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code


Generated by Claude Code

…ry doors (#14179)
rollbackMetaItem, revertCommit (both limbs) and deleteMetaItem's legacy
raw-engine exit mutate live state and never reached emitMetadataMutation —
the documented choke point and, since #13331, the cluster publish point. Each
now announces after its write, with the same org scope and singular type key
the write used. Row-absent exits stay silent; the cluster receive path is
untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… id that a default parameter swallows (#14179)
A default parameter fires on an explicitly passed `undefined`, so the harness's
bootstrap-mode cases were built on an environment boot and measured the
two-tier gate's 403 rather than the legacy exit. The factory now takes a named
mode and asserts the topology it constructed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…re running it (#14179)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… ledger (#14179)
`check:engine-double-contract` reds until the ledger learns about a file that
pins engine doubles, or the pin never protects it. Regenerated with
`--write`: 3 rows added, all naming the new test file, 0 lost.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 7 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx(via meta.rollbackItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/rollback, selected by route anchor /:type/:name/rollback), rollbackItem (sdk, the bare tail of client method meta.rollbackItem, bound to POST /api/v1/meta/:type/:name/rollback))
  • content/docs/kernel/contracts/metadata-service.mdx(via /:type/:name/rollback (route, bridged from symbol rollbackMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via deleteMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
What this run could not see
  • 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 — 8 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 41aa979295be112cc6c27fcd6cc6c5ac20c519d5packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2431d3164baf7f9401f96757f5f0beb6e663570 — the merge of head e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 into base 41aa979295be112cc6c27fcd6cc6c5ac20c519d5, 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 b2431d3164baf7f9401f96757f5f0beb6e663570 && git checkout b2431d3164baf7f9401f96757f5f0beb6e663570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 && git checkout -B drift-repro 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 && git merge --no-ff e60660c3a62281b5ef3edf397f1b2f50d4d3ec44
node scripts/docs-audit/affected-docs.mjs --json 41aa979295be112cc6c27fcd6cc6c5ac20c519d5

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

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14179 (comment 5504761434) → at 05:32Z every check run on head e60660c3a was completed with success or skipped (33 runs; Lint & Repo Gates finished 05:31Z), governed-surface test on the four changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


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/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point - #14411

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation
Sep 2, 2026
Merged

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point#14411
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14179

emitMetadataMutation documents itself as "the ONE choke point every authoring surface funnels through", and since the #13331 fan-out landed it is also the metadata.mutated cluster publish point. Three live write paths never reached it. Each now emits after its write, with the same org scope the write received and the same singular type key the registry was written under — mirroring the emitting siblings rather than inventing an event shape.

Verified at e60660c3a (the branch head this description describes; the gate union below was run on that same commit).

The three doors

doorreached bybeforeafterpinned by
rollbackMetaItem, after its registry write-throughPOST /api/v1/meta/:type/:name/rollbackwrite-through, no emitemitMetadataMutation({ type: singularType, name, state: 'active', organizationId: orgId })new file, §2 "announces the restored body on the mutation choke point" + "folds a PLURAL request type…"
revertCommit — RESTORE limb, after the per-item write-throughPOST /packages/:id/commits/:commitId/revertwrite-through, no emitsame event with PLURAL_TO_SINGULAR[it.type] ?? it.type, state: 'active', per-item itemOrgIdnew file, §2 "RESTORE limb announces the pre-commit body it wrote back" + the mixed-batch case
revertCommit — SOFT-REMOVE limb, after repo.delete + restoreArtifactRegistryViewsame door, items the commit createddelete + heal, no emitsame key/scope, state: 'deleted', gated on a row actually having been removednew file, §2 "SOFT-REMOVE limb announces a deleted…" + §4's already-absent negative control
deleteMetaItem legacy raw-engine exit (the non-useRepoPath side)DELETE /api/v1/meta/:type/:name on a control-plane bootstrap kernelengine.delete + optional storage drop + heal, no emitmirrors the repository twin exactly: state: 'deleted', singularTypeForRepo, request.organizationId ?? nullnew file, §2 "announces the deletion it really performs" + "carries the org scope the delete predicate used"

Four call sites, three doors — revertCommit's two limbs are one door with two exits, exactly as the card's inventory counts it. this.emitMetadataMutation( call sites go 3 to 7; applyRegistryWriteThrough call sites stay at 5, so protocol.object-registry-write-through-spelling.test.ts's closed-caller-set pin is untouched and green.

Premise, re-measured

At the branch base 5c9e40ad9: git grep -n "applyRegistryWriteThrough(" -- packages/metadata-protocol/src/protocol.ts returns 6 hits, one of them the declaration at :13660 — FIVE call sites; the same grep for emitMetadataMutation( returns 4, one of them the declaration at :4962 — THREE call sites. The card's count holds.

One correction to the card's attribution of the fifth write-through: it sits inside applyRemoteMetadataMutation (the #13331 RECEIVE-side applier), not inside publishMetadataMutation (the publisher, which makes no write-through call at all). The substance is unchanged — it is the receive side, and it is deliberately not a door.

Reachability of the legacy delete exit — the NOT MEASURED item, now measured

The unblock comment recorded that it had not verified the branch's own docblock claim ("only reachable in control-plane bootstrap mode where environmentId is undefined"), noting correctly that useRepoPath does not itself test environmentId.

Measured: the claim holds, by a mechanism the docblock does not name. What confines the branch is the two-tier delete authorization above it, which runs only when environmentId !== undefined and refuses both limbs for exactly the types that would reach the legacy path:

  • artifact-backed: artifactBacked AND NOT overlayAllowed AND NOT legacyOverlayRemoval produces NOT_OVERRIDABLE 403
  • not artifact-backed: NOT artifactBacked AND NOT overlayAllowed AND NOT runtimeCreateAllowed produces NOT_CREATABLE 403

The #6960 carve-out is the only way past the first limb and it cannot apply: it reads supportsOverlay, and the useRepoPath === false set is exactly agent, api, capability, field, job — five of the registry's 27 entries, none of which carries supportsOverlay: true. Both 403s are driven end to end in §3, and the registry property itself is pinned there too, so a registry edit that opens the door has to come past this file.

So door 4's exposure is narrower than the comment feared: unreachable through the public deleteMetaItem on a normal boot. That is why its repair is pinned under bootstrap mode — which is where the defect is real, and where the registry every organization shares is the one being healed. The OS_METADATA_WRITABLE escape hatch only moves a type ONTO the repository (emitting) path, never onto this one; the harness clears its memoised cache so that cannot silently change what is measured.

The receive path stays silent — cited, not duplicated

Adding an emit inside applyRemoteMetadataMutation would re-broadcast every received event and ping-pong across replicas, which the loopback guard (own-messages only) cannot stop. That negative is already pinned in protocol.cluster-mutation-fanout.test.ts, in "the peer's onMetadataMutation listeners receive the remote event, once, after convergence": expect(writerSeen).toHaveLength(1), commented "the remote replay stays local to the receiving node (no echo)". A peer that re-emitted would re-publish, the writer would apply that peer-origin message past its own loopback guard, and that assertion would read 2. It is cited rather than re-built.

Alongside it, §5 of the new file carries three structural pins that make the same claim locally and cheaply: the emitMetadataMutation call-site count is 7, notifyMutationListenersLocal keeps exactly its two declared callers (the choke point and the receive path), and applyRemoteMetadataMutation's body contains notifyMutationListenersLocal and does not contain emitMetadataMutation.

The cluster half is inherited, not re-measured

emitMetadataMutation is two lines — the local fan-out, then publishMetadataMutation — so a door that reaches the choke point reaches the publisher. protocol.cluster-mutation-fanout.test.ts already proves that end to end over a two-replica pub/sub double (Arm B, "a runtime-authored object registers on the PEER after the writer's save", with Arm A as the no-bridge control). Combined with §5's call-site count, the new doors inherit the cluster leg structurally. Building a second cluster harness here would re-measure that file's subject rather than this card's, so it is not built.

Ablation — direction declared before the run, in the committed test header

Declared (commit 43640d6c6, before running): removing door 1's emit from protocol.ts turns RED both of §2's rollback cases and §5's call-site count, and leaves everything else GREEN — 3 RED / 15 GREEN of 18.

Measured, exactly that:

BEFORE: anchor=1 emit-call-sites=7
AFTER: anchor=0 emit-call-sites=6 blob=ba5fa53549caa6a282bb325a2ae93f62a47fcdc6
MUTATION CONFIRMED ON DISK (anchor 1->0, emit sites 7->6, blob differs from HEAD)
x announces the restored body on the mutation choke point
x folds a PLURAL request type to the singular the write-through registered under
x has exactly seven `emitMetadataMutation` call sites
Tests 3 failed | 15 passed (18)
RESTORE: blob=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 head=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 anchor=1 emits=7 diff-bytes=0
RESTORE CONFIRMED BYTE-EXACT (blob == HEAD blob, git diff HEAD empty, anchor 1, emits 7)

Mechanics: the fix was committed first, so the restore leg had a real restore point; the mutation ran under an EXIT INT TERM trap whose paths are absolute (seeded from git rev-parse --show-toplevel, not from a cwd); the mutation is proved by anchored counts of the removed text and a blob hash that differs from HEAD's, never by perl -i's exit code; the restore is proved by git checkout HEAD -- path followed by a blob-hash match against the HEAD blob AND an empty git diff HEAD, never by an exit code. The first attempt of this script refused to run — its precondition read anchor=0 because the bracketed issue tag was being read as a regex character class — and that refusal is the design working: a zero-match mutation would otherwise have produced a fully green "ablation".

No rebuild is involved and that is itself a measurement: the pin file imports the same-package relative ./protocol.js, so vitest compiles src/protocol.ts. An ablation that goes red with no build is what proves it; a dist-resolved one would have stayed green.

Deliberately unchanged

Files, and one beyond the dispatched surface

  • packages/metadata-protocol/src/protocol.ts — the three doors (four call sites), +60 lines, comments included.
  • packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts — new, 18 cases.
  • .changeset/recovery-doors-emit-metadata-mutation.md@objectstack/metadata-protocol: patch.
  • scripts/engine-double-contract.pinned.jsonnot on the dispatched file surface, declared here.check:engine-double-contract reds on a new pin file until the RETAINED ledger records it ("New pinned coverage is GOOD and nothing is wrong with your change — the ledger just has to learn about it, or it never protects this file"). Regenerated with that gate's own --write: 3 rows added, all naming the new test file, 0 lost, no other row touched. Reusing an existing double instead was not available — the file is new, so it has no double to override.

No existing sibling pin needed updating: the whole @objectstack/metadata-protocol suite is green unchanged (2125 passed), including the write-through-spelling call-site count, the cluster fan-out arms and the delete-rewrap envelope pins.

Gate union, run on e60660c3a

Derived on the real change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands after the last commit — 44 commands, 7 more than the dispatch's list because the ledger file above joined the diff (agent-test-spelling, bash32-floor, cli-command-ids, entry-guard, parse-guard, pnpm-filter-targets, watch-hint-literal; all seven PASS). Exit codes captured after a redirect, never through a pipe.

  • pnpm --filter @objectstack/metadata-protocol test — PASS, 154 files passed / 2 skipped, 2125 tests passed / 10 skipped.
  • pnpm --filter @objectstack/metadata-protocol typecheck — PASS (tsc --noEmit). The new pin file is inside that program: tsc --listFilesOnly lists it, 1 hit among 699 files.
  • pnpm lint — PASS over the whole repo in 68s (eslint . --no-inline-config), so no narrowing was needed and none is claimed.
  • pnpm check:nul-bytes — PASS (7859 files scanned), plus a manual control-character scan over the four changed paths: zero hits.
  • The 44-command union: 40 PASS, 4 NOT MEASURED, 0 findings.

The four NOT MEASURED are exit-3 PREREQUISITE-NOT-MET, recorded with the gates' own words and read as neither pass nor finding — every one needs an input a per-package local run does not have, and CI supplies all four:

gateits own verdict
check-test-completeness.mjs"pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED"
pm/check-half-states.mjsexit 3, an unread instrument (needs the GitHub board) — "never as a quiet board"
check:dual-build-cjs-loads"PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/… Run pnpm build first. This is NOT a pass"
check:type-check-debt"Build the closure first, exactly as lint.yml does… This is NOT a pass and NOT a finding"

Merge state

main landed 5 commits since this branch's base (5c9e40ad9 to 4485f7dad): CLI, lint, docs and three merge-driver scripts. None touches packages/metadata-protocol or packages/spec, so the AGENTS.md §10 re-check is scoped out rather than skipped, and the merge queue validates the merge commit itself. No other in-flight claude/issue-14* branch on origin touches protocol.ts (24 branches fetched and diffed against their merge-base at PR time), so the serial lane the dispatch declared is still clear.

Not this card

#13609 (a deleted datasource prolonging cluster-wide) is a different defect and is not addressed here: datasource carries allowOrgOverride: false, allowRuntimeCreate: true, so useRepoPath is true and it takes the repository exit, which has emitted since #2588. This branch changes nothing about it, and that card stays open on its own premise.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code


Generated by Claude Code

…ry doors (#14179)
rollbackMetaItem, revertCommit (both limbs) and deleteMetaItem's legacy
raw-engine exit mutate live state and never reached emitMetadataMutation —
the documented choke point and, since #13331, the cluster publish point. Each
now announces after its write, with the same org scope and singular type key
the write used. Row-absent exits stay silent; the cluster receive path is
untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… id that a default parameter swallows (#14179)
A default parameter fires on an explicitly passed `undefined`, so the harness's
bootstrap-mode cases were built on an environment boot and measured the
two-tier gate's 403 rather than the legacy exit. The factory now takes a named
mode and asserts the topology it constructed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…re running it (#14179)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… ledger (#14179)
`check:engine-double-contract` reds until the ledger learns about a file that
pins engine doubles, or the pin never protects it. Regenerated with
`--write`: 3 rows added, all naming the new test file, 0 lost.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 7 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx(via meta.rollbackItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/rollback, selected by route anchor /:type/:name/rollback), rollbackItem (sdk, the bare tail of client method meta.rollbackItem, bound to POST /api/v1/meta/:type/:name/rollback))
  • content/docs/kernel/contracts/metadata-service.mdx(via /:type/:name/rollback (route, bridged from symbol rollbackMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via deleteMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
What this run could not see
  • 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 — 8 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 41aa979295be112cc6c27fcd6cc6c5ac20c519d5packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2431d3164baf7f9401f96757f5f0beb6e663570 — the merge of head e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 into base 41aa979295be112cc6c27fcd6cc6c5ac20c519d5, 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 b2431d3164baf7f9401f96757f5f0beb6e663570 && git checkout b2431d3164baf7f9401f96757f5f0beb6e663570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 && git checkout -B drift-repro 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 && git merge --no-ff e60660c3a62281b5ef3edf397f1b2f50d4d3ec44
node scripts/docs-audit/affected-docs.mjs --json 41aa979295be112cc6c27fcd6cc6c5ac20c519d5

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

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14179 (comment 5504761434) → at 05:32Z every check run on head e60660c3a was completed with success or skipped (33 runs; Lint & Repo Gates finished 05:31Z), governed-surface test on the four changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


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/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point - #14411

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation
Sep 2, 2026
Merged

fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point#14411
os-musk merged 4 commits into
mainfrom
claude/issue-14179-recovery-doors-emit-mutation

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14179

emitMetadataMutation documents itself as "the ONE choke point every authoring surface funnels through", and since the #13331 fan-out landed it is also the metadata.mutated cluster publish point. Three live write paths never reached it. Each now emits after its write, with the same org scope the write received and the same singular type key the registry was written under — mirroring the emitting siblings rather than inventing an event shape.

Verified at e60660c3a (the branch head this description describes; the gate union below was run on that same commit).

The three doors

doorreached bybeforeafterpinned by
rollbackMetaItem, after its registry write-throughPOST /api/v1/meta/:type/:name/rollbackwrite-through, no emitemitMetadataMutation({ type: singularType, name, state: 'active', organizationId: orgId })new file, §2 "announces the restored body on the mutation choke point" + "folds a PLURAL request type…"
revertCommit — RESTORE limb, after the per-item write-throughPOST /packages/:id/commits/:commitId/revertwrite-through, no emitsame event with PLURAL_TO_SINGULAR[it.type] ?? it.type, state: 'active', per-item itemOrgIdnew file, §2 "RESTORE limb announces the pre-commit body it wrote back" + the mixed-batch case
revertCommit — SOFT-REMOVE limb, after repo.delete + restoreArtifactRegistryViewsame door, items the commit createddelete + heal, no emitsame key/scope, state: 'deleted', gated on a row actually having been removednew file, §2 "SOFT-REMOVE limb announces a deleted…" + §4's already-absent negative control
deleteMetaItem legacy raw-engine exit (the non-useRepoPath side)DELETE /api/v1/meta/:type/:name on a control-plane bootstrap kernelengine.delete + optional storage drop + heal, no emitmirrors the repository twin exactly: state: 'deleted', singularTypeForRepo, request.organizationId ?? nullnew file, §2 "announces the deletion it really performs" + "carries the org scope the delete predicate used"

Four call sites, three doors — revertCommit's two limbs are one door with two exits, exactly as the card's inventory counts it. this.emitMetadataMutation( call sites go 3 to 7; applyRegistryWriteThrough call sites stay at 5, so protocol.object-registry-write-through-spelling.test.ts's closed-caller-set pin is untouched and green.

Premise, re-measured

At the branch base 5c9e40ad9: git grep -n "applyRegistryWriteThrough(" -- packages/metadata-protocol/src/protocol.ts returns 6 hits, one of them the declaration at :13660 — FIVE call sites; the same grep for emitMetadataMutation( returns 4, one of them the declaration at :4962 — THREE call sites. The card's count holds.

One correction to the card's attribution of the fifth write-through: it sits inside applyRemoteMetadataMutation (the #13331 RECEIVE-side applier), not inside publishMetadataMutation (the publisher, which makes no write-through call at all). The substance is unchanged — it is the receive side, and it is deliberately not a door.

Reachability of the legacy delete exit — the NOT MEASURED item, now measured

The unblock comment recorded that it had not verified the branch's own docblock claim ("only reachable in control-plane bootstrap mode where environmentId is undefined"), noting correctly that useRepoPath does not itself test environmentId.

Measured: the claim holds, by a mechanism the docblock does not name. What confines the branch is the two-tier delete authorization above it, which runs only when environmentId !== undefined and refuses both limbs for exactly the types that would reach the legacy path:

  • artifact-backed: artifactBacked AND NOT overlayAllowed AND NOT legacyOverlayRemoval produces NOT_OVERRIDABLE 403
  • not artifact-backed: NOT artifactBacked AND NOT overlayAllowed AND NOT runtimeCreateAllowed produces NOT_CREATABLE 403

The #6960 carve-out is the only way past the first limb and it cannot apply: it reads supportsOverlay, and the useRepoPath === false set is exactly agent, api, capability, field, job — five of the registry's 27 entries, none of which carries supportsOverlay: true. Both 403s are driven end to end in §3, and the registry property itself is pinned there too, so a registry edit that opens the door has to come past this file.

So door 4's exposure is narrower than the comment feared: unreachable through the public deleteMetaItem on a normal boot. That is why its repair is pinned under bootstrap mode — which is where the defect is real, and where the registry every organization shares is the one being healed. The OS_METADATA_WRITABLE escape hatch only moves a type ONTO the repository (emitting) path, never onto this one; the harness clears its memoised cache so that cannot silently change what is measured.

The receive path stays silent — cited, not duplicated

Adding an emit inside applyRemoteMetadataMutation would re-broadcast every received event and ping-pong across replicas, which the loopback guard (own-messages only) cannot stop. That negative is already pinned in protocol.cluster-mutation-fanout.test.ts, in "the peer's onMetadataMutation listeners receive the remote event, once, after convergence": expect(writerSeen).toHaveLength(1), commented "the remote replay stays local to the receiving node (no echo)". A peer that re-emitted would re-publish, the writer would apply that peer-origin message past its own loopback guard, and that assertion would read 2. It is cited rather than re-built.

Alongside it, §5 of the new file carries three structural pins that make the same claim locally and cheaply: the emitMetadataMutation call-site count is 7, notifyMutationListenersLocal keeps exactly its two declared callers (the choke point and the receive path), and applyRemoteMetadataMutation's body contains notifyMutationListenersLocal and does not contain emitMetadataMutation.

The cluster half is inherited, not re-measured

emitMetadataMutation is two lines — the local fan-out, then publishMetadataMutation — so a door that reaches the choke point reaches the publisher. protocol.cluster-mutation-fanout.test.ts already proves that end to end over a two-replica pub/sub double (Arm B, "a runtime-authored object registers on the PEER after the writer's save", with Arm A as the no-bridge control). Combined with §5's call-site count, the new doors inherit the cluster leg structurally. Building a second cluster harness here would re-measure that file's subject rather than this card's, so it is not built.

Ablation — direction declared before the run, in the committed test header

Declared (commit 43640d6c6, before running): removing door 1's emit from protocol.ts turns RED both of §2's rollback cases and §5's call-site count, and leaves everything else GREEN — 3 RED / 15 GREEN of 18.

Measured, exactly that:

BEFORE: anchor=1 emit-call-sites=7
AFTER: anchor=0 emit-call-sites=6 blob=ba5fa53549caa6a282bb325a2ae93f62a47fcdc6
MUTATION CONFIRMED ON DISK (anchor 1->0, emit sites 7->6, blob differs from HEAD)
x announces the restored body on the mutation choke point
x folds a PLURAL request type to the singular the write-through registered under
x has exactly seven `emitMetadataMutation` call sites
Tests 3 failed | 15 passed (18)
RESTORE: blob=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 head=fa53e5ac6b7ab5ff6d47aaf4e86ba7ba89352270 anchor=1 emits=7 diff-bytes=0
RESTORE CONFIRMED BYTE-EXACT (blob == HEAD blob, git diff HEAD empty, anchor 1, emits 7)

Mechanics: the fix was committed first, so the restore leg had a real restore point; the mutation ran under an EXIT INT TERM trap whose paths are absolute (seeded from git rev-parse --show-toplevel, not from a cwd); the mutation is proved by anchored counts of the removed text and a blob hash that differs from HEAD's, never by perl -i's exit code; the restore is proved by git checkout HEAD -- path followed by a blob-hash match against the HEAD blob AND an empty git diff HEAD, never by an exit code. The first attempt of this script refused to run — its precondition read anchor=0 because the bracketed issue tag was being read as a regex character class — and that refusal is the design working: a zero-match mutation would otherwise have produced a fully green "ablation".

No rebuild is involved and that is itself a measurement: the pin file imports the same-package relative ./protocol.js, so vitest compiles src/protocol.ts. An ablation that goes red with no build is what proves it; a dist-resolved one would have stayed green.

Deliberately unchanged

Files, and one beyond the dispatched surface

  • packages/metadata-protocol/src/protocol.ts — the three doors (four call sites), +60 lines, comments included.
  • packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts — new, 18 cases.
  • .changeset/recovery-doors-emit-metadata-mutation.md@objectstack/metadata-protocol: patch.
  • scripts/engine-double-contract.pinned.jsonnot on the dispatched file surface, declared here.check:engine-double-contract reds on a new pin file until the RETAINED ledger records it ("New pinned coverage is GOOD and nothing is wrong with your change — the ledger just has to learn about it, or it never protects this file"). Regenerated with that gate's own --write: 3 rows added, all naming the new test file, 0 lost, no other row touched. Reusing an existing double instead was not available — the file is new, so it has no double to override.

No existing sibling pin needed updating: the whole @objectstack/metadata-protocol suite is green unchanged (2125 passed), including the write-through-spelling call-site count, the cluster fan-out arms and the delete-rewrap envelope pins.

Gate union, run on e60660c3a

Derived on the real change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands after the last commit — 44 commands, 7 more than the dispatch's list because the ledger file above joined the diff (agent-test-spelling, bash32-floor, cli-command-ids, entry-guard, parse-guard, pnpm-filter-targets, watch-hint-literal; all seven PASS). Exit codes captured after a redirect, never through a pipe.

  • pnpm --filter @objectstack/metadata-protocol test — PASS, 154 files passed / 2 skipped, 2125 tests passed / 10 skipped.
  • pnpm --filter @objectstack/metadata-protocol typecheck — PASS (tsc --noEmit). The new pin file is inside that program: tsc --listFilesOnly lists it, 1 hit among 699 files.
  • pnpm lint — PASS over the whole repo in 68s (eslint . --no-inline-config), so no narrowing was needed and none is claimed.
  • pnpm check:nul-bytes — PASS (7859 files scanned), plus a manual control-character scan over the four changed paths: zero hits.
  • The 44-command union: 40 PASS, 4 NOT MEASURED, 0 findings.

The four NOT MEASURED are exit-3 PREREQUISITE-NOT-MET, recorded with the gates' own words and read as neither pass nor finding — every one needs an input a per-package local run does not have, and CI supplies all four:

gateits own verdict
check-test-completeness.mjs"pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED"
pm/check-half-states.mjsexit 3, an unread instrument (needs the GitHub board) — "never as a quiet board"
check:dual-build-cjs-loads"PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/… Run pnpm build first. This is NOT a pass"
check:type-check-debt"Build the closure first, exactly as lint.yml does… This is NOT a pass and NOT a finding"

Merge state

main landed 5 commits since this branch's base (5c9e40ad9 to 4485f7dad): CLI, lint, docs and three merge-driver scripts. None touches packages/metadata-protocol or packages/spec, so the AGENTS.md §10 re-check is scoped out rather than skipped, and the merge queue validates the merge commit itself. No other in-flight claude/issue-14* branch on origin touches protocol.ts (24 branches fetched and diffed against their merge-base at PR time), so the serial lane the dispatch declared is still clear.

Not this card

#13609 (a deleted datasource prolonging cluster-wide) is a different defect and is not addressed here: datasource carries allowOrgOverride: false, allowRuntimeCreate: true, so useRepoPath is true and it takes the repository exit, which has emitted since #2588. This branch changes nothing about it, and that card stays open on its own premise.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code


Generated by Claude Code

…ry doors (#14179)
rollbackMetaItem, revertCommit (both limbs) and deleteMetaItem's legacy
raw-engine exit mutate live state and never reached emitMetadataMutation —
the documented choke point and, since #13331, the cluster publish point. Each
now announces after its write, with the same org scope and singular type key
the write used. Row-absent exits stay silent; the cluster receive path is
untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… id that a default parameter swallows (#14179)
A default parameter fires on an explicitly passed `undefined`, so the harness's
bootstrap-mode cases were built on an environment boot and measured the
two-tier gate's 403 rather than the legacy exit. The factory now takes a named
mode and asserts the topology it constructed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…re running it (#14179)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… ledger (#14179)
`check:engine-double-contract` reds until the ledger learns about a file that
pins engine doubles, or the pin never protects it. Regenerated with
`--write`: 3 rows added, all naming the new test file, 0 lost.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 7 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx(via meta.rollbackItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/rollback, selected by route anchor /:type/:name/rollback), rollbackItem (sdk, the bare tail of client method meta.rollbackItem, bound to POST /api/v1/meta/:type/:name/rollback))
  • content/docs/kernel/contracts/metadata-service.mdx(via /:type/:name/rollback (route, bridged from symbol rollbackMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via deleteMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
What this run could not see
  • 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 — 8 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 41aa979295be112cc6c27fcd6cc6c5ac20c519d5packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2431d3164baf7f9401f96757f5f0beb6e663570 — the merge of head e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 into base 41aa979295be112cc6c27fcd6cc6c5ac20c519d5, 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 b2431d3164baf7f9401f96757f5f0beb6e663570 && git checkout b2431d3164baf7f9401f96757f5f0beb6e663570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 e60660c3a62281b5ef3edf397f1b2f50d4d3ec44 && git checkout -B drift-repro 41aa979295be112cc6c27fcd6cc6c5ac20c519d5 && git merge --no-ff e60660c3a62281b5ef3edf397f1b2f50d4d3ec44
node scripts/docs-audit/affected-docs.mjs --json 41aa979295be112cc6c27fcd6cc6c5ac20c519d5

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

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14179 (comment 5504761434) → at 05:32Z every check run on head e60660c3a was completed with success or skipped (33 runs; Lint & Repo Gates finished 05:31Z), governed-surface test on the four changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


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/lteststooling

Projects

None yet

2 participants

@os-musk@claude