Skip to content

fix(metadata-protocol): a hydrated view expansion carries its container's tenant marker, so an unscoped kernel answers an expanded view as env_local does (#21511) - #21603

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21511-expansion-tenant-marker
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21511-expansion-tenant-marker

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21511
Clause-②: no

What this changes

On an unscoped (control-plane) kernel, registry hydration registers every view a stored environment-wide view container expands, each under its own name. hydrateOverlayIntoRegistry registers the container with the tenant-authorship marker (stateTenantAuthorship, ADR-0010 _provenance: 'org'), and hydrateExpandedViewItems registered the expansions without it. An expansion of a package-bound container therefore sat under its bare name, wearing that package's _packageId and no marker, and SchemaRegistry.getArtifactItem's bare-key fallback took it for a view the package ships.

This PR implements triage's ruling (comment 5964348889): each expansion inherits its container's authorship.

  • hydrateExpandedViewItems asks expandRuntimeViewContainer for tenant-authored expansions (tenantAuthored: true).
  • Under that option, expandRuntimeViewContainer applies stateTenantAuthorship to each expansion BEFORE that expansion's own artifact envelope is merged over it. This is the order the container gets (mergeArtifactProtection(stateTenantAuthorship(data), envelope)), so where the container's own package ships a view of that name, the artifact's _provenance, _packageId and _lock still win (ADR-0010 §3.3).
  • The two registry-free reads that call expandRuntimeViewContainer (the list read's expansion pass and the by-name read's step 1b, through expandStoredViewContainers) pass no such option and serve exactly what they served before.
  • No reader changed. isArtifactBacked (which resettable reads) and the layered read's code arm already ask isTenantAuthored; they now get the marker to read.

Measured before the change

At base a7ab047c, with the #21508 harness (showcaseHarness) and the card's probe (a container os_qa_probe on showcase_task with listViews.in_progress, read as showcase_task.os_qa_probe.in_progress):

kernel container getMetaItem(...).resettable getMetaItemLayered(...).code
env_local package-bound (com.example.repairassets) false null
env_local package-less, environment-wide false null
unscoped package-bound true the hydrated expansion
unscoped package-less, environment-wide false the hydrated expansion

At the base the registry held the container as { name: 'os_qa_probe', _provenance: 'org' } and the expansion as { name: 'showcase_task.os_qa_probe.in_progress', _packageId: 'com.example.repairassets' }, with no _provenance. For the package-less arm the expansion carried no _packageId, so resettable was already false. Its code layer was still wrong, through the layered read's runtime-only getItem arm, which drops only tenant-marked entries.

After the change, both kernels give env_local's answer for every member kind in both arms.

The save door (ruling: no save-door rule change)

The fix changes what isArtifactBacked answers for an expanded name on the unscoped kernel, and the save door reads that predicate. The door's acceptance is pinned rather than assumed. After the container is saved, a write by the expanded name is accepted on both kernels and in both arms, stored once in the container's scope, and that row then answers the name on the by-name read and on the object door. The outcome is identical across the two kernels. The same probe was accepted on all four kernel and arm combinations at the base. Under reverse verification leg 1 below, the save-door pins stay green, so the acceptance does not move with the fix.

Tests

packages/metadata-protocol/src/view-container-runtime-expansion.test.ts gets a new describe block, #21511 an expanded view of a stored container answers as tenant-authored on both kernels, with 15 tests in #21508's harness. It covers the package-bound and package-less environment-wide arms. Hydration never registers an organization-scoped row.

  • For each arm and each of the five member kinds: the expanded view is not resettable and has no code layer on either kernel, and the unscoped kernel's whole answer equals env_local's (resettable, editable, deletable, lock, provenance, packageId, code). That is 10 tests.
  • For each arm: on the unscoped kernel, every registered expansion carries the container's marker, keeps the container's package, and is not a code artifact (isCodeArtifactBody).
  • For each arm: the save-door pin described above.
  • CONTROL: a package-less overlay of the showcase's own showcase_task container. Its expansions, showcase_task.default and showcase_task.in_progress, stay resettable with the packaged code layer on both kernels, and on the unscoped kernel the registered expansion keeps the artifact's envelope (_provenance: 'package', _packageId: com.example.showcase) over the marker.

Reverse verification (both runs recorded)

Each run starts from the committed fix, and each leg restores with git checkout HEAD -- ABSOLUTE_PATH. Each restore is proven by blob hash equal to the HEAD blob, an empty git diff HEAD, and a clean git status, all inside a script armed with trap restore EXIT INT TERM. The subject is imported from source (./index.js), so no dist/ is involved.

Run 1, at e8e00609 (the fix commit, before merging main):

  • Leg 1: protocol.ts was reverted to the base blob 3ac2573f (git restore --source=a7ab047c). The landing was proven by the on-disk blob equalling the base blob; the base blob carries 0 occurrences of tenantAuthored: true, and HEAD carries 1. Result: 12 failed, 132 passed (144). The 10 resettable/code pins failed with unscoped: no package ships it: expected true to be false (package-bound) and unscoped: no artifact, so no code layer: expected {…} to be null (package-less). The 2 marker pins failed with expected undefined to be 'org'. The 2 save-door pins and the CONTROL stayed green.
  • Leg 2: the marker was applied after the envelope instead of before, through scripts/ablation-replace.mjs, with anchor 1→0, replacement 0→1, and blob b106f11e → 5d6e6263. Result: 1 failed, 143 passed. Only the CONTROL failed: showcase_task.default: the artifact's envelope is merged over the marker, not under it: expected { _provenance: 'org' } … { _provenance: 'package' }.
  • Restored HEAD: 144 passed.

Run 2, at 09033d87 (after merging origin/main 6c5697df, which includes the landed #21545 as eb9ef791):

  • Leg 1 reverted to the merged base's blob 24cd0629 (6c5697df): 12 failed, 132 passed, with the same 12 tests and the same messages.
  • Leg 2: 1 failed, 143 passed, the CONTROL alone.
  • HEAD: 144 passed.

Verification at 09033d87

  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: Test Files 207 passed, 3 skipped (210); Tests 3207 passed, 19 skipped (3226); VERDICT command-exit 0.
  • pnpm --filter @objectstack/metadata-protocol typecheck (tsc --noEmit) exited 0. Its --listFiles reaches 210 of the package's 210 test files, including the edited test file.
  • Lint, as a proven narrowing: eslint --no-inline-config --format json over the two edited .ts files gives 2 files, 0 errors and 0 warnings. The changeset .md is outside every files glob of eslint.config.mjs. --print-config shows no parserOptions.project or projectService (type-aware linting is not enabled), so this diff cannot move the verdict on any untouched file. The full pnpm lint is CI's.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands (no paths) derived 64 families for this change set. 63 exited 0. pnpm check:dual-build-cjs-loads is NOT MEASURED: it exited 3 (PREREQUISITE NOT MET), because it needs every package's dist/ and 67 had none. This diff changes no exports, entry points or build config. --ran reconciliation: 64 accounted, 63 run, 1 NOT MEASURED. Two first runs were prerequisite misses and were re-run green after the prerequisite was met. check-plugin-teardown-shape --self-test needed its pinned fixture commit fetched into the shallow clone. check:lean-entry-closure needed @objectstack/objectql built.

Region and surface

protocol.ts hunks: the stateTenantAuthorship docblock (its "ONE caller" sentence now names both callers), expandRuntimeViewContainer's options type and its merge line, and hydrateExpandedViewItems' call and docblock. The claim names the hydrateExpandedViewItems region. expandRuntimeViewContainer sits in the same hydration block, and the stamp must go there so that it precedes each expansion's own envelope (the order above), without a second copy of the envelope rule. That is the one widening of the region, declared here. The save door and the data door's read region are not edited. #21545's hunks (landed as eb9ef791, merged here) are disjoint from these.

Acceptance notes

  • Card premise, refined: for the package-less environment-wide arm, only the code layer was wrong at the base; resettable was already false (table above). Both values are pinned now.
  • Save-door intent on the unscoped kernel: for a write by an expanded name of a package-bound container, isArtifactBacked now answers false, as on env_local. Reading the save path (not separately measured), the write intent it derives is therefore the runtime-only one rather than the artifact-override one. The ruling expects this ("layered by the corrected predicate on both kernels"). Acceptance is unchanged, as measured above.
  • Not measured over REST. The reads are pinned at the protocol methods the by-name and /layers REST routes call.
  • The branch was merged with origin/main at 6c5697df. One later main commit (f6b75208, spec conformance-case notes and a lint test) is not merged. It touches none of this PR's files.

Changeset: .changeset/21511-expansion-tenant-marker.md, patch for @objectstack/metadata-protocol.


Generated by Claude Code

claude added 3 commits October 3, 2026 12:56
…er's tenant marker

On an unscoped kernel, registry hydration registered each expansion of a
stored environment-wide view container without the tenant-authorship
marker its container carries. An expansion of a package-bound container
then read as a code artifact through the registry's bare-key fallback:
the by-name read answered resettable and the layers read answered the
expansion as its code layer, where env_local answered neither.

hydrateExpandedViewItems now asks expandRuntimeViewContainer for
tenant-authored expansions: stateTenantAuthorship first, then the
expansion's own artifact envelope, the order the container gets. The
registry-free reads are unchanged.

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 3, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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
  • 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 b610eabf721672ab621dd1f45e2d1dafbf76a740 → packageMentionDocs.

Which tree this was computed on

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

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

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

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