Skip to content

fix(metadata-protocol): another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of a view container are saved - #21979

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21967-view-expansion-per-package
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21967-view-expansion-per-package

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21967
Clause-②: no

Another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of a view container are saved. This is triage's direction A (6012036101, unlocked by 6013043442): the view list's expansion upsert is keyed per package. A package-bound container row writes only its own package's slot, and a package-less row stands in for every package with no row of its own (ADR-0048). The by-name read selects through the same function, so the list and the by-name read agree.

All source edits are in @objectstack/metadata-protocol (packages/metadata-protocol/src/protocol.ts). The other edits are a docblock in @objectstack/metadata-core and the done-when sentence on content/docs/ui/public-data-collection.mdx (declared to domain:devx on #6023). No rest-server.ts edit, no packages/spec edit, no governed path.

Commits (each can be read on its own)

  1. 2981f6eb78 test: the pins below, committed red against the base protocol.ts.
  2. c457f99935 the view list's expansion upsert, per package (the card's direction A):
  3. c966e63a0a the own-row test (finding(metadata-protocol): a stored view row named exactly like a container expansion is shadowed in the object door by the expansion, while the by-name read answers the stored row #21510's one predicate, namesWithOwnStoredRow) takes the package whose slot is being filled. A row counts when it is bound to that package or package-less; with no package, every row counts. The list asks it per slot and the by-name read for the package it names, so it is still the one predicate both doors ask.
    • Why it is in this PR: without it, one package's stored row of a form's exact name kept every other package's copy expansion of that name out of the env-wide list. On an environment-scoped kernel the endpoints then missed a withdrawal saved in the other package's copy, while the by-name read naming that package served it (pin (e)).
    • The done-when sentence "The endpoints do too" is only true with this commit.
    • It can be dropped on its own at review. If it is, the docs sentence needs a narrower exception for that case.
  4. dc853419db the done-when wording and the changeset. The page's "with one exception" sentence, its remedy and its "tracked in finding(metadata-protocol): a saved env-wide copy of a view container leaves its own expansion alone per name in the env-wide view list, so the anonymous form doors can miss another package's withdrawal, saved or shipped (#21934 item 1's residual) #21967" clause are removed; the over-close sentence stays. The anonymousFormIntakeWithdrawnIn docblock says the list holds one item per package, a package's saved container copy serving that package's item. The released security(metadata): tighten the draft publish gate and package identity for org view overlays (follow-up to #21864) #21934 changeset is not edited; .changeset/21967-view-expansion-per-package.md (@objectstack/metadata-protocol patch) states the change.

Hypotheses (PM's H1 to H4), measured at base f76c6221ac

Pins (packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts, 23 cases)

They are driven through the real protocol reads, as the file's existing cases are. The doors are modelled by their own composition (the organization's list, each open candidate judged over the env-wide list). The registry double holds each package's shipped container and its expanded views under PACKAGE:NAME, as the real SchemaRegistry holds them. Every case runs on an environment-scoped and an unscoped kernel.

  • (a) package A's env-wide copy saved with the form open, package B shipping it withdrawn: the env-wide list holds B's withdrawal beside A's item, and the doors serve no copy of a restored organization overlay. Run with no code package owning the object, and with A owning it. Control: with no package withdrawing it, the doors serve the overlay.
  • (b) the same with B's withdrawal saved in B's own copy, both save orders.
  • (c) a package-less copy stands in for every package with no copy of its own; a package's own copy serves that package's slot ahead of it, both save orders.
  • (d) for a name two packages' copies expand, and for a package-less copy: each package's slot in the env-wide list, getMetaItem naming the package, and the list scoped to the package serve the same item.
  • (e) one package's stored row of the form's exact name serves that package's slot and hides no other package's copy. Control: a package-less row of the name serves every slot.

No dogfood case was added: the doors' verdict is a pure composition of getMetaItems and the shared function, pinned in-process as #21934's pins are.

Measured

  • Base reading (pins at 2981f6eb78 over base protocol.ts): 19 red, 4 green. The greens are the (a) control, both (e) controls, and (e) on the unscoped kernel, where registry hydration served B's copy. Every red failed in the defect's shape, for example expected [ [ 'pkg_a', true, … ] ] to deeply equal [ [ 'pkg_a', … ], [ 'pkg_b', … ] ].
  • Reverse verification on committed head dc853419db, through scripts/ablation-replace.mjs. The anchor hit once, the blob changed on disk, and each restore was proved (blob equal to HEAD def1f5213f34, git diff HEAD empty). The subject is imported from source (./protocol.js), so there is no dist leg. Predictions were written before the runs.
    • A1, the order's ablation (the view branch set back to base's by-name upsert, verbatim): predicted 19 red / 4 green, measured 19 red / 4 green, the same split as the base.
    • A2 (the own-row test asked with no package): predicted 1 red, measured 1 red: (e) on the environment-scoped kernel.
    • A3 (the by-name read without its package-less read): predicted 2 red, measured 1 red, on the environment-scoped kernel. The prediction missed the unscoped kernel: there the by-name read falls through to the registry, whose bare-name getItem answers the hydrated expansion with the same body.
  • Clause-② measured on the built entry declarations, base protocol.ts against head, comments stripped:
    • @objectstack/metadata-protocol dist/index.d.ts and index.d.cts: identical (2162 lines each).
    • @objectstack/metadata-core index.d.ts, index.d.cts, repository-*.d.ts and testing.d.ts: identical.
    • The raw bytes differ in comments only. No exported signature moves, so Clause-②: no, as claimed.
  • Tests:
    • @objectstack/metadata-protocol at c966e63a0a (later commits change no source in it): typecheck green (tsc --noEmit). Full suite in two halves: 218 files passed, 3 skipped; 28019 tests passed, 19 skipped.
    • The related suites again after the own-row commit: 8 files, 884 tests passed.
    • @objectstack/metadata-core (comment only): both typecheck programs green; 18 files, 411 tests passed. The edited test file is in metadata-protocol's tsc program (counted with --listFiles).
  • Gates, all at head dc853419db (origin/main is still the base f76c6221ac, so the merge is a no-op):
    • node scripts/pm/dispatch-gates.mjs --commands with no paths derived 93 commands from the 5 changed paths. All 93 were run and exit 0. --ran with recorded exit codes: "93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN".
    • Three of them first answered PREREQUISITE NOT MET (unbuilt workspace packages): check:skill-examples, check:lean-entry-closure and check:dual-build-cjs-loads. check:type-check-debt's own re-measure then built the workspace, and all three were rerun green: 262 examples, 2 conditions, 106 entry points in 66 packages.
    • The artifact-roster block printed outside the total (53 commands) was run as well. 51 exit 0. check-closing-target-claim and check-single-claim-paths need this PR's context, so they run after it opens (in the report). check-partof-closing-keyword ran green over this body.
    • The four symbol-anchor sweeps are green: check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors and check:adr-anchors.
    • Lint, narrowed to the 3 touched TypeScript files with eslint --no-inline-config (as pnpm lint runs it): 3 files, 0 errors, 0 warnings.
      • The .mdx and the changeset match no eslint files glob.
      • eslint.config.mjs enables no type-aware linting (no parserOptions.project or projectService), and it reads only two baselines from disk, neither touched. So the diff cannot move an untouched file's verdict.
    • No raw control byte in any changed file (grep -P over the five paths: 0 each). check:nul-bytes is green.

Acceptance notes


Generated by Claude Code

claude added 4 commits October 6, 2026 09:56
…to its own package's slot

The pins drive the protocol's real list and by-name reads, and the
anonymous form doors' own verdict over the env-wide view list:

- (a) one package's env-wide container copy saved with a form open,
  another package shipping it withdrawn;
- (b) the same with the withdrawal saved in the other package's copy,
  in both save orders;
- (c) a package-less copy stands in for every package with no copy of
  its own;
- (d) the list's slot for a package, the by-name read naming it and the
  list scoped to it serve the same item;
- (e) a stored row of the form's own name serves its own package's slot
  and hides no other package's copy.

On the base protocol.ts: 19 red, 4 green (the (a) control, the two (e)
controls, and (e) on the unscoped kernel, where registry hydration
serves the other package's copy).

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…o its own package's slot of each name

The list read's view branch upserted the last expansion of a name over
every package's item of that name. So one package's stored env-wide
copy of a view container displaced another package's item of each name
the copy expands, and the anonymous form doors, which judge against the
env-wide view list, could miss that package's withdrawal of a form,
shipped or saved.

Each slot of a name an expansion writes now serves, for its package,
the expansion of the package's own container row, else of a
package-less one, which stands in for every package with no container
row of its own (ADR-0048). A slot neither reaches keeps its item. The
selection is one function, servedViewExpansion, whose package order is
the package dimension of servedOverlayRowCandidates.

The by-name read (resolveRowlessExpandedView) selects through the same
function: naming a package, the package-less rows in scope stand in, as
in the list scoped to that package, which now expands them too in the
slots the package seats (a stand-in never seats a slot). So a package's
slot in the list, the list scoped to it and the by-name read naming it
serve the same expansion.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…kage's slot only

The list read asked its own-row test with no package for every slot of
a name, so one package's stored row of a name kept every other
package's container expansion of that name out of the list. The by-name
read naming the other package served that expansion, so the two doors
disagreed, and the anonymous form doors could miss a withdrawal saved
in the other package's copy of the container (on an environment-scoped
kernel; an unscoped kernel's registry hydration happened to serve it).

namesWithOwnStoredRow takes the package whose slot is being filled: a
row counts when it is bound to that package or package-less (the
package dimension of servedOverlayRowCandidates, shared with
servedViewExpansion through addressPackages); with no package, every
row counts. The list asks it per slot, the by-name read for the package
it names. It is still the one predicate both doors ask.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…public form page and the intake docblock

The public data collection page's "Known limit: packages and names"
stated one exception for the anonymous form endpoints: a saved
environment-wide copy of a view container served its expansion alone
for each form it expands. The view list now serves each package's own
item of such a name, so the exception and its tracking clause are
removed. The over-close sentence stays. The anonymousFormIntakeWithdrawnIn
docblock says the same. The changeset states the change in the view
list.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-core, @objectstack/metadata-protocol, touching 6 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/metadata-core/src/anonymous-form-intake.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

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

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-core/src/anonymous-form-intake.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 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 — 13 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 f76c6221acd3997dd778fdd3e8e7d43e0bec4851 → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: dc853419db59c78e54ec31404716d3a66038dc7d
Local-runs: none

Inputs read: card #21967 body; comments 6011733182 (reach correction), 6012036101 (grade, direction A, stop condition), 6013043442 (unlock, done-when), 6013393991 (claim), 6014548600 (os-dev-report); PR #21979 body and file list; the net diff f76c6221ac..dc853419db; the 35 check-runs on the head; the ruling comments 5946423948, 5955628428, 5973827435, 5987404976, 5988911029, 5957375321, 5964342087 and the #21817 landing note 5995212457; the #20301 changeset at base; card #21980. The dispatching seat's conclusions and the dispatch order were not read as inputs. Line numbers below are at the head unless marked base.

① Derived judgments

Gate verdicts. All 33 completed check-runs on the head conclude success. Console Pin Gate and Packed-tarball smoke (opt-in) are skipped (path-filtered and opt-in). The seven required contexts are green: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. File list: 5 files, 494 insertions, 55 deletions. No governed path. Head repo is objectstack-ai/objectstack, not a fork.

1. The env-wide view list keyed per package: RIGHT. Base protocol.ts:9070–:9105 collapsed every name a stored container expands to the last expansion in row order (byName.set at base :9092; every other item of the name dropped at base :9094–:9104). Head protocol.ts:9141–:9231 serves each (name, package) slot through servedViewExpansion (:2042–:2056), whose package order is addressPackages (:2012–:2015), derived from servedOverlayRowCandidates (:1994–:2004). A slot that neither its own package's container nor a package-less one reaches keeps its item (:9192). So every withdrawing body base held is held at head. The last expansion of a name is either a package-bound container's expansion, served in that package's slot (:9210–:9212, or :9222–:9228 when nothing lists the name), or a package-less container's expansion, served in every package slot that has no own container (:9201) and listed on its own when no package-bound container expands the name (:9224). Pins (a) and (b), protocol.org-scoped-write-refused.test.ts:1416–:1458, hold the withdrawing body beside the open copy on both kernels, judged through the doors' own composition (doorsServe, :1409–:1414), which restates findPublicFormView (rest-server.ts:10735–:10751) over the organization's list and the env-wide layer (:10803, :10811–:10818).

The one case where the head's list holds fewer withdrawing bodies than base, judged RIGHT: a package-less copy that withdraws the form, saved beside an own copy of every package that has a slot of the name. It is reachable, because the save door leaves every row under the save name out of the siblings (:19449–:19452). Base listed the package-less expansion only when the store returned it last, a row-order accident. Head serves each package its own copy and lists the package-less one nowhere (:9201 is not reached; :9224 skips it). That is ADR-0048's prefer-local resolution, the one mergePackageAwareOverlay already applies to stored rows of the name itself (:2199–:2220: a package-less row is emitted in no slot when every package has its own row), and the one getMetaItem naming the package answers. Comment 5987404976 (#21804) rules one prefer-local resolution and no second resolver. Comment 5973827435 (#21639) refuses a merge rule as a second precedence order. A rule that let the shadowed fallback close the form would be that second rule. It is not a layering re-open: all the copies are env-wide administrator saves at one layer, and the kill switch's layers (organization over env-wide over package artifact) are untouched.

2. The standInNames and unseated branch, and the package-scoped list: RIGHT. standInRows are the package-less rows of the package-agnostic read (:9015–:9016). Head expands them as stand-ins only when packageId is set (:9154–:9157). Membership (#21817, comment 5988911029, "the scoped list's membership does not grow"): the items loop serves only the slots of listed items (:9204–:9213); the expansions loop skips every package-less expansion in a scoped list (:9224, the packageId !== undefined arm); a package-less ROW of the name stays held back unless the package's OWN container seats it (:9187, the added served.container.packageId !== undefined condition; base :9087 seated on any expansion, which was always the package's own because base expanded overlays only, base :9073); an unseated stand-in is still dropped after the last merge (:9433–:9435). So nothing enters a scoped list that the package does not ship or expand itself. Content: in a slot the package seats, a package-less container's expansion stands in only when the package has no own container expansion of the name and no own row of it (:9177, :9192), stamped with the package (:9201) as the merge stamps a package-less row standing in (:2218). Pin (d), :1492–:1527, asserts the scoped list, the env-wide slot and the by-name read agree, for a two-package case and a package-less case, on both kernels.

3. Commit c966e63, namesWithOwnStoredRow keyed by the slot's package: RIGHT. Base :9071 counted a row of any package, so one package's row of a name filtered every package's expansion of it out (base :9073–:9074). Head :9786–:9794 counts, for a package's slot, a row bound to that package or package-less (addressPackages), and with no package every row. What another package's expansion can now displace in its own slot is only that package's shipped item or a lower-layer item of that package: :9192 returns held whenever the slot's package or a package-less row holds the name. A row bound to package P is not a candidate at package Q's address under ADR-0048 (:2001), so P's row never had a claim on Q's slot. Base's by-name read naming Q already ignored P's row (it read Q's rows only, readActiveOverlayRows with packageId, :9605), so base's two doors disagreed. Comment 5964342087 (#21510) rules the stored row wins on both doors through one predicate; head keeps one predicate, both doors call it (:9150, :9860), and a row still wins its own address's slot. The case it closes is pin (e), :1529–:1546: P's row of the exact form name beside Q's withdrawing copy. Base served Q's shipped open item in Q's slot, so the env-wide list missed Q's saved withdrawal (red at base on the environment-scoped kernel per 6014548600). A package-less row of the name still holds every slot (control, :1548). The seat's answer A to open_questions[0] is sound: dropping the commit would leave the env-wide list missing Q's saved withdrawal in exactly that case, and the docs sentence "The endpoints do too" would be false.

4. The doors and the save check need no change: RIGHT. findPublicFormView judges each open candidate of the organization's list against the env-wide list by anonymousFormIntakeWithdrawnIn (rest-server.ts:10744), which compares name only (anonymous-form-intake.ts:347) and withdraws on any body of the name with an explicit false switch (:348–:350). resolveFormBySlug reads getMetaItems for the organization (rest-server.ts:10803) and env-wide (:10817), both through readFlattenedMetaItems. anonymousFormIntakeReopenRefusal reads the same env-wide list (protocol.ts:16217–:16221) and the raw rows of the save name per package (:16229–:16235), both unchanged. More bodies per name in the list can only add withdrawals under a name compare; the verdict needs no package. rest-server.ts is not in the file list.

5. The by-name read's package-less rows add no new reach: RIGHT. resolveRowlessExpandedView naming a package now also reads readActiveOverlayRows({ type }, orgId) filtered to package_id null (:9849–:9852), with the same gated orgId its callers already pass (getMetaItem :10302; the layered read :10841; the gate is organizationIdForMetaRead). The same caller already reads the package-less row of the name as a served-row candidate (:2001), and the scoped list already reads this exact set as lockRows and standInRows (:9015–:9016). No row a caller could not read before is reached. Naming no package, the read is unchanged (:9845–:9848; servedViewExpansion with undefined is any expansion, the last winning, :2051).

6. The H3 rulings, read from the comments: no contradiction found.

7. The done-when wording: RIGHT. public-data-collection.mdx:71 drops the exception sentence, its remedy and the "tracked in #21967" clause; "The endpoints do too." and the over-close sentence stay, as 6013043442 added to done-when. anonymous-form-intake.ts:326–:333 states the per-package rule positively. Both match head's behaviour. #21980 (a copy that expands under its own name on another package's object) does not falsify "every package's environment-wide definition of the name": that copy defines a different name, so it is not a body of the name.

8. Test fidelity: RIGHT. The registry double (:1327–:1364) holds each package's shipped container and its expandViewContainer views under PACKAGE:NAME, a bare slot for the unscoped kernel's hydration, and a package-scoped getArtifactItem, which mirrors the loaders the reach correction 6011733182 names. 23 cases, both kernels, 19 red at base per the report. The reverse verification A1 to A3 in 6014548600 is consistent with the diff; the A3 miss is explained by the unscoped kernel's registry fallback at getMetaItem step 3 and is a measurement note, not a head defect.

② Semver level

.changeset/21967-view-expansion-per-package.md: @objectstack/metadata-protocol patch, body carries Clause-②: no. Right level: a served-content fix in a released package. No key, export, status or error code moves. Every source edit is a module-private function (addressPackages, servedViewExpansion) or a private method (namesWithOwnStoredRow, resolveRowlessExpandedView, the view branch of readFlattenedMetaItems), so the exported surface of @objectstack/metadata-protocol is unchanged on the diff. @objectstack/metadata-core changes a docblock only; no changeset is owed for it. The PR body's first two lines are Fixes #21967 and Clause-②: no, matching the claim 6013393991 and the changeset. Check Changeset is green. No ADR-0087 marker is owed: the changeset is not breaking. Clause-②: no, confirmed.

③ Boundary flags

Dev flags from 6014548600, each answered:

Triage's pins (6012036101) both hold: (a) and (b) for a shipped and a saved withdrawal with one copy present; (d) for list and by-name agreement. The done-when of 6013043442 is met in this PR. No dogfood case was added; the doors' verdict is the composition the pins reproduce (judgment 1), within triage's "only if a door-level pin is measured necessary".

Implemented-by: claude/issue-21967-view-expansion-per-package
Reviewed-by: session_017ErfyP2Rx7XWHJA27QjyUi

VERDICT: PASS

Adopted by domain:engine#1 (session_017ErfyP2Rx7XWHJA27QjyUi) at 2026-10-06T11:20Z as the record of head dc853419db, the current head. This review is seat-commissioned. The card is security and the change is large, but Clause-②: no does not owe one.

ACCEPT (seat review). The seat read the protocol.ts diff:

  • servedViewExpansion is the one selection both doors use.
  • The list's view branch upserts per slot.
  • namesWithOwnStoredRow is keyed by package (c966e63a0a).
  • resolveRowlessExpandedView stands the package-less rows in when naming a package.

Readings against main at 8a399b2b15: NOT governed (0 of 5 paths), every skip is in the roster (2), and git merge-tree is clean. CI on the head: 33 success.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 6, 2026 11:21
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 6, 2026 11:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit db87a02 Oct 6, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21967-view-expansion-per-package branch October 6, 2026 11:58
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…less sys_metadata row is served as (objectstack-ai#21990)

Fixes objectstack-ai#21978
Clause-②: no

## What this changes

`SysMetadataRepository`
(`packages/metadata-protocol/src/sys-metadata-repository.ts`) served a
`sys_metadata` row that has no `checksum` as the hash of its stored body
(`rowToItem`), but `put` and `delete` judged the caller's parent against
the raw column (`existing.checksum ?? null`). So a row like that could
never be written or removed through the metadata door. Every
`saveMetaItem` / `deleteMetaItem` answered `409 METADATA_CONFLICT`
("Expected parent hmac-sha256:… but current is null"), whether the
parent was the version the door served or no `If-Match` was sent at all,
because the door takes the parent from the same read. Publish, rollback
and commit revert over such a row hit the same lock, and the
post-promotion drain of a checksum-less draft was refused and silenced
as a benign race.

Per triage's direction (6014717866), with nothing narrowed and no
backfill:

- **One helper**, `servedVersion(ref, row)`: the stored `checksum`, else
`hashSpec(body, type)`. `rowToItem` now reads it, so every read hands
out this one value.
- **One lock**, `lockAccepts(ref, row, parent)`, used by `put` and
`delete`. It accepts the row's stored stamp, which is the old compare
unchanged: a row with a `checksum` is judged exactly as before, and a
`null` parent still matches a checksum-less row. For a checksum-less row
it also accepts the served version.
- **The conflict's head** (`lockHead`) is the served version, so a 409
on such a row names the version a read hands out (before this, `null`).
A checksum-less row whose bytes do not parse keeps `null` there, so a
lock refusal never becomes a parse error.
- The lineage fields (`previous_checksum`, the event's `parentHash`) and
the no-op check keep reading the raw stamp. So the first write over a
checksum-less row, even with an identical body, stamps the row as usual.
Nothing is rewritten at rest, and the header's "no backfill" non-goal
stands, now with one line on how such a row is served.

**File surface:** as dispatched. The producer that wrote such rows (the
datasource admin door) already stamps a checksum since PR objectstack-ai#21977, which
is on `main`, so the remaining work is the stored rows, and that lands
in this repository class. Two test files in the same package: the pins,
plus one fixture comment in
`protocol-publish-drafts-package-scope.test.ts` that this change made
false. Changeset: `@objectstack/metadata-protocol` patch.

## Pins (`protocol.served-content-hash.test.ts`, the existing
conflict-test double)

Through the protocol's real `saveMetaItem` / `deleteMetaItem` /
`publishMetaItem`, on a row seeded with no `checksum`:

- (a) saved and deleted with the version its read serves, in the keyed
form a door hands out: the repository's own `get` read, keyed;
- (a) unpinned (last-write-wins) save and delete succeed: the dogfood
shape;
- (b) a stale keyed token and the raw served hash are still refused with
`METADATA_CONFLICT` / `409` on both doors; `actualHead` is the served
token, the row is untouched, and retrying with that `actualHead`
succeeds;
- (c) a `null` parent still succeeds: `storedParentVersion: row.checksum
?? null`, the stored-row migration's in-process spelling;
- (d) after each write the row carries `hashSpec(newBody, 'view')`; an
identical re-save stamps it too;
- publish over a checksum-less active row; the drain removes a
checksum-less draft row;
- repository level: a row WITH a checksum whose stamp differs from its
body's hash refuses the body's hash and `null` (both name the stamp as
head) and accepts its stamp; a checksum-less row accepts `null` and its
served version, and refuses anything else with the served version as
head.

## Reverse verification (committed HEAD `5c4815a6ab`)

The mutation went through `scripts/ablation-replace.mjs` with an
EXIT/INT/TERM restore trap and absolute paths. It restored the raw
compare in both `put` and `delete` (anchor hit x2 → x0, replacement x0 →
x2, blob `dc58518587` → `494fa3f0ee`; on disk, raw-compare 0 → 2 and
`lockAccepts` call 2 → 0).

- Predicted beforehand: 7 of the 9 new pins red, and green for the
`null`-parent pin and the stamped-row pin, which guard against widening
and against narrowing rather than this mutation.
- **Observed: `Tests 7 failed | 16 passed (23)`**, the 7 predicted. The
save door reproduced the card's text verbatim: "view/case_grid has been
modified since you loaded it. Expected parent hmac-sha256:e532d121… but
current is null." The drain pin read the draft row still present, and
the repository pin read `actualHead` `null`.
- Restore was proven by observation: blob after restore `dc58518587`
equals the HEAD blob, `git diff HEAD` is empty, and `git status
--porcelain` is empty.
- An earlier invocation was a no-op: the tool refused with exit 2 before
writing, because it located the repository from the shared checkout's
cwd. On-disk counts were unchanged, and it was rerun from the worktree
root.

The subject is imported by relative `src` path (`./protocol.js`,
`./sys-metadata-repository.js`), so no `dist/` sits on the ablation's
resolution path.

## Clause-② (measured against the built entry declarations)

`packages/metadata-protocol/dist/index.d.ts` was built at HEAD, and
again with BASE `8a399b2b15`'s repository source swapped in behind a
trap. The swap was restored and proven by blob equality, and HEAD was
rebuilt, giving a byte-identical `index.d.ts`. The diff's non-comment
lines are `private servedVersion;`, `private lockHead;` and `private
lockAccepts;`, with 0 removed; everything else is doc text.
`index.d.cts` has the identical diff. No exported type or signature
moves. Behaviourally, `put` / `delete` accept for a checksum-less row
the version the same repository already serves for it, which is the
declared version token, not a new class of input.

## Tests and gates: all on HEAD `81606021e2` (after merging
`origin/main` twice, the second bringing PR objectstack-ai#21979's `protocol.ts`
change)

- `pnpm --filter @objectstack/metadata-protocol test`: `Test Files 218
passed | 3 skipped (221)`, `Tests 28028 passed | 19 skipped (28047)`.
`typecheck`: `tsc --noEmit` clean, and the test file is in the program
(`--listFiles` count 1). Lock VERDICT command-exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived the
63 commands, and all ran at exit 0. `check:type-check-debt` ran under
the verify lock ("1 ledger entr(ies) re-measured … 26 raw tsc error(s)
total, none above its recorded number"). `check:dual-build-cjs-loads`
and `check:lean-entry-closure` ran after a full `turbo run build` (72
tasks, 71 cached). Reconciliation, `--ran` with per-command exit codes:
"63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN".
- The artifact-roster block (55 families, outside the total): 52 at exit
0. `check-closing-target-claim`, `check-partof-closing-keyword` and
`check-single-claim-paths` answered NOT WIRED (exit 2, no PR context);
they are rerun against this PR and reported in the `os-dev-report`
comment.
- The four symbol-anchor sweeps (`check:adr-symbol-anchors`,
`check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`,
`check:adr-anchors`): exit 0.
- NOT MEASURED locally, owned by CI: the five path-scheduled CI jobs
(Test Core shards, Temporal Conformance, Dogfood Regression Gate,
Dogfood Verify CLI, Build Core) and the workspace type-check lanes.
`packages/qa/dogfood/test/datasource-meta-door-reaches-admin-door.dogfood.test.ts`
was not run locally.

## Census: writers of `sys_metadata` that can store a row with no
`checksum`

| Writer | Where | `checksum` | Still producing such rows |
|---|---|---|---|
| `SysMetadataRepository.put` (insert / update) |
`metadata-protocol/src/sys-metadata-repository.ts` | always
`hashSpec(body, type)` | no |
| `SysMetadataRepository.delete` | same file | removes the row. Its
tombstone goes to `sys_metadata_history` with `checksum: null` by design
| n/a (history table) |
| datasource admin door `writeDatasourceRow` |
`service-datasource/src/datasource-admin-plugin.ts` | `hashSpec(record,
'datasource')` since PR objectstack-ai#21977; none before | no. Its pre-objectstack-ai#21977 rows
are the stored population this PR makes writable |
| datasource admin door delete fallback | same file | `update { state:
'inactive' }`, which keeps the column | no |
| `DatabaseLoader` save / create / `registerRollback` |
`metadata/src/loaders/database-loader.ts` | `contentHash` stamp | no |
| protocol orphan adoption (`package_id` rebind) |
`metadata-protocol/src/protocol.ts` | partial update, which keeps the
column | no |
| protocol legacy delete, permission-set overlay discard |
`protocol.ts`, `plugin-security/src/permission-set-overlay-discard.ts` |
delete only | no |
| `env_id` → `project_id` migration |
`metadata/src/migrations/migrate-env-id-to-project-id.ts` | column
rename DDL | no |
| stored-row migration, flow credential move | `protocol.ts`
`migrateStoredMetadata`,
`service-automation/src/flow-credential-migration.ts` | through
`saveMetaItem` → `put` (stamps) | no. Both were refused on such rows
before this PR and succeed now |
| generic data door, MCP data bridge, flow write nodes, hook bodies | —
| refused: `sys_metadata` declares `apiMethods: ['get', 'list']`, plus
the stored-metadata family refusals | no |

A tombstone reads back as a `delete` event with `hash: null`
(`history()` / `rowToEvent`). `getByHash` never matches it, and
`restoreVersion` refuses it with `VERSION_NOT_RESTORABLE`. **No writer
is still live after this change, so no follow-up card.**

## Acceptance notes

-
`packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts`
(about `:190`) explains its explicit `parentVersion: null` by saying a
raw-seeded row's derived parent "would 409". After this change it would
not; the `null` it passes stays valid. Comment drift in another package,
left as is. Owner: none.
- The first write over a checksum-less row records `previous_checksum:
null` / `parentHash: null`, the raw stamp. That is deliberate: no
history row carries the served hash, so naming it would be a parent link
to nothing.
- A conflict-audit note on such a row now reads "current is (withheld)"
where it read "current is null", because the head is no longer null.
- `DraftDrainFailure.draftHash` is documented as "the row's `checksum`".
It is the served version, the same value for a stamped row. This is a
doc imprecision predating this PR.
- Rollback (`restoreVersion`) and commit revert over a checksum-less
active row take the served parent and pass the same lock. This was read
in code; only publish is pinned as the representative internal caller.
- No door read serves a version token for a stored row that has no
history; the tokens come from receipts, history events and a 409's
`actualHead`. So for a legacy row, the 409 is the first place a client
sees its token. The stale-version pin covers that retry.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants