Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/view-container-object-derivation-name-last.md
Original file line numberDiff line numberDiff line change
@@ -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 (`<object>.<key>`) 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.
24 changes: 12 additions & 12 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`)

Expand DownExpand Up@@ -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 |

Expand All@@ -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` |
Expand Down
16 changes: 16 additions & 0 deletions packages/metadata/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
74 changes: 59 additions & 15 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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;
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/view-container-object-derivation-name-last.md
Original file line numberDiff line numberDiff line change
@@ -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 (`<object>.<key>`) 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.
24 changes: 12 additions & 12 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`)

Expand DownExpand Up@@ -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 |

Expand All@@ -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` |
Expand Down
16 changes: 16 additions & 0 deletions packages/metadata/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
74 changes: 59 additions & 15 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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;
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/view-container-object-derivation-name-last.md
Original file line numberDiff line numberDiff line change
@@ -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 (`<object>.<key>`) 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.
24 changes: 12 additions & 12 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`)

Expand DownExpand Up@@ -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 |

Expand All@@ -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` |
Expand Down
16 changes: 16 additions & 0 deletions packages/metadata/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
74 changes: 59 additions & 15 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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;
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/view-container-object-derivation-name-last.md
Original file line numberDiff line numberDiff line change
@@ -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 (`<object>.<key>`) 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.
24 changes: 12 additions & 12 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`)

Expand DownExpand Up@@ -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 |

Expand All@@ -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` |
Expand Down
16 changes: 16 additions & 0 deletions packages/metadata/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
74 changes: 59 additions & 15 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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;
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/view-container-object-derivation-name-last.md
Original file line numberDiff line numberDiff line change
@@ -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 (`<object>.<key>`) 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.
24 changes: 12 additions & 12 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`)

Expand DownExpand Up@@ -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 |

Expand All@@ -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` |
Expand Down
16 changes: 16 additions & 0 deletions packages/metadata/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
74 changes: 59 additions & 15 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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;
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/view-container-object-derivation-name-last.md
Original file line numberDiff line numberDiff line change
@@ -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 (`<object>.<key>`) 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.
24 changes: 12 additions & 12 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`)

Expand DownExpand Up@@ -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 |

Expand All@@ -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` |
Expand Down
16 changes: 16 additions & 0 deletions packages/metadata/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
74 changes: 59 additions & 15 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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;
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/view-container-object-derivation-name-last.md
Original file line numberDiff line numberDiff line change
@@ -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 (`<object>.<key>`) 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.
24 changes: 12 additions & 12 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`)

Expand DownExpand Up@@ -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 |

Expand All@@ -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` |
Expand Down
16 changes: 16 additions & 0 deletions packages/metadata/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
74 changes: 59 additions & 15 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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;
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/view-container-object-derivation-name-last.md
Original file line numberDiff line numberDiff line change
@@ -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 (`<object>.<key>`) 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.
24 changes: 12 additions & 12 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`)

Expand DownExpand Up@@ -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 |

Expand All@@ -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` |
Expand Down
16 changes: 16 additions & 0 deletions packages/metadata/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
74 changes: 59 additions & 15 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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;
}
Expand Down
Loading
Loading