diff --git a/.changeset/view-container-object-derivation-name-last.md b/.changeset/view-container-object-derivation-name-last.md new file mode 100644 index 0000000000..b543d486b0 --- /dev/null +++ b/.changeset/view-container-object-derivation-name-last.md @@ -0,0 +1,61 @@ +--- +"@objectstack/metadata": minor +"@objectstack/objectql": patch +--- + +fix(objectql,metadata): the ObjectQL boot loop derives a view container's object through the shared `deriveViewContainerObject`, so the row's own `name` is the LAST term at every SOURCE registrar (#14399) + +Three sites derive "which object does an aggregated `defineView` container bind +to". After #13407 / #13913 / #13912 all three read the container's own top-level +`object` before the `list.data.object` chain, but they still disagreed about the +row's own `name`: + +- `packages/objectql/src/engine.ts` `resolveMetadataItemName('views', item)` — + the boot-loop SOURCE registrar — read `name` FIRST, before `object`; +- `deriveViewContainerObject` (`@objectstack/metadata`, used by the artifact/HMR + SOURCE registrar and by `getViewsByObject()`) and `expandRuntimeViewContainer` + (`@objectstack/metadata-protocol`, the runtime door) both read `name` LAST. + +A container written as `{ name: 'lead_views', object: 'crm_lead', list: { … } }` +therefore registered under `lead_views` through the boot loop and under +`crm_lead` everywhere else, with the whole expansion (`.`) carried +along — and since `getViewsByObject()` / `GET /meta/view?object=` filter the +expanded items by their `object`, which registrar loaded the document decided +whether the views were addressable under the object at all. No error, no +diagnostic. + +The boot loop's container branch now calls `deriveViewContainerObject` — by +import, not by re-spelling: a fourth hand-copy of the chain was the defect, not +the repair. The direction is the 2026-08-07 meta-rule rather than taste (one +operation, two inconsistent implementations, the side bound by a DECLARATION +wins): `ViewSchema.object`'s own `.describe()` names its readers, while the boot +loop's order argued from item identity, which declares nothing about the +binding. The two sites that already held the winning order are untouched. + +**`@objectstack/metadata` — new public export (`minor`).** +`deriveViewContainerObject` was module-local; it is now on the package's root +entry, because `packages/objectql` is a SOURCE registrar for the same containers +and has to mint the same key. `packages/objectql` already declares +`@objectstack/metadata` as a dependency and nothing in `packages/metadata` +depends on `objectql`, so the import adds no cycle. + +**Scope of the behaviour change.** Only the `views` CONTAINER branch moves, gated +on `isAggregatedViewContainer`: the assembled `viewItems:` channel (standalone +ViewItems and flattened overlays, every member of `AssembledViewArtifactSchema` +requiring `viewKind`) still keys by its own `name` first, which is its identity +and not a binding. `item.id` is untouched and cannot fire for a container — +`ViewSchema` is a `strictObject` declaring `name` and `object` and no `id`. + +**No migration surface.** Measured on this tree: of the 54 non-test sources that +author or carry view containers, ZERO declare a `name` that differs from the +object they bind to, so every in-tree container derived identically at all three +sites before this change and does after it. What moves is the latent shape only. + +⚠️ One card premise was measured false and is recorded in the new pin rather +than quietly dropped: the artifact/HMR registrar does not silently mint a second +key for a divergent container. It derives `crm_lead` correctly and then refuses +the whole artifact load — `assertMetadataRegisterContract` (#7378 row 1), +`VALIDATION_ERROR` / 400 — because the document's own `data.name` still reads +`lead_views`. The boot loop reconciles that field and the artifact door does +not; that residual asymmetry is a separate defect at a separate site and is +filed as its own card. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 211a566390..7daa24a77c 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,18 +109,18 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11160` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11343` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9895` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11204` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11387` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9939` | | 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` | -| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9943`, `readonly-strict-errors.ts:66` | -| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5762` | -| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3606`, `:3616`, `:3643` | +| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9987`, `readonly-strict-errors.ts:66` | +| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5806` | +| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3650`, `:3660`, `:3687` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` | -| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6460` | -| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11955` | -| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11884` | +| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6504` | +| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11999` | +| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11928` | ### 3. Sharing (`plugin-sharing`) @@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 62 | `objectql/src/engine.ts:3413` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14304` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 62 | `objectql/src/engine.ts:3457` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 63 | `objectql/src/engine.ts:14348` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1971` (rationale at `:1881`–`1883`, #3760), `flow.zod.ts:685` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9878`–`9895` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9922`–`9939` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` | diff --git a/packages/metadata/src/index.ts b/packages/metadata/src/index.ts index 9580306c2c..9033eb6525 100644 --- a/packages/metadata/src/index.ts +++ b/packages/metadata/src/index.ts @@ -39,6 +39,22 @@ export { YAMLSerializer } from './serializers/yaml-serializer.js'; export * as Migration from './migration/index.js'; export { TypeScriptSerializer } from './serializers/typescript-serializer.js'; +// View container binding +// +// [#14399] `deriveViewContainerObject` is this package's ONE spelling of "which +// object does an aggregated `defineView` container bind to" — the container's +// own top-level `object` first, then `list.data.object`, `form.data.object`, +// and the row's own `name` last (its own docblock carries the ruling). It is +// published here because the ObjectQL boot-loop registrar +// (`packages/objectql/src/engine.ts`, `resolveMetadataItemName`) is a SOURCE +// registrar for the same containers and has to mint the same registry key: it +// used to consult the row's `name` FIRST, so a container whose `name` differs +// from its `object` registered under two different keys depending on which +// registrar loaded it. `packages/objectql` already declares this package as a +// dependency and nothing here depends on it, so the import is the repair — a +// fifth hand-copy of the chain is the defect, not the fix. +export { deriveViewContainerObject } from './view-container-expansion.js'; + // Re-export types from spec export type { MetadataFormat, diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 36bec3e680..37ab4cb63c 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -185,6 +185,11 @@ import { AssembledViewArtifactSchema, isViewContainerShaped, } from '@objectstack/spec'; +// [#14399] The ONE spelling of "which object does an aggregated `defineView` +// container bind to", imported rather than re-spelled. See +// `resolveMetadataItemName` below for why this registrar had a fourth copy and +// why it lost it. +import { deriveViewContainerObject } from '@objectstack/metadata'; import { bindHooksToEngine } from './hook-binder.js'; import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; @@ -2014,28 +2019,67 @@ const METADATA_ARRAY_KEYS = [ * * Most metadata items expose a top-level `name` (or `id`). The `View` * container defined by `@objectstack/spec/ui` is special: it aggregates - * `list / form / listViews / formViews` for a single object and is - * keyed implicitly by its target object name (see `data.object`). + * `list / form / listViews / formViews` for a single object and is keyed by + * the OBJECT it binds to, not by its own row identity — which is what + * `/api/v1/meta/views/:object`, `getViewsByObject()` and + * `GET /meta/view?object=` all address it by. * - * Per spec, `ViewSchema` does NOT have a top-level `name` field - * (view.zod.ts), so we resolve it from the inner data source. This - * matches the server-side metadata API contract (`/api/v1/meta/views/:object`). + * ⚠️ [#14399] The sentence that used to stand here — "per spec, `ViewSchema` + * does NOT have a top-level `name` field" — is measurably false and was the + * premise for consulting `item.name` first. `ViewSchema` declares an optional + * `name` (`view.zod.ts`), described there as "supplied by the metadata door; + * for an object-scoped container it is the object name". "Is the object name" + * is a CONVENTION the door does not enforce, so the two never actually had to + * agree — and where they disagreed, this registrar and the other two picked + * different keys for the same document. */ function resolveMetadataItemName(key: string, item: any): string | undefined { if (!item) return undefined; + // [#14399] The aggregated `views` CONTAINER branch, taken FIRST and answered + // by the shared derivation. Everything below is unchanged. + // + // This registrar used to consult `item.name` before anything else, for every + // key including this one — so a container written as + // `{ name: 'lead_views', object: 'crm_lead', list: {…} }` registered under + // `lead_views` here while the artifact/HMR SOURCE registrar + // (`MetadataPlugin._parseAndRegisterArtifact`) and the runtime door + // (`expandRuntimeViewContainer`) both registered it under `crm_lead`. Same + // document, two source registrars, two registry keys and two sets of expanded + // item names, with `getViewsByObject()` / `GET /meta/view?object=` answering + // for the object only when the right registrar happened to load it. + // + // The 2026-08-07 meta-rule settles the direction rather than taste: one + // operation with two inconsistent implementations, the side bound by a + // DECLARATION wins. `ViewSchema.object`'s own `.describe()` names its readers + // (`getViewsByObject()` / `GET /meta/view?object=`); this loop's old order + // argued from item identity, which declares nothing about the binding. So the + // container branch adopts `deriveViewContainerObject` — by import, because a + // fourth hand-copy of a chain that already exists three times is the defect + // this repair exists to close, not the repair. + // + // The gate is `isAggregatedViewContainer`, which is what makes this the + // CONTAINER branch and nothing wider: it is false for every artifact carrying + // a `viewKind`, so the assembled `viewItems:` channel below (standalone + // ViewItems and flattened list/form overlays — every member of + // `AssembledViewArtifactSchema` requires `viewKind`) still resolves by its own + // `name` first, which is its identity and not a binding. + // + // `item.id` is untouched and stays reachable for every other key. It cannot + // fire for a spec-valid container: `ViewSchema` is a `strictObject` that + // declares `name` and `object` and no `id`, so an `id` on a container is + // refused at the authoring and metadata doors before this seam sees it. + if (key === 'views' && isAggregatedViewContainer(item)) { + return deriveViewContainerObject(item); + } if (item.name) return item.name; if (item.id) return item.id; if (key === 'views') { - // Independent ViewItems ("Object has-many View") carry a top-level `name` - // (handled above) and bind to their object via `object`. The aggregated - // container has no top-level name/object, so fall back to its inner data - // source — matching the loader's expansion key. - return ( - item?.object || - item?.list?.data?.object || - item?.form?.data?.object || - undefined - ); + // A `views` entry that is NOT an aggregated container and carries neither + // `name` nor `id` — e.g. a flattened overlay whose optional `name` was + // omitted. Same derivation, and identical to the chain that used to be + // written out here: with `item.name` already known falsy, the helper's + // trailing `name` term contributes nothing. + return deriveViewContainerObject(item); } return undefined; } diff --git a/packages/objectql/src/view-container-divergent-name-registrars.test.ts b/packages/objectql/src/view-container-divergent-name-registrars.test.ts new file mode 100644 index 0000000000..bacfcd5500 --- /dev/null +++ b/packages/objectql/src/view-container-divergent-name-registrars.test.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14399] Where the row's own `name` sits in the view-container + * object-derivation chain — pinned at BOTH SOURCE registrars. + * + * --------------------------------------------------------------------------- + * The divergence + * --------------------------------------------------------------------------- + * Three sites derive "which object does an aggregated `defineView` container + * bind to", and after #13407 / #13913 / #13912 all three read the container's + * own top-level `object` before the `list.data.object` chain. They still + * disagreed about the row's own `name`: + * + * - `engine.ts` `resolveMetadataItemName('views', item)` — the ObjectQL boot + * loop, a SOURCE registrar: `name` → `id` → `object` → `list.data.object` + * → `form.data.object`. **`name` FIRST.** + * - `deriveViewContainerObject` (`@objectstack/metadata`) — used by the + * artifact/HMR SOURCE registrar and by `getViewsByObject()`: `object` → + * `list.data.object` → `form.data.object` → `name`. **`name` LAST.** + * - `expandRuntimeViewContainer` (`@objectstack/metadata-protocol`) — the + * runtime door: same order as the second. + * + * For the ordinary container the two values agree and nothing differs. They + * differ for `{ name: 'lead_views', object: 'crm_lead', list: {…} }`, and the + * 2026-08-07 meta-rule settles which order survives rather than taste: one + * operation with two inconsistent implementations, the side bound by a + * DECLARATION wins. `ViewSchema.object`'s own `.describe()` names its readers + * ("read by `getViewsByObject()` / `GET /meta/view?object=`"); the boot loop's + * order argued from item identity, which declares nothing about the binding. So + * the boot loop adopted `deriveViewContainerObject` — by import. The two sites + * that already held the winning order were not touched. + * + * --------------------------------------------------------------------------- + * ⭐ MEASURED CORRECTION to the card's reachable-divergence walk-through + * --------------------------------------------------------------------------- + * The card predicted that the artifact/HMR registrar "derives `crm_lead` and + * mints `crm_lead.default`, registering the container under `crm_lead`", i.e. + * two SILENT keys for one document. Measured on `origin/main` `937ec142d`, the + * second half of that is false and the divergence is sharper than reported: + * + * - the boot loop registered it under `lead_views`, silently; + * - the artifact door derives `crm_lead` correctly and then **refuses the + * whole artifact load, loudly** — `assertMetadataRegisterContract` (#7378 + * row 1) rejects `register('view', 'crm_lead')` because the document's own + * `data.name` is still `'lead_views'`. `VALIDATION_ERROR` / 400. + * + * The boot loop reconciles that field (`toRegister = { …item, name: itemName }`) + * and the artifact door does not, so the same document is silently mis-keyed by + * one registrar and a hard boot failure through the other. That residual + * asymmetry is a SEPARATE defect at a separate site and is filed as its own + * card; what belongs here is the derivation, and both sites are pinned on it + * below — the artifact door's refusal message names the key it derived, which + * is direct evidence of its answer. + * + * --------------------------------------------------------------------------- + * Why this file drives BOTH registrars rather than pinning one + * --------------------------------------------------------------------------- + * The card's sharpest observation is that NO fixture anywhere in the repo sets + * a container `name` that differs from its bound object, so the divergence was + * un-rehearsed in BOTH directions: each registrar was individually green on + * every shape it had ever been shown, and the correction above is what a + * one-sided pin would still have missed. `@objectstack/objectql` already + * declares `@objectstack/metadata` as a dependency (and the reverse edge does + * not exist), which is what lets one file hold both. + * + * Refs: #14399, #13912 (`plugin-artifact-view-container-object.test.ts` — the + * artifact door's own-`object` pin this extends), #13913, #13407, #7378, #7163. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectQL } from './engine'; +import { isAggregatedViewContainer } from '@objectstack/spec'; +import { MetadataPlugin, deriveViewContainerObject } from '@objectstack/metadata'; + +const PKG = 'com.acme.crm'; +const MANIFEST = { id: PKG, name: 'CRM', version: '1.0.0', type: 'app' }; + +/** + * The card's shape: the row's own `name` is NOT the object it binds to. + * + * No view arm carries `data` at all — the same arrangement + * `plugin-artifact-view-container-object.test.ts` measured as the only one the + * artifact door's strict parse admits with no `data.object` anywhere, so this + * one fixture passes BOTH doors and the two registrars are answering about + * literally the same document. + */ +const divergentContainer = { + name: 'lead_views', + object: 'crm_lead', + list: { label: 'All Leads', type: 'grid', columns: [{ field: 'name' }] }, + listViews: { hot: { label: 'Hot Leads', type: 'grid', columns: [{ field: 'name' }] } }, +}; + +/** The same container with its identity field omitted — the shape that has a + * `name`/`object` disagreement to make, and does not make one. */ +const anonymousContainer = (() => { + const { name: _drop, ...rest } = divergentContainer; + return rest; +})(); + +/** Every key either registrar could plausibly mint for these containers. */ +const CANDIDATE_KEYS = [ + 'crm_lead', + 'crm_lead.default', + 'crm_lead.hot', + 'lead_views', + 'lead_views.default', + 'lead_views.hot', +]; + +/** The one answer: keyed by the DECLARED binding, expansion included. */ +const AGREED_KEYS = ['crm_lead', 'crm_lead.default', 'crm_lead.hot']; + +// --------------------------------------------------------------------------- +// Registrar A — the ObjectQL boot loop (`registerMetadataCollections`). +// --------------------------------------------------------------------------- + +function bootRegistrarKeys(container: unknown): string[] { + const engine = new ObjectQL(); + engine.registerApp({ id: PKG, name: 'crm', views: [container] } as any); + return CANDIDATE_KEYS.filter((k) => engine.registry.getItem('view', k) !== undefined); +} + +// --------------------------------------------------------------------------- +// Registrar B — the metadata artifact/HMR door (`_parseAndRegisterArtifact`), +// driven exactly as its own #13912 pin drives it. +// --------------------------------------------------------------------------- + +function fakeCtx() { + return { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn(() => undefined), + trigger: vi.fn(), + } as any; +} + +async function loadThroughArtifactDoor(container: unknown): Promise { + const plugin = new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }) as any; + // Fresh deep copy — the door mutates items in place (`applyProtection`). + const definition = JSON.parse(JSON.stringify({ manifest: MANIFEST, views: [container] })); + await plugin._parseAndRegisterArtifact(fakeCtx(), definition, 'fixture-14399'); + return plugin; +} + +async function artifactRegistrarKeys(container: unknown): Promise { + const plugin = await loadThroughArtifactDoor(container); + const found: string[] = []; + for (const k of CANDIDATE_KEYS) { + if (await plugin.manager.get('view', k)) found.push(k); + } + return found; +} + +describe('#14399 — the row\'s own `name` is the LAST term of the container derivation, at every SOURCE registrar', () => { + it('the fixture is the divergent shape (premise guard)', () => { + // If a later edit makes `name` equal `object`, or drops one of them, the + // cases below stop testing this defect and start passing trivially: the + // two orders only differ when both fields exist and disagree. + expect(divergentContainer.name).not.toBe(divergentContainer.object); + expect(isAggregatedViewContainer(divergentContainer)).toBe(true); + expect(JSON.stringify(divergentContainer)).not.toContain('"data"'); + // ...and the shared derivation really does prefer the declared binding. + expect(deriveViewContainerObject(divergentContainer)).toBe('crm_lead'); + }); + + it('THE PIN: the boot loop keys the container by its declared `object`, not by its own `name`', () => { + // Pre-fix this was exactly ['lead_views', 'lead_views.default', + // 'lead_views.hot'] — the container and its whole expansion filed under + // the row identity, so `getViewsByObject('crm_lead')` and + // `GET /meta/view?object=crm_lead` had nothing for this document. + expect(bootRegistrarKeys(divergentContainer)).toEqual(AGREED_KEYS); + }); + + it('and the artifact/HMR registrar derives the SAME binding for the same document', async () => { + // The second SOURCE registrar's answer, read from the one place it is + // observable on this shape: its refusal names the key it derived. + // `toEqual(AGREED_KEYS)` above would also be satisfied by both sides + // moving to `lead_views`, so the agreed VALUE is pinned at both. + const err = await loadThroughArtifactDoor(divergentContainer).catch((e) => e as any); + expect(err).toBeInstanceOf(Error); + expect(err.message).toContain("register('view', 'crm_lead')"); + expect(err.message).toContain("data.name is 'lead_views'"); + }); + + it('MEASURED CORRECTION: the artifact door does not silently mis-key it — it refuses, enveloped (#7378 row 1)', async () => { + // The card predicted a second SILENT key here. Measured: the door + // derives `crm_lead`, then `assertMetadataRegisterContract` refuses the + // whole artifact load because the document's own `data.name` still says + // `lead_views`. Asserting the ADR-0112 envelope, not merely "it threw": + // a bare `toThrow()` would stay green on any unrelated failure. + const err = await loadThroughArtifactDoor(divergentContainer).catch((e) => e as any); + expect(err.code).toBe('VALIDATION_ERROR'); + expect(err.status).toBe(400); + // The residual asymmetry, stated as an assertion so it cannot drift + // unnoticed: the boot loop reconciles `data.name` to the derived key and + // this door does not. Filed separately; #14399 owns the derivation only. + const engine = new ObjectQL(); + engine.registerApp({ id: PKG, name: 'crm', views: [divergentContainer] } as any); + expect((engine.registry.getItem('view', 'crm_lead') as any).name).toBe('crm_lead'); + }); + + it('so the boot loop\'s expanded items are addressable under the object', () => { + const engine = new ObjectQL(); + engine.registerApp({ id: PKG, name: 'crm', views: [divergentContainer] } as any); + const bound = (engine.registry.listItems('view') ?? []) + .filter((v: any) => v?.viewKind) + .map((v: any) => v.object); + expect(bound.length).toBeGreaterThan(0); + // The card's symptom: these used to bind to `lead_views`. + expect([...new Set(bound)]).toEqual(['crm_lead']); + }); + + // ------------------------------------------------------------------ + // Controls — green in BOTH directions. The repair moves the CONTAINER + // branch only; anything that keys by its own identity must not move. + // ------------------------------------------------------------------ + + it('CONTROL: with no `name` to disagree, both registrars mint exactly the same keys', async () => { + // The cross-registrar agreement the card asked for, on the shape that + // can actually reach both stores. Green before and after — the point is + // that the repair does not move it. + expect(bootRegistrarKeys(anonymousContainer)).toEqual(AGREED_KEYS); + expect(await artifactRegistrarKeys(anonymousContainer)).toEqual(AGREED_KEYS); + }); + + it('CONTROL: a container whose `name` already equals its `object` is unchanged', async () => { + const agreeing = { ...divergentContainer, name: 'crm_lead' }; + expect(bootRegistrarKeys(agreeing)).toEqual(AGREED_KEYS); + expect(await artifactRegistrarKeys(agreeing)).toEqual(AGREED_KEYS); + }); + + it('CONTROL: a container with no `object` anywhere still keys by its own `name`', () => { + // `name` did not stop being consulted — it moved to LAST. A container + // that declares no binding anywhere else is still registered, under the + // only identity it has. + const nameOnly = { + name: 'lead_views', + list: { label: 'All', type: 'grid', columns: [{ field: 'name' }] }, + }; + expect(bootRegistrarKeys(nameOnly)).toEqual(['lead_views', 'lead_views.default']); + }); + + it('CONTROL: a standalone ViewItem still keys by its own `name`, not its `object`', () => { + // The `viewItems:` channel carries NON-container artifacts (every member + // of `AssembledViewArtifactSchema` requires `viewKind`, so + // `isAggregatedViewContainer` is false for all of them). Their `name` is + // their identity, not a binding, and it must still be read FIRST — this + // is the regression the change would cause if the container branch were + // not gated on `isAggregatedViewContainer`. + const engine = new ObjectQL(); + engine.registerApp({ + id: PKG, + name: 'crm', + viewItems: [{ + name: 'crm_lead.hot', + object: 'crm_lead', + viewKind: 'list', + config: { type: 'grid', columns: [{ field: 'name' }] }, + }], + } as any); + expect(engine.registry.getItem('view', 'crm_lead.hot')).toBeDefined(); + expect(engine.registry.getItem('view', 'crm_lead')).toBeUndefined(); + }); +});