diff --git a/.changeset/objectql-boot-loop-refuses-divergent-view-name.md b/.changeset/objectql-boot-loop-refuses-divergent-view-name.md new file mode 100644 index 0000000000..6b07355ea3 --- /dev/null +++ b/.changeset/objectql-boot-loop-refuses-divergent-view-name.md @@ -0,0 +1,48 @@ +--- +"@objectstack/objectql": minor +--- + +fix(objectql): the boot loop refuses a view container whose `name` disagrees with the object it binds to, instead of silently rewriting the author's field (#14666) + +**BREAKING** accept-set narrowing on the ObjectQL boot loop's SOURCE registrar +(`registerMetadataCollections`), shipped as `minor` under the repo's +launch-window convention for breaking changes. Ruled on #14666 (2026-09-03, +direction 2). + +An aggregated `defineView` container is keyed by the OBJECT it binds to, not +by its own row identity, and `ViewSchema` declares an optional `name` whose +own description says that for an object-scoped container it *is* the object +name. Nothing enforced that. A container written as +`{ name: 'lead_views', object: 'crm_lead', list: { ... } }` therefore reached +the two SOURCE registrars and got opposite answers: this boot loop overwrote +`name` with the derived key `crm_lead` and registered it, discarding the +author's field with no diagnostic, while the artifact/HMR loader +(`MetadataPlugin._parseAndRegisterArtifact`) refused the whole artifact load +through `assertMetadataRegisterContract` (#7378 row 1, `VALIDATION_ERROR` / +400). Same document, and whether it loaded at all depended on how the package +was loaded. + +The boot loop now **refuses loudly**, with the same `VALIDATION_ERROR` / 400 +envelope the artifact door raises, naming the container's own `name`, the +object key it derived, and both remedies: drop `name`, or set it to that +derived key. #7378 row 1 already ruled that resolving such a disagreement +silently, in either direction, files the item under a key the caller never +wrote, so the two registrars converge on the refusal rather than on the +rewrite; the artifact door is unchanged. + +**Refused shape**, precisely: an aggregated view container in a stack `views:` +collection that carries a non-empty top-level `name` AND derives a different +object key from its own `object` (or, failing that, `list.data.object` / +`form.data.object`). + +Scope, which the ruling names as this change's main risk. A container with no +`name` is untouched, and still registers under its derived key. So is a +container whose `name` already equals that key, and one that declares no +binding anywhere else, since the derivation then falls back to that same +`name` and cannot disagree with itself. No other metadata kind changes +behaviour: the refusal is gated inside the `views` branch of the generic +registration loop. Standalone ViewItems and flattened overlays travelling in +the assembled `viewItems:` channel are untouched, because a container cannot +reach that channel at all. Every one of these has a control test. + + diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index c86c955925..3125699692 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:11373` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11556` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10106` | +| 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:11450` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11633` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10183` | | 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:1795` | -| 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:10154`, `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:5973` | +| 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:10231`, `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:6050` | | 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:3799`, `:3809`, `:3836` | | 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:99` | -| 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:6671` | -| 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:12172` | -| 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:12101` | +| 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:6748` | +| 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:12249` | +| 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:12178` | ### 3. Sharing (`plugin-sharing`) @@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| | 62 | `objectql/src/engine.ts:3606` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14616` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 63 | `objectql/src/engine.ts:14693` | 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:702` | | "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:10089`–`10106` | +| "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:10166`–`10183` | | "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:1580` (#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:299` | diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 860d49623b..ada18bfa06 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -5295,6 +5295,83 @@ export class ObjectQL implements IObjectQLEngine { this.logger.warn(`Skipping ${pluralToSingular(key)} without a derivable name`, { id: ownerId }); continue; } + // [#14666] The DIVERGENT-container refusal (maintainer ruling + // 2026-09-03, direction 2). This seam used to reconcile a + // container's own `name` to the derived key silently, one line + // below: the author's field discarded with no diagnostic, while + // the OTHER source registrar + // (`MetadataPlugin._parseAndRegisterArtifact` -> + // `assertMetadataRegisterContract`, #7378 row 1) hard-failed the + // whole artifact load on that very same document. One document, + // two SOURCE registrars, opposite outcomes, and which one an + // author got depended on how their package was loaded. #7378 + // row 1 already rules that resolving a name/key disagreement + // silently "in either direction" files the item under a key the + // caller never wrote, so the boot loop converges onto the + // refusal; the artifact door was not moved onto the rewrite. + // + // MEASURED, and it is why only this site changed: the second + // `toRegister` ternary in this method (the assembled + // `viewItems:` channel below) cannot carry a container at all — + // `AssembledViewArtifactSchema` is the view vocabulary MINUS the + // container branch, and every body it admits is + // `isAggregatedViewContainer === false`. Its ternary can only + // MINT a `name` onto an overlay that has none; it can never + // discard an authored one, because `resolveMetadataItemName` + // reads a non-container's own `name` FIRST. + // + // Scope is the ruling's named main risk, so the gate is three + // independent narrowings and each one is load-bearing: + // * `key === 'views'` — this is the GENERIC metadata loop; no + // other kind changes behaviour and they all keep the + // reconcile below; + // * `isAggregatedViewContainer(item)` — the CONTAINER branch + // only. A standalone ViewItem's `name` is its identity, not + // a binding, and it has no disagreement to make; + // * `name` present AND different. A container carrying no + // `name` is untouched (the reconcile below still mints the + // derived key onto it); so is one whose `name` already + // equals the derived key; and so is one that declares no + // binding anywhere else, because `deriveViewContainerObject` + // then derives the key FROM that same `name` and it cannot + // disagree with itself. + // + // The runtime string deliberately carries NO tracker id: it is read by + // authors and operators who cannot resolve `#NNNN` + // (`check:doc-authoring`, maintainer ruling 2026-08-12). The rule it + // enforces is #7378 row 1, named in this comment instead, where the + // reader who can resolve it is already looking. + // + // The ADR-0112 envelope is the artifact door's exactly — + // `VALIDATION_ERROR` / 400 — because a document refused at one + // SOURCE registrar must be refused the same way at the other, + // and that equality is asserted in + // `view-container-divergent-name-registrars.test.ts`. The prose + // is this seam's own on purpose: `assertMetadataRegisterContract` + // opens its message with `IMetadataService.register(...)`, an API + // this seam does not call, and a refusal that misnames its own + // door is the opposite of "locate the mismatch". + if ( + key === 'views' + && isAggregatedViewContainer(item) + && typeof item.name === 'string' + && item.name + && item.name !== itemName + ) { + const err: Error & { code?: string; status?: number } = new Error( + `Invalid \`views:\` container from ${sourceLabel} '${ownerId}': the container's own ` + + `\`name\` is '${item.name}', which disagrees with the object key it binds to, ` + + `'${itemName}' (derived from its own \`object\`, else \`list.data.object\` / ` + + '`form.data.object`). A disagreement is almost always an authoring bug, and resolving ' + + 'it silently in either direction can file the item under a key the caller never wrote ' + + '(refuse loudly, locate the mismatch) — the artifact/HMR loader refuses ' + + 'this same document. Register under one name: drop `name`, or set it to ' + + `'${itemName}'.`, + ); + err.code = 'VALIDATION_ERROR'; + err.status = 400; + throw err; + } const toRegister = item.name === itemName ? item : { ...item, name: itemName }; // [#5320] The `views:` tighten — containers ONLY, the contract the // stack schema has always declared (`stack.zod.ts`, diff --git a/packages/objectql/src/view-container-divergent-name-registrars.test.ts b/packages/objectql/src/view-container-divergent-name-registrars.test.ts index bacfcd5500..ecda23a093 100644 --- a/packages/objectql/src/view-container-divergent-name-registrars.test.ts +++ b/packages/objectql/src/view-container-divergent-name-registrars.test.ts @@ -46,12 +46,40 @@ * `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. + * and the artifact door does not, so the same document was silently mis-keyed + * by one registrar and a hard boot failure through the other. + * + * --------------------------------------------------------------------------- + * ⭐ [#14666] That residual asymmetry is CLOSED, and this file inverted with it + * --------------------------------------------------------------------------- + * The asymmetry above was filed as its own card and ruled on 2026-09-03 + * (direction 2, maintainer, via the director seat): the boot loop's SOURCE + * registrar REFUSES a container whose `name` is set and differs from the key + * derived from `object` (or `type`) — the same refusal the artifact/HMR door + * already raised, naming both values — and stops rewriting `name` silently. + * Directions 1 (make the artifact door rewrite too, reversing #7378 row 1) and + * 3 (forbid `ViewSchema.name` on object-scoped containers in spec) were + * refused. + * + * ⚠️ So the pins below changed MEANING, not just expectations, and a reader + * arriving from #14399 should know which is which. What this file pinned + * before was *what each door did*; nothing in it asserted which was right, and + * the card warned that "an implementer who reads a green suite as agreement + * will be misled". Two boot-loop assertions inverted — they are marked ⭐ in + * place, each carrying what it used to read. The artifact door's pins did NOT + * move; the ruling says they stay, and they are now also the reference the + * boot loop's refusal envelope is asserted EQUAL to. + * + * The derivation #14399 owns is not lost by that inversion: at both SOURCE + * registrars it is now read the same way — the refusal names the key it + * derived, which is direct evidence of its answer. + * + * Scope was the ruling's named main risk ("keeping that scope tight is the + * implementation's main risk"), so the CONTROLS carry the weight here: a + * container with no `name`, one whose `name` already agrees, one that declares + * no binding at all, a standalone ViewItem, a non-`views` metadata kind, and + * the assembled `viewItems:` channel must all be untouched, and each has a + * case below. * * --------------------------------------------------------------------------- * Why this file drives BOTH registrars rather than pinning one @@ -70,7 +98,7 @@ import { describe, it, expect, vi } from 'vitest'; import { ObjectQL } from './engine'; -import { isAggregatedViewContainer } from '@objectstack/spec'; +import { isAggregatedViewContainer, AssembledViewArtifactSchema } from '@objectstack/spec'; import { MetadataPlugin, deriveViewContainerObject } from '@objectstack/metadata'; const PKG = 'com.acme.crm'; @@ -116,10 +144,36 @@ const AGREED_KEYS = ['crm_lead', 'crm_lead.default', 'crm_lead.hot']; // Registrar A — the ObjectQL boot loop (`registerMetadataCollections`). // --------------------------------------------------------------------------- -function bootRegistrarKeys(container: unknown): string[] { +/** + * Drive the boot loop once and report BOTH outcomes — the keys it minted and + * the refusal it raised, if any. + * + * [#14666] Split out of `bootRegistrarKeys` because the divergent container is + * now REFUSED here, and "what did it register before it threw?" became a + * question worth asking: a refusal that has already filed half the document is + * not a refusal. Every caller that expects registration keeps using + * `bootRegistrarKeys`, which re-throws — so a control that starts refusing + * fails loudly, carrying the refusal's own message, instead of silently + * reading an empty registry. + */ +function bootRegistrar(container: unknown): { keys: string[]; error: any } { 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); + let error: any = null; + try { + engine.registerApp({ id: PKG, name: 'crm', views: [container] } as any); + } catch (e) { + error = e; + } + return { + keys: CANDIDATE_KEYS.filter((k) => engine.registry.getItem('view', k) !== undefined), + error, + }; +} + +function bootRegistrarKeys(container: unknown): string[] { + const { keys, error } = bootRegistrar(container); + if (error) throw error; + return keys; } // --------------------------------------------------------------------------- @@ -165,12 +219,37 @@ describe('#14399 — the row\'s own `name` is the LAST term of the container der 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('THE PIN (#14666): the boot loop REFUSES the divergent container, and its refusal names the key it derived', () => { + // ⭐ THIS ASSERTION IS THE INVERSION. Its two earlier lives, in order: + // * before #14399 the boot loop minted ['lead_views', + // 'lead_views.default', 'lead_views.hot'] — the container and its + // whole expansion filed under the row identity, so + // `getViewsByObject('crm_lead')` had nothing for this document; + // * #14399 moved `name` to LAST in the derivation, so it minted + // AGREED_KEYS instead — the right key, but with the author's + // `name` silently overwritten on the way past. + // The #14666 ruling (direction 2, 2026-09-03) ends the second: a + // container whose `name` disagrees with its derived binding is + // REFUSED, exactly as the artifact/HMR door has always refused it. + // + // #14399's derivation answer is NOT lost by inverting this — it moves + // to where the artifact door's answer was already read in this file: + // the refusal names the key it derived. Both SOURCE registrars are now + // read the same way, which is the convergence the card asked for. + const { keys, error } = bootRegistrar(divergentContainer); + expect(error).toBeInstanceOf(Error); + // Both values named — the ruling's requirement, and what makes the + // diagnostic locate the mismatch instead of merely reporting one. + expect(error.message).toContain("`name` is 'lead_views'"); + expect(error.message).toContain("'crm_lead'"); + // The derivation itself, still pinned: `crm_lead` is what it derived, + // NOT the row's own `lead_views`. A refusal naming `lead_views` as the + // derived key would mean #14399 had regressed. + expect(error.message).toContain("binds to, 'crm_lead'"); + // Nothing filed: a refusal that has already registered half the + // document would leave the registry in the state the card calls the + // real defect. + expect(keys).toEqual([]); }); it('and the artifact/HMR registrar derives the SAME binding for the same document', async () => { @@ -193,17 +272,36 @@ describe('#14399 — the row\'s own `name` is the LAST term of the container der 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'); + // ⭐ [#14666] THE SECOND INVERSION, and the card's actual subject. This + // used to read + // + // expect((engine.registry.getItem('view', 'crm_lead') as any).name) + // .toBe('crm_lead'); + // + // i.e. it pinned the boot loop SILENTLY rewriting the author's + // `lead_views` to the derived key while this door hard-failed — one + // document, two SOURCE registrars, opposite outcomes. Ruled + // (2026-09-03, direction 2): the boot loop converges onto the refusal. + // So the assertion is now CONVERGENCE, envelope included — if either + // door ever moves again, this fails. + const boot = bootRegistrar(divergentContainer); + expect(boot.error).toBeInstanceOf(Error); + expect(boot.error.code).toBe(err.code); + expect(boot.error.status).toBe(err.status); + expect(boot.error.code).toBe('VALIDATION_ERROR'); + expect(boot.error.status).toBe(400); }); it('so the boot loop\'s expanded items are addressable under the object', () => { + // [#14666] Driven on the ANONYMOUS container now. The expansion + // property this pins — expanded items bind to the derived object, not + // to the container's row identity — is unchanged, but the divergent + // shape no longer reaches expansion at all: it is refused before + // anything registers, which is what the two tests above assert. The + // shape that still travels this path is the one carrying no `name`, + // and it is the shape the ruling explicitly leaves unaffected. const engine = new ObjectQL(); - engine.registerApp({ id: PKG, name: 'crm', views: [divergentContainer] } as any); + engine.registerApp({ id: PKG, name: 'crm', views: [anonymousContainer] } as any); const bound = (engine.registry.listItems('view') ?? []) .filter((v: any) => v?.viewKind) .map((v: any) => v.object); @@ -263,4 +361,69 @@ describe('#14399 — the row\'s own `name` is the LAST term of the container der expect(engine.registry.getItem('view', 'crm_lead.hot')).toBeDefined(); expect(engine.registry.getItem('view', 'crm_lead')).toBeUndefined(); }); + + // ------------------------------------------------------------------ + // [#14666] Scope controls. The ruling names keeping the refusal's scope + // tight as the implementation's MAIN RISK, and `registerMetadataCollections` + // is the GENERIC loop every metadata kind runs — so the three narrowings in + // the gate get a control each, plus the measurement that decided which of + // the method's two `toRegister` ternaries had to move. + // ------------------------------------------------------------------ + + it('SCOPE: a NON-`views` metadata kind is untouched, even carrying a container-shaped body', () => { + // Guards the gate's `key === 'views'` term. This loop serves objects, + // apps, roles, agents, … — a refusal leaking out of the `views` branch + // would turn a divergent `name` into a boot failure for every one of + // them, which is exactly what the ruling forbids. + const engine = new ObjectQL(); + engine.registerApp({ + id: PKG, + name: 'crm', + objects: [{ name: 'crm_lead', object: 'something_else', label: 'Lead' }], + } as any); + expect(engine.registry.getItem('object', 'crm_lead')).toBeDefined(); + }); + + it('SCOPE: a non-container `views:` entry keyed by its own `name` is untouched, `object` notwithstanding', () => { + // Guards the gate's `isAggregatedViewContainer(item)` term. This entry + // carries BOTH a `name` and an `object` that disagree textually, and it + // must still register under its own `name`: it is not a container, so + // its `name` is its identity and `resolveMetadataItemName` reads it + // FIRST — there is no binding for it to disagree with. + const engine = new ObjectQL(); + engine.registerApp({ + id: PKG, + name: 'crm', + views: [{ name: 'solo', object: 'crm_lead' }], + } as any); + expect(engine.registry.getItem('view', 'solo')).toBeDefined(); + expect(engine.registry.getItem('view', 'crm_lead')).toBeUndefined(); + }); + + it('SCOPE: the assembled `viewItems:` channel cannot carry a container, so its own `toRegister` was left alone', () => { + // ⭐ The measurement that scoped this change to ONE of the two + // identical-looking `toRegister` ternaries in + // `registerMetadataCollections`. The second one (the `viewItems:` + // channel) rewrites `body.name` the same way and was NOT touched, + // because a view CONTAINER cannot reach it: `AssembledViewArtifactSchema` + // is the view vocabulary MINUS the container branch, and every body it + // admits carries a `viewKind`, which makes `isAggregatedViewContainer` + // false by definition. Its ternary can therefore only MINT a name onto + // an overlay that has none — never discard an authored one, which is + // the thing #7378 row 1 refuses. + // + // Pinned here so that a later hand cannot make either mistake the + // ruling's scope constraint warns about: copying the refusal onto a + // site that has nothing to refuse, or widening this channel to accept + // containers and quietly restoring the silent rewrite. + expect(AssembledViewArtifactSchema.safeParse(divergentContainer).success).toBe(false); + expect(isAggregatedViewContainer(divergentContainer)).toBe(true); + const overlay = { + name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', + type: 'grid', columns: [{ field: 'name' }], + }; + const parsed = AssembledViewArtifactSchema.safeParse(overlay); + expect(parsed.success).toBe(true); + expect(isAggregatedViewContainer(parsed.success ? parsed.data : undefined)).toBe(false); + }); });