Skip to content

fix(metadata-protocol): a by-name read naming no package wears the envelope of the package whose body it serves - #22055

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22024-no-package-envelope-pairs-body
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22024-no-package-envelope-pairs-body

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22024
Clause-②: no

What this changes

getMetaItem with no packageId (the method behind GET /api/v1/meta/TYPE/NAME when no package is named) merges the registry artifact's protection envelope over the body it serves, through mergeArtifactProtection. The fields are _packageId, _packageVersion and _provenance. With no package named, that envelope was lookupArtifactItem(type, name, undefined): the first composite, which is the first-registered package's. When two packages ship a name and the served body is the other package's (its stored copy of a container they both ship, its package-bound row, or its MetadataService item), the answer served that body under the first-registered package's _packageId. The answer's top-level packageId / provenance / packageVersion are read off the served item (servedLockState → extractProtection), so they carried the same wrong value.

The envelope lookup is now lookupArtifactItem(type, name, envelopePackageId(request.packageId, item)). envelopePackageId is request.packageId ?? item._packageId, which is exactly the expression the list (readFlattenedMetaItems) already used for each item it serves. It is now one module-level function, and both doors call it. The list's call is a byte-equivalent refactor. Result: one served body wears one envelope on both doors.

Unchanged:

Files:

H1: where the body and the envelope part (confirmed)

Measured in-process through the real getMetaItem naming no package, on both kernels and in both registry orders, at base 8caa131e52 and at the fix. The probe was not committed. Setup: pkg_a and pkg_b both ship view container task.

Step that answers Body served Package known at the merge (the served item's _packageId) Envelope on base Envelope now
draft preview / state: 'draft' the draft row n/a none (returns before the merge) unchanged
1, stored row bound to pkg_b pkg_b's row pkg_b (row package_id) first-registered (pkg_a when first) pkg_b
1, package-less stored row the row none first-registered unchanged (first-registered)
1b, expansion of pkg_b's stored copy the copy's view pkg_b (the container's package) first-registered pkg_b
1b, expansion of a package-less copy the copy's view the package of the container it overlays (runtimeViewContainerPackage: first-registered) first-registered unchanged
2, MetadataService item stamped pkg_b the service's item pkg_b first-registered pkg_b
3, registry composite (no row) first composite that composite's own package same package unchanged
3, shipped-flow arm lookupArtifactItem itself its own package same artifact unchanged

The card's measured case, with pkg_a registered first: on base the read answered Intake (pkg_b copy) with _packageId: pkg_a and _packageVersion: pkg_a@1, and the response's top-level packageId was pkg_a. Now all three say pkg_b. This holds for all 12 combinations (2 kernels × object owned by pkg_a / pkg_b / no package × 2 orders). With pkg_b registered first, base answered pkg_b too, by coincidence.

H2: the fix's shape (confirmed)

  • Naming no package, the envelope lookup is scoped to the served item's package. Naming a package passes it unchanged.
  • The producer is where the card said: getMetaItem's envelope merge. Nothing else had to move.
  • Package-less served bodies. Base grafts the first-registered package's envelope. The direction ("the package whose row or expansion was served") yields the package-less lookup, because such a body names no package. That is one answer, and it is the lookup the read already made, so nothing is invented.
    • For a name one package ships, it is that package's envelope: the ordinary tenant overlay.
    • For a name two packages ship, the list serves the same body in both packages' slots, each with its own envelope. The read's pair is one of them; the control pin in (i) holds this.
  • Fields that move, all copied by mergeArtifactProtection from the one artifact: _packageId, _packageVersion and _provenance. With them move the response's top-level packageId / packageVersion / provenance.
  • Fields that do not move: the _lock, _lockReason, _lockDocsUrl and _lockSource family. The item-lock resolution sets it.

H3: the 18 lookupArtifactItem( lines, classified

git grep -n 'lookupArtifactItem(' -- packages/metadata-protocol/src, tests excluded, prints 18 lines. That holds at the merge base a7a48b784d, where protocol.ts is byte-identical to 8caa131e52; line numbers below are that file's. It still prints 18 at HEAD.

Line Member · arguments No package, or can be undefined? Does its envelope reach an answer whose body came from elsewhere?
8399 packagedArtifactBase · type, name none No. It is the packaged base the i18n translators compare a served view or dashboard against; nothing of it is grafted onto the body.
8696 readCodeLayerForCarryForward · type, name, pkg can be undefined No. It feeds a save's credential carry-forward, a write. The write door strips the derived provenance keys before it persists.
9554 readFlattenedMetaItems · request.type, itemName, itemPackageId can be undefined No. Scoped by construction to each item's own package. This is the list's rule, now envelopePackageId.
10391 getMetaItem · request.type, request.name, request.packageId (shipped-flow arm) can be undefined No. The served body IS this artifact, and the envelope lookup now asks that artifact's own package, which answers itself.
10431 getMetaItem · the envelope merge can be undefined Yes, the card's defect. Fixed: envelopePackageId(request.packageId, item).
10764 getMetaItemLayered · request.type, request.name, request.packageId (code layer) can be undefined No by the graft test; see Acceptance note 1. The result IS the code layer, body and envelope one artifact. The layered response reads its provenance off that layer by its own rule, and nothing is grafted onto effective.
16356 isArtifactBacked · type, name none No. A boolean predicate.
16442 isNestedArtifactField · 'object', name.slice(0, sep) none No. A boolean containment test; an object has one owner.
16861 packagedArtifactOwner · folded.type, folded.name none No. It returns a shipping package id for flow classification, never an envelope on a served body.
17261 the definition n/a n/a
17331 shippedArtifactsOf · type, name none No. It is the lock's artifact layer (the item-lock resolution), deliberately unchanged.
17335 shippedArtifactsOf · type, name, packageId named No. Scoped, and kept only when it is that package's own.
18451 hydrateOverlayIntoRegistry · type, (data as any).name, options.packageId ?? undefined undefined for a package-less row No. Scoped to the row's own package. A package-less row names none (the H2 cell).
18617 expandRuntimeViewContainer · type, String(item.name), ownPackageId not called when undefined No. Own-package filter.
18656 shippedViewContainerOf · type, name, packageId returns before the lookup when undefined No. Own-package filter.
18708 runtimeViewContainerPackage · type, container.name none No. It answers the package a package-less container row overlays. That package becomes the expansion's own _packageId, and its own artifact lends the envelope (line 18617). The by-name read now looks up at that same package, so body and envelope agree.
18794 isShippedByAnotherPackage · type, name none No. A boolean predicate.
19658 viewContainerNameCollisionRefusal · type, name none No. The save door's collision check: the package id names a shipper in a refusal sentence.

Totals: one yes (fixed), 16 no (one of them with an open question), the definition.

H4: the list path (confirmed, now pinned)

The env-wide list already looked each item's envelope up at that item's own package. Measured: it serves (Intake (shipped by pkg_a), pkg_a) and (Intake (pkg_b copy), pkg_b). #21980's block (d) pins the reads NAMING a package against the list's slots, and block (h) pins only the body of the read naming none. The no-package read's envelope against the list was not pinned. Block (i)(c) pins it now, and the rule is one function both doors call.

Pins

In protocol.org-scoped-write-refused.test.ts, block (i), reusing #21980's harness (harness, PLACEMENTS, MEMBERS, saveCopyOf). Each case runs on both kernels.

  • (a) 14 cells: 12 copy cells (3 members, the object owned by pkg_a or by no package, 2 registry orders) and 2 cells of a pkg_b-bound row of the name. In each, the read naming no package answers [Intake (pkg_b copy), pkg_b, package] (title, _packageId, _provenance) and top-level packageId: pkg_b.
  • (b) On the 12 copy cells, the read naming each package keeps its own body, its own _packageId and its own top-level packageId.
  • (c) On the 14 cells, the env-wide list holds the same [title, _packageId, _provenance] for the body the no-package read serves.
  • control ×2 A package-less copy that stands in for both packages: the read's triple is one of the list's, and the list serves the copy in both slots.
  • (d) protocol.lookup-artifact-item-call-sites.test.ts records every lookupArtifactItem( in protocol.ts, keyed by its class member and argument text, with a disposition. The idiom is the repo's readFileSync(new URL('./protocol.ts', import.meta.url)) caller-set pins (hydrateOverlayIntoRegistry, applyRegistryWriteThrough). It goes red when a call is added, removed or re-scoped without its row. Non-vacuity: one definition, no site outside a member, and the population equal to the ledger. It also asserts that both read doors call envelopePackageId.

Reverse verification (on committed HEAD, restore proven)

The mutation, through node scripts/ablation-replace.mjs:

  • Anchor request.type, request.name, envelopePackageId(request.packageId, item), → request.type, request.name, request.packageId, (the base's lookup).

  • Anchor x1 → x0, replacement x0 → x1; blob 05c2a69909ca → 9efd4116e951.

  • Restore: blob after restore 05c2a69909ca == the HEAD blob, and git diff HEAD is empty.

  • The subject is imported from ./protocol.js (source), so no dist/ is on the path and no rebuild applies.

  • Run 1, on ef02620dbd, where (a) and (c) still shared one case. Predicted 8 red, measured 8 red / 183 green: the 6 copy cells and the 1 row cell with pkg_a first, plus the ledger. (c) was never reached behind (a), so the pins were split.

  • Run 2, on 4c75f230e0. Predicted 15 red, measured 15 distinct red: (a) ×7, (c) ×7, ledger ×1.

    • Every (a) and (c) red case shows the base's mislabel: received ["Intake (pkg_b copy)", "pkg_a", "package"] where pkg_b was expected. In (c) that is the list holding pkg_b while the read wears pkg_a.
    • Every (b) case and both controls stay green, the predicted direction.
    • The run's total printed 29 failures because a scratch copy of the test file ran beside it (14 duplicates). The copy was deleted and never committed.

Clause-② (measured: no)

  • Built entry declarations. dist/index.d.ts and dist/index.d.cts are byte-identical between a build of base protocol.ts (8caa131e52) and HEAD: sha256 f91b87a1031d3aee39c721835976fcc846025fa30f185b216262a4d6be751916 for all four files. Positive control: envelopePackageId occurs 3 times in HEAD's dist/index.js and 0 times in base's.
  • Accept set. No validation, refusal or write path is touched. The diff changes the envelope a read wears, and nothing is accepted or refused differently.

Tests and gates (at HEAD 96059eb740, after merging origin/main a7a48b784d)

  • pnpm --filter @objectstack/metadata-protocol exec vitest run: Test Files 220 passed | 3 skipped (223); Tests 28181 passed | 19 skipped (28200).
  • pnpm --filter @objectstack/metadata-protocol run typecheck (tsc --noEmit): exit 0. --listFiles includes both touched test files.
  • node scripts/pm/dispatch-gates.mjs --commands (no paths) derived 64 commands. All 64 exit 0.
    • check:dual-build-cjs-loads and check:lean-entry-closure first answered PREREQUISITE NOT MET (exit 3) on a partly built tree. After turbo run build over every package (71/71), both exit 0.
    • --ran: ✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero — all 64 recorded an exit code and none of them is 3).
    • Beyond the dispatch list, the change set added check:engine-double-contract, check:objectql-double-limit, check:query-options-erasure, check:type-check-coverage, check:type-check-debt and check:where-matcher. All were run.
  • Artifact-roster block (53 families, outside the total): 50 exit 0.
    • check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths are NOT WIRED without a PR context (exit 2), so they are NOT MEASURED. The pnpm check: forms of the same three are self-test only (exit 0).
  • The four symbol-anchor sweeps all exit 0:
    • check:adr-symbol-anchors: 2167 anchors across 140 records resolve.
    • check:scripts-symbol-anchors: 3763 anchors across 282 scripts resolve.
    • check:spec-docblock-symbol-anchors: 5168 anchors across 1886 spec sources resolve.
    • check:adr-anchors: OK, 42386 citations across 5271 files resolve.

Acceptance notes

  1. The layered read's envelope (line 10764) is classified, not changed. This is an open question for the seat.
    • Naming no package, getMetaItemLayered takes its code layer from the no-package lookup (first-registered for a shared name). The response's top-level provenance / packageId / packageVersion are read off code ?? overlay.
    • Measured, with pkg_b's copy served and pkg_a first: code is pkg_a's shipped item, effective._packageId is pkg_b, and top-level packageId is pkg_a. This is unchanged by this PR.
    • After this PR, getMetaItem's item agrees with the layered effective on _packageId, which it did not on base. The two reads' top-level packageId now differ for that address, where on base both said pkg_a.
    • Moving the layered envelope means moving the code layer itself: which shipped item is the diff baseline and the reset reference. That is a body change, outside this card's envelope-only scope.
  2. A pre-existing defect next door, not touched here. Reported to the seat for filing.
    • On an unscoped kernel, a stored view-item row of a name both packages ship, bound to pkg_b, is hydrated by write-through into the registry's bare slot.
    • The read NAMING pkg_a then reaches step 3, and registry.getItem(view, NAME, 'pkg_a') answers the bare slot first. It serves pkg_b's row body (Intake (pkg_b copy)) under _packageId: pkg_a, while the list scoped to pkg_a serves pkg_a's shipped item.
    • The environment-scoped kernel answers correctly. Measured in-process in both registry orders, with this PR's envelope change reverted (named reads take the same path either way), and not over HTTP.
    • Pin (b) is therefore asserted on the copy cells only.
  3. A legacy stored row whose body still carries its own _packageId (written before the derived-provenance strip at the write door) is served with that stamp, as before. The envelope is now looked up at it, exactly as the list already does for the same row. Not measured.
  4. Docs: no hand-written docs line about a by-name read's _packageId becomes false. content/docs/ui/doc-pages.mdx's "best-effort (first match)" is about which body a bare link resolves, and that is unchanged.

Generated by Claude Code

claude added 3 commits October 7, 2026 03:53
…velope of the package whose body it serves

getMetaItem with no packageId merged the first-registered package's
artifact envelope (_packageId, _packageVersion, _provenance) over the body
it served, so a name two packages ship answered one package's stored copy,
row or MetadataService item under the other package's _packageId. The
envelope is now looked up at the served item's own package, the rule the
list read already applies to each item (envelopePackageId, shared by both
doors). A read naming a package, the lock and a package-less served item
are unchanged.

Pins: the measured case on both kernels, both registry orders and two
ownerships; the read naming each package; list and by-name envelope parity;
and a disposition ledger over every lookupArtifactItem( site in protocol.ts.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
… and the list parity as separate cases

Each of (a) the read naming no package, (b) the reads naming each package
and (c) the list's envelope for the same body is its own case, so each can
fail on its own under reverse verification.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 7, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/data-modeling/drivers.mdx (via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
What this run could not see
  • 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 — 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 a7a48b784d5401908933c6cdeeb97adbfc88d1e2 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 a7a48b784d5401908933c6cdeeb97adbfc88d1e2 → 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

ACCEPT (seat review) — PR #22055 at head 96059eb740

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-07T05:06Z. The dev's report is os-dev-report on #22024 (6031073024).


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 05:07
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 7, 2026 05:07
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit 2015c54 Oct 7, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22024-no-package-envelope-pairs-body branch October 7, 2026 05:46
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/m tests tooling

Projects

None yet

2 participants