Skip to content

refactor(metadata-protocol): move the post-persistence mutation fan-out out of protocol.ts into protocol-mutation-fan-out.ts (#25467 cut 25) - #26807

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-ybjm3h
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-ybjm3h

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Part of #25467
Clause-②: no
Metadata-protocol: none
Ruling-ref: 6270097840

Cut 25 of the protocol.ts split (#24586 ruling A, recorded in 6270097840). Cuts 1–24 landed as #25494, #25518, #25538, #25589, #25601, #25623, #25642, #25719, #25731, #25740, #25797, #25939, #25972, #26014, #26104, #26168, #26264, #26317, #26350, #26366, #26404, #26449, #26654 and #26704. The claim is 6446194706. It takes the cut 25 proposal of the cut 24 report (6443136164), a themed group. All four members passed the AST screen and the read-seam census, and all four moved unedited.

Four ObjectStackProtocolImplementation members move unedited into one new module, packages/metadata-protocol/src/protocol-mutation-fan-out.ts (#23050 shape B). Together they are the post-persistence mutation fan-out:

protocol.ts loses 25 net lines (29,152 to 29,127). The cut is move-only, and the card stays open for later cuts.

Branch claude/project-thread-ybjm3h is the harness-pinned branch for this thread. It was restarted from main 0f3d068cf1 (after cut 24's merge). It names no issue, so the claim comment is the identity record. It holds the move commit 62c90d6199 and one merge of main, 02bc68c682 (13 commits). The merge touches no file of packages/metadata-protocol/. The package's src/ tree is 91361684 at the move commit and at the merge.

Serial with the file's in-flight PRs

Measured just before this PR was opened, against the merged head 02bc68c682: of the 26 open PRs, none edits protocol.ts, and none names any of the four members, MutationFanOutHost or the new module. Four touch other files of packages/metadata-protocol/src/:

Each one's git merge-tree is clean against this head.

main moved three commits after the merge: #26787, #26788 (dogfood tests) and #26796 (create-objectstack). None touches this package, git merge-tree with this head is clean, and the derivation found nothing stale, so main was not merged again.

The order's screen (private statics, new Error(, the read-seam census, the call-site counts)

All four members passed:

  • No private-static read. The AST walk found no access on the ObjectStackProtocolImplementation identifier and none on this.constructor.
  • No error constructed. No body has new Error(, so no PRODUCERS row is owed.
  • Read-seam census. check-durability-degradation-log-level --list was taken on the base and again on the cut. Both read 88 read seam(s); the base count has grown from 82 since cut 24, through main. The lists are identical once [@:][0-9]+ is stripped. The full readings are under "The durability --list".
  • The recovery-doors call-site counts hold. protocol.recovery-doors-emit-mutation.test.ts and protocol.recovery-doors-mutation-projector.test.ts read protocol.ts by path and count its text. The counts at base and head:
Pattern counted in protocol.ts Base Head
this.emitMetadataMutation( 7 7
private emitMetadataMutation( 1 1
this.notifyMutationListenersLocal( 2 2
private notifyMutationListenersLocal( 1 1
this.runMutationProjector( 7 7
private async runMutationProjector( 1 1

The callers that are counted stay in the class: emitMetadataMutation (local, then cluster), applyRemoteMetadataMutation (local only), the seven projecting doors and the per-item publish door. That test file also slices applyRemoteMetadataMutation's body by text, and that body does not move. Both files pass at both ends, in the package suite.

What moves

Member Base line Visibility Body lines AST tokens (params + return + body) Reads
runMutationProjector :7493 private async 12 113 mutationProjectors (field)
notifyMutationListenersLocal :7563 private 10 66 metadataMutationListeners (field)
publishMetadataMutation :7675 private 16 115 clusterPubSub, clusterNodeId (fields)
emitMetaItemPublished :7864 private async 15 80 metaItemPublishedListeners (field)

protocol-mutation-fan-out.ts has 152 lines.

The shape (#23050 shape B, as cuts 1–24 apply it)

  • The class keeps all four members. Each keeps its name, docblock, signature and position. Each body now delegates, marked [#25467 cut 25].

  • Every member read is a field, so each is handed over as a getter. MutationFanOutHost types each field as the class member itself (ObjectStackProtocolImplementation['…']). Each function types its this as the Pick of what it reads, and each stub annotates its literal as ThisParameterType<typeof …>.

    • The getters read the protocol's own listener arrays, projector map and cluster wiring at call time.
    • attachMetadataMutationPubSub/detachMetadataMutationPubSub reassign clusterPubSub and clusterNodeId, and the getter sees the current values, exactly as the body did. This is the field hand-over cuts 20, 23 and 24 used.
    • No body reassigns a field; the listener arrays and the projector map are only iterated or read.
  • None of the four reads another, so no closure is owed.

  • No member changes visibility.

  • Every caller still calls the class method. The sites (base) are:

    • this.runMutationProjector( at :21364, :22976, :26599, :26718, :27073, :27923 and :28121.
    • this.notifyMutationListenersLocal( at :7550 and :7787.
    • this.publishMetadataMutation( at :7551.
    • this.emitMetaItemPublished( at :22394.

    None of them changes.

Module-level declarations, by reader

Measured by an AST walk over each body's identifiers, against protocol.ts's top-level declarations and import bindings (property names excluded):

Name Where protocol.ts has it Other readers in protocol.ts What this cut does
METADATA_MUTATION_CLUSTER_CHANNEL exported const, :5926 yes (attachMetadataMutationPubSub, and docblocks) none (already exported); the new module imports it from ./protocol.js
ClusterMetadataMutationPayload exported interface yes (attachMetadataMutationPubSub) none; imported as a type
MetadataMutationEvent, MutationProjectionOutcome, MetaItemPublishedEvent exported interfaces yes (the stubs' declared signatures and others) none; imported as types

No import of protocol.ts loses its last reader, and no declaration gains export.

The published .d.ts: one emitter trap, met and avoided

The series bar is ruling 6348891160: sorted-union identity, measured first, with raw identity reported when it holds. Both hold for this cut, as pushed.

Disclosed: the first layout of this cut failed the bar, and only through comments. The cut's first import comment was 16 characters longer. With it, the head .d.ts moved to 0105198c6b324117… (782,358 bytes; normalised c211e183dfe9c5e8…). The diff against base was one hunk: an inferred type from @objectstack/spec printed as generatedBy /** … */ ? /** … */ : string | undefined instead of generatedBy?: string | undefined;. Both comments were protocol.ts's own :967 docblock ("Zod schemas used to validate overlay items…"), which has nothing to do with that type.

  • The two files print identically with comments stripped (TypeScript printer, removeComments; sha 99dc518ed3a640f9… for both). So no type moved.
  • The mechanism, as measured: tsc's declaration emitter reuses a node whose source is another file, but reads its comment ranges from protocol.ts's text at the same offset. The cut's added import comment had shifted that docblock to start at exactly that offset (50,150).
  • Shortening the cut's own import comment by 16 characters moved the docblock off that offset, and raw identity returned (below). The bundles' JS does not change with comment text (index.js 8ef90e4aa9cbf4b4… both ways).
  • The trap is latent on main: any edit that moves the docblock onto that offset would trigger it. Diff the .d.ts hunk before blaming a change for it.

Base 0f3d068cf1 and head 62c90d6199 were built with pnpm --filter @objectstack/metadata-protocol build against one dependency tree. The closure was built at head by a full pnpm build (116/116). For the base build, the package's src/ was swapped tree-only to base (the new module removed) and restored from HEAD under a trap. Afterwards git diff HEAD was empty, git status was clean, and both touched blobs equalled HEAD's.

  • Normalised sha256 (the ruling's bar): 310d157f1455a7b76c3d9a60e905ac5e07debb23ec4a6a615b510870341bdbec at both ends, on dist/index.d.ts and dist/index.d.cts. The normaliser sorted 80 string-literal union runs. Sorted-union identity holds.
  • Raw sha256: 724eaa784128c37a7e4b59d85e21212ba6378a5b07279853c97828cd8368e932 at both ends, 777,143 bytes. Raw identity holds. It is also the hash cut 24 measured, since the base is cut 24's merge.
  • Again on the merged tree 02bc68c682 (closure rebuilt by a full pnpm build, 116/116): the same raw and normalised sha256, and the same index.js/index.cjs hashes, as the pre-merge head.
  • The two builds differ where they should.
    • index.js hashes 0cb9bc1754636851… at base and 8ef90e4aa9cbf4b4… at head (index.cjs 40c568bf03d0672b… → 65ffd5926831fb5d…).
    • Each of the four ….call(host spellings occurs 0 times in the base bundles and once in the head bundles (ESM and CJS alike).
    • Each function …( declaration goes 0 → 1.
  • Positive control on the head tree. private was dropped from runMutationProjector on disk (restored from HEAD under a trap) and the package rebuilt. Both .d.ts files move to c996c4705a6c30f8… (normalised 5db8ebd53a1498a3…, which differs from the bar's 310d157f…). So the bar can see a change on these members.
  • Export tables. The ESM and CJS bundles, loaded at runtime from the package, each export the same 78 names at base and head. The sorted list hashes to 9f18a0177ac736ed… on all of them. None of the four functions, and not MutationFanOutHost, appears.

No changeset: skip-changeset, per ruling 6348891160.

The durability --list

check-durability-degradation-log-level exits 0 at both ends with identical output: 88 read seam(s) at both ends. Its --list is identical once [@:][0-9]+ is stripped (164 lines, and no entry changes file).

  • Three of the bodies have a try (runMutationProjector, and the two listener loops). Each guards a callback, not a call in the gate's read vocabulary (find/findOne/count).
  • Each catch logs at warn, as before. No seam enters or leaves the census.
  • On the merged tree the verdict still reads 88 read seam(s).

Proof of move-only

Bodies, by AST token. The compare reads the TypeScript compiler's AST leaf tokens, with JSDoc skipped, over the parameters (less the added this), the return type and the body. It compares base protocol.ts at 0f3d068cf1 against the head module:

runMutationProjector: base tokens 113, head tokens 113, differ 0, text-equal(ws-collapsed, comments incl.) true
notifyMutationListenersLocal: base tokens 66, head tokens 66, differ 0, text-equal(ws-collapsed, comments incl.) true
publishMetadataMutation: base tokens 115, head tokens 115, differ 0, text-equal(ws-collapsed, comments incl.) true
emitMetaItemPublished: base tokens 80, head tokens 80, differ 0, text-equal(ws-collapsed, comments incl.) true
TOTAL tokens=374 differ=0 members=4
CLASS members base=301 head=301 order-same=true byte-identical=297 stubs-with-identical-docblock+signature=4 other=[]
TOP base=254 head=255 byte-identical=254

Negative controls, one per member. Each changed one token in an in-memory copy of the head module, and only the mutated member moved (differ 1; the other three stayed 0):

"return { success: true };"            -> "return { success: false };"            runMutationProjector differ 1
"listener(evt);"                        -> "listener(e);"                          notifyMutationListenersLocal differ 1
"if (evt.state === 'draft') return;"   -> "if (evt.state !== 'draft') return;"   publishMetadataMutation differ 1
"await listener(evt);"                  -> "void listener(evt);"                   emitMetaItemPublished differ 1
  • Class side. There are 301 members at base and at head, in the same order. The 297 that did not move are byte-identical to base, leading comments included. All four stubs sit at their base positions and keep their docblocks and signatures byte-identical.
  • Top level of protocol.ts. All 254 base statements are byte-identical, leading comments included. Head adds one statement, the new module's import.
  • Size. +179 / −52 over 2 files, 231 changed lines (the proposal's estimate was 200–260).

Readers of the moved lines

The tests and scripts that read protocol.ts by path were grepped for the four member names, the fields they read, and the bodies' log text: mutation projector failed, metadata-mutation listener failed, cluster metadata-mutation publish failed, post-publish re-bind announce FAILED, partitionKey. These are the files that name protocol.ts and call readFileSync. So were the test files in packages/, docs/qa/ and the @objectstack/spec liveness ledgers.

  • By path, two readers name the members, and neither reads a moved body. They are the two recovery-doors tests above. They count call sites and declarations, which stay, and their table holds at both ends. The emit-mutation test also names publishMetadataMutation in a comment.

  • Behaviour through the class method, no edit owed. These drive the four members through the protocol:

    • protocol.cluster-mutation-fanout.test.ts, mutation-listeners.test.ts and both recovery-doors tests (metadata-protocol).
    • plugin-publish-announce.test.ts, plugin-authored-hooks.test.ts and plugin-authored-actions.test.ts (objectql).

    The metadata-protocol ones pass at both ends, in the package suite.

  • The liveness ledgers. No row points at any of the four. Carried forward: manifest.json's dependencies row → protocol.ts#resolveWritePackageScope (cut 19), and seed.json → protocol.ts#applySeedBodies (cut 14). check:liveness checks only that the #symbol is named in the file. Re-pointing them is a packages/spec/** edit, outside this card.

  • ADR anchors. check-adr-anchors exits 0 at both ends.

    • The decision-letter count is unchanged at 1,898. No moved body cites an ADR letter.
    • The citation count goes 59,968 → 59,969 and the file count 8,393 → 8,394, from the new module's header (ADR-0094).
  • Engine-double contract (check-engine-double-contract): exits 0 at both ends.

    • The updateData/deleteData seams keep their entries at new line numbers (13863 → 13838, 13888 → 13863).
    • productionFiles is 3,798 at base and 3,800 at head. The +2 are the new file and the gitignored packages/spec/src/migrations/registry.ts that the spec build writes in the head tree, as in cuts 20–24.
  • Identical output at both ends: check-adr-symbol-anchors, check-system-context-census, check-tenant-audit-census, check-query-options-erasure-ratchet, check-filter-alias-parity and check-durability-degradation-log-level. check-nul-bytes differs only in the scanned file count, +1. The base-end readings were taken in a clean detached worktree at 0f3d068cf1.

Verification

  • tsc --noEmit in packages/metadata-protocol exits 0 at head and on the merged tree.
  • pnpm exec eslint --no-inline-config packages/metadata-protocol exits 0 with no output (the whole touched package, with the root lint script's flags).
  • packages/metadata-protocol suite, base and head, compared per test name and status. Each leg ran vitest --maxWorkers=2 with the JSON reporter under the shared verify lock.
    • Base 0f3d068cf1: 21,635 tests (21,616 passed, 0 failed, 19 skipped), 272 files.
    • Head 62c90d6199: 21,635 tests (21,616 passed, 0 failed, 19 skipped), 272 files.
    • None lost, none added, none changed status. The base leg is the package src/ swapped tree-only from 0f3d068cf1 (the new module removed) and restored from HEAD under a trap.
    • The merged head 02bc68c682 gives the same 21,635 tests, per test identical to the pre-merge head (0 lost, 0 added, 0 changed).
  • check-platform-checklist --changed-from origin/main (merged tree): OK. 1 anchored file is in the change set, 29 anchors were judged, and 0 were lost.
  • Governed surface. check-governed-merges --branch claude/project-thread-ybjm3h finds 0 of 2 paths in the register (13 surfaces), so the PR is NOT governed. It has 231 changed lines, under the 3,000-line human-merge bar.
  • Derived gates. dispatch-gates --commands --repo objectstack-ai/objectstack derives 46 gates on 2 changed paths from 02bc68c682, with no stale-tree finding. All 46 exit 0 after the full pnpm build (116/116). dispatch-gates --ran reports 46 derived, 46 run, 0 NOT-MEASURED and 0 UNRUN, and exits 0.
  • check-durability-degradation-log-level --list on 02bc68c682: 88 read seam(s).

Carry-forward

These stay in protocol.ts:

  • The read-seam members (cut 21). A member named in a protocol.ts read-seam entry of check-durability-degradation-log-level --list, as the guarded wrapper or the guarding member, stays until the gate follows imported wrappers or a ruling accepts the loss.
    • This is the census-negative set that cuts 22–23 measured: findEnvironmentRows, packagelessAddress, findServedOverlayRow, foldStoredCollection, applyObjectRegistryMutation, unmanagedImportCollisions, rollBackUnmanagedImport, readRevertableCommit and storedFlowBindingAgrees.
    • rethrowUnlessMetadataStoreUnprovisioned cannot move at all.
    • This cut re-trialled only its own members and the cut 26 candidates.
  • Public members whose return type is inferred.
  • The bodies kept by a by-path reader:
    • probeRecord, getMetaItemCached, findReferencesToMeta, refuseUnmintableMetaType and metaTypeNamespaceExists.
    • viewContainerNameCollisionRefusal with overlaidShippedContainerViewNames, and reportPageRequiresAbsentAtLoad.
    • The 7 applyRegistryWriteThrough callers (among them applyRemoteMetadataMutation), readCodeLayerForCarryForward and ensureObjectStorage.
    • emitMetadataMutation, a counted call site.
  • The private-static readers: refusePackagedBaseRemoval, packagedBaseRefusal, runPublishSideEffects, promoteDraftForPublish, publishPackageDrafts and discardPackageDrafts.
  • The field writers: warnClosureReadUnavailable and resolveSduiManifest.

🤖 Generated with Claude Code

https://claude.ai/code/session_016SP1spj613tyGCgxoD1An6

…ut out of protocol.ts into protocol-mutation-fan-out.ts (#25467 cut 25)

Move-only, shape B. runMutationProjector, notifyMutationListenersLocal,
publishMetadataMutation and emitMetaItemPublished move, unedited, into one
module. Each class member keeps its name, docblock and signature as a stub
that hands the fields it reads over as getters. The callers stay in the
class, so the recovery-doors call-site counts over protocol.ts do not move.

Claude-Session: https://claude.ai/code/session_016SP1spj613tyGCgxoD1An6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

15 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 55 of 207 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 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 c8583f80ac47177421c9c1fb71e5e33e431ad9cf → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 09a37ebf972b9ab2756df9ed6e3ebb5ffe86d2ed — the merge of head 02bc68c6829dfde7fc3ae00cc96a60e83a41558e into base c8583f80ac47177421c9c1fb71e5e33e431ad9cf, 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 09a37ebf972b9ab2756df9ed6e3ebb5ffe86d2ed && git checkout 09a37ebf972b9ab2756df9ed6e3ebb5ffe86d2ed
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c8583f80ac47177421c9c1fb71e5e33e431ad9cf 02bc68c6829dfde7fc3ae00cc96a60e83a41558e && git checkout -B drift-repro c8583f80ac47177421c9c1fb71e5e33e431ad9cf && git merge --no-ff 02bc68c6829dfde7fc3ae00cc96a60e83a41558e

node scripts/docs-audit/affected-docs.mjs --json c8583f80ac47177421c9c1fb71e5e33e431ad9cf

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-27T17:19Z

Head 02bc68c682, report 6448075845 (on #25467), claim 6446194706. No contract review is owed: there is no packages/spec/** diff, no governed path, no narrowing and no Clause-②: yes.

item reading
Shape Draft, base main, Part of #25467 (cut 25). 2 files: protocol.ts and one new module for the claimed theme, protocol-mutation-fan-out.ts (runMutationProjector, notifyMutationListenersLocal, publishMetadataMutation, emitMetaItemPublished). skip-changeset (move-only). Clause-②: no, Metadata-protocol: none.
Move check (seat) All 52 non-blank lines the PR removes from protocol.ts are found verbatim (indent-stripped) in the new module. The added lines in protocol.ts are the import with its header comment and four stubs, each through MutationFanOutHost with getters for the fields it reads (mutationProjectors, metadataMutationListeners, clusterPubSub and clusterNodeId, metaItemPublishedListeners). Every caller keeps calling the class members, so the recovery-doors call-site counts (7/2/7, declarations 1) hold. emitMetadataMutation and applyRemoteMetadataMutation stay.
Evidence CI green on head 02bc68c682 (34 success, 16 skipped, 0 failed). Governed check: NOT governed, 231 changed lines. The report gives an AST compare of 374 tokens with 0 differ (each negative control moves only its own member), raw and sorted-union .d.ts identity (ruling 6348891160) with the positive control moving the hash, the suite at 21,635 tests per-test identical at base, head and merged, the read-seam census at 88 with an identical --list (it grew from 82 through main), and 46 of 46 derived gates. No open PR edits protocol.ts.
Disclosed In the first layout raw .d.ts identity failed on comments only: tsc carried protocol.ts's :967 docblock into an inferred spec type, because the cut's import comment shifted it onto a reused node offset. The dev shortened that import comment by 16 characters, raw identity came back, and every head reading was re-run before push. The trap is latent on main and is written up in the report.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, once every check on this head is green or an expected skip.

Generated by Claude Code

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

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants