Skip to content

fix(metadata-protocol): the artifact envelope is stamped only on the bundle member the package ships (#23074) - #23094

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23074-member-envelope
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23074-member-envelope

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23074
Clause-②: no

What was wrong

An email_template is identified by (name, locale), so its locales form a bundle. When the environment saves an overlay of a member the package does not ship (a zh-CN version of a template the package ships in en-US only), that overlay is the environment's own item. mergeArtifactProtection grafted the bundle's artifact envelope onto it anyway: every caller looked the artifact up by name, canonical-first, so the zh-CN overlay was registered and served wearing the en-US artifact's _packageId, _packageVersion and _provenance: 'package', and isCodeArtifactBody read it as packaged code.

PR #23070 (stage 2 of #22877, 038d4c9588) routed around one reader: it lifted the member's runtime shadow on ANY member's artifact in removeRuntimeShadow. This PR changes the graft itself.

Measured on origin/main 62ce45d140 (before) and on this branch (after)

The fixture is the stage 1/2 harness packages/objectql/src/environment-overlay-email-locale-restart.test.ts: a real ObjectQL engine, the real protocol, and a versioned package that ships acme.digest in en-US only. Each reading comes after en-US and zh-CN overlays are saved and the kernel restarts, so both overlays are hydrated. isCodeArtifactBody appears as "code".

reader of the unshipped zh-CN overlay before after
registry entry (getItem(type, name, undefined, 'zh-CN')) _packageId: com.acme.mail, _packageVersion: 1.0.0, _provenance: package; code true _provenance: org, no package stamps; code false
registry artifact lookup (getArtifactItem(..., 'zh-CN')) answers the overlay itself as the package's zh-CN artifact undefined
by-name read, GET ?member=zh-CN: body and envelope body wears the envelope; provenance: package, packageId, packageVersion: 1.0.0 no envelope; provenance / packageId / packageVersion absent
by-name read: lock / editable / deletable none / true / true none / true / true (unchanged, see below)
list, served and for execution the zh-CN item wears the envelope no envelope
overlay-entry retirement (removeOverlayEntry(..., 'zh-CN')) refused as code retires it
shadow heal (removeRuntimeShadow(..., 'zh-CN')) lifts it (the #23070 name-level test) declines: no zh-CN artifact (member level, below)
DELETE ?member=zh-CN, end state entry gone, receipt reset: true same

CONTROL, measured at the same readers: the overlay of a shipped member (acme.welcome in zh-CN, and the canonical en-US) wears its artifact's envelope, is answered by its artifact, is served under its package, is refused by the retirement and un-shadowed by the heal. Nothing about it changes.

The other isCodeArtifactBody readers never see an email_template member, because each is gated on its own type: runtime/src/domains/actions.ts (actions), runtime/src/flow-clone.ts, service-automation's flow precedence and the protocol's flow write refusal (flows), plugin-security/src/packaged-permission-set-lock.ts (permission sets), the protocol's declined-row predicates (shipped flows, code datasources, sealed types; email_template is overlay-enabled, so never sealed), and the registry's object branch and collision warning (objects, and composite artifacts only).

The change

  • mergeArtifactProtection (metadata-protocol) takes the type and grafts an artifact only onto a body of the same bundle member, read by itemDiscriminator (bundleMemberOf), the identity's one declaration in ITEM_KEY_DISCRIMINATORS. An artifact of another member counts as no artifact. Every type without members reads undefined on both sides, so its merge is byte-for-byte what it was.
  • Its four call sites pass the type, and the three that can serve a bundle member look the artifact up at the body's own member (lookupArtifactItem's member): the boot and write-through hydration (hydrateOverlayIntoRegistry), the by-name read (getMetaItem) and the list (readFlattenedMetaItems). Without this second half, a shipped non-canonical member would lose its envelope, because the canonical-first lookup answers the en-US artifact for a zh-CN body (ablation A2 below). The view expansion (expandRuntimeViewContainer) only passes the type: a view has no members.
  • removeRuntimeShadow (objectql) returns to member level for a named member: a shadow is lifted only where an artifact of that member takes its place. An unshipped member's overlay now declines there, and the heal's last tier (removeOverlayEntry) retires it, because it is no longer grafted. Naming no member keeps the group sweep, as before.
  • No exported signature changes: mergeArtifactProtection and bundleMemberOf are module-private, and removeRuntimeShadow keeps its signature.

Deliberately unchanged

  • The lock binds the name. The read's lock and the _lock gate both come from the one item-lock resolution over every package that ships the NAME (artifactLockLayerAt). The merge applies the read's resolved lock before its member check, so a template the package locks stays locked in every locale, at the read and at the gate. Making the lock per member would loosen protection, and that is a contract decision this card does not carry.
  • resettable and the delete receipt come from isArtifactBacked(type, name), which is name-level and not the graft. An unshipped member still reads resettable: true and its reset answers reset: true. See the acceptance notes.

Pins (real doors)

All in packages/objectql/src/environment-overlay-email-locale-restart.test.ts, new block [#23074], with each CONTROL as its own case:

  • the boot hydration: the unshipped member registers as the tenant row it is (no stamps, not code, no zh-CN artifact); CONTROL: the shipped members' overlays wear their artifacts' envelopes;
  • the by-name read ?member=zh-CN: served as the environment's item (no envelope, lock: none, editable, deletable); CONTROL: the shipped member is served under its package;
  • the list, served and for execution: the same split;
  • the reset ?member=zh-CN: the unshipped member is declined by the shadow heal and retired by the overlay-entry tier, at once and after a restart; CONTROL: the shipped member is un-shadowed onto its artifact and refused by the retirement;
  • a registry that ignores member (answers the canonical artifact): the graft's own check keeps that artifact off the zh-CN body; the canonical member still wears it;
  • CONTROL: a type with no members (translation, which carries a locale but is identified by its name) keeps its artifact's envelope when the overlay's locale differs from the artifact's.

Also updated: the registry-i18n-bundle-key.test.ts case that pinned the name-level lift for an unshipped member now pins the member-level decline and the retirement. The two call-site ledgers (protocol.lookup-artifact-item-call-sites.test.ts, protocol.register-item-call-sites.test.ts) record the new argument text.

Ablation

Run from committed state with scripts/ablation-replace.mjs, under a trap restore. Each metadata-protocol leg was rebuilt and checked with scripts/ablation-dist-preflight.mjs, because objectql's suite reads that package's dist/. The two legs that delete the guard fail the DTS step (TS6133: the new type parameter is unread), but ESM and CJS are emitted, and the preflight proves the ESM the suite consumes carries the mutation. Every restore was proven by blob hash equal to HEAD and an empty git diff HEAD, then rebuilt and preflighted present again. The final run was 29/29 green.

leg mutation red green
A3 the unconditional graft back: guard removed, canonical-first lookups 6: all five unshipped cases, plus #22877's door case for a member no artifact ships every CONTROL (23)
A1 the guard alone removed only "a registry that answers another member's artifact" 28
A2 the member-addressed lookups alone removed exactly the four shipped-member CONTROLs 25
A4 removeRuntimeShadow back to the name-level test (registry source, no build) only the unshipped reset case 28

A3 also shows the decision about removeRuntimeShadow: with the graft back, the member-level heal leaves the grafted overlay registered (the #22877 door case turns red). So the member-level test is sound because of the graft fix, and not without it.

One leg is declared void: a first A3 run coincided with an unlocked check:type-check-debt, whose full build rewrote metadata-protocol/dist/index.js mid-leg. The preflight caught it (marker still present), and the run was redone with nothing in parallel (the readings above).

Evidence

  • Real door: packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts, the showcase file that found the defect (real SQLite, real sendTemplate, real MetadataService), passes 4/4 on the fixed dist/: DELETE ?member=zh-CN, then the next zh-CN send uses the admin's en-US wording at once, and ?member=zh-CN is 404 after a cold boot.
  • pnpm --filter @objectstack/metadata-protocol typecheck exit 0; pnpm --filter @objectstack/objectql typecheck exit 0 (its check:test-typecheck held the ledger).
  • @objectstack/metadata-protocol vitest run: 228 files passed, 3 skipped; 28203 tests passed. @objectstack/objectql --project local: 408/408 files, 7984 tests; --project repo: 1/1 file, 5 tests. All at 5d156f7044.
  • Lint, a proven narrowing: eslint --no-inline-config --format json over the six changed TS files (eslint.config.mjs applies to all six, and none is reported as ignored) gives 6 files, 0 errors, 0 warnings. This repo's config enables no type-aware linting (no parserOptions.project, no typed rules), so this diff cannot change the verdict on any untouched file.
  • Gates: the derived union node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 5d156f7044 (the dispatch's 58 commands plus 11 changeset families this diff adds): 69 commands, 69 exit 0, each exit code captured before any pipe. Reconciled with --ran: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. check:dual-build-cjs-loads and check:type-check-debt were measured after a full turbo run build of ./packages/* (72 tasks) at that head. Every heavy run went through scripts/pm/os-verify-lock.sh, and each result is its VERDICT command-exit 0 line.

Acceptance notes


Generated by Claude Code

…bundle member the package ships

An environment overlay of an email_template member the package does not
ship (a zh-CN beside a shipped en-US) was hydrated wearing the bundle's
artifact envelope, so isCodeArtifactBody read it as packaged code.
mergeArtifactProtection now grafts an artifact only onto a body of its
own bundle member (itemDiscriminator), and its callers look the artifact
up at the body's own member. removeRuntimeShadow's named-member test
returns to member level.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/objectql, touching 9 documentable anchor(s).

16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6662e3f590e9790a28f598d3eb6f665e46f10703.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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 — 24 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 6662e3f590e9790a28f598d3eb6f665e46f10703 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6662e3f590e9790a28f598d3eb6f665e46f10703

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 12, 2026 21:41
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 12, 2026 21:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit abf8655 Oct 12, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23074-member-envelope branch October 12, 2026 22:09
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