Skip to content

fix(metadata-protocol): one collision predicate at the save door — a stored view container never takes a name already served from elsewhere (#21639, #21638) - #21648

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21639-view-container-collision
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21639-view-container-collision

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #21639
Fixes #21638
Clause-②: no (narrowing)

#21638 carries its own claim on this branch (5975022936), as the Closing-Target Claim Guard requires for every card a PR closes. Triage folded it into #21639's claim.

The runtime save door's accept set narrows: one predicate now refuses a stored view container whose save name, or any name its expansion produces, is a name already served from elsewhere. Nothing widens. A package-less container row stored under the name of a view item a package ships now belongs to no package, as triage's fold of #21638 rules. Dispatched by domain:engine seat 1 under claim 5974259402 (file surface corrected by 5974270195), branch claude/issue-21639-view-container-collision. Triage's ruling 5973827435 and its fold 5973838571 are implemented as written.

What changed

All of it is in packages/metadata-protocol/src/protocol.ts, the claimed surface. The producer is the save door itself, so nothing moved to another package.

Census, taken first (the ruling's stop conditions)

(2): does a live writer save a second container of one object on purpose? Readings are at BASE 7b07749f05, re-read after merging origin/main (head d5101d1831). objectui was read at the old pin 89cad75d55 and at the new pin ab1879721595, which origin/main moved to while this ran.

Census input Evidence A second container of one object, a container under another container's expanded name, or a container under a shipped view item's name, on purpose?
#13407's authoring path: the platform checklist's live view-authoring item studio-authoring.view-authoring-live (P1, revision 2) Step 1 saves ONE container, qa_repair_asset_views = { object: 'repair_asset', list, form }, on a runtime-authored object. No other checklist item stores a container on repair_asset. #13407's own repro (a container bound to note under another name, per PR #21637's census) is one container too. No.
#21412's P2 / P2b (seat answer 5961930912) One container, lead_views, bound to crm_lead. Pinned in the #21412 block of the same test file, still green. No.
#21334's arm (rulings 5946423948, 5955628428) It expands under the container's own name, OBJECT.CONTAINER_NAME and OBJECT.CONTAINER_NAME.KEY, never a name the owning package ships. Pinned as two allowed rows below. No.
Studio's metadata editor re-save (objectui at both pins) Creators write view items: the metadata-admin createBuildBody (anchors.ts:291, "Emit a canonical ViewItem"), the spec create seed for view (metadata-create-seeds.ts:62, viewKind: 'list'), and the flat configs of data-objectstack createView and setViewConfig. Re-savers save the loaded body under the name it carries: ResourceEditPage (:1477), the Interfaces pillar's StudioDesignSurface (:2475, new at ab1879721595), and updateView. No creator writes a container. A re-saver writes a colliding container only when the store already holds the collision, and that re-save is now refused until its body stops colliding.
Package duplication (duplicatePackage, a writer through this door, outside the ruled set) Throwaway probe, deleted afterwards. A container pone_extra_views in com.example.pone, bound to crm_lead (outside the package, shipped by no code package), duplicated into com.example.ptwo. At BASE: copied, and the object door's crm_lead.default became the copy's, wearing _packageId: com.example.ptwo. The source package's view was silently replaced. At HEAD: failed[] carries this refusal, naming pone_extra_views, and the source's view stays. Yes, as a byproduct; not on purpose, as this dev reads it. The copy is meant to be an independent base, and the collision is the defect itself. Raised as an open question in the report.
migrateStoredMetadata It re-saves rows already stored, inside a try that records outcome: 'failed'. Not a creator.
The in-repo AI author The MCP tools in packages/mcp/src/mcp-http-tools.ts have no metadata write. skills/objectstack-ui teaches defineView in source. No. The cloud AI author is outside this repository and the ruled set: NOT MEASURED.
Packaged containers and stored rows Source registrars never reach this door. The example apps seed no sys_metadata view rows. Hosted tenants: NOT MEASURED. n/a

The attribution half: does a live overlay path depend on a package-less container row taking a shipped item's package?

  • Callers. runtimeViewContainerPackage is called by expandRuntimeViewContainer (and by the new overlaidShippedContainerViewNames). expandRuntimeViewContainer is called by expandStoredViewContainers (the list read, the by-name read's resolveRowlessExpandedView, and this predicate), by hydrateExpandedViewItems (the registry), and by the predicate itself.
  • The overlay that does take a package is unchanged and pinned (CONTROL): a package-less row under the package's own container name. That is the path behind the checklist's packaged-display-class-direct-edit designer overlay.
  • No writer stores a package-less container under a view item's name. Studio's creators write view items, the save door now refuses the shape, and the restore doors and draft promotion re-write only bodies already stored.
  • Verdict: no live overlay path depends on it, so the ruling's attribution lands.

Every accept-set change at saveMetaItem, type view

Each row covers publish and draft mode, both scopes and both kernels. "Stored container" means one in the caller's selection.

Input Before (BASE 7b07749f05) After
A container bound to another object, under a name a stored container expands Accepted: stored, and registered on an unscoped kernel. The sibling's view under that name was served by neither door, and the by-name read answered the raw container. Refused VALIDATION_ERROR / 400, naming the stored container. Nothing is stored or registered.
An unbound container under such a name Same as above. Same as above.
A container whose expansion takes a name a stored container already expands: a second container of one object whose bare list takes OBJECT.default, under a free name or under the object's name Accepted. The one read last replaced the other's view on both doors. Refused, naming the stored container.
A container under the name of a view item a package ships: package-less, organization-scoped, or bound to a writable package Accepted. The packaged view was no longer listed on the object door, and by-name answered the raw container. Package-less, the row also took the shipping package, so its bare list replaced OBJECT.default on both doors with that package's _packageId. Refused, naming the package.
An overlay of a package's own container whose member takes the name of a view item the package ships on its own Accepted. The member replaced that packaged view on both doors. Refused, naming the package.
A container under its own expanded name (#21558), or under a name another stored container of the same object expands (#21620) Refused VALIDATION_ERROR / 400. Refused, with the same envelope. The prescription now comes first, and the own-expansion arm no longer prescribes a save under a name a stored container holds.
A form-only container under a registered view item's name Refused VALIDATION_ERROR / 400 under #21558 or #21620. Under any other registered name: 422 from the identity stamp. Every shape this predicate covers is refused VALIDATION_ERROR / 400 first, before the stamp.
Everything else Unchanged. Unchanged.

Rows already stored. They keep their bytes, and no row is re-saved. A package-less container row stored under a shipped view item's name now reads as belonging to no package:

  • On that package's object it expands under its own name (showcase_task.showcase_task.in_progress), with no _packageId and no default.
  • The packaged views it used to replace (showcase_task.default, or showcase_task.edit for a listViews.edit member) are served again on both doors (pinned).
  • The row still takes its own name's slot on both doors. That is the read doors' name-keyed overlay, out of surface and unchanged.

A new save of a row in a refused shape, a re-save included, is refused until its body stops colliding. Delete stays open.

The enumeration pin (the card's acceptance)

view-container-runtime-expansion.test.ts, block #21639. Each row runs on both kernels (env_local and unscoped) and both scopes, unless the row names one scope.

  • A refused row asserts:
    • code and status;
    • the save name, the colliding name and the owner;
    • the owner and the view-item prescription inside the first 500 characters;
    • that no stored row's name is ever prescribed as a name to save under;
    • in publish and in draft mode, that nothing is stored or registered;
    • that the owner's view still answers on both doors.
  • An allowed row asserts that it saves, and that each named view answers on both doors.
  • The structural tests fail when:
    • a row is neither refused nor allowed, or is both;
    • a ruled shape has no row;
    • a cell of the predicate has no refused row.
# Shape Verdict Owner named / reason
1 A container under a name its own expansion produces (#21558's own-expansion name) REFUSED its own expansion
2 The same, while a stored container holds its object's name REFUSED its own expansion; the prescription names that container
3 A container of the same object under a name a stored container expands (#21620's sibling) REFUSED the stored container crm_lead
4 A container bound to another object under that name ((1)'s other-object container) REFUSED the stored container crm_lead
5 An unbound container under that name ((1)'s unbound container) REFUSED the stored container crm_lead
6 A second container of one object, free name, bare list on OBJECT.default ((2)'s second container default) REFUSED the stored container crm_lead
7 A container under its object's name, after a free-named container took OBJECT.default REFUSED the stored container lead_other_views
8 A package-less container under a shipped view item's name (#21638's shipped item name) REFUSED the package com.example.showcase
9 A writable-package container under a shipped view item's name REFUSED the package com.example.showcase
10 An overlay of the package's container whose new member takes a view item the package ships on its own REFUSED the package com.example.showcase
11 A view item under a stored container's expanded name (allowed: #21510's sanctioned override) ALLOWED a view item is not a container: it is that name's sanctioned override
12 A view item under a shipped view item's name ALLOWED the sanctioned override of the packaged view by name
13 A container under its object's name (allowed) ALLOWED the container contract's own name (ADR-0017 §3.2), and nothing it expands is served elsewhere
14 A container's own re-save ALLOWED the row under the save name is the row this save replaces
15 A container under a name of its own beside a sibling, with names the sibling does not expand ALLOWED the census writers' shape, colliding with nothing
16 An overlay of the package's own container, by its own name ALLOWED ADR-0005: the row stands in for the shipped container and its views
17 A package-less container on another package's object ALLOWED #21334's arm: its names derive from its own name
18 The same, in a writable package ALLOWED #21334's arm, bound to its own package
19 Under another organization's container's expanded name (organization scope) ALLOWED the caller's selection decides, as it does for the readers
20 An environment-wide container under an organization's container's expanded name (environment scope) ALLOWED the caller's selection decides; reading every organization's rows at an environment-wide save would be a cross-tenant read

The PM's mechanism hypotheses

Tests

All readings are at HEAD d5101d1831 unless named otherwise.

  • Premise.
    • Method: the new pins, run against BASE's protocol.ts (blob 56bc12dce760), restored by a trap-guarded script. Restore proof: blob e5765e5ba0f9 equal to HEAD at 0af0f28e49, and git diff HEAD empty. The command: vitest run src/view-container-runtime-expansion.test.ts -t '#21639|#21638'.
    • Result: 40 failed, 50 passed (231 skipped).
    • Red:
      • the 7 newly refused shapes × 2 scopes × 2 kernels (28), each failing on the save is refused;
      • row 2 × 4, where BASE's arm reads "Save the container under its object's name, 'crm_lead'" while a stored container holds crm_lead;
      • the 8 attribution pins, where the packaged label and config were replaced.
    • Green: rows 1 and 3, every allowed row, the 2 structural tests, and the 4 attribution controls.
  • New pins: 90.
    • The enumeration block: 2 structural tests + 76 row cells.
    • The #21638 attribution block: 2 at-rest cases + 1 control, × 2 scopes × 2 kernels.
  • The file: 321 passed (231 pre-existing + 90).
  • The package: pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 gives Test Files 209 passed, 3 skipped (212); Tests 3463 passed, 19 skipped (3482); VERDICT command-exit 0.
  • Typecheck: pnpm --filter @objectstack/metadata-protocol typecheck (tsc --noEmit) exits 0, and tsc --noEmit --listFiles includes the test file.
  • Downstream sample.
    • Direction: consumers (downstream) of @objectstack/metadata-protocol.
    • Build: against dist/ rebuilt at d5101d1831 by turbo run build --filter='@objectstack/dogfood^...' --filter='@objectstack/metadata-protocol...' --concurrency=2: 63 of 63 tasks. The built dist/index.js carries the new predicate, and the old method names are gone from it.
    • objectql, 11 files, 229 tests: protocol-meta, protocol-view-identity-overlay, protocol-org-overlay-registry-gate, protocol-commit-history, protocol-packaged-view-base, protocol-save-meta-repo-path, protocol-save-meta-repo-path-real-engine, view-container-divergent-name-registrars, view-container-name-refusal, engine-nested-plugin-view-expansion, metadata-validation-sweep.
    • rest, 1 file, 7 tests: public-form-routes.stored-row.
    • dogfood, 2 files, 7 tests: view-container-cross-package-default (PR fix(metadata-protocol): a bare-list view container on another package's object expands under its own name #21430's pin over REST) and view-container-default-form.
    • All pass. The rest of the downstream run is CI's.
  • Over REST, measured with a throwaway dogfood probe on the booted showcase, deleted afterwards:
    • PUT /api/v1/meta/view/showcase_task.in_progress with a container answers 400 VALIDATION_ERROR, and the object door still serves showcase_task.in_progress as "In Progress" from com.example.showcase;
    • a second container on one runtime object answers 400;
    • a view item under the shipped name answers 200.

Reverse verification

The change was committed first. Both legs ran from committed e5ac5cf14a, the same protocol.ts blob as HEAD, since the later merge touched nothing under metadata-protocol. Each leg was a trap-guarded script around scripts/ablation-replace.mjs --delete.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, at d5101d1831 derives 64 families. That is the dispatch lead's 56 plus the 8 the changeset adds:

  • check-adr-0087-registration ×2;

  • check-empty-changeset ×2;

  • release-rehearsal-clone --self-test and release-pending-publish --self-test;

  • check:objectui-changeset and check:pm-changeset-deadline-census.

  • All 64 exited 0. pnpm check:dual-build-cjs-loads first exited 3: PREREQUISITE NOT MET, because 8 packages had no dist/. It exited 0 after those were built through the lock.

  • check:dual-build-cjs-loads and check:type-check-debt ran through the verify lock.

  • --ran: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN".

  • check-adr-0087-registration accepts the changeset's not-required (no-migration-prescription) disposition.

  • Lint, a declared narrowing.

    • Measured: eslint --no-inline-config --format json over the 2 changed .ts files reports 2 files, 0 errors and 0 warnings, with no "file ignored" message. The changeset .md has no matching ESLint configuration.
    • No untouched file can change verdict: type-aware linting is off. eslint.config.mjs:328 says so, and --print-config shows parserOptions.project and projectService both null.
    • Repo-wide pnpm lint is CI's.
  • Branch state: origin/main was merged twice, f30588ceb7 and then d5101d1831. Neither moved a byte under packages/metadata-protocol.

Acceptance notes


Generated by Claude Code

claude added 9 commits October 3, 2026 22:59
…tainer at the save door; a package-less container row named after a shipped view item belongs to no package (WIP)

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…container collision predicate, and the package-less container row's attribution

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
… name as the container, never as a name to save under; #21558's pins name the shipping package

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
… asked positively by the save door's shipped arm and the container row's package attribution

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…t narrows nothing it does not hold

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…ate, and the package-less container row's attribution

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…wner and its prescription inside the 500-character wire bound, the explanation after

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

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/client-sdk.mdx (via VALIDATION_ERROR (literal, a string literal in viewContainerNameCollisionRefusal))
  • content/docs/api/error-catalog.mdx (via VALIDATION_ERROR (literal, a string literal in viewContainerNameCollisionRefusal))
  • content/docs/api/error-handling-client.mdx (via VALIDATION_ERROR (literal, a string literal in viewContainerNameCollisionRefusal))
  • content/docs/api/error-handling-server.mdx (via VALIDATION_ERROR (literal, a string literal in viewContainerNameCollisionRefusal))
  • content/docs/automation/jobs.mdx (via VALIDATION_ERROR (literal, a string literal in viewContainerNameCollisionRefusal))
  • content/docs/automation/webhooks.mdx (via VALIDATION_ERROR (literal, a string literal in viewContainerNameCollisionRefusal))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/drivers.mdx (via VALIDATION_ERROR (literal, a string literal in viewContainerNameCollisionRefusal))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/protocol/kernel/error-handling.mdx (via VALIDATION_ERROR (literal, a string literal in viewContainerNameCollisionRefusal))
  • content/docs/protocol/objectql/types.mdx (via VALIDATION_ERROR (literal, a string literal in viewContainerNameCollisionRefusal))
  • content/docs/ui/forms.mdx (via VALIDATION_ERROR (literal, a string literal in viewContainerNameCollisionRefusal))

⛔ 5 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/v17/17-1.mdx (via VALIDATION_ERROR (literal, a string literal in viewContainerNameCollisionRefusal))
  • content/docs/releases/v17/17-5.mdx (via VALIDATION_ERROR (literal, a string literal in viewContainerNameCollisionRefusal))
  • content/docs/releases/v17/17-6.mdx (via VALIDATION_ERROR (literal, a string literal in viewContainerNameCollisionRefusal))

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 15fe567c9c74e088684944094aa686b9bd3b386c → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 15fe567c9c74e088684944094aa686b9bd3b386c

⚠️ 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 15fe567c9c74e088684944094aa686b9bd3b386c → 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 — PR #21648 at head d5101d1831 (#21639, with #21638 folded in)

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-04T00:05Z. The os-dev report is on #21639. Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main, assignee os-project-manager.
  • Scope: 3 files, +746/-183: protocol.ts, the pin file and the changeset. check-governed-merges reports NOT governed.
  • The diff, read (non-comment lines):
  • Prescriptions, checked against the family rule. Each message names the other owner, and none prescribes a save under a name another stored row holds.
    • The own-expansion arm keeps "save under its object's name" only when no stored row holds that name. Otherwise it names that container, or a name of its own.
    • Deviation 1 (prescription first) — accepted. Ordering the prescription before the explanation is a measured fix: the first REST probe received the shipped-arm prescription cut at the 500-character bound (CLIENT_MESSAGE_MAX). A pin holds the owner and the view-item arm inside the first 500 characters.
  • The enumeration pin is the card's acceptance: 20 rows, each either refused (with its owner) or allowed (with its reason).
    • Structural tests fail on a row that is neither or both, on a ruled shape with no row, and on a predicate cell with no refused row.
    • Row 7 is accepted on the ruling's literal rule: the object-named container is refused after a free-named container took OBJECT.default. Only a row stored before this change can produce it, because row 6 now refuses the free-named one. The message names that container.
  • Open question (package duplication) — the seat answers A. Package duplication can copy a container whose object is outside the copied package into a second container of that object. That is a byproduct, not a writer saving one on purpose, so (2)'s stop condition does not fire.
    • Before this change, the copy silently took the source's view, which ADR-0126 rules out.
    • It now lands in failed[] with the refusal and its remedy.
    • B would re-open the precedence order the ruling forbids. C is a new duplication behaviour and a new card if anyone asks for it.
    • The changeset states it.
  • Clause-②: no (narrowing) — accepted. There is no path leg and nothing widens. The attribution change restores packaged views a row used to hide, which is not a new accepted input. No contract review is owed.
  • Changeset, checked against the diff:
    • minor, the BREAKING banner, and adr-0087: not-required (no-migration-prescription) with the census facts, including duplication.
    • "One rule" matches the predicate's order and arms.
    • Each "before and after per shape" bullet matches an enumeration row: 4 and 5, 6 and 7, 8 and 9, 10, and 1 to 3.
    • "What still saves" matches rows 11 to 20.
    • "Rows stored before this change" matches the attribution branch: a row named after a shipped item expands under its own name with no package, and the packaged views come back.
    • "The fix" carries the family's arms.
  • Evidence:
    • Premise at BASE: 40 of the 90 new pins red, in exactly the newly refused cells, row 2's old prescription and the 8 at-rest attribution pins.

    • Suites: the package passes 3463 of 3463, typecheck exits 0, and the downstream objectql, rest and dogfood samples are green.

    • REST probes on the booted showcase: a 400 for a container under a shipped name and for a second container, and a 200 for a view item under the shipped name.

    • Reverse verification, two legs:

      • the predicate's throw ablated: 104 refusal pins red across all four families, and every allowed row and control green;
      • the attribution guard ablated: exactly the 8 at-rest pins red.

      Both restores were proved by blob equality.

    • Gates: dispatch-gates --ran reconciles 64 of 64.

  • CI on d5101d18, at this read: 12 check runs are in progress, and check-expected-skips is NOT MEASURED. The seat lands only once every check is green or an expected skip.

Out-of-scope, noted, not filed:


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 4, 2026 00:44
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 4, 2026 00:44
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit eea82af Oct 4, 2026
42 of 43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21639-view-container-collision branch October 4, 2026 01:09
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…nd an unknown --object is refused (objectstack-ai#21662)

Fixes objectstack-ai#21644

Clause-②: no

A deployment-level flag is now written only by a full-scope run. `os
migrate value-shapes` and `os migrate files-to-references` narrowed by
`--object` apply their fixes, record no deployment flag, and say so. A
full-scope `--apply` records the flag exactly as before. Across the
family (`value-shapes`, `files-to-references`, `summary-nulls`,
`duplicates`), an `--object` name the deployment does not declare is
refused with `OBJECT_NOT_FOUND` before anything is read or written. The
refusal names the unknown name and the declared objects. This follows
triage ruling `5974774596`. `--apply --object` is not refused.

## Measured first (base `759dbe9ed3`)

### A1. The reach, at the public door (hypothesis confirmed)

A throwaway SQLite project held three objects, one of them
`os21644_site` with a `location` field. A served-shape boot seeded one
clean row per object. Then one off-shape value was written past the
write path: the site's `geo` stored as `{latitude, longitude}`. The
fresh-datastore attestation had recorded both ADR-0104 flags as verified
at birth, so the flag table was emptied first.

- `os migrate value-shapes --json` exited 1, with `gatePassed: false`
and `blocking: 1`.
- `os migrate value-shapes --object os21644_sitee --apply --yes --json`
(misspelled) exited **0**. It answered `gatePassed: true` with
`scannedObjects: []`, and the `adr-0104-value-shapes` row read
**verified** (`verified_at` set, `blocking: 0`).

### A2. The census, one row per command

| command | `--object` | `--apply` records a deployment flag | where it
is written (base) | unknown `--object` on base (measured) |
| --- | --- | --- | --- | --- |
| `value-shapes` | repeatable | `adr-0104-value-shapes` | the CLI:
`recordDataMigrationRun` at `value-shapes.ts:221` | exit 0,
`scannedObjects: []`; with `--apply`, the flag is recorded **verified**
|
| `files-to-references` | repeatable | `adr-0104-file-references`, then
the column step's `columns_moved_at` | the producer:
`runFilesToReferencesMigration` at
`files-to-references-migration.ts:120`; the column stamp is
`recordFileColumnMove` in the CLI (`files-to-references.ts:472`) | exit
0, both scans' `scannedObjects: []`; with `--apply`, the flag is
recorded **verified**, **and the column step moved
`os21644_product.image` and stamped `columns_moved_at`** |
| `summary-nulls` | repeatable | none (its header: "No deployment flag,
deliberately") | none | exit 0, `fields: []`, on a dry run and on
`--apply` |
| `duplicates` | single | none: no `--apply`, and it writes nothing |
none | exit 0, `scanned: []`, `filter: { object: 'os21644_sitee' }` |

A correctly spelled narrowed `files-to-references --apply` on base also
recorded the flag verified and moved the column. Every scan draws its
default candidates from the same registry: `options.objects ??
Object.keys(engine.getConfigs())` in `scanValueShapes`,
`backfillFileReferences`, `verifyFileReferences` and
`backfillSummaryNulls`, and `stack.allObjects()` for
`collectScanTargets`. Each keeps only the candidates it covers, which is
where an undeclared name was dropped.

### A3. The narrowed run

- **CLI-recorded flag (`value-shapes`).** The flag write is skipped on a
narrowed run, whether the run passes or fails. `--json` carries `flag:
null` and `filter: { objects }` (`null` on a full-scope run, the shape
`duplicates` already keeps). Both faces print one sentence: the run was
narrowed, no deployment flag was recorded, and the command that records
one is the same command without `--object`.
- **Producer-recorded flag (`files-to-references`).**
`runFilesToReferencesMigration` skips the write when it is given
`objects`. This is the declared `service-storage` path only. Its `flag`
result is `null` on a narrowed run. The CLI prints the same sentence and
carries `filter`.
- **The column step (`files-to-references`) does not run on a narrowed
run.** The census row above is why. The step retypes every single-value
media column in the database on the authority of the gate, and a
narrowed gate vouches only for the named objects. Its stamp also
requires a verified flag, which a narrowed run no longer records. Left
running, a narrowed `--apply` would move columns and then fail to record
the move. It now returns a stated skip, `narrowed_run`, and the human
face says why.
- **What "narrowed" means.** Any `--object` narrows, even a list that
names every declared object. The flag is earned by the one spelling that
means "every object", which is a run without `--object`. Treating a full
list as full scope would need a second definition of "the whole
deployment", checked against the registry of the moment, and that
registry changes with the composition between two runs. The operator
also gets one unambiguous prescription.
- **Deviation from the dispatch wording ("skips it when `objects` is
non-empty").** The producer treats **any** `objects` as narrowed, `[]`
included. A scan handed `[]` walks nothing (`[] ?? …` is `[]`). A
non-empty test would therefore record a verified flag over an empty
scan, the card's own defect at the producer's API. A unit pin holds
this.
- The prompts and closing lines that promised a flag on a narrowed run
now say it records none. ⛔ `--apply --object` is not refused, and the
full-scope write is unchanged.

### A4. Unknown `--object`

- **Checked against the registry the command's own boot resolved, before
the scan.** For `value-shapes`, `files-to-references` and
`summary-nulls` that registry is `Object.keys(engine.getConfigs())`. For
`duplicates` it is the names of `stack.allObjects()`. These are the same
sets the scans draw from, so the refusal and the scan judge one
population. There is no `packages/objectql` edit and no scanner edit.
- **The refusal is objectstack-ai#21643's.** It is `objectNotFoundError` from
`@objectstack/core`: `code: 'OBJECT_NOT_FOUND'`, `status: 404`, and
`object` naming the first unknown name. Its message names every unknown
name and the declared objects, sorted. There is no new error code.
`value-shapes`, `files-to-references` and `summary-nulls` answer `{
error, code }`, as `unmapped-columns` does. `duplicates` keeps its own
error shape, `{ error: 'report_failed', detail, code }`: its catch now
passes `errorCodeFields` through.
- **The list is the declared set, not the covered subset.** Computing
the covered subset for `value-shapes` needs
`isScannableValueShapeField`, which `@objectstack/objectql` does not
export, and that package is fenced. The declared set is also exactly the
accept set. A declared object the command has nothing to check on is
accepted, because an empty answer about a real object is true. On the
fixture boot the list is 12 names, platform objects included.
- **Clause-②: no stands as the claim declared it.** A misspelled name
moves from exit 0 to exit 1, which is the ruled correction of a wrong
answer. Every declared name and `--apply --object` are still accepted.

## Changes

- `packages/cli/src/utils/migrate-object-scope.ts` (new):
`refuseUndeclaredObjects`, `isNarrowedRun` and `narrowedFlagNote`,
shared by the four commands.
- `packages/cli/src/commands/migrate/value-shapes.ts`: refuses an
unknown name, skips the flag on a narrowed run, adds `filter`, and
adjusts the narrowed prompt and closing lines.
- `packages/cli/src/commands/migrate/files-to-references.ts`: refuses an
unknown name, adds the `narrowed_run` column-step skip, adds `filter`,
and adjusts the narrowed prompt and closing lines.
- `packages/cli/src/commands/migrate/summary-nulls.ts` and
`duplicates.ts`: refuse an unknown name. `duplicates`' error document
carries the error's `code`.
-
`packages/services/service-storage/src/files-to-references-migration.ts`:
skips the flag write when given `objects`.
- `content/docs/deployment/cli.mdx`: one paragraph under "Data
migrations" (`--object` narrows, an unknown name is refused, only a
full-scope run records a flag), and the two `--object` example comments.
- `.changeset/21644-narrowed-apply-flag.md`: `@objectstack/cli` patch
and `@objectstack/service-storage` patch, `Clause-②: no`.

`packages/objectql`, `packages/platform-objects`, `packages/spec`, every
other `service-storage` path, and `content/docs/releases/` are
untouched.

## Pins

- **`object-scope.integration.test.ts`** spawns the CLI against SQLite,
one database copy per run, and is one enumeration over the census
(`FAMILY`).
- A narrowed `--apply` (`value-shapes`, `files-to-references`): exit 0,
`flag: null`, `filter: { objects }`, no flag row, and the note on stderr
naming the full-scope command.
- A full-scope `--apply`: the flag recorded verified, in the document
and in the row.
- `summary-nulls` and `duplicates`: no flag row, narrowed or not, as
before.
- A narrowed `--apply` after an earned flag leaves that row byte-equal.
- `files-to-references` narrowed: `columnMove: null` and
`columnsMovedAt: null`. Its full-scope control moves
`os21644_product.image` and stamps it.
- Unknown `--object`, on all four: exit 1 and `OBJECT_NOT_FOUND`, naming
the name and the declared objects. The one document is the refusal and
no report, no flag row is written, and the app rows are unchanged. The
human face exits 1 and names it.
- The measured repro. Control: the full-scope scan sees `blocking: 1`
and exits 1. The misspelled `--object --apply` exits 1 with
`OBJECT_NOT_FOUND`, and the flag stays unrecorded. Spelled right, the
narrowed run finds the value, exits 1, and still records no flag.
- **`migrate-object-scope.test.ts`** (unit): the envelope (`code`,
`status`, `object`), every unknown name named once, the declared list
sorted, the empty-registry message, the accepted cases, and what
`isNarrowedRun` treats as narrowed (an empty list and a full list both
narrow).
- **`files-to-references-migration.test.ts`** (`service-storage`, beside
the producer):
  - a narrowed apply converts and records no flag;
  - a narrowed failing apply records nothing;
  - a narrowed apply leaves an earned flag row equal;
  - `objects: []` records nothing.

## Reverse verification (implementation committed first; all three legs
re-run at the final head `fe988c20f0`)

Each leg ran through `node scripts/ablation-replace.mjs` in wrap mode,
under a script trap that restores from `HEAD`. The spawned CLI loads its
commands from `src/` through `bin/run-dev.js`. In the first round
`packages/cli/dist` did not exist. In the final round it held a build of
the unmutated source, and legs 1a and 2 still went red, which shows the
spawned CLI read the mutated `src/`. The `service-storage` unit pin
imports the producer from `src/`. Neither needed a rebuild.

- **Leg 1a, the narrowed-run skip in the CLI** (`value-shapes.ts`):
- The anchor `if (apply && !narrowed) {` became `if (apply) {`: anchor 1
to 0, replacement 0 to 1, blob `9f241dc2` to `d3a5c236`.
- **3 red, 17 green.** Red: the `value-shapes` narrowed pin, the
earned-flag-unchanged pin, and the spelled-right repro. Green: both
full-scope controls (the column-step control among them), the
`files-to-references` narrowed pin (its skip is the producer's), and
every unknown-name pin.
- Restored: blob `9f241dc2` equals `HEAD`, and `git diff HEAD` is empty.
- **Leg 1b, the narrowed-run skip in the producer**
(`files-to-references-migration.ts`):
- The same anchor and replacement: anchor 1 to 0, blob `1aa9fea2` to
`d4bb5002`.
- **4 red, 6 green.** Red: all four narrowed pins (passing, failing,
earned-flag-unchanged, and `objects: []`). Green: the six original pins,
the full-scope apply among them.
  - Restored: blob `1aa9fea2` equals `HEAD`.
- **A first round is recorded here because one of its readings was
vacuous.** At `80e5eda6ea` this leg read 3 red and 7 green: the
earned-flag-unchanged pin stayed green under the mutation, because the
fake engine's rewrite landed in the same millisecond as the earned row.
The pin now dates the earned row in the past (`fe988c20f0`), and the
re-run is the reading above.
- **Leg 2, the unknown-name refusal** (`migrate-object-scope.ts`):
- The anchor `if (unknown.length === 0) return;` became `if
(unknown.length >= 0) return;`: anchor 1 to 0, replacement 0 to 1, blob
`b78a88b4` to `9286a990`.
- **Unit: 3 red, 3 green.** Red: the three refusal cases. Green: the
accepted cases and the two `isNarrowedRun` cases.
- **Integration: 10 red, 10 green.** Red: all eight unknown-name pins
(two per command, on all four), the human face, and the misspelled
repro. Green: every narrowed and full-scope pin, and the repro's
control.
  - Restored: blob `b78a88b4` equals `HEAD`.
- In the first round, the `duplicates` "refused before anything was
read" pin stayed green under this mutation: it asserted only on a key
that report never carries. It now asserts that the one document is the
refusal, which reds on all four commands.

After all legs, `git diff HEAD` was empty and `git status --porcelain`
was clean.

## Local verification (final head `fe988c20f0`, on base `759dbe9ed3`)

`origin/main` was `759dbe9ed3` for the whole verification. Just before
this PR opened, it gained four commits, `f40bb3217f` to `1a230548cf`
(objectstack-ai#21649, objectstack-ai#21632, objectstack-ai#21648, objectstack-ai#21650). None of them touches this diff's paths
(`packages/spec`, `metadata-protocol`, `service-automation`, `lint`,
skills and docs references), so they were not merged in. CI runs on the
merge ref.

- **Builds.** The CLI's dependency closure (`turbo run build
--filter=@objectstack/cli^...`) gave VERDICT 0.
`@objectstack/service-storage` was rebuilt after the producer change
(exit 0), and `@objectstack/cli` was built (exit 0). A repo build for
the gate prerequisites gave VERDICT 0 (turbo: 72 tasks, 71 cached).
- **`@objectstack/cli` typecheck** (`tsc --noEmit` plus
`check:test-typecheck`): exit 0 at `80e5eda6ea`. No CLI file changed
after that commit.
- **`@objectstack/cli` unit project in full** at `80e5eda6ea`:
- 255 of 257 files passed, with 3742 tests passed and 29 skipped (the
two files below).
- The other two files,
`test/published-subpath-{console,hook-body}.pin.test.ts`, refused before
testing because `packages/cli` was not built (their own prerequisite
message). After the CLI build, both passed: 2 files, 29 tests.
- **`@objectstack/service-storage`**: typecheck exit 0, and the full
suite at `fe988c20f0` passed 41 files and 633 tests.
- **`os migrate` integration pins on built packages**, at `80e5eda6ea`:
  - this PR's pin plus the absent-database roster: 2 files, 59 passed;
- the one-shot family plus `duplicates.integration`: 2 files, 79 passed.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) derived 97 commands at
`fe988c20f0`.
  - All 97 ran there, and each exited 0.
- `--ran` with exit codes: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN.
- An earlier round at `80e5eda6ea` had three gates answer `PREREQUISITE
NOT MET` (exit 3): `check:skill-examples`, `check:dual-build-cjs-loads`
and `check:i18n-coverage`. They read packages outside the CLI closure.
The repo build cleared them.
- **Full `pnpm lint`** (`eslint . --no-inline-config` over the whole
repo): exit 0 at `fe988c20f0`, with nothing printed.
- **No exported symbol was renamed or moved,** so the liveness-ledger
anchor check had nothing to read.

## Acceptance notes

- **A narrowed run's counterexample is not recorded.** The ruling says a
narrowed `--apply` records no flag, so it records none even when it
finds a violation. Such a counterexample is deployment-level evidence,
since one off-shape value disproves "every value is on shape". The
operator still gets exit 1 and the findings, and the next full-scope run
closes the gate. This is an observation, not a filing. Carrier: none.
- **The declared list includes platform objects.** It is 12 names on the
fixture's lean boot, and a deployment that composes more plugins prints
more. A long list in an error message is the price of naming the exact
accept set. Carrier: none.
- **A narrowed `value-shapes --apply` still takes the plain
(DDL-performing) boot** even though it now writes nothing. That is
unchanged, and the boot paragraph in the docs still describes it
truthfully. Carrier: none.

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

---------

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