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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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