Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): the three recovery doors announce their writes on the mutation choke point - #14411
Conversation
…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
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
os-musk
commented
Sep 2, 2026
Enqueue provenance ( Generated by Claude Code |
Fixes#14179
emitMetadataMutationdocuments itself as "the ONE choke point every authoring surface funnels through", and since the #13331 fan-out landed it is also themetadata.mutatedcluster 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
rollbackMetaItem, after its registry write-throughPOST /api/v1/meta/:type/:name/rollbackemitMetadataMutation({ type: singularType, name, state: 'active', organizationId: orgId })revertCommit— RESTORE limb, after the per-item write-throughPOST /packages/:id/commits/:commitId/revertPLURAL_TO_SINGULAR[it.type] ?? it.type,state: 'active', per-itemitemOrgIdrevertCommit— SOFT-REMOVE limb, afterrepo.delete+restoreArtifactRegistryViewstate: 'deleted', gated on a row actually having been removeddeleted…" + §4's already-absent negative controldeleteMetaItemlegacy raw-engine exit (the non-useRepoPathside)DELETE /api/v1/meta/:type/:nameon a control-plane bootstrap kernelengine.delete+ optional storage drop + heal, no emitstate: 'deleted',singularTypeForRepo,request.organizationId ?? nullFour 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;applyRegistryWriteThroughcall sites stay at 5, soprotocol.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.tsreturns 6 hits, one of them the declaration at:13660— FIVE call sites; the same grep foremitMetadataMutation(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 insidepublishMetadataMutation(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
environmentIdis undefined"), noting correctly thatuseRepoPathdoes not itself testenvironmentId.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 !== undefinedand refuses both limbs for exactly the types that would reach the legacy path:artifactBacked AND NOT overlayAllowed AND NOT legacyOverlayRemovalproducesNOT_OVERRIDABLE403NOT artifactBacked AND NOT overlayAllowed AND NOT runtimeCreateAllowedproducesNOT_CREATABLE403The #6960 carve-out is the only way past the first limb and it cannot apply: it reads
supportsOverlay, and theuseRepoPath === falseset is exactlyagent, api, capability, field, job— five of the registry's 27 entries, none of which carriessupportsOverlay: 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
deleteMetaItemon 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. TheOS_METADATA_WRITABLEescape 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
applyRemoteMetadataMutationwould re-broadcast every received event and ping-pong across replicas, which the loopback guard (own-messages only) cannot stop. That negative is already pinned inprotocol.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
emitMetadataMutationcall-site count is 7,notifyMutationListenersLocalkeeps exactly its two declared callers (the choke point and the receive path), andapplyRemoteMetadataMutation's body containsnotifyMutationListenersLocaland does not containemitMetadataMutation.The cluster half is inherited, not re-measured
emitMetadataMutationis two lines — the local fan-out, thenpublishMetadataMutation— so a door that reaches the choke point reaches the publisher.protocol.cluster-mutation-fanout.test.tsalready 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 fromprotocol.tsturns 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:
Mechanics: the fix was committed first, so the restore leg had a real restore point; the mutation ran under an
EXIT INT TERMtrap whose paths are absolute (seeded fromgit 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 byperl -i's exit code; the restore is proved bygit checkout HEAD -- pathfollowed by a blob-hash match against the HEAD blob AND an emptygit diff HEAD, never by an exit code. The first attempt of this script refused to run — its precondition readanchor=0because 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 compilessrc/protocol.ts. An ablation that goes red with no build is what proves it; adist-resolved one would have stayed green.Deliberately unchanged
revertCommit's soft-remove limb over an already-gone row: nothing mutated, so nothing is announced. The soft-remove gate isif (current), mirroring the repository delete twin, whose row-absent exit has always been silent even though it still runs the self-heal. Adeletedsignal there would wake every peer to converge on a no-op. All three are pinned in §4.draft.publishMetadataMutationowns the draft filter, not the doors; §4 pins that this change did not move that boundary.packages/specandpackages/metadataare untouched — the event shape needed no change. So is the runtime: TS-config boot registers a 'metadata' service without attachClusterPubSub — cross-node invalidation disabled; new object gives OBJECT_NOT_FOUND on non-writing replicas, never heals #13331 publish/subscribe seam, which was read and not edited.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.json— not on the dispatched file surface, declared here.check:engine-double-contractreds 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-protocolsuite 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
e60660c3aDerived on the real change set with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsafter 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 --listFilesOnlylists 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 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:
check-test-completeness.mjsturbo run testlog — or, running the family locally, record this gate as NOT MEASURED"pm/check-half-states.mjscheck:dual-build-cjs-loadspnpm buildfirst. This is NOT a pass"check:type-check-debtMerge state
mainlanded 5 commits since this branch's base (5c9e40ad9to4485f7dad): CLI, lint, docs and three merge-driver scripts. None touchespackages/metadata-protocolorpackages/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-flightclaude/issue-14*branch on origin touchesprotocol.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:
datasourcecarriesallowOrgOverride: false, allowRuntimeCreate: true, souseRepoPathis 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