fix(metadata-protocol): recovery doors run the ADR-0094 mutation projector - #14982

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector
Sep 3, 2026
Merged

fix(metadata-protocol): recovery doors run the ADR-0094 mutation projector#14982
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14415

Ruling (A)

Director seat, summon #11, decision batch #17, item 2 (verbatim reply: 「同意」). Full ruling text is quoted on the issue thread (comment 5522731140).

What changed

rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit restored/removed the metadata row and the in-memory registry, but never called the awaited ADR-0094 mutation projector — so a derived read-model (e.g. permission -> sys_permission_set) stayed on the rolled-back-FROM state until an unrelated save/publish/delete on the same name, or boot reconciliation (D3), re-derived it. saveMetaItem, runPublishSideEffects, and deleteMetaItem's repository branch already ran this hook — this closes the same gap for the projector that a prior card (#14179) closed for the mutation-event choke point, at the same four call sites.

All four sites now call this.runMutationProjector(...), awaited BEFORE the existing fire-and-forget emitMetadataMutation call — the order saveMetaItem's own comment establishes:

doorstatebody
rollbackMetaItem (after registry write-through)activerestored version's body
revertCommit restore limbactivepre-commit body it wrote back
revertCommit soft-remove limbdeletednone (matches deleteMetaItem's shape)
deleteMetaItem legacy raw-engine exitdeletednone — same call the repository branch already makes

deleteMetaItem's legacy exit also now populates projectionApplied on its success return — the same optional key its repository-branch sibling has carried since ADR-0094 shipped (no new declared field; rollbackMetaItem / revertCommit carry none, unchanged).

Symbol fence honored: touched only rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit in packages/metadata-protocol/src/protocol.ts. Did not touch listCommits or its emitters (fenced to #14038).

Zone 2 measurement, quoted with file:line (HEAD a32f97047)

  • A. Confirmed on origin/main @ d5cbb44f3 (the commit the issue cites): runMutationProjector had exactly 3 call sites (:15438 saveMetaItem, :16671 runPublishSideEffects, :20455 deleteMetaItem repo branch); emitMetadataMutation had 7 (including the four target sites at :19435, :19534, :19915, :20625 — none preceded by a projector call). All four target sites existed and none already projected.
  • B.runMutationProjector is private async (protocol.ts, declared ~L4932 pre-diff), best-effort (catches, logs via console.warn, never throws), and MetadataMutationProjector/registerMutationProjector are a server-side extension seam — not part of ObjectStackProtocol's wire contract (same status as onMetadataMutation). Confirms Clause-②: no.
  • C. The call deleteMetaItem's repository branch already makes: this.runMutationProjector({ type: singularTypeForRepo, name: request.name, state: 'deleted', organizationId: orgId }) (pre-diff :20455-20460). The legacy exit was missing exactly that call (same shape, same state, org scope from request.organizationId ?? null instead of the resolved orgId — the legacy path's own scoping variable, unchanged from what its existing emitMetadataMutation call already used).
  • D. No existing caller depends on the old ordering: packages/plugins/plugin-security/src/permission-set-projection.test.ts (the one projector actually registered today, for permission) has zero tests exercising rollback/revert against the projector — this gap is exactly what the issue reports, so there's nothing today relying on the pre-fix silence. Ran the full objectql consumer suite that drives these three doors directly (protocol-commit-history, protocol-delete-object-registry-heal, protocol-lock-enforcement, protocol-org-overlay-registry-gate, protocol-publish-canonical-fold, protocol-publish-rollback, protocol-revert-org-scope, protocol-writepath-object-ownership — 113 tests) plus the sibling emit-mutation, mutation-listener and cluster-fanout suites (53 tests): all green.

Tests

New file: packages/metadata-protocol/src/protocol.recovery-doors-mutation-projector.test.ts — one pin per door (registers a fake projector, asserts it receives the correct state/body and runs before the mutation listener), a positive control (saveMetaItem), negative controls (unregistered type, throwing projector, already-absent row), a wire-shape check (deleteMetaItem legacy exit's projectionApplied), and a structural guard (exactly 7 runMutationProjector call sites).

Ablation (declared and run before landing): removing the rollbackMetaItem projector call — git checkout HEAD -- <path> restore, confirmed clean via git diff HEAD empty after — turned exactly 2 of the 10 new tests red (the rollback pin's body assertion, and the 7-call-site structural count dropping to 6), the other 8 green. Matches the file's own docblock claim.

Union re-run at HEAD a32f97047 (metadata-protocol + objectql):

Test Files 4 passed (4) — metadata-protocol (53 tests)
Test Files 3 passed (3) — objectql conformance (28 tests)

Gates run (dispatch-derived, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, 48 families): all green except two genuine PREREQUISITE NOT MET (exit 3, not a finding) that need a full-tree pnpm build this local run did not do — check:dual-build-cjs-loads and check:type-check-debt's --re-measure; CI's lint.yml builds the full workspace closure before both. check:engine-double-contract initially flagged the new test file's engine double as unrecorded coverage — fixed via node scripts/check-engine-double-contract.mjs --write (3 rows added, 0 lost), re-run green.

Companion ADR PR

Per the ruling, the code PR must not land without the ADR-0094 D2 amendment in flight: #14980 (draft, docs/adr/** governed surface, review requested from os-zhuang and hotlong, human merge only).

Changeset

patch.changeset/recovery-door-mutation-projector.md (internal projection ordering; no published schema or wire shape moves).

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…ector
Adds the awaited runMutationProjector call to rollbackMetaItem, both limbs
of revertCommit, and deleteMetaItem's legacy raw-engine exit, awaited
before the existing fire-and-forget emitMetadataMutation call at each site.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…d changeset
Adds protocol.recovery-doors-mutation-projector.test.ts pinning that
rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy
raw-engine exit run the ADR-0094 projector with the correct state/body,
before the existing emitMetadataMutation listener call, and adds the patch
changeset.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…coverage
node scripts/check-engine-double-contract.mjs --write, picking up the
delete/findOne/update doubles in protocol.recovery-doors-mutation-projector.test.ts
(mirrors the already-pinned stub in protocol.recovery-doors-emit-mutation.test.ts).
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@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 — 9 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

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

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

@os-musk
os-musk marked this pull request as ready for review September 3, 2026 16:51
@os-musk
os-musk enabled auto-merge September 3, 2026 16:51
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Independent review — PASS; marked ready and armed for the merge queue

An independent reviewer (not the author) re-derived this PR from the ruling and the code rather than from the PR body. Verdict PASS, no blocking defects. What was re-proved rather than accepted:

Clause-② = no, confirmed. Re-derived against the mechanical floor (any new exported symbol, or any new key on a published payload, is always yes). The diff adds only method-body statements inside four existing private methods — no new export. The projectionApplied key the legacy deleteMetaItem exit now populates is not new: it is declared once on that method's single shared return type at packages/metadata-protocol/src/protocol.ts:20271, that declaration line is untouched by this diff, and it already existed on main before this work began. The repository branch has populated the same optional key on the same shared type since ADR-0094 shipped (:20516). runMutationProjector is private async (:4932), never throws, and is a server-side extension seam — not part of the wire contract.

Ablation independently reproduced, both directions. Removing rollbackMetaItem's projector block (:19935-19950) and confirming the on-disk call-site count actually dropped 7 → 6:

 ❯ src/protocol.recovery-doors-mutation-projector.test.ts (10 tests | 2 failed) 78ms
× door 1 — rollbackMetaItem projects the restored body > runs the projector with state: active and the restored body, before the listener
× every projecting door goes through runMutationProjector > has exactly seven `this.runMutationProjector(` call sites
Test Files 1 failed (1)
Tests 2 failed | 8 passed (10)

Exactly the claimed 2 RED / 8 GREEN. Restored via git checkout HEAD --, proven clean with an empty git diff HEAD and git status --short, then 10/10 at restored HEAD. Sibling suites 43/43; an objectql consumer spot-check 28/28; typecheck clean.

Symbol fence against #14038 honored.#14038 is still open and scoped to listCommits (:18988-19106) and its emitters. This diff's five hunks land at :19435, :19544 (both inside revertCommit, :19107-19760), :19935 (inside rollbackMetaItem, :19761-20250), and :20661 / :20699 (inside deleteMetaItem). None falls within or before listCommits.

Org scope checked, no mismatch. The legacy branch never resolves an orgId variable at all: scopedWhere, restoreArtifactRegistryView, the pre-existing emitMetadataMutation call and the new projector call all use request.organizationId ?? null consistently — the branch's own established convention, unchanged here.

No double-run or unhandled-rejection path.runMutationProjector (:4932-4945) awaits inside its own try/catch, so sync throws and async rejections are both caught; all four call sites additionally sit inside their enclosing method's try/catch. revertCommit processes each item exactly once (:19184).

Gates re-derived off the merge base with no path arguments (48 commands): 44 green, 4 legitimate PREREQUISITE NOT MET (exit 3, each self-documented — the saved-turbo-log completeness gate, the repo-scoped-egress half-state gate, and two that read built dist/ for packages this change does not touch and need a whole-workspace build CI performs first). check-governed-merges.mjs --test on the final four-file list: NOT governed (0/4) — ordinary merge-queue landing.

Landing. All 33 check runs on head a32f97047 are success or skipped, zero red. Marked ready and auto-merge armed. The ruling's constraint that this PR must not land without the ADR-0094 D2 amendment in flight is satisfied: #14980 is open (draft, os-zhuang + hotlong requested, skip-changeset, human merge only — that PR is not touched by this seat).

One note for the ruling record, not a defect in this PR

Read with maximal literalness, the ruling's first clause says "rollbackMetaItem … and both limbs of revertCommit project with state: 'active'" and then, one clause later, that "revertCommit's soft-remove limb … project[s] with state: 'deleted'" — the soft-remove limb is one of "both limbs", so the two readings collide. Every independent restatement of the same decision — the issue's own "Suggested shape" section the ruling explicitly adopts, the claim comment, the ADR PR body, and this PR's table — states the coherent version (rollback + restore limb → active; soft-remove limb + legacy delete → deleted), which is what the diff implements and what the tests pin. Flagged so the wording can be tightened on the record if a maintainer wants; it changed nothing about what was built.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 95464edSep 3, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14415-recovery-door-mutation-projector branch September 3, 2026 17:34
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 \u003cpre\u003e\u003ccode\u003e 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): recovery doors run the ADR-0094 mutation projector - #14982

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector
Sep 3, 2026
Merged

fix(metadata-protocol): recovery doors run the ADR-0094 mutation projector#14982
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14415

Ruling (A)

Director seat, summon #11, decision batch #17, item 2 (verbatim reply: 「同意」). Full ruling text is quoted on the issue thread (comment 5522731140).

What changed

rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit restored/removed the metadata row and the in-memory registry, but never called the awaited ADR-0094 mutation projector — so a derived read-model (e.g. permission -> sys_permission_set) stayed on the rolled-back-FROM state until an unrelated save/publish/delete on the same name, or boot reconciliation (D3), re-derived it. saveMetaItem, runPublishSideEffects, and deleteMetaItem's repository branch already ran this hook — this closes the same gap for the projector that a prior card (#14179) closed for the mutation-event choke point, at the same four call sites.

All four sites now call this.runMutationProjector(...), awaited BEFORE the existing fire-and-forget emitMetadataMutation call — the order saveMetaItem's own comment establishes:

doorstatebody
rollbackMetaItem (after registry write-through)activerestored version's body
revertCommit restore limbactivepre-commit body it wrote back
revertCommit soft-remove limbdeletednone (matches deleteMetaItem's shape)
deleteMetaItem legacy raw-engine exitdeletednone — same call the repository branch already makes

deleteMetaItem's legacy exit also now populates projectionApplied on its success return — the same optional key its repository-branch sibling has carried since ADR-0094 shipped (no new declared field; rollbackMetaItem / revertCommit carry none, unchanged).

Symbol fence honored: touched only rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit in packages/metadata-protocol/src/protocol.ts. Did not touch listCommits or its emitters (fenced to #14038).

Zone 2 measurement, quoted with file:line (HEAD a32f97047)

  • A. Confirmed on origin/main @ d5cbb44f3 (the commit the issue cites): runMutationProjector had exactly 3 call sites (:15438 saveMetaItem, :16671 runPublishSideEffects, :20455 deleteMetaItem repo branch); emitMetadataMutation had 7 (including the four target sites at :19435, :19534, :19915, :20625 — none preceded by a projector call). All four target sites existed and none already projected.
  • B.runMutationProjector is private async (protocol.ts, declared ~L4932 pre-diff), best-effort (catches, logs via console.warn, never throws), and MetadataMutationProjector/registerMutationProjector are a server-side extension seam — not part of ObjectStackProtocol's wire contract (same status as onMetadataMutation). Confirms Clause-②: no.
  • C. The call deleteMetaItem's repository branch already makes: this.runMutationProjector({ type: singularTypeForRepo, name: request.name, state: 'deleted', organizationId: orgId }) (pre-diff :20455-20460). The legacy exit was missing exactly that call (same shape, same state, org scope from request.organizationId ?? null instead of the resolved orgId — the legacy path's own scoping variable, unchanged from what its existing emitMetadataMutation call already used).
  • D. No existing caller depends on the old ordering: packages/plugins/plugin-security/src/permission-set-projection.test.ts (the one projector actually registered today, for permission) has zero tests exercising rollback/revert against the projector — this gap is exactly what the issue reports, so there's nothing today relying on the pre-fix silence. Ran the full objectql consumer suite that drives these three doors directly (protocol-commit-history, protocol-delete-object-registry-heal, protocol-lock-enforcement, protocol-org-overlay-registry-gate, protocol-publish-canonical-fold, protocol-publish-rollback, protocol-revert-org-scope, protocol-writepath-object-ownership — 113 tests) plus the sibling emit-mutation, mutation-listener and cluster-fanout suites (53 tests): all green.

Tests

New file: packages/metadata-protocol/src/protocol.recovery-doors-mutation-projector.test.ts — one pin per door (registers a fake projector, asserts it receives the correct state/body and runs before the mutation listener), a positive control (saveMetaItem), negative controls (unregistered type, throwing projector, already-absent row), a wire-shape check (deleteMetaItem legacy exit's projectionApplied), and a structural guard (exactly 7 runMutationProjector call sites).

Ablation (declared and run before landing): removing the rollbackMetaItem projector call — git checkout HEAD -- <path> restore, confirmed clean via git diff HEAD empty after — turned exactly 2 of the 10 new tests red (the rollback pin's body assertion, and the 7-call-site structural count dropping to 6), the other 8 green. Matches the file's own docblock claim.

Union re-run at HEAD a32f97047 (metadata-protocol + objectql):

Test Files 4 passed (4) — metadata-protocol (53 tests)
Test Files 3 passed (3) — objectql conformance (28 tests)

Gates run (dispatch-derived, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, 48 families): all green except two genuine PREREQUISITE NOT MET (exit 3, not a finding) that need a full-tree pnpm build this local run did not do — check:dual-build-cjs-loads and check:type-check-debt's --re-measure; CI's lint.yml builds the full workspace closure before both. check:engine-double-contract initially flagged the new test file's engine double as unrecorded coverage — fixed via node scripts/check-engine-double-contract.mjs --write (3 rows added, 0 lost), re-run green.

Companion ADR PR

Per the ruling, the code PR must not land without the ADR-0094 D2 amendment in flight: #14980 (draft, docs/adr/** governed surface, review requested from os-zhuang and hotlong, human merge only).

Changeset

patch.changeset/recovery-door-mutation-projector.md (internal projection ordering; no published schema or wire shape moves).

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…ector
Adds the awaited runMutationProjector call to rollbackMetaItem, both limbs
of revertCommit, and deleteMetaItem's legacy raw-engine exit, awaited
before the existing fire-and-forget emitMetadataMutation call at each site.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…d changeset
Adds protocol.recovery-doors-mutation-projector.test.ts pinning that
rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy
raw-engine exit run the ADR-0094 projector with the correct state/body,
before the existing emitMetadataMutation listener call, and adds the patch
changeset.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…coverage
node scripts/check-engine-double-contract.mjs --write, picking up the
delete/findOne/update doubles in protocol.recovery-doors-mutation-projector.test.ts
(mirrors the already-pinned stub in protocol.recovery-doors-emit-mutation.test.ts).
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@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 — 9 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

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

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

@os-musk
os-musk marked this pull request as ready for review September 3, 2026 16:51
@os-musk
os-musk enabled auto-merge September 3, 2026 16:51
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Independent review — PASS; marked ready and armed for the merge queue

An independent reviewer (not the author) re-derived this PR from the ruling and the code rather than from the PR body. Verdict PASS, no blocking defects. What was re-proved rather than accepted:

Clause-② = no, confirmed. Re-derived against the mechanical floor (any new exported symbol, or any new key on a published payload, is always yes). The diff adds only method-body statements inside four existing private methods — no new export. The projectionApplied key the legacy deleteMetaItem exit now populates is not new: it is declared once on that method's single shared return type at packages/metadata-protocol/src/protocol.ts:20271, that declaration line is untouched by this diff, and it already existed on main before this work began. The repository branch has populated the same optional key on the same shared type since ADR-0094 shipped (:20516). runMutationProjector is private async (:4932), never throws, and is a server-side extension seam — not part of the wire contract.

Ablation independently reproduced, both directions. Removing rollbackMetaItem's projector block (:19935-19950) and confirming the on-disk call-site count actually dropped 7 → 6:

 ❯ src/protocol.recovery-doors-mutation-projector.test.ts (10 tests | 2 failed) 78ms
× door 1 — rollbackMetaItem projects the restored body > runs the projector with state: active and the restored body, before the listener
× every projecting door goes through runMutationProjector > has exactly seven `this.runMutationProjector(` call sites
Test Files 1 failed (1)
Tests 2 failed | 8 passed (10)

Exactly the claimed 2 RED / 8 GREEN. Restored via git checkout HEAD --, proven clean with an empty git diff HEAD and git status --short, then 10/10 at restored HEAD. Sibling suites 43/43; an objectql consumer spot-check 28/28; typecheck clean.

Symbol fence against #14038 honored.#14038 is still open and scoped to listCommits (:18988-19106) and its emitters. This diff's five hunks land at :19435, :19544 (both inside revertCommit, :19107-19760), :19935 (inside rollbackMetaItem, :19761-20250), and :20661 / :20699 (inside deleteMetaItem). None falls within or before listCommits.

Org scope checked, no mismatch. The legacy branch never resolves an orgId variable at all: scopedWhere, restoreArtifactRegistryView, the pre-existing emitMetadataMutation call and the new projector call all use request.organizationId ?? null consistently — the branch's own established convention, unchanged here.

No double-run or unhandled-rejection path.runMutationProjector (:4932-4945) awaits inside its own try/catch, so sync throws and async rejections are both caught; all four call sites additionally sit inside their enclosing method's try/catch. revertCommit processes each item exactly once (:19184).

Gates re-derived off the merge base with no path arguments (48 commands): 44 green, 4 legitimate PREREQUISITE NOT MET (exit 3, each self-documented — the saved-turbo-log completeness gate, the repo-scoped-egress half-state gate, and two that read built dist/ for packages this change does not touch and need a whole-workspace build CI performs first). check-governed-merges.mjs --test on the final four-file list: NOT governed (0/4) — ordinary merge-queue landing.

Landing. All 33 check runs on head a32f97047 are success or skipped, zero red. Marked ready and auto-merge armed. The ruling's constraint that this PR must not land without the ADR-0094 D2 amendment in flight is satisfied: #14980 is open (draft, os-zhuang + hotlong requested, skip-changeset, human merge only — that PR is not touched by this seat).

One note for the ruling record, not a defect in this PR

Read with maximal literalness, the ruling's first clause says "rollbackMetaItem … and both limbs of revertCommit project with state: 'active'" and then, one clause later, that "revertCommit's soft-remove limb … project[s] with state: 'deleted'" — the soft-remove limb is one of "both limbs", so the two readings collide. Every independent restatement of the same decision — the issue's own "Suggested shape" section the ruling explicitly adopts, the claim comment, the ADR PR body, and this PR's table — states the coherent version (rollback + restore limb → active; soft-remove limb + legacy delete → deleted), which is what the diff implements and what the tests pin. Flagged so the wording can be tightened on the record if a maintainer wants; it changed nothing about what was built.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 95464edSep 3, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14415-recovery-door-mutation-projector branch September 3, 2026 17:34
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): recovery doors run the ADR-0094 mutation projector - #14982

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector
Sep 3, 2026
Merged

fix(metadata-protocol): recovery doors run the ADR-0094 mutation projector#14982
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14415

Ruling (A)

Director seat, summon #11, decision batch #17, item 2 (verbatim reply: 「同意」). Full ruling text is quoted on the issue thread (comment 5522731140).

What changed

rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit restored/removed the metadata row and the in-memory registry, but never called the awaited ADR-0094 mutation projector — so a derived read-model (e.g. permission -> sys_permission_set) stayed on the rolled-back-FROM state until an unrelated save/publish/delete on the same name, or boot reconciliation (D3), re-derived it. saveMetaItem, runPublishSideEffects, and deleteMetaItem's repository branch already ran this hook — this closes the same gap for the projector that a prior card (#14179) closed for the mutation-event choke point, at the same four call sites.

All four sites now call this.runMutationProjector(...), awaited BEFORE the existing fire-and-forget emitMetadataMutation call — the order saveMetaItem's own comment establishes:

doorstatebody
rollbackMetaItem (after registry write-through)activerestored version's body
revertCommit restore limbactivepre-commit body it wrote back
revertCommit soft-remove limbdeletednone (matches deleteMetaItem's shape)
deleteMetaItem legacy raw-engine exitdeletednone — same call the repository branch already makes

deleteMetaItem's legacy exit also now populates projectionApplied on its success return — the same optional key its repository-branch sibling has carried since ADR-0094 shipped (no new declared field; rollbackMetaItem / revertCommit carry none, unchanged).

Symbol fence honored: touched only rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit in packages/metadata-protocol/src/protocol.ts. Did not touch listCommits or its emitters (fenced to #14038).

Zone 2 measurement, quoted with file:line (HEAD a32f97047)

  • A. Confirmed on origin/main @ d5cbb44f3 (the commit the issue cites): runMutationProjector had exactly 3 call sites (:15438 saveMetaItem, :16671 runPublishSideEffects, :20455 deleteMetaItem repo branch); emitMetadataMutation had 7 (including the four target sites at :19435, :19534, :19915, :20625 — none preceded by a projector call). All four target sites existed and none already projected.
  • B.runMutationProjector is private async (protocol.ts, declared ~L4932 pre-diff), best-effort (catches, logs via console.warn, never throws), and MetadataMutationProjector/registerMutationProjector are a server-side extension seam — not part of ObjectStackProtocol's wire contract (same status as onMetadataMutation). Confirms Clause-②: no.
  • C. The call deleteMetaItem's repository branch already makes: this.runMutationProjector({ type: singularTypeForRepo, name: request.name, state: 'deleted', organizationId: orgId }) (pre-diff :20455-20460). The legacy exit was missing exactly that call (same shape, same state, org scope from request.organizationId ?? null instead of the resolved orgId — the legacy path's own scoping variable, unchanged from what its existing emitMetadataMutation call already used).
  • D. No existing caller depends on the old ordering: packages/plugins/plugin-security/src/permission-set-projection.test.ts (the one projector actually registered today, for permission) has zero tests exercising rollback/revert against the projector — this gap is exactly what the issue reports, so there's nothing today relying on the pre-fix silence. Ran the full objectql consumer suite that drives these three doors directly (protocol-commit-history, protocol-delete-object-registry-heal, protocol-lock-enforcement, protocol-org-overlay-registry-gate, protocol-publish-canonical-fold, protocol-publish-rollback, protocol-revert-org-scope, protocol-writepath-object-ownership — 113 tests) plus the sibling emit-mutation, mutation-listener and cluster-fanout suites (53 tests): all green.

Tests

New file: packages/metadata-protocol/src/protocol.recovery-doors-mutation-projector.test.ts — one pin per door (registers a fake projector, asserts it receives the correct state/body and runs before the mutation listener), a positive control (saveMetaItem), negative controls (unregistered type, throwing projector, already-absent row), a wire-shape check (deleteMetaItem legacy exit's projectionApplied), and a structural guard (exactly 7 runMutationProjector call sites).

Ablation (declared and run before landing): removing the rollbackMetaItem projector call — git checkout HEAD -- <path> restore, confirmed clean via git diff HEAD empty after — turned exactly 2 of the 10 new tests red (the rollback pin's body assertion, and the 7-call-site structural count dropping to 6), the other 8 green. Matches the file's own docblock claim.

Union re-run at HEAD a32f97047 (metadata-protocol + objectql):

Test Files 4 passed (4) — metadata-protocol (53 tests)
Test Files 3 passed (3) — objectql conformance (28 tests)

Gates run (dispatch-derived, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, 48 families): all green except two genuine PREREQUISITE NOT MET (exit 3, not a finding) that need a full-tree pnpm build this local run did not do — check:dual-build-cjs-loads and check:type-check-debt's --re-measure; CI's lint.yml builds the full workspace closure before both. check:engine-double-contract initially flagged the new test file's engine double as unrecorded coverage — fixed via node scripts/check-engine-double-contract.mjs --write (3 rows added, 0 lost), re-run green.

Companion ADR PR

Per the ruling, the code PR must not land without the ADR-0094 D2 amendment in flight: #14980 (draft, docs/adr/** governed surface, review requested from os-zhuang and hotlong, human merge only).

Changeset

patch.changeset/recovery-door-mutation-projector.md (internal projection ordering; no published schema or wire shape moves).

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…ector
Adds the awaited runMutationProjector call to rollbackMetaItem, both limbs
of revertCommit, and deleteMetaItem's legacy raw-engine exit, awaited
before the existing fire-and-forget emitMetadataMutation call at each site.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…d changeset
Adds protocol.recovery-doors-mutation-projector.test.ts pinning that
rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy
raw-engine exit run the ADR-0094 projector with the correct state/body,
before the existing emitMetadataMutation listener call, and adds the patch
changeset.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…coverage
node scripts/check-engine-double-contract.mjs --write, picking up the
delete/findOne/update doubles in protocol.recovery-doors-mutation-projector.test.ts
(mirrors the already-pinned stub in protocol.recovery-doors-emit-mutation.test.ts).
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@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 — 9 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

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

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

@os-musk
os-musk marked this pull request as ready for review September 3, 2026 16:51
@os-musk
os-musk enabled auto-merge September 3, 2026 16:51
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Independent review — PASS; marked ready and armed for the merge queue

An independent reviewer (not the author) re-derived this PR from the ruling and the code rather than from the PR body. Verdict PASS, no blocking defects. What was re-proved rather than accepted:

Clause-② = no, confirmed. Re-derived against the mechanical floor (any new exported symbol, or any new key on a published payload, is always yes). The diff adds only method-body statements inside four existing private methods — no new export. The projectionApplied key the legacy deleteMetaItem exit now populates is not new: it is declared once on that method's single shared return type at packages/metadata-protocol/src/protocol.ts:20271, that declaration line is untouched by this diff, and it already existed on main before this work began. The repository branch has populated the same optional key on the same shared type since ADR-0094 shipped (:20516). runMutationProjector is private async (:4932), never throws, and is a server-side extension seam — not part of the wire contract.

Ablation independently reproduced, both directions. Removing rollbackMetaItem's projector block (:19935-19950) and confirming the on-disk call-site count actually dropped 7 → 6:

 ❯ src/protocol.recovery-doors-mutation-projector.test.ts (10 tests | 2 failed) 78ms
× door 1 — rollbackMetaItem projects the restored body > runs the projector with state: active and the restored body, before the listener
× every projecting door goes through runMutationProjector > has exactly seven `this.runMutationProjector(` call sites
Test Files 1 failed (1)
Tests 2 failed | 8 passed (10)

Exactly the claimed 2 RED / 8 GREEN. Restored via git checkout HEAD --, proven clean with an empty git diff HEAD and git status --short, then 10/10 at restored HEAD. Sibling suites 43/43; an objectql consumer spot-check 28/28; typecheck clean.

Symbol fence against #14038 honored.#14038 is still open and scoped to listCommits (:18988-19106) and its emitters. This diff's five hunks land at :19435, :19544 (both inside revertCommit, :19107-19760), :19935 (inside rollbackMetaItem, :19761-20250), and :20661 / :20699 (inside deleteMetaItem). None falls within or before listCommits.

Org scope checked, no mismatch. The legacy branch never resolves an orgId variable at all: scopedWhere, restoreArtifactRegistryView, the pre-existing emitMetadataMutation call and the new projector call all use request.organizationId ?? null consistently — the branch's own established convention, unchanged here.

No double-run or unhandled-rejection path.runMutationProjector (:4932-4945) awaits inside its own try/catch, so sync throws and async rejections are both caught; all four call sites additionally sit inside their enclosing method's try/catch. revertCommit processes each item exactly once (:19184).

Gates re-derived off the merge base with no path arguments (48 commands): 44 green, 4 legitimate PREREQUISITE NOT MET (exit 3, each self-documented — the saved-turbo-log completeness gate, the repo-scoped-egress half-state gate, and two that read built dist/ for packages this change does not touch and need a whole-workspace build CI performs first). check-governed-merges.mjs --test on the final four-file list: NOT governed (0/4) — ordinary merge-queue landing.

Landing. All 33 check runs on head a32f97047 are success or skipped, zero red. Marked ready and auto-merge armed. The ruling's constraint that this PR must not land without the ADR-0094 D2 amendment in flight is satisfied: #14980 is open (draft, os-zhuang + hotlong requested, skip-changeset, human merge only — that PR is not touched by this seat).

One note for the ruling record, not a defect in this PR

Read with maximal literalness, the ruling's first clause says "rollbackMetaItem … and both limbs of revertCommit project with state: 'active'" and then, one clause later, that "revertCommit's soft-remove limb … project[s] with state: 'deleted'" — the soft-remove limb is one of "both limbs", so the two readings collide. Every independent restatement of the same decision — the issue's own "Suggested shape" section the ruling explicitly adopts, the claim comment, the ADR PR body, and this PR's table — states the coherent version (rollback + restore limb → active; soft-remove limb + legacy delete → deleted), which is what the diff implements and what the tests pin. Flagged so the wording can be tightened on the record if a maintainer wants; it changed nothing about what was built.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 95464edSep 3, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14415-recovery-door-mutation-projector branch September 3, 2026 17:34
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 \u003e 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): recovery doors run the ADR-0094 mutation projector - #14982

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector
Sep 3, 2026
Merged

fix(metadata-protocol): recovery doors run the ADR-0094 mutation projector#14982
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14415

Ruling (A)

Director seat, summon #11, decision batch #17, item 2 (verbatim reply: 「同意」). Full ruling text is quoted on the issue thread (comment 5522731140).

What changed

rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit restored/removed the metadata row and the in-memory registry, but never called the awaited ADR-0094 mutation projector — so a derived read-model (e.g. permission -> sys_permission_set) stayed on the rolled-back-FROM state until an unrelated save/publish/delete on the same name, or boot reconciliation (D3), re-derived it. saveMetaItem, runPublishSideEffects, and deleteMetaItem's repository branch already ran this hook — this closes the same gap for the projector that a prior card (#14179) closed for the mutation-event choke point, at the same four call sites.

All four sites now call this.runMutationProjector(...), awaited BEFORE the existing fire-and-forget emitMetadataMutation call — the order saveMetaItem's own comment establishes:

doorstatebody
rollbackMetaItem (after registry write-through)activerestored version's body
revertCommit restore limbactivepre-commit body it wrote back
revertCommit soft-remove limbdeletednone (matches deleteMetaItem's shape)
deleteMetaItem legacy raw-engine exitdeletednone — same call the repository branch already makes

deleteMetaItem's legacy exit also now populates projectionApplied on its success return — the same optional key its repository-branch sibling has carried since ADR-0094 shipped (no new declared field; rollbackMetaItem / revertCommit carry none, unchanged).

Symbol fence honored: touched only rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit in packages/metadata-protocol/src/protocol.ts. Did not touch listCommits or its emitters (fenced to #14038).

Zone 2 measurement, quoted with file:line (HEAD a32f97047)

  • A. Confirmed on origin/main @ d5cbb44f3 (the commit the issue cites): runMutationProjector had exactly 3 call sites (:15438 saveMetaItem, :16671 runPublishSideEffects, :20455 deleteMetaItem repo branch); emitMetadataMutation had 7 (including the four target sites at :19435, :19534, :19915, :20625 — none preceded by a projector call). All four target sites existed and none already projected.
  • B.runMutationProjector is private async (protocol.ts, declared ~L4932 pre-diff), best-effort (catches, logs via console.warn, never throws), and MetadataMutationProjector/registerMutationProjector are a server-side extension seam — not part of ObjectStackProtocol's wire contract (same status as onMetadataMutation). Confirms Clause-②: no.
  • C. The call deleteMetaItem's repository branch already makes: this.runMutationProjector({ type: singularTypeForRepo, name: request.name, state: 'deleted', organizationId: orgId }) (pre-diff :20455-20460). The legacy exit was missing exactly that call (same shape, same state, org scope from request.organizationId ?? null instead of the resolved orgId — the legacy path's own scoping variable, unchanged from what its existing emitMetadataMutation call already used).
  • D. No existing caller depends on the old ordering: packages/plugins/plugin-security/src/permission-set-projection.test.ts (the one projector actually registered today, for permission) has zero tests exercising rollback/revert against the projector — this gap is exactly what the issue reports, so there's nothing today relying on the pre-fix silence. Ran the full objectql consumer suite that drives these three doors directly (protocol-commit-history, protocol-delete-object-registry-heal, protocol-lock-enforcement, protocol-org-overlay-registry-gate, protocol-publish-canonical-fold, protocol-publish-rollback, protocol-revert-org-scope, protocol-writepath-object-ownership — 113 tests) plus the sibling emit-mutation, mutation-listener and cluster-fanout suites (53 tests): all green.

Tests

New file: packages/metadata-protocol/src/protocol.recovery-doors-mutation-projector.test.ts — one pin per door (registers a fake projector, asserts it receives the correct state/body and runs before the mutation listener), a positive control (saveMetaItem), negative controls (unregistered type, throwing projector, already-absent row), a wire-shape check (deleteMetaItem legacy exit's projectionApplied), and a structural guard (exactly 7 runMutationProjector call sites).

Ablation (declared and run before landing): removing the rollbackMetaItem projector call — git checkout HEAD -- <path> restore, confirmed clean via git diff HEAD empty after — turned exactly 2 of the 10 new tests red (the rollback pin's body assertion, and the 7-call-site structural count dropping to 6), the other 8 green. Matches the file's own docblock claim.

Union re-run at HEAD a32f97047 (metadata-protocol + objectql):

Test Files 4 passed (4) — metadata-protocol (53 tests)
Test Files 3 passed (3) — objectql conformance (28 tests)

Gates run (dispatch-derived, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, 48 families): all green except two genuine PREREQUISITE NOT MET (exit 3, not a finding) that need a full-tree pnpm build this local run did not do — check:dual-build-cjs-loads and check:type-check-debt's --re-measure; CI's lint.yml builds the full workspace closure before both. check:engine-double-contract initially flagged the new test file's engine double as unrecorded coverage — fixed via node scripts/check-engine-double-contract.mjs --write (3 rows added, 0 lost), re-run green.

Companion ADR PR

Per the ruling, the code PR must not land without the ADR-0094 D2 amendment in flight: #14980 (draft, docs/adr/** governed surface, review requested from os-zhuang and hotlong, human merge only).

Changeset

patch.changeset/recovery-door-mutation-projector.md (internal projection ordering; no published schema or wire shape moves).

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…ector
Adds the awaited runMutationProjector call to rollbackMetaItem, both limbs
of revertCommit, and deleteMetaItem's legacy raw-engine exit, awaited
before the existing fire-and-forget emitMetadataMutation call at each site.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…d changeset
Adds protocol.recovery-doors-mutation-projector.test.ts pinning that
rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy
raw-engine exit run the ADR-0094 projector with the correct state/body,
before the existing emitMetadataMutation listener call, and adds the patch
changeset.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…coverage
node scripts/check-engine-double-contract.mjs --write, picking up the
delete/findOne/update doubles in protocol.recovery-doors-mutation-projector.test.ts
(mirrors the already-pinned stub in protocol.recovery-doors-emit-mutation.test.ts).
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@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 — 9 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

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

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

@os-musk
os-musk marked this pull request as ready for review September 3, 2026 16:51
@os-musk
os-musk enabled auto-merge September 3, 2026 16:51
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Independent review — PASS; marked ready and armed for the merge queue

An independent reviewer (not the author) re-derived this PR from the ruling and the code rather than from the PR body. Verdict PASS, no blocking defects. What was re-proved rather than accepted:

Clause-② = no, confirmed. Re-derived against the mechanical floor (any new exported symbol, or any new key on a published payload, is always yes). The diff adds only method-body statements inside four existing private methods — no new export. The projectionApplied key the legacy deleteMetaItem exit now populates is not new: it is declared once on that method's single shared return type at packages/metadata-protocol/src/protocol.ts:20271, that declaration line is untouched by this diff, and it already existed on main before this work began. The repository branch has populated the same optional key on the same shared type since ADR-0094 shipped (:20516). runMutationProjector is private async (:4932), never throws, and is a server-side extension seam — not part of the wire contract.

Ablation independently reproduced, both directions. Removing rollbackMetaItem's projector block (:19935-19950) and confirming the on-disk call-site count actually dropped 7 → 6:

 ❯ src/protocol.recovery-doors-mutation-projector.test.ts (10 tests | 2 failed) 78ms
× door 1 — rollbackMetaItem projects the restored body > runs the projector with state: active and the restored body, before the listener
× every projecting door goes through runMutationProjector > has exactly seven `this.runMutationProjector(` call sites
Test Files 1 failed (1)
Tests 2 failed | 8 passed (10)

Exactly the claimed 2 RED / 8 GREEN. Restored via git checkout HEAD --, proven clean with an empty git diff HEAD and git status --short, then 10/10 at restored HEAD. Sibling suites 43/43; an objectql consumer spot-check 28/28; typecheck clean.

Symbol fence against #14038 honored.#14038 is still open and scoped to listCommits (:18988-19106) and its emitters. This diff's five hunks land at :19435, :19544 (both inside revertCommit, :19107-19760), :19935 (inside rollbackMetaItem, :19761-20250), and :20661 / :20699 (inside deleteMetaItem). None falls within or before listCommits.

Org scope checked, no mismatch. The legacy branch never resolves an orgId variable at all: scopedWhere, restoreArtifactRegistryView, the pre-existing emitMetadataMutation call and the new projector call all use request.organizationId ?? null consistently — the branch's own established convention, unchanged here.

No double-run or unhandled-rejection path.runMutationProjector (:4932-4945) awaits inside its own try/catch, so sync throws and async rejections are both caught; all four call sites additionally sit inside their enclosing method's try/catch. revertCommit processes each item exactly once (:19184).

Gates re-derived off the merge base with no path arguments (48 commands): 44 green, 4 legitimate PREREQUISITE NOT MET (exit 3, each self-documented — the saved-turbo-log completeness gate, the repo-scoped-egress half-state gate, and two that read built dist/ for packages this change does not touch and need a whole-workspace build CI performs first). check-governed-merges.mjs --test on the final four-file list: NOT governed (0/4) — ordinary merge-queue landing.

Landing. All 33 check runs on head a32f97047 are success or skipped, zero red. Marked ready and auto-merge armed. The ruling's constraint that this PR must not land without the ADR-0094 D2 amendment in flight is satisfied: #14980 is open (draft, os-zhuang + hotlong requested, skip-changeset, human merge only — that PR is not touched by this seat).

One note for the ruling record, not a defect in this PR

Read with maximal literalness, the ruling's first clause says "rollbackMetaItem … and both limbs of revertCommit project with state: 'active'" and then, one clause later, that "revertCommit's soft-remove limb … project[s] with state: 'deleted'" — the soft-remove limb is one of "both limbs", so the two readings collide. Every independent restatement of the same decision — the issue's own "Suggested shape" section the ruling explicitly adopts, the claim comment, the ADR PR body, and this PR's table — states the coherent version (rollback + restore limb → active; soft-remove limb + legacy delete → deleted), which is what the diff implements and what the tests pin. Flagged so the wording can be tightened on the record if a maintainer wants; it changed nothing about what was built.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 95464edSep 3, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14415-recovery-door-mutation-projector branch September 3, 2026 17:34
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): recovery doors run the ADR-0094 mutation projector - #14982

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector
Sep 3, 2026
Merged

fix(metadata-protocol): recovery doors run the ADR-0094 mutation projector#14982
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14415

Ruling (A)

Director seat, summon #11, decision batch #17, item 2 (verbatim reply: 「同意」). Full ruling text is quoted on the issue thread (comment 5522731140).

What changed

rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit restored/removed the metadata row and the in-memory registry, but never called the awaited ADR-0094 mutation projector — so a derived read-model (e.g. permission -> sys_permission_set) stayed on the rolled-back-FROM state until an unrelated save/publish/delete on the same name, or boot reconciliation (D3), re-derived it. saveMetaItem, runPublishSideEffects, and deleteMetaItem's repository branch already ran this hook — this closes the same gap for the projector that a prior card (#14179) closed for the mutation-event choke point, at the same four call sites.

All four sites now call this.runMutationProjector(...), awaited BEFORE the existing fire-and-forget emitMetadataMutation call — the order saveMetaItem's own comment establishes:

doorstatebody
rollbackMetaItem (after registry write-through)activerestored version's body
revertCommit restore limbactivepre-commit body it wrote back
revertCommit soft-remove limbdeletednone (matches deleteMetaItem's shape)
deleteMetaItem legacy raw-engine exitdeletednone — same call the repository branch already makes

deleteMetaItem's legacy exit also now populates projectionApplied on its success return — the same optional key its repository-branch sibling has carried since ADR-0094 shipped (no new declared field; rollbackMetaItem / revertCommit carry none, unchanged).

Symbol fence honored: touched only rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit in packages/metadata-protocol/src/protocol.ts. Did not touch listCommits or its emitters (fenced to #14038).

Zone 2 measurement, quoted with file:line (HEAD a32f97047)

  • A. Confirmed on origin/main @ d5cbb44f3 (the commit the issue cites): runMutationProjector had exactly 3 call sites (:15438 saveMetaItem, :16671 runPublishSideEffects, :20455 deleteMetaItem repo branch); emitMetadataMutation had 7 (including the four target sites at :19435, :19534, :19915, :20625 — none preceded by a projector call). All four target sites existed and none already projected.
  • B.runMutationProjector is private async (protocol.ts, declared ~L4932 pre-diff), best-effort (catches, logs via console.warn, never throws), and MetadataMutationProjector/registerMutationProjector are a server-side extension seam — not part of ObjectStackProtocol's wire contract (same status as onMetadataMutation). Confirms Clause-②: no.
  • C. The call deleteMetaItem's repository branch already makes: this.runMutationProjector({ type: singularTypeForRepo, name: request.name, state: 'deleted', organizationId: orgId }) (pre-diff :20455-20460). The legacy exit was missing exactly that call (same shape, same state, org scope from request.organizationId ?? null instead of the resolved orgId — the legacy path's own scoping variable, unchanged from what its existing emitMetadataMutation call already used).
  • D. No existing caller depends on the old ordering: packages/plugins/plugin-security/src/permission-set-projection.test.ts (the one projector actually registered today, for permission) has zero tests exercising rollback/revert against the projector — this gap is exactly what the issue reports, so there's nothing today relying on the pre-fix silence. Ran the full objectql consumer suite that drives these three doors directly (protocol-commit-history, protocol-delete-object-registry-heal, protocol-lock-enforcement, protocol-org-overlay-registry-gate, protocol-publish-canonical-fold, protocol-publish-rollback, protocol-revert-org-scope, protocol-writepath-object-ownership — 113 tests) plus the sibling emit-mutation, mutation-listener and cluster-fanout suites (53 tests): all green.

Tests

New file: packages/metadata-protocol/src/protocol.recovery-doors-mutation-projector.test.ts — one pin per door (registers a fake projector, asserts it receives the correct state/body and runs before the mutation listener), a positive control (saveMetaItem), negative controls (unregistered type, throwing projector, already-absent row), a wire-shape check (deleteMetaItem legacy exit's projectionApplied), and a structural guard (exactly 7 runMutationProjector call sites).

Ablation (declared and run before landing): removing the rollbackMetaItem projector call — git checkout HEAD -- <path> restore, confirmed clean via git diff HEAD empty after — turned exactly 2 of the 10 new tests red (the rollback pin's body assertion, and the 7-call-site structural count dropping to 6), the other 8 green. Matches the file's own docblock claim.

Union re-run at HEAD a32f97047 (metadata-protocol + objectql):

Test Files 4 passed (4) — metadata-protocol (53 tests)
Test Files 3 passed (3) — objectql conformance (28 tests)

Gates run (dispatch-derived, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, 48 families): all green except two genuine PREREQUISITE NOT MET (exit 3, not a finding) that need a full-tree pnpm build this local run did not do — check:dual-build-cjs-loads and check:type-check-debt's --re-measure; CI's lint.yml builds the full workspace closure before both. check:engine-double-contract initially flagged the new test file's engine double as unrecorded coverage — fixed via node scripts/check-engine-double-contract.mjs --write (3 rows added, 0 lost), re-run green.

Companion ADR PR

Per the ruling, the code PR must not land without the ADR-0094 D2 amendment in flight: #14980 (draft, docs/adr/** governed surface, review requested from os-zhuang and hotlong, human merge only).

Changeset

patch.changeset/recovery-door-mutation-projector.md (internal projection ordering; no published schema or wire shape moves).

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…ector
Adds the awaited runMutationProjector call to rollbackMetaItem, both limbs
of revertCommit, and deleteMetaItem's legacy raw-engine exit, awaited
before the existing fire-and-forget emitMetadataMutation call at each site.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…d changeset
Adds protocol.recovery-doors-mutation-projector.test.ts pinning that
rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy
raw-engine exit run the ADR-0094 projector with the correct state/body,
before the existing emitMetadataMutation listener call, and adds the patch
changeset.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…coverage
node scripts/check-engine-double-contract.mjs --write, picking up the
delete/findOne/update doubles in protocol.recovery-doors-mutation-projector.test.ts
(mirrors the already-pinned stub in protocol.recovery-doors-emit-mutation.test.ts).
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@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 — 9 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

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

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

@os-musk
os-musk marked this pull request as ready for review September 3, 2026 16:51
@os-musk
os-musk enabled auto-merge September 3, 2026 16:51
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Independent review — PASS; marked ready and armed for the merge queue

An independent reviewer (not the author) re-derived this PR from the ruling and the code rather than from the PR body. Verdict PASS, no blocking defects. What was re-proved rather than accepted:

Clause-② = no, confirmed. Re-derived against the mechanical floor (any new exported symbol, or any new key on a published payload, is always yes). The diff adds only method-body statements inside four existing private methods — no new export. The projectionApplied key the legacy deleteMetaItem exit now populates is not new: it is declared once on that method's single shared return type at packages/metadata-protocol/src/protocol.ts:20271, that declaration line is untouched by this diff, and it already existed on main before this work began. The repository branch has populated the same optional key on the same shared type since ADR-0094 shipped (:20516). runMutationProjector is private async (:4932), never throws, and is a server-side extension seam — not part of the wire contract.

Ablation independently reproduced, both directions. Removing rollbackMetaItem's projector block (:19935-19950) and confirming the on-disk call-site count actually dropped 7 → 6:

 ❯ src/protocol.recovery-doors-mutation-projector.test.ts (10 tests | 2 failed) 78ms
× door 1 — rollbackMetaItem projects the restored body > runs the projector with state: active and the restored body, before the listener
× every projecting door goes through runMutationProjector > has exactly seven `this.runMutationProjector(` call sites
Test Files 1 failed (1)
Tests 2 failed | 8 passed (10)

Exactly the claimed 2 RED / 8 GREEN. Restored via git checkout HEAD --, proven clean with an empty git diff HEAD and git status --short, then 10/10 at restored HEAD. Sibling suites 43/43; an objectql consumer spot-check 28/28; typecheck clean.

Symbol fence against #14038 honored.#14038 is still open and scoped to listCommits (:18988-19106) and its emitters. This diff's five hunks land at :19435, :19544 (both inside revertCommit, :19107-19760), :19935 (inside rollbackMetaItem, :19761-20250), and :20661 / :20699 (inside deleteMetaItem). None falls within or before listCommits.

Org scope checked, no mismatch. The legacy branch never resolves an orgId variable at all: scopedWhere, restoreArtifactRegistryView, the pre-existing emitMetadataMutation call and the new projector call all use request.organizationId ?? null consistently — the branch's own established convention, unchanged here.

No double-run or unhandled-rejection path.runMutationProjector (:4932-4945) awaits inside its own try/catch, so sync throws and async rejections are both caught; all four call sites additionally sit inside their enclosing method's try/catch. revertCommit processes each item exactly once (:19184).

Gates re-derived off the merge base with no path arguments (48 commands): 44 green, 4 legitimate PREREQUISITE NOT MET (exit 3, each self-documented — the saved-turbo-log completeness gate, the repo-scoped-egress half-state gate, and two that read built dist/ for packages this change does not touch and need a whole-workspace build CI performs first). check-governed-merges.mjs --test on the final four-file list: NOT governed (0/4) — ordinary merge-queue landing.

Landing. All 33 check runs on head a32f97047 are success or skipped, zero red. Marked ready and auto-merge armed. The ruling's constraint that this PR must not land without the ADR-0094 D2 amendment in flight is satisfied: #14980 is open (draft, os-zhuang + hotlong requested, skip-changeset, human merge only — that PR is not touched by this seat).

One note for the ruling record, not a defect in this PR

Read with maximal literalness, the ruling's first clause says "rollbackMetaItem … and both limbs of revertCommit project with state: 'active'" and then, one clause later, that "revertCommit's soft-remove limb … project[s] with state: 'deleted'" — the soft-remove limb is one of "both limbs", so the two readings collide. Every independent restatement of the same decision — the issue's own "Suggested shape" section the ruling explicitly adopts, the claim comment, the ADR PR body, and this PR's table — states the coherent version (rollback + restore limb → active; soft-remove limb + legacy delete → deleted), which is what the diff implements and what the tests pin. Flagged so the wording can be tightened on the record if a maintainer wants; it changed nothing about what was built.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 95464edSep 3, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14415-recovery-door-mutation-projector branch September 3, 2026 17:34
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): recovery doors run the ADR-0094 mutation projector - #14982

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector
Sep 3, 2026
Merged

fix(metadata-protocol): recovery doors run the ADR-0094 mutation projector#14982
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14415

Ruling (A)

Director seat, summon #11, decision batch #17, item 2 (verbatim reply: 「同意」). Full ruling text is quoted on the issue thread (comment 5522731140).

What changed

rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit restored/removed the metadata row and the in-memory registry, but never called the awaited ADR-0094 mutation projector — so a derived read-model (e.g. permission -> sys_permission_set) stayed on the rolled-back-FROM state until an unrelated save/publish/delete on the same name, or boot reconciliation (D3), re-derived it. saveMetaItem, runPublishSideEffects, and deleteMetaItem's repository branch already ran this hook — this closes the same gap for the projector that a prior card (#14179) closed for the mutation-event choke point, at the same four call sites.

All four sites now call this.runMutationProjector(...), awaited BEFORE the existing fire-and-forget emitMetadataMutation call — the order saveMetaItem's own comment establishes:

doorstatebody
rollbackMetaItem (after registry write-through)activerestored version's body
revertCommit restore limbactivepre-commit body it wrote back
revertCommit soft-remove limbdeletednone (matches deleteMetaItem's shape)
deleteMetaItem legacy raw-engine exitdeletednone — same call the repository branch already makes

deleteMetaItem's legacy exit also now populates projectionApplied on its success return — the same optional key its repository-branch sibling has carried since ADR-0094 shipped (no new declared field; rollbackMetaItem / revertCommit carry none, unchanged).

Symbol fence honored: touched only rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit in packages/metadata-protocol/src/protocol.ts. Did not touch listCommits or its emitters (fenced to #14038).

Zone 2 measurement, quoted with file:line (HEAD a32f97047)

  • A. Confirmed on origin/main @ d5cbb44f3 (the commit the issue cites): runMutationProjector had exactly 3 call sites (:15438 saveMetaItem, :16671 runPublishSideEffects, :20455 deleteMetaItem repo branch); emitMetadataMutation had 7 (including the four target sites at :19435, :19534, :19915, :20625 — none preceded by a projector call). All four target sites existed and none already projected.
  • B.runMutationProjector is private async (protocol.ts, declared ~L4932 pre-diff), best-effort (catches, logs via console.warn, never throws), and MetadataMutationProjector/registerMutationProjector are a server-side extension seam — not part of ObjectStackProtocol's wire contract (same status as onMetadataMutation). Confirms Clause-②: no.
  • C. The call deleteMetaItem's repository branch already makes: this.runMutationProjector({ type: singularTypeForRepo, name: request.name, state: 'deleted', organizationId: orgId }) (pre-diff :20455-20460). The legacy exit was missing exactly that call (same shape, same state, org scope from request.organizationId ?? null instead of the resolved orgId — the legacy path's own scoping variable, unchanged from what its existing emitMetadataMutation call already used).
  • D. No existing caller depends on the old ordering: packages/plugins/plugin-security/src/permission-set-projection.test.ts (the one projector actually registered today, for permission) has zero tests exercising rollback/revert against the projector — this gap is exactly what the issue reports, so there's nothing today relying on the pre-fix silence. Ran the full objectql consumer suite that drives these three doors directly (protocol-commit-history, protocol-delete-object-registry-heal, protocol-lock-enforcement, protocol-org-overlay-registry-gate, protocol-publish-canonical-fold, protocol-publish-rollback, protocol-revert-org-scope, protocol-writepath-object-ownership — 113 tests) plus the sibling emit-mutation, mutation-listener and cluster-fanout suites (53 tests): all green.

Tests

New file: packages/metadata-protocol/src/protocol.recovery-doors-mutation-projector.test.ts — one pin per door (registers a fake projector, asserts it receives the correct state/body and runs before the mutation listener), a positive control (saveMetaItem), negative controls (unregistered type, throwing projector, already-absent row), a wire-shape check (deleteMetaItem legacy exit's projectionApplied), and a structural guard (exactly 7 runMutationProjector call sites).

Ablation (declared and run before landing): removing the rollbackMetaItem projector call — git checkout HEAD -- <path> restore, confirmed clean via git diff HEAD empty after — turned exactly 2 of the 10 new tests red (the rollback pin's body assertion, and the 7-call-site structural count dropping to 6), the other 8 green. Matches the file's own docblock claim.

Union re-run at HEAD a32f97047 (metadata-protocol + objectql):

Test Files 4 passed (4) — metadata-protocol (53 tests)
Test Files 3 passed (3) — objectql conformance (28 tests)

Gates run (dispatch-derived, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, 48 families): all green except two genuine PREREQUISITE NOT MET (exit 3, not a finding) that need a full-tree pnpm build this local run did not do — check:dual-build-cjs-loads and check:type-check-debt's --re-measure; CI's lint.yml builds the full workspace closure before both. check:engine-double-contract initially flagged the new test file's engine double as unrecorded coverage — fixed via node scripts/check-engine-double-contract.mjs --write (3 rows added, 0 lost), re-run green.

Companion ADR PR

Per the ruling, the code PR must not land without the ADR-0094 D2 amendment in flight: #14980 (draft, docs/adr/** governed surface, review requested from os-zhuang and hotlong, human merge only).

Changeset

patch.changeset/recovery-door-mutation-projector.md (internal projection ordering; no published schema or wire shape moves).

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…ector
Adds the awaited runMutationProjector call to rollbackMetaItem, both limbs
of revertCommit, and deleteMetaItem's legacy raw-engine exit, awaited
before the existing fire-and-forget emitMetadataMutation call at each site.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…d changeset
Adds protocol.recovery-doors-mutation-projector.test.ts pinning that
rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy
raw-engine exit run the ADR-0094 projector with the correct state/body,
before the existing emitMetadataMutation listener call, and adds the patch
changeset.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…coverage
node scripts/check-engine-double-contract.mjs --write, picking up the
delete/findOne/update doubles in protocol.recovery-doors-mutation-projector.test.ts
(mirrors the already-pinned stub in protocol.recovery-doors-emit-mutation.test.ts).
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@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 — 9 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

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

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

@os-musk
os-musk marked this pull request as ready for review September 3, 2026 16:51
@os-musk
os-musk enabled auto-merge September 3, 2026 16:51
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Independent review — PASS; marked ready and armed for the merge queue

An independent reviewer (not the author) re-derived this PR from the ruling and the code rather than from the PR body. Verdict PASS, no blocking defects. What was re-proved rather than accepted:

Clause-② = no, confirmed. Re-derived against the mechanical floor (any new exported symbol, or any new key on a published payload, is always yes). The diff adds only method-body statements inside four existing private methods — no new export. The projectionApplied key the legacy deleteMetaItem exit now populates is not new: it is declared once on that method's single shared return type at packages/metadata-protocol/src/protocol.ts:20271, that declaration line is untouched by this diff, and it already existed on main before this work began. The repository branch has populated the same optional key on the same shared type since ADR-0094 shipped (:20516). runMutationProjector is private async (:4932), never throws, and is a server-side extension seam — not part of the wire contract.

Ablation independently reproduced, both directions. Removing rollbackMetaItem's projector block (:19935-19950) and confirming the on-disk call-site count actually dropped 7 → 6:

 ❯ src/protocol.recovery-doors-mutation-projector.test.ts (10 tests | 2 failed) 78ms
× door 1 — rollbackMetaItem projects the restored body > runs the projector with state: active and the restored body, before the listener
× every projecting door goes through runMutationProjector > has exactly seven `this.runMutationProjector(` call sites
Test Files 1 failed (1)
Tests 2 failed | 8 passed (10)

Exactly the claimed 2 RED / 8 GREEN. Restored via git checkout HEAD --, proven clean with an empty git diff HEAD and git status --short, then 10/10 at restored HEAD. Sibling suites 43/43; an objectql consumer spot-check 28/28; typecheck clean.

Symbol fence against #14038 honored.#14038 is still open and scoped to listCommits (:18988-19106) and its emitters. This diff's five hunks land at :19435, :19544 (both inside revertCommit, :19107-19760), :19935 (inside rollbackMetaItem, :19761-20250), and :20661 / :20699 (inside deleteMetaItem). None falls within or before listCommits.

Org scope checked, no mismatch. The legacy branch never resolves an orgId variable at all: scopedWhere, restoreArtifactRegistryView, the pre-existing emitMetadataMutation call and the new projector call all use request.organizationId ?? null consistently — the branch's own established convention, unchanged here.

No double-run or unhandled-rejection path.runMutationProjector (:4932-4945) awaits inside its own try/catch, so sync throws and async rejections are both caught; all four call sites additionally sit inside their enclosing method's try/catch. revertCommit processes each item exactly once (:19184).

Gates re-derived off the merge base with no path arguments (48 commands): 44 green, 4 legitimate PREREQUISITE NOT MET (exit 3, each self-documented — the saved-turbo-log completeness gate, the repo-scoped-egress half-state gate, and two that read built dist/ for packages this change does not touch and need a whole-workspace build CI performs first). check-governed-merges.mjs --test on the final four-file list: NOT governed (0/4) — ordinary merge-queue landing.

Landing. All 33 check runs on head a32f97047 are success or skipped, zero red. Marked ready and auto-merge armed. The ruling's constraint that this PR must not land without the ADR-0094 D2 amendment in flight is satisfied: #14980 is open (draft, os-zhuang + hotlong requested, skip-changeset, human merge only — that PR is not touched by this seat).

One note for the ruling record, not a defect in this PR

Read with maximal literalness, the ruling's first clause says "rollbackMetaItem … and both limbs of revertCommit project with state: 'active'" and then, one clause later, that "revertCommit's soft-remove limb … project[s] with state: 'deleted'" — the soft-remove limb is one of "both limbs", so the two readings collide. Every independent restatement of the same decision — the issue's own "Suggested shape" section the ruling explicitly adopts, the claim comment, the ADR PR body, and this PR's table — states the coherent version (rollback + restore limb → active; soft-remove limb + legacy delete → deleted), which is what the diff implements and what the tests pin. Flagged so the wording can be tightened on the record if a maintainer wants; it changed nothing about what was built.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 95464edSep 3, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14415-recovery-door-mutation-projector branch September 3, 2026 17:34
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): recovery doors run the ADR-0094 mutation projector - #14982

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector
Sep 3, 2026
Merged

fix(metadata-protocol): recovery doors run the ADR-0094 mutation projector#14982
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14415

Ruling (A)

Director seat, summon #11, decision batch #17, item 2 (verbatim reply: 「同意」). Full ruling text is quoted on the issue thread (comment 5522731140).

What changed

rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit restored/removed the metadata row and the in-memory registry, but never called the awaited ADR-0094 mutation projector — so a derived read-model (e.g. permission -> sys_permission_set) stayed on the rolled-back-FROM state until an unrelated save/publish/delete on the same name, or boot reconciliation (D3), re-derived it. saveMetaItem, runPublishSideEffects, and deleteMetaItem's repository branch already ran this hook — this closes the same gap for the projector that a prior card (#14179) closed for the mutation-event choke point, at the same four call sites.

All four sites now call this.runMutationProjector(...), awaited BEFORE the existing fire-and-forget emitMetadataMutation call — the order saveMetaItem's own comment establishes:

doorstatebody
rollbackMetaItem (after registry write-through)activerestored version's body
revertCommit restore limbactivepre-commit body it wrote back
revertCommit soft-remove limbdeletednone (matches deleteMetaItem's shape)
deleteMetaItem legacy raw-engine exitdeletednone — same call the repository branch already makes

deleteMetaItem's legacy exit also now populates projectionApplied on its success return — the same optional key its repository-branch sibling has carried since ADR-0094 shipped (no new declared field; rollbackMetaItem / revertCommit carry none, unchanged).

Symbol fence honored: touched only rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit in packages/metadata-protocol/src/protocol.ts. Did not touch listCommits or its emitters (fenced to #14038).

Zone 2 measurement, quoted with file:line (HEAD a32f97047)

  • A. Confirmed on origin/main @ d5cbb44f3 (the commit the issue cites): runMutationProjector had exactly 3 call sites (:15438 saveMetaItem, :16671 runPublishSideEffects, :20455 deleteMetaItem repo branch); emitMetadataMutation had 7 (including the four target sites at :19435, :19534, :19915, :20625 — none preceded by a projector call). All four target sites existed and none already projected.
  • B.runMutationProjector is private async (protocol.ts, declared ~L4932 pre-diff), best-effort (catches, logs via console.warn, never throws), and MetadataMutationProjector/registerMutationProjector are a server-side extension seam — not part of ObjectStackProtocol's wire contract (same status as onMetadataMutation). Confirms Clause-②: no.
  • C. The call deleteMetaItem's repository branch already makes: this.runMutationProjector({ type: singularTypeForRepo, name: request.name, state: 'deleted', organizationId: orgId }) (pre-diff :20455-20460). The legacy exit was missing exactly that call (same shape, same state, org scope from request.organizationId ?? null instead of the resolved orgId — the legacy path's own scoping variable, unchanged from what its existing emitMetadataMutation call already used).
  • D. No existing caller depends on the old ordering: packages/plugins/plugin-security/src/permission-set-projection.test.ts (the one projector actually registered today, for permission) has zero tests exercising rollback/revert against the projector — this gap is exactly what the issue reports, so there's nothing today relying on the pre-fix silence. Ran the full objectql consumer suite that drives these three doors directly (protocol-commit-history, protocol-delete-object-registry-heal, protocol-lock-enforcement, protocol-org-overlay-registry-gate, protocol-publish-canonical-fold, protocol-publish-rollback, protocol-revert-org-scope, protocol-writepath-object-ownership — 113 tests) plus the sibling emit-mutation, mutation-listener and cluster-fanout suites (53 tests): all green.

Tests

New file: packages/metadata-protocol/src/protocol.recovery-doors-mutation-projector.test.ts — one pin per door (registers a fake projector, asserts it receives the correct state/body and runs before the mutation listener), a positive control (saveMetaItem), negative controls (unregistered type, throwing projector, already-absent row), a wire-shape check (deleteMetaItem legacy exit's projectionApplied), and a structural guard (exactly 7 runMutationProjector call sites).

Ablation (declared and run before landing): removing the rollbackMetaItem projector call — git checkout HEAD -- <path> restore, confirmed clean via git diff HEAD empty after — turned exactly 2 of the 10 new tests red (the rollback pin's body assertion, and the 7-call-site structural count dropping to 6), the other 8 green. Matches the file's own docblock claim.

Union re-run at HEAD a32f97047 (metadata-protocol + objectql):

Test Files 4 passed (4) — metadata-protocol (53 tests)
Test Files 3 passed (3) — objectql conformance (28 tests)

Gates run (dispatch-derived, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, 48 families): all green except two genuine PREREQUISITE NOT MET (exit 3, not a finding) that need a full-tree pnpm build this local run did not do — check:dual-build-cjs-loads and check:type-check-debt's --re-measure; CI's lint.yml builds the full workspace closure before both. check:engine-double-contract initially flagged the new test file's engine double as unrecorded coverage — fixed via node scripts/check-engine-double-contract.mjs --write (3 rows added, 0 lost), re-run green.

Companion ADR PR

Per the ruling, the code PR must not land without the ADR-0094 D2 amendment in flight: #14980 (draft, docs/adr/** governed surface, review requested from os-zhuang and hotlong, human merge only).

Changeset

patch.changeset/recovery-door-mutation-projector.md (internal projection ordering; no published schema or wire shape moves).

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…ector
Adds the awaited runMutationProjector call to rollbackMetaItem, both limbs
of revertCommit, and deleteMetaItem's legacy raw-engine exit, awaited
before the existing fire-and-forget emitMetadataMutation call at each site.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…d changeset
Adds protocol.recovery-doors-mutation-projector.test.ts pinning that
rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy
raw-engine exit run the ADR-0094 projector with the correct state/body,
before the existing emitMetadataMutation listener call, and adds the patch
changeset.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…coverage
node scripts/check-engine-double-contract.mjs --write, picking up the
delete/findOne/update doubles in protocol.recovery-doors-mutation-projector.test.ts
(mirrors the already-pinned stub in protocol.recovery-doors-emit-mutation.test.ts).
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@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 — 9 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

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

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

@os-musk
os-musk marked this pull request as ready for review September 3, 2026 16:51
@os-musk
os-musk enabled auto-merge September 3, 2026 16:51
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Independent review — PASS; marked ready and armed for the merge queue

An independent reviewer (not the author) re-derived this PR from the ruling and the code rather than from the PR body. Verdict PASS, no blocking defects. What was re-proved rather than accepted:

Clause-② = no, confirmed. Re-derived against the mechanical floor (any new exported symbol, or any new key on a published payload, is always yes). The diff adds only method-body statements inside four existing private methods — no new export. The projectionApplied key the legacy deleteMetaItem exit now populates is not new: it is declared once on that method's single shared return type at packages/metadata-protocol/src/protocol.ts:20271, that declaration line is untouched by this diff, and it already existed on main before this work began. The repository branch has populated the same optional key on the same shared type since ADR-0094 shipped (:20516). runMutationProjector is private async (:4932), never throws, and is a server-side extension seam — not part of the wire contract.

Ablation independently reproduced, both directions. Removing rollbackMetaItem's projector block (:19935-19950) and confirming the on-disk call-site count actually dropped 7 → 6:

 ❯ src/protocol.recovery-doors-mutation-projector.test.ts (10 tests | 2 failed) 78ms
× door 1 — rollbackMetaItem projects the restored body > runs the projector with state: active and the restored body, before the listener
× every projecting door goes through runMutationProjector > has exactly seven `this.runMutationProjector(` call sites
Test Files 1 failed (1)
Tests 2 failed | 8 passed (10)

Exactly the claimed 2 RED / 8 GREEN. Restored via git checkout HEAD --, proven clean with an empty git diff HEAD and git status --short, then 10/10 at restored HEAD. Sibling suites 43/43; an objectql consumer spot-check 28/28; typecheck clean.

Symbol fence against #14038 honored.#14038 is still open and scoped to listCommits (:18988-19106) and its emitters. This diff's five hunks land at :19435, :19544 (both inside revertCommit, :19107-19760), :19935 (inside rollbackMetaItem, :19761-20250), and :20661 / :20699 (inside deleteMetaItem). None falls within or before listCommits.

Org scope checked, no mismatch. The legacy branch never resolves an orgId variable at all: scopedWhere, restoreArtifactRegistryView, the pre-existing emitMetadataMutation call and the new projector call all use request.organizationId ?? null consistently — the branch's own established convention, unchanged here.

No double-run or unhandled-rejection path.runMutationProjector (:4932-4945) awaits inside its own try/catch, so sync throws and async rejections are both caught; all four call sites additionally sit inside their enclosing method's try/catch. revertCommit processes each item exactly once (:19184).

Gates re-derived off the merge base with no path arguments (48 commands): 44 green, 4 legitimate PREREQUISITE NOT MET (exit 3, each self-documented — the saved-turbo-log completeness gate, the repo-scoped-egress half-state gate, and two that read built dist/ for packages this change does not touch and need a whole-workspace build CI performs first). check-governed-merges.mjs --test on the final four-file list: NOT governed (0/4) — ordinary merge-queue landing.

Landing. All 33 check runs on head a32f97047 are success or skipped, zero red. Marked ready and auto-merge armed. The ruling's constraint that this PR must not land without the ADR-0094 D2 amendment in flight is satisfied: #14980 is open (draft, os-zhuang + hotlong requested, skip-changeset, human merge only — that PR is not touched by this seat).

One note for the ruling record, not a defect in this PR

Read with maximal literalness, the ruling's first clause says "rollbackMetaItem … and both limbs of revertCommit project with state: 'active'" and then, one clause later, that "revertCommit's soft-remove limb … project[s] with state: 'deleted'" — the soft-remove limb is one of "both limbs", so the two readings collide. Every independent restatement of the same decision — the issue's own "Suggested shape" section the ruling explicitly adopts, the claim comment, the ADR PR body, and this PR's table — states the coherent version (rollback + restore limb → active; soft-remove limb + legacy delete → deleted), which is what the diff implements and what the tests pin. Flagged so the wording can be tightened on the record if a maintainer wants; it changed nothing about what was built.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 95464edSep 3, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14415-recovery-door-mutation-projector branch September 3, 2026 17:34
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): recovery doors run the ADR-0094 mutation projector - #14982

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector
Sep 3, 2026
Merged

fix(metadata-protocol): recovery doors run the ADR-0094 mutation projector#14982
os-musk merged 4 commits into
mainfrom
claude/issue-14415-recovery-door-mutation-projector

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14415

Ruling (A)

Director seat, summon #11, decision batch #17, item 2 (verbatim reply: 「同意」). Full ruling text is quoted on the issue thread (comment 5522731140).

What changed

rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit restored/removed the metadata row and the in-memory registry, but never called the awaited ADR-0094 mutation projector — so a derived read-model (e.g. permission -> sys_permission_set) stayed on the rolled-back-FROM state until an unrelated save/publish/delete on the same name, or boot reconciliation (D3), re-derived it. saveMetaItem, runPublishSideEffects, and deleteMetaItem's repository branch already ran this hook — this closes the same gap for the projector that a prior card (#14179) closed for the mutation-event choke point, at the same four call sites.

All four sites now call this.runMutationProjector(...), awaited BEFORE the existing fire-and-forget emitMetadataMutation call — the order saveMetaItem's own comment establishes:

doorstatebody
rollbackMetaItem (after registry write-through)activerestored version's body
revertCommit restore limbactivepre-commit body it wrote back
revertCommit soft-remove limbdeletednone (matches deleteMetaItem's shape)
deleteMetaItem legacy raw-engine exitdeletednone — same call the repository branch already makes

deleteMetaItem's legacy exit also now populates projectionApplied on its success return — the same optional key its repository-branch sibling has carried since ADR-0094 shipped (no new declared field; rollbackMetaItem / revertCommit carry none, unchanged).

Symbol fence honored: touched only rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy raw-engine exit in packages/metadata-protocol/src/protocol.ts. Did not touch listCommits or its emitters (fenced to #14038).

Zone 2 measurement, quoted with file:line (HEAD a32f97047)

  • A. Confirmed on origin/main @ d5cbb44f3 (the commit the issue cites): runMutationProjector had exactly 3 call sites (:15438 saveMetaItem, :16671 runPublishSideEffects, :20455 deleteMetaItem repo branch); emitMetadataMutation had 7 (including the four target sites at :19435, :19534, :19915, :20625 — none preceded by a projector call). All four target sites existed and none already projected.
  • B.runMutationProjector is private async (protocol.ts, declared ~L4932 pre-diff), best-effort (catches, logs via console.warn, never throws), and MetadataMutationProjector/registerMutationProjector are a server-side extension seam — not part of ObjectStackProtocol's wire contract (same status as onMetadataMutation). Confirms Clause-②: no.
  • C. The call deleteMetaItem's repository branch already makes: this.runMutationProjector({ type: singularTypeForRepo, name: request.name, state: 'deleted', organizationId: orgId }) (pre-diff :20455-20460). The legacy exit was missing exactly that call (same shape, same state, org scope from request.organizationId ?? null instead of the resolved orgId — the legacy path's own scoping variable, unchanged from what its existing emitMetadataMutation call already used).
  • D. No existing caller depends on the old ordering: packages/plugins/plugin-security/src/permission-set-projection.test.ts (the one projector actually registered today, for permission) has zero tests exercising rollback/revert against the projector — this gap is exactly what the issue reports, so there's nothing today relying on the pre-fix silence. Ran the full objectql consumer suite that drives these three doors directly (protocol-commit-history, protocol-delete-object-registry-heal, protocol-lock-enforcement, protocol-org-overlay-registry-gate, protocol-publish-canonical-fold, protocol-publish-rollback, protocol-revert-org-scope, protocol-writepath-object-ownership — 113 tests) plus the sibling emit-mutation, mutation-listener and cluster-fanout suites (53 tests): all green.

Tests

New file: packages/metadata-protocol/src/protocol.recovery-doors-mutation-projector.test.ts — one pin per door (registers a fake projector, asserts it receives the correct state/body and runs before the mutation listener), a positive control (saveMetaItem), negative controls (unregistered type, throwing projector, already-absent row), a wire-shape check (deleteMetaItem legacy exit's projectionApplied), and a structural guard (exactly 7 runMutationProjector call sites).

Ablation (declared and run before landing): removing the rollbackMetaItem projector call — git checkout HEAD -- <path> restore, confirmed clean via git diff HEAD empty after — turned exactly 2 of the 10 new tests red (the rollback pin's body assertion, and the 7-call-site structural count dropping to 6), the other 8 green. Matches the file's own docblock claim.

Union re-run at HEAD a32f97047 (metadata-protocol + objectql):

Test Files 4 passed (4) — metadata-protocol (53 tests)
Test Files 3 passed (3) — objectql conformance (28 tests)

Gates run (dispatch-derived, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, 48 families): all green except two genuine PREREQUISITE NOT MET (exit 3, not a finding) that need a full-tree pnpm build this local run did not do — check:dual-build-cjs-loads and check:type-check-debt's --re-measure; CI's lint.yml builds the full workspace closure before both. check:engine-double-contract initially flagged the new test file's engine double as unrecorded coverage — fixed via node scripts/check-engine-double-contract.mjs --write (3 rows added, 0 lost), re-run green.

Companion ADR PR

Per the ruling, the code PR must not land without the ADR-0094 D2 amendment in flight: #14980 (draft, docs/adr/** governed surface, review requested from os-zhuang and hotlong, human merge only).

Changeset

patch.changeset/recovery-door-mutation-projector.md (internal projection ordering; no published schema or wire shape moves).

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…ector
Adds the awaited runMutationProjector call to rollbackMetaItem, both limbs
of revertCommit, and deleteMetaItem's legacy raw-engine exit, awaited
before the existing fire-and-forget emitMetadataMutation call at each site.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…d changeset
Adds protocol.recovery-doors-mutation-projector.test.ts pinning that
rollbackMetaItem, both limbs of revertCommit, and deleteMetaItem's legacy
raw-engine exit run the ADR-0094 projector with the correct state/body,
before the existing emitMetadataMutation listener call, and adds the patch
changeset.
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…coverage
node scripts/check-engine-double-contract.mjs --write, picking up the
delete/findOne/update doubles in protocol.recovery-doors-mutation-projector.test.ts
(mirrors the already-pinned stub in protocol.recovery-doors-emit-mutation.test.ts).
Fixes#14415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@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 — 9 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

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

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

@os-musk
os-musk marked this pull request as ready for review September 3, 2026 16:51
@os-musk
os-musk enabled auto-merge September 3, 2026 16:51
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Independent review — PASS; marked ready and armed for the merge queue

An independent reviewer (not the author) re-derived this PR from the ruling and the code rather than from the PR body. Verdict PASS, no blocking defects. What was re-proved rather than accepted:

Clause-② = no, confirmed. Re-derived against the mechanical floor (any new exported symbol, or any new key on a published payload, is always yes). The diff adds only method-body statements inside four existing private methods — no new export. The projectionApplied key the legacy deleteMetaItem exit now populates is not new: it is declared once on that method's single shared return type at packages/metadata-protocol/src/protocol.ts:20271, that declaration line is untouched by this diff, and it already existed on main before this work began. The repository branch has populated the same optional key on the same shared type since ADR-0094 shipped (:20516). runMutationProjector is private async (:4932), never throws, and is a server-side extension seam — not part of the wire contract.

Ablation independently reproduced, both directions. Removing rollbackMetaItem's projector block (:19935-19950) and confirming the on-disk call-site count actually dropped 7 → 6:

 ❯ src/protocol.recovery-doors-mutation-projector.test.ts (10 tests | 2 failed) 78ms
× door 1 — rollbackMetaItem projects the restored body > runs the projector with state: active and the restored body, before the listener
× every projecting door goes through runMutationProjector > has exactly seven `this.runMutationProjector(` call sites
Test Files 1 failed (1)
Tests 2 failed | 8 passed (10)

Exactly the claimed 2 RED / 8 GREEN. Restored via git checkout HEAD --, proven clean with an empty git diff HEAD and git status --short, then 10/10 at restored HEAD. Sibling suites 43/43; an objectql consumer spot-check 28/28; typecheck clean.

Symbol fence against #14038 honored.#14038 is still open and scoped to listCommits (:18988-19106) and its emitters. This diff's five hunks land at :19435, :19544 (both inside revertCommit, :19107-19760), :19935 (inside rollbackMetaItem, :19761-20250), and :20661 / :20699 (inside deleteMetaItem). None falls within or before listCommits.

Org scope checked, no mismatch. The legacy branch never resolves an orgId variable at all: scopedWhere, restoreArtifactRegistryView, the pre-existing emitMetadataMutation call and the new projector call all use request.organizationId ?? null consistently — the branch's own established convention, unchanged here.

No double-run or unhandled-rejection path.runMutationProjector (:4932-4945) awaits inside its own try/catch, so sync throws and async rejections are both caught; all four call sites additionally sit inside their enclosing method's try/catch. revertCommit processes each item exactly once (:19184).

Gates re-derived off the merge base with no path arguments (48 commands): 44 green, 4 legitimate PREREQUISITE NOT MET (exit 3, each self-documented — the saved-turbo-log completeness gate, the repo-scoped-egress half-state gate, and two that read built dist/ for packages this change does not touch and need a whole-workspace build CI performs first). check-governed-merges.mjs --test on the final four-file list: NOT governed (0/4) — ordinary merge-queue landing.

Landing. All 33 check runs on head a32f97047 are success or skipped, zero red. Marked ready and auto-merge armed. The ruling's constraint that this PR must not land without the ADR-0094 D2 amendment in flight is satisfied: #14980 is open (draft, os-zhuang + hotlong requested, skip-changeset, human merge only — that PR is not touched by this seat).

One note for the ruling record, not a defect in this PR

Read with maximal literalness, the ruling's first clause says "rollbackMetaItem … and both limbs of revertCommit project with state: 'active'" and then, one clause later, that "revertCommit's soft-remove limb … project[s] with state: 'deleted'" — the soft-remove limb is one of "both limbs", so the two readings collide. Every independent restatement of the same decision — the issue's own "Suggested shape" section the ruling explicitly adopts, the claim comment, the ADR PR body, and this PR's table — states the coherent version (rollback + restore limb → active; soft-remove limb + legacy delete → deleted), which is what the diff implements and what the tests pin. Flagged so the wording can be tightened on the record if a maintainer wants; it changed nothing about what was built.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 95464edSep 3, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14415-recovery-door-mutation-projector branch September 3, 2026 17:34
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