Skip to content

Commit e367002

Browse files
fix(metadata-protocol): the save door refuses a view container saved under a name its own expansion produces (#21558) (#21618)
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 #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) - A container under its object's name saves and expands as before. - A view item under an expanded name saves, as #21510's sanctioned override for that name. ### Stored rows and the other writers through this door - The read doors are unchanged. A row stored in this shape before this change keeps its bytes and reads as it does after #21510: the by-name read answers the raw container, and the object door lists nothing under that name. The container's other expanded names still fill names that have no row of their own. Delete stays open. Re-saving the same body is refused, with the prescription. - `migrateStoredMetadata` (`os migrate meta --stored`) and `duplicatePackage` re-save stored rows through this door. For such a row they now record the refusal: migration as a `failed` row with this reason, duplication as a `failed[]` entry. Neither re-saves it. ## 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](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f97660c commit e367002

3 files changed

Lines changed: 257 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
The runtime save door refuses a view container saved under a name its own expansion produces
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing at one runtime write door over existing keys: no key of `ViewSchema` or of any other metadata schema is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. Whether such a container was meant as the object's container or as a view item of that name is authoring intent no conversion entry can decide. New saves are refused with the remedy; a row stored before this change keeps its bytes and is served as before, and no stored row is re-saved. The census found no such row and no writer that produces the shape by default: no seeded `sys_metadata` view rows in the example apps, no packaged container with a top-level `name` among the twelve `defineView` sites in `examples/`, and no Studio or in-repo AI writer that saves a container under an expanded name unless its author types that name into the container (Studio's generic metadata editor saves a body under its own `name`); hosted tenants were not measured. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered); and the change narrows what a runtime write door accepts, not a runtime interface or a type surface alone (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** accept-set narrowing at the runtime save door, shipped as `minor` under the repo's launch-window convention for breaking changes, the grade the same door's `name` refusals shipped with.
12+
13+
**What was accepted before.** `saveMetaItem`, which `PUT /api/v1/meta/view/:name` and the dispatcher's metadata save both call, accepted an aggregated view container (`list` / `form` / `listViews` / `formViews`) saved under one of the names its own expansion produces: for example `{ name: 'crm_lead.default', object: 'crm_lead', list: { … } }` saved as `crm_lead.default`, the name its bare `list` expands to. That row is the name's own stored row, and an expansion fills only names that have no row of their own (the object door adopts that rule in this same release), so the container's expansion never filled it. The object door (`GET /api/v1/meta/view?object=…`), which never lists a container, listed nothing under the name, and the by-name read answered the raw container. No door answered a view item for the name, and nothing said why.
14+
15+
**What is refused now.** That save, with `VALIDATION_ERROR` / 400, before anything is stored or registered, in draft and in publish mode. Whether a name is one the container's own expansion produces is decided by the same expansion the read doors run, 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 with no `name` is judged under the save name the door stamps on it. A container on another package's object expands under its own name, which is never the name it is saved under, so it is not refused.
16+
17+
**What still saves.** A container under its object's name, which expands as before. A view item (a body carrying `viewKind`) under an expanded name, the sanctioned override for that name. The read doors are unchanged. A row stored in this shape before this change keeps its bytes and is served as before; `migrate meta --stored` and package duplication, which re-save stored rows through this door, now report such a row as failed with this refusal instead of re-saving it.
18+
19+
**The fix.** Save the container under its object's name (`crm_lead`), or save a view item (`name`, `object`, `viewKind`, `config`) under the expanded name (`crm_lead.default`).

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17884,6 +17884,77 @@ export class ObjectStackProtocolImplementation implements
1788417884
}
1788517885
}
1788617886

17887+
/**
17888+
* [#21558] The save door's refusal of a view container saved under a name
17889+
* its OWN expansion produces — `{ name: 'crm_lead.default', object:
17890+
* 'crm_lead', list }` saved as `crm_lead.default`, whose bare `list`
17891+
* expands to exactly that name.
17892+
*
17893+
* Both read doors give a name with a stored row of its own that row, and
17894+
* let an expansion fill only a name with no row (#21510's one predicate,
17895+
* `namesWithOwnStoredRow`). Such a container IS the row of that name, so
17896+
* its own expansion never fills it: the object door, which never
17897+
* enumerates a container, lists nothing under the name, and the by-name
17898+
* read answers the raw container. No door answers a view item for it, and
17899+
* nothing told the author why. Triage's ruling refuses the shape here, at
17900+
* authoring (Prime Directive 12), and keeps the readers' one predicate
17901+
* whole: ⛔ no second own-row test in the readers.
17902+
*
17903+
* "A name its own expansion produces" is answered by the readers' own
17904+
* expansion, {@link expandRuntimeViewContainer}, never by a copy of its
17905+
* naming, so the save door and the read doors cannot disagree about it:
17906+
* every member kind and the expander's de-duplication are covered as the
17907+
* readers place them. A container on another package's object expands
17908+
* under its own name (#21334), as `<object>.<container name>…`, which is
17909+
* never the container name itself, so that arm is never refused. The
17910+
* package binding is the request's, as the registry write-through
17911+
* registers the expansion.
17912+
*
17913+
* The body judged is the one the author sent, with the door's own `name`
17914+
* stamp ({@link normalizeViewMetadata}: a missing or falsy `name` becomes
17915+
* the save name) applied first, since the expansion of an unnamed
17916+
* container is placed by that name. It is asked BEFORE that function's
17917+
* identity patch: a container whose only member is `form` is not one of
17918+
* the shapes the patch leaves alone, so under the name of a registered
17919+
* view item it would take that item's `viewKind`, stop being a container,
17920+
* and reach the schema as a malformed view item instead of this refusal.
17921+
*
17922+
* A view item (`viewKind` set) is not a container, so a view item saved
17923+
* under an expanded name is untouched: it is the sanctioned override for
17924+
* that name. Rows already stored in this shape are untouched too: the
17925+
* read doors serve them as before, and only a new save is refused.
17926+
*
17927+
* `VALIDATION_ERROR` / 400, the envelope of the name check it sits beside
17928+
* (`savedItemNameRefusal`): an authoring refusal of the request's own
17929+
* name, decided from the body. The prescription is the ruling's: save the
17930+
* container under its object's name, or save a view item under the
17931+
* expanded name. Runtime words carry no tracker number.
17932+
*/
17933+
private containerOwnExpansionNameRefusal(
17934+
type: string,
17935+
item: unknown,
17936+
saveName: string,
17937+
packageId: string | null | undefined,
17938+
): (Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined {
17939+
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
17940+
const body = item as Record<string, unknown>;
17941+
const stamped = body.name ? body : { ...body, name: saveName };
17942+
const own = this.expandRuntimeViewContainer(type, stamped, { packageId })
17943+
.find((expanded) => expanded.name === saveName);
17944+
if (!own) return undefined;
17945+
const object = String(own.object);
17946+
const err = new Error(
17947+
`Invalid view container: it is saved under '${saveName}', which is a name its own expansion `
17948+
+ `produces (its ${String(own.viewKind)} view on '${object}'). An expanded view fills only a name `
17949+
+ `that has no stored row of its own, and this container would be that row, so no read would answer `
17950+
+ `a view under '${saveName}'. Save the container under its object's name, '${object}', or save a `
17951+
+ `view item (name, object, viewKind and config) under '${saveName}'.`,
17952+
) as Error & { code: 'VALIDATION_ERROR'; status: 400 };
17953+
err.code = 'VALIDATION_ERROR';
17954+
err.status = 400;
17955+
return err;
17956+
}
17957+
1788717958
// [#21207] `parentVersion` is a CALLER's version token — the keyed form a
1788817959
// receipt served — and is compared in that form (`storedParentForToken`).
1788917960
// `storedParentVersion` is the in-process twin for a caller that read the
@@ -18365,6 +18436,16 @@ export class ObjectStackProtocolImplementation implements
1836518436
const nameRefusal = savedItemNameRefusal(singularType, request.item, request.name, 'save');
1836618437
if (nameRefusal) throw nameRefusal;
1836718438
}
18439+
// [#21558] …and a view container saved under a name its OWN
18440+
// expansion produces, with the same envelope. Asked of the body as
18441+
// authored, before the stamp below can take a registry entry's
18442+
// `viewKind` onto it. See {@link containerOwnExpansionNameRefusal}.
18443+
{
18444+
const ownExpansionRefusal = this.containerOwnExpansionNameRefusal(
18445+
singularType, request.item, request.name, request.packageId,
18446+
);
18447+
if (ownExpansionRefusal) throw ownExpansionRefusal;
18448+
}
1836818449
let baseline: unknown;
1836918450
if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'
1837018451
&& typeof this.engine.registry?.getItem === 'function') {

0 commit comments

Comments
 (0)