Skip to content

fix(metadata-protocol): the save door refuses a view container saved under a name its own expansion produces (#21558) - #21618

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21558-container-own-expansion-name
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21558-container-own-expansion-name

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21558
Clause-②: yes (narrowing) — the runtime save door's accept set narrows: a view container saved under a name its own expansion produces is refused with VALIDATION_ERROR / 400. Nothing widens.

What changed

saveMetaItem (packages/metadata-protocol/src/protocol.ts), the method behind PUT /api/v1/meta/view/NAME and the dispatcher's metadata save, now refuses a view container saved under a name its own expansion produces. The card's case is { name: 'showcase_task.default', object: 'showcase_task', list } saved as showcase_task.default, the name its bare list expands to. This implements triage's ruling 5966930701: refuse at the write door, with a named error and the prescription. ⛔ The read doors are not changed, and no stored row is re-saved.

  • Where. A new private method, containerOwnExpansionNameRefusal, is called right after the name check placed at this door earlier (savedItemNameRefusal). It runs before the view identity stamp (normalizeViewMetadata).
  • The predicate. It asks the readers' own expansion, expandRuntimeViewContainer, whether the save name is one the container expands to. It does not copy the naming rule. So every member kind (a bare or named list, listViews, form, formViews) and the expander's de-duplicated names (_2) are judged where the readers place them. A container on another package's object expands under its own name (the arm from metadata: a view container with a bare list on another package's object silently replaces that object's packaged default view on GET /meta/view?object= — while the by-name read still serves the original #21334) and is never refused. A body with no name is judged under the save name the door stamps on it.
  • The envelope. VALIDATION_ERROR / 400, the same as the name check it sits beside. H4: the existing save-door name refusal uses this code, so no new code is minted and the error-code ledger is untouched.
  • The words. "Invalid view container: it is saved under 'NAME', which is a name its own expansion produces (its list view on 'OBJECT'). An expanded view fills only a name that has no stored row of its own, and this container would be that row, so no read would answer a view under 'NAME'. Save the container under its object's name, 'OBJECT', or save a view item (name, object, viewKind and config) under 'NAME'." The text carries no tracker number.

Every accept-set change at saveMetaItem, type view

Input Before After
A container whose save name is one of its own expansion's names. Applies to publish and draft mode, both scopes and both kernels. Accepted: stored, and registered on an unscoped kernel. Refused VALIDATION_ERROR / 400. Nothing is stored or registered.
The same container with no body name (the door stamps the save name). Accepted. Refused, with the same envelope.
A container whose only member is form, saved under its own expanded name where the registry already holds a view item of that name. Refused INVALID_METADATA / 422. The identity stamp copied that item's viewKind onto the body, so the schema saw a malformed view item. Refused VALIDATION_ERROR / 400 by this check, which now runs first.
Everything else. Unchanged. Unchanged.

What still saves (the ruling's controls, pinned on both kernels and in both scopes)

Stored rows and the other writers through this door

Census (taken before the refusal was written)

At BASE 37442d4750, objectui at its pin 89cad75d55:

  • Writers in this repository. The view writers that reach saveMetaItem are REST PUT /api/v1/meta/view/NAME and the dispatcher (runtime/src/domains/meta.ts:1424), which pass the caller's body through, plus migrateStoredMetadata and duplicatePackage. runtime/src/domains/packages.ts:1583 saves app only, and runtime/src/domains/automation.ts:1628 saves flow only.
  • Studio (objectui at its pin). No Studio writer produces the shape by default:
    • app-shell ObjectView.tsx:1471 saves through buildViewConfigSaveBody, and :1530 through viewEnvelope. ObjectDataPage.tsx:381 uses createRuntimeMetadata. All three write a view item (viewKind: 'list').
    • data-objectstack index.ts:5522 (setViewConfig) and :5811 (createView) write flat view configs. updateView reduces a container it reads to its list (:5895).
    • PublicFormsPage.tsx:229/301 saves items listed by getMetaItems, which never lists a container.
    • The metadata-admin createBuildBody (anchors.ts:291) emits a view item.
    • The metadata-admin edit page (ResourceEditPage.tsx:1477) saves a body under its own name. It produces the refused shape only if an author types an expanded name into a container's name.
  • AI author. This repository has no view-writing AI tool. service-ai was removed in 21d4f8901b (the open edition is MCP-only, ADR-0025 S2), and the MCP tool list in mcp-http-tools.ts has no metadata write. The published skills/objectstack-ui tells authors to write defineView containers in source. Those go through the source registrars, which refuse a container name that disagrees with its object. The cloud AI author is outside this repository: NOT MEASURED.
  • Packaged containers. There are 12 defineView( sites in examples/ (crm 3, showcase 7, todo 2), and none carries a top-level name. platform-objects carries object-level listViews, not view containers. Structurally, a source registrar files a container under its object and refuses a name that disagrees with it, and an expanded name (OBJECT.KEY) is never the object's name. So a packaged container under its own expanded name cannot boot.
  • Stored rows. The example apps seed no sys_metadata view rows. In this repository's tests, no suite stores this shape: the full metadata-protocol suite and the downstream samples below stay green with the refusal on. Hosted tenants: NOT MEASURED.

Tests

  • Premise, measured on this branch before the fix (BASE 37442d4750, pins present, refusal absent). vitest run src/view-container-runtime-expansion.test.ts -t '#21558' gave 27 failed / 9 passed:
    • 23 refusal pins failed with expected null to be an instance of Error, meaning the save was accepted. These are 16 member cells, 4 draft cells and 3 runtime-object cells.
    • The 4 form cells answered INVALID_METADATA / 422 (the identity-stamp row in the table above).
    • The 9 controls passed.
  • Door probe (a throwaway test, deleted afterwards), with the refusal ablated on both kernels: save=accepted objectDoor=[] byName=raw container. With the fix: save=refused VALIDATION_ERROR/400 on both kernels.
  • With the fix, at 079069661f:
    • The file: 181 passed.
    • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: Test Files 207 passed | 3 skipped (210), Tests 3248 passed | 19 skipped (3267), VERDICT command-exit 0.
    • typecheck (tsc --noEmit): VERDICT command-exit 0. tsc --listFiles includes the test file.
  • New pins, 37 in all, in view-container-runtime-expansion.test.ts:
    • On both kernels × both scopes: every member kind saved under its own expanded name is refused with the envelope, nothing is stored or registered, and every packaged name still answers its packaged view on both doors. The card's save is refused in draft mode too.
    • The two ruled controls.
    • On a runtime-authored object: crm_lead.default, crm_lead.pipeline, the de-duplicated crm_lead.default_2, and an unnamed container, plus a control.
  • Downstream sample, against the rebuilt dist. Direction: consumers of @objectstack/metadata-protocol, built with turbo run build --filter='@objectstack/metadata-protocol...' --filter='@objectstack/objectql^...' --filter='@objectstack/rest^...', 24 tasks, VERDICT command-exit 0:
    • objectql: protocol-meta, protocol-view-identity-overlay, protocol-org-overlay-registry-gate and protocol-commit-history (4 files, 162 tests), plus metadata-validation-sweep, view-container-divergent-name-registrars and engine-nested-plugin-view-expansion (3 files, 27 tests).
    • rest: public-form-routes.stored-row (1 file, 7 tests).
    • All pass. The rest of the downstream run is CI's.

Reverse verification

The fix was committed first (df4fcd4636). A trap-guarded script then ran scripts/ablation-replace.mjs on the anchor if (ownExpansionRefusal) throw ownExpansionRefusal;:

  • Mutation. The anchor went from 1 occurrence to 0 and the blob from 19953a79f484 to 96e1e0adefea.
  • Result. -t '#21558|PROBE' gave 28 failed / 10 passed: every refusal pin went red, and the 9 controls plus the probe stayed green. The direction is red, as predicted.
  • Restore. git checkout HEAD -- ABS_PATH brought the blob back to 19953a79f484, equal to HEAD, and git diff HEAD was empty. Both the tool and the script's own trap verified this.
  • No dist leg. The subject is imported through the relative ./index.js (the source), not through a package exports.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) at 079069661f derived 64 families: the dispatch lead's 50, plus the ones this changeset and the test added.

  • All 64 exited 0.
  • check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: dists missing). After a workspace build it measured 106 entries in 66 packages and exited 0.
  • --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, narrowed: eslint --no-inline-config --format json over the 2 changed .ts files reports 2 files, 0 errors, 0 warnings. The changeset .md has no matching ESLint configuration. Type-aware linting is never enabled (eslint.config.mjs:326-328, and --print-config shows no parserOptions.project), so files this diff does not touch cannot change verdict. Repo-wide pnpm lint is CI's.
  • Over REST (PUT /api/v1/meta/view/NAME on a booted stack): NOT MEASURED. The pins are in-process at the method that door calls, on both kernels.

Acceptance notes

  • A sibling shape stays open (reported to the seat, not fixed here). A container saved under a name that ANOTHER container's expansion produces is accepted. Measured in-process on both kernels: a container crm_lead with listViews.pipeline is stored, then { name: 'crm_lead.pipeline', object: 'crm_lead', list } is saved as crm_lead.pipeline. The save is accepted. The object door then lists nothing under crm_lead.pipeline, and its crm_lead.default becomes the second container's list. The by-name read answers the raw container. The ruling covers only a name the container's own expansion produces.
  • The view identity stamp's container test omits form. viewIdentityPatch leaves list, listViews and formViews containers alone, but not a container whose only member is form. Saved under the name of a registered view item, such a container takes that item's viewKind and is refused 422 as a malformed view item. For its own expanded names this check now answers first. Under any other view item's name, that is the sibling shape above.
  • Restore and publish doors. rollbackMetaItem, revertCommit and the draft promotion do not run this check. A version or draft stored before this change can still be written back in this shape. A new draft in this shape can no longer be stored. Kept to the save door per the claimed surface.
  • Package binding. The expansion is judged with the request's package binding, which is the binding the registry write-through registers it under.
  • dist/index.d.ts gains one private member line. No public member or exported type changes.

#21510 and #21511 are context only; this PR leaves both as they are.


Generated by Claude Code

claude added 3 commits October 3, 2026 17:23
…under a name its own expansion produces

A container such as { name: 'crm_lead.default', object: 'crm_lead', list }
saved as crm_lead.default is the stored row of a name its own bare list
expands to. Both read doors give a name with a row of its own that row and
never let an expansion fill it, and the object door never enumerates a
container, so no door answered a view item for the name. The save door now
refuses the shape with VALIDATION_ERROR / 400 and the prescription: save the
container under its object's name, or save a view item under the expanded
name. The predicate is the readers' own expansion (expandRuntimeViewContainer),
so every member kind and the expander's de-duplication are judged as the
readers place them. The read doors 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 5 documentable anchor(s).

19 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 440cd329a96a3b130ba9c2fdc048a241e7a019a0.

⛔ 6 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 — 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 440cd329a96a3b130ba9c2fdc048a241e7a019a0 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 440cd329a96a3b130ba9c2fdc048a241e7a019a0

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 079069661f0cd6607d5e59cb7030759ce85df9ee
Local-runs: none

Rendered by an isolated at-tier reviewer and adopted by domain:engine#1.

Inputs read:

① Derived judgments

Every accept-set and public-surface change the diff implies, judged against the head's source:

  1. The runtime save door narrows, as ruled — right.
    • saveMetaItem gains one call, containerOwnExpansionNameRefusal(singularType, request.item, request.name, request.packageId). It sits directly after savedItemNameRefusal and before normalizeViewMetadata, and is unconditioned on mode, source or writeFace.
    • It returns a refusal exactly when the readers' own expandRuntimeViewContainer produces an item whose name equals the save name. That is the ruling's predicate ("a name its own expansion produces"), decided by the readers' own expansion rather than a copy of the naming. So the save door and the read doors cannot disagree about which names are self-named.
    • There is no over-refusal: every refused save is one that, if stored, would have been the name's own row, and would therefore have kept its own expansion out under finding(metadata-protocol): a stored view row named exactly like a container expansion is shadowed in the object door by the expansion, while the by-name read answers the stored row #21510's rule.
  2. Envelope VALIDATION_ERROR / 400 — right, and not new.
    • It is the code and status savedItemNameRefusal already throws at this door. Verified in @objectstack/metadata/view-container-name at the head: err.code = 'VALIDATION_ERROR'; err.status = 400.
    • There is no error-code ledger edit, no new code and no new envelope shape.
    • The message names the save name, the member kind and the object. It states why no read would answer the name, carries the ruled prescription (the object's own name, or a view item under the expanded name), and carries no tracker number.
  3. Draft and publish mode are both refused — right. The check sits before the mode branch at persist: mode is read at the prologue but first consulted at the store. So a draft save is refused with nothing stored. The pins cover draft on both kernels and both scopes.
  4. A container under its object's name — unchanged, right. expandViewContainer(object, container) names every member OBJECT.KEY, which is never OBJECT, so the predicate cannot fire. Pinned on both kernels and both scopes: it saves and expands as before.
  5. A view item under an expanded name — unchanged, right. The spec's isAggregatedViewContainer answers false for any body with viewKind, so expandRuntimeViewContainer returns nothing and the save proceeds. Pinned: it is the finding(metadata-protocol): a stored view row named exactly like a container expansion is shadowed in the object door by the expansion, while the by-name read answers the stored row #21510 sanctioned override.
  6. A container on another package's object — unchanged, right.
  7. Non-view types — unchanged, right. expandRuntimeViewContainer returns [] for any type other than view, so the check is a no-op for every other type even though it is called unconditionally.
  8. The unnamed-container stamp — right. The check stamps body.name ? body : { ...body, name: saveName }, the same truthiness normalizeViewMetadata uses (it.name ? undefined : { name: saveName }). So the body judged is the body the readers will see.
  9. The package binding — right.
    • stripDerivedProvenance runs before the check, so a caller-sent _packageId is gone at judgment.
    • The expansion's package is then request.packageId or the overlay lookup. That is exactly what the readers compute from the stored row: container.packageId, the row's binding request.packageId ?? null, and the same artifact lookup.
    • So the save-time and read-time cross-package judgments agree.
  10. One error-precedence move, stated — right.
    • Before: a form-only container saved under its own expanded name, where the registry holds a view item of that name, answered INVALID_METADATA / 422, because the identity stamp copied the item's viewKind onto it.
    • Now: it answers this VALIDATION_ERROR / 400.
    • Verified against viewIdentityPatch, which leaves alone only bodies with list, listViews or formViews keys. Nothing was stored in either case, and the PR table and the changeset both state it.
  11. The internal re-savers now report such a row as failed — right, and loud.
    • migrateStoredMetadata (source: 'migrate-stored') and duplicatePackage (writeFace: 'package-duplicate') both call saveMetaItem inside a try whose catch records the row.
    • One records outcome: 'failed' with clientFacingFailureText, which quotes a declared 4xx verbatim; the other records the row in failed[] with the same text.
    • So neither re-saves the row, and both name the prescription. The check is not gated on source, so this holds by construction.
    • The ruling's "does not silently re-save them" holds, and the changeset states the new reporting.
    • Note for the seat: a package duplication that meets such a row now reports success: false, because its success is true only when nothing failed and at least one row was copied. The census found no such row.
  12. The read doors — unchanged, right. The diff touches no read path, no namesWithOwnStoredRow and not expandRuntimeViewContainer itself. The ruling's "no second own-row test in the readers" is kept.
  13. Public surface — nothing widens.
    • One new private method on the class: one private member line in the emitted d.ts.
    • No export, no type, no schema and no registry row.
    • The test file's 37 new pins are in-package, and the test is in the tsc program (CI typecheck green).
  14. The three ruled pins are present:
    • the measured save is refused on both kernels: five member kinds × two kernels × two scopes, plus draft, plus three runtime-object names including the expander's de-duplicated _2;
    • a container under its object's name saves and expands;
    • a view item under an expanded name saves.
    • The ablation reported in the PR is consistent with the diff's single throw site: removing the one anchor turns 28 pins red and leaves the 9 controls green, and it was restored by blob equality.
  15. The ruled census was taken first, and is complete to the repository's edge:
    • in-repo writers: REST PUT, the dispatcher and the two re-savers (packages.ts and automation.ts save other types);
    • Studio at the objectui pin: no writer produces the shape unless an author types an expanded name into the generic editor;
    • the in-repo AI author: none, because the MCP tool list has no metadata write;
    • packaged containers: 12 defineView sites, none with a top-level name, and a source registrar refuses a disagreeing name;
    • stored rows: no seeded view rows and no test row.
    • Hosted tenants and the cloud AI author are declared NOT MEASURED, which is what the ruling asks when rows cannot be seen.

② Semver level

③ Boundary flags

The dev's open_questions is empty. Every deviation and out-of-scope finding in the os-dev report, answered:

Implemented-by: claude/issue-21558-container-own-expansion-name
Reviewed-by: session_017ErfyP2Rx7XWHJA27QjyUi

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 18:46
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 18:46
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit e367002 Oct 3, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21558-container-own-expansion-name branch October 3, 2026 19:21
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