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
48 changes: 48 additions & 0 deletions .changeset/objectql-boot-loop-refuses-divergent-view-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/objectql": minor
---

fix(objectql): the boot loop refuses a view container whose `name` disagrees with the object it binds to, instead of silently rewriting the author's field (#14666)

**BREAKING** accept-set narrowing on the ObjectQL boot loop's SOURCE registrar
(`registerMetadataCollections`), shipped as `minor` under the repo's
launch-window convention for breaking changes. Ruled on #14666 (2026-09-03,
direction 2).

An aggregated `defineView` container is keyed by the OBJECT it binds to, not
by its own row identity, and `ViewSchema` declares an optional `name` whose
own description says that for an object-scoped container it *is* the object
name. Nothing enforced that. A container written as
`{ name: 'lead_views', object: 'crm_lead', list: { ... } }` therefore reached
the two SOURCE registrars and got opposite answers: this boot loop overwrote
`name` with the derived key `crm_lead` and registered it, discarding the
author's field with no diagnostic, while the artifact/HMR loader
(`MetadataPlugin._parseAndRegisterArtifact`) refused the whole artifact load
through `assertMetadataRegisterContract` (#7378 row 1, `VALIDATION_ERROR` /
400). Same document, and whether it loaded at all depended on how the package
was loaded.

The boot loop now **refuses loudly**, with the same `VALIDATION_ERROR` / 400
envelope the artifact door raises, naming the container's own `name`, the
object key it derived, and both remedies: drop `name`, or set it to that
derived key. #7378 row 1 already ruled that resolving such a disagreement
silently, in either direction, files the item under a key the caller never
wrote, so the two registrars converge on the refusal rather than on the
rewrite; the artifact door is unchanged.

**Refused shape**, precisely: an aggregated view container in a stack `views:`
collection that carries a non-empty top-level `name` AND derives a different
object key from its own `object` (or, failing that, `list.data.object` /
`form.data.object`).

Scope, which the ruling names as this change's main risk. A container with no
`name` is untouched, and still registers under its derived key. So is a
container whose `name` already equals that key, and one that declares no
binding anywhere else, since the derivation then falls back to that same
`name` and cannot disagree with itself. No other metadata kind changes
behaviour: the refusal is gated inside the `views` branch of the generic
registration loop. Standalone ViewItems and flattened overlays travelling in
the assembled `viewItems:` channel are untouched, because a container cannot
reach that channel at all. Every one of these has a control test.

<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing over an existing optional key: `ViewSchema.name` is neither removed, renamed nor re-shaped, and forbidding it on object-scoped containers in spec was the direction the ruling explicitly refused, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. Which of the three repairs an affected container wants is authoring intent no migration entry can decide: the author may have meant the container name to go, may have meant it to become the object key, or may have mistyped `object` and want THAT corrected instead, and the stored document carries no evidence of which. The refusal is the channel that reaches the author, at the registration site, naming both values and both remedies. Measured in-repo population of affected sources is zero: no `views:` collection reaching this seam carries a divergent container `name` (the three engine-booting fixtures with inline containers are in `packages/objectql`, and the example apps' `.view.ts` containers declare no top-level `name` at all). -->
20 changes: 10 additions & 10 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:11373` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11556` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10106` |
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11450` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11633` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10183` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1795` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10154`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5973` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10231`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:6050` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3799`, `:3809`, `:3836` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:99` |
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6671` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12172` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12101` |
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6748` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12249` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12178` |

### 3. Sharing (`plugin-sharing`)

Expand Down Expand Up @@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them.
| # | Site | Package | What it does |
|:--|:---|:---|:---|
| 62 | `objectql/src/engine.ts:3606` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:14616` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 63 | `objectql/src/engine.ts:14693` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |

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:702` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10089`–`10106` |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10166`–`10183` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1580` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |
Expand Down
77 changes: 77 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5295,6 +5295,83 @@ export class ObjectQL implements IObjectQLEngine {
this.logger.warn(`Skipping ${pluralToSingular(key)} without a derivable name`, { id: ownerId });
continue;
}
// [#14666] The DIVERGENT-container refusal (maintainer ruling
// 2026-09-03, direction 2). This seam used to reconcile a
// container's own `name` to the derived key silently, one line
// below: the author's field discarded with no diagnostic, while
// the OTHER source registrar
// (`MetadataPlugin._parseAndRegisterArtifact` ->
// `assertMetadataRegisterContract`, #7378 row 1) hard-failed the
// whole artifact load on that very same document. One document,
// two SOURCE registrars, opposite outcomes, and which one an
// author got depended on how their package was loaded. #7378
// row 1 already rules that resolving a name/key disagreement
// silently "in either direction" files the item under a key the
// caller never wrote, so the boot loop converges onto the
// refusal; the artifact door was not moved onto the rewrite.
//
// MEASURED, and it is why only this site changed: the second
// `toRegister` ternary in this method (the assembled
// `viewItems:` channel below) cannot carry a container at all —
// `AssembledViewArtifactSchema` is the view vocabulary MINUS the
// container branch, and every body it admits is
// `isAggregatedViewContainer === false`. Its ternary can only
// MINT a `name` onto an overlay that has none; it can never
// discard an authored one, because `resolveMetadataItemName`
// reads a non-container's own `name` FIRST.
//
// Scope is the ruling's named main risk, so the gate is three
// independent narrowings and each one is load-bearing:
// * `key === 'views'` — this is the GENERIC metadata loop; no
// other kind changes behaviour and they all keep the
// reconcile below;
// * `isAggregatedViewContainer(item)` — the CONTAINER branch
// only. A standalone ViewItem's `name` is its identity, not
// a binding, and it has no disagreement to make;
// * `name` present AND different. A container carrying no
// `name` is untouched (the reconcile below still mints the
// derived key onto it); so is one whose `name` already
// equals the derived key; and so is one that declares no
// binding anywhere else, because `deriveViewContainerObject`
// then derives the key FROM that same `name` and it cannot
// disagree with itself.
//
// The runtime string deliberately carries NO tracker id: it is read by
// authors and operators who cannot resolve `#NNNN`
// (`check:doc-authoring`, maintainer ruling 2026-08-12). The rule it
// enforces is #7378 row 1, named in this comment instead, where the
// reader who can resolve it is already looking.
//
// The ADR-0112 envelope is the artifact door's exactly —
// `VALIDATION_ERROR` / 400 — because a document refused at one
// SOURCE registrar must be refused the same way at the other,
// and that equality is asserted in
// `view-container-divergent-name-registrars.test.ts`. The prose
// is this seam's own on purpose: `assertMetadataRegisterContract`
// opens its message with `IMetadataService.register(...)`, an API
// this seam does not call, and a refusal that misnames its own
// door is the opposite of "locate the mismatch".
if (
key === 'views'
&& isAggregatedViewContainer(item)
&& typeof item.name === 'string'
&& item.name
&& item.name !== itemName
) {
const err: Error & { code?: string; status?: number } = new Error(
`Invalid \`views:\` container from ${sourceLabel} '${ownerId}': the container's own `
+ `\`name\` is '${item.name}', which disagrees with the object key it binds to, `
+ `'${itemName}' (derived from its own \`object\`, else \`list.data.object\` / `
+ '`form.data.object`). A disagreement is almost always an authoring bug, and resolving '
+ 'it silently in either direction can file the item under a key the caller never wrote '
+ '(refuse loudly, locate the mismatch) — the artifact/HMR loader refuses '
+ 'this same document. Register under one name: drop `name`, or set it to '
+ `'${itemName}'.`,
);
err.code = 'VALIDATION_ERROR';
err.status = 400;
throw err;
}
const toRegister = item.name === itemName ? item : { ...item, name: itemName };
// [#5320] The `views:` tighten — containers ONLY, the contract the
// stack schema has always declared (`stack.zod.ts`,
Expand Down
Loading
Loading