Skip to content

Commit d0ee598

Browse files
claude[bot]claude
andauthored
fix(objectql): refuse a view container whose name disagrees with its derived object key (#14666) (#15319)
The ObjectQL boot loop's SOURCE registrar (`registerMetadataCollections`) silently rewrote an aggregated view container's own `name` to the key derived from its `object` binding, discarding the author's field with no diagnostic. The other SOURCE registrar — the metadata artifact/HMR door — refused the very same document through `assertMetadataRegisterContract` (#7378 row 1), `VALIDATION_ERROR` / 400. One document, two registrars, opposite outcomes, and which one an author got depended on how their package was loaded. Ruled direction 2 (maintainer, 2026-09-03): the boot loop converges onto the refusal. It now throws the same envelope the artifact door raises, naming the container's own `name`, the object key it derived, and both remedies. Scope, the ruling's named main risk: the gate is `views` + aggregated container + `name` present + `name` different. A container with no `name`, one whose `name` already agrees, one that declares no binding elsewhere, every non-`views` metadata kind, and the assembled `viewItems:` channel are all untouched, each with a control test. Measured, and why only one of the method's two identical `toRegister` ternaries moved: a view container cannot reach the `viewItems:` site at all — `AssembledViewArtifactSchema` is the view vocabulary minus the container branch, and every body it admits is `isAggregatedViewContainer === false`. That ternary can only mint a name onto an overlay that has none, never discard an authored one. `view-container-divergent-name-registrars.test.ts` inverts its two boot-loop assertions (it pinned today's behaviour, not which door was right); the artifact-door pins stay and are now the reference the boot loop's envelope is asserted equal to. `content/docs/permissions/system-context.mdx` line anchors re-anchored with `check-system-context-census.mjs --fix` — pure +71 line rot, no prose change. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2c753fe commit d0ee598

4 files changed

Lines changed: 321 additions & 33 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
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)
6+
7+
**BREAKING** accept-set narrowing on the ObjectQL boot loop's SOURCE registrar
8+
(`registerMetadataCollections`), shipped as `minor` under the repo's
9+
launch-window convention for breaking changes. Ruled on #14666 (2026-09-03,
10+
direction 2).
11+
12+
An aggregated `defineView` container is keyed by the OBJECT it binds to, not
13+
by its own row identity, and `ViewSchema` declares an optional `name` whose
14+
own description says that for an object-scoped container it *is* the object
15+
name. Nothing enforced that. A container written as
16+
`{ name: 'lead_views', object: 'crm_lead', list: { ... } }` therefore reached
17+
the two SOURCE registrars and got opposite answers: this boot loop overwrote
18+
`name` with the derived key `crm_lead` and registered it, discarding the
19+
author's field with no diagnostic, while the artifact/HMR loader
20+
(`MetadataPlugin._parseAndRegisterArtifact`) refused the whole artifact load
21+
through `assertMetadataRegisterContract` (#7378 row 1, `VALIDATION_ERROR` /
22+
400). Same document, and whether it loaded at all depended on how the package
23+
was loaded.
24+
25+
The boot loop now **refuses loudly**, with the same `VALIDATION_ERROR` / 400
26+
envelope the artifact door raises, naming the container's own `name`, the
27+
object key it derived, and both remedies: drop `name`, or set it to that
28+
derived key. #7378 row 1 already ruled that resolving such a disagreement
29+
silently, in either direction, files the item under a key the caller never
30+
wrote, so the two registrars converge on the refusal rather than on the
31+
rewrite; the artifact door is unchanged.
32+
33+
**Refused shape**, precisely: an aggregated view container in a stack `views:`
34+
collection that carries a non-empty top-level `name` AND derives a different
35+
object key from its own `object` (or, failing that, `list.data.object` /
36+
`form.data.object`).
37+
38+
Scope, which the ruling names as this change's main risk. A container with no
39+
`name` is untouched, and still registers under its derived key. So is a
40+
container whose `name` already equals that key, and one that declares no
41+
binding anywhere else, since the derivation then falls back to that same
42+
`name` and cannot disagree with itself. No other metadata kind changes
43+
behaviour: the refusal is gated inside the `views` branch of the generic
44+
registration loop. Standalone ViewItems and flattened overlays travelling in
45+
the assembled `viewItems:` channel are untouched, because a container cannot
46+
reach that channel at all. Every one of these has a control test.
47+
48+
<!-- 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). -->

content/docs/permissions/system-context.mdx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,18 @@ that silently does not happen.
109109

110110
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
111111
|:--|:---|:---|:---|:---|
112-
| 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` |
113-
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11556` |
114-
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10106` |
112+
| 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` |
113+
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11633` |
114+
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10183` |
115115
| 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` |
116-
| 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` |
117-
| 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` |
116+
| 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` |
117+
| 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` |
118118
| 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` |
119119
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
120120
| 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` |
121-
| 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` |
122-
| 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` |
123-
| 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` |
121+
| 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` |
122+
| 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` |
123+
| 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` |
124124

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

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

@@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs.
195195
|:---|:---|:---|
196196
| "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` |
197197
| "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) |
198-
| "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` |
198+
| "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` |
199199
| "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) |
200200
| "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` |
201201
| "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` |

packages/objectql/src/engine.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5295,6 +5295,83 @@ export class ObjectQL implements IObjectQLEngine {
52955295
this.logger.warn(`Skipping ${pluralToSingular(key)} without a derivable name`, { id: ownerId });
52965296
continue;
52975297
}
5298+
// [#14666] The DIVERGENT-container refusal (maintainer ruling
5299+
// 2026-09-03, direction 2). This seam used to reconcile a
5300+
// container's own `name` to the derived key silently, one line
5301+
// below: the author's field discarded with no diagnostic, while
5302+
// the OTHER source registrar
5303+
// (`MetadataPlugin._parseAndRegisterArtifact` ->
5304+
// `assertMetadataRegisterContract`, #7378 row 1) hard-failed the
5305+
// whole artifact load on that very same document. One document,
5306+
// two SOURCE registrars, opposite outcomes, and which one an
5307+
// author got depended on how their package was loaded. #7378
5308+
// row 1 already rules that resolving a name/key disagreement
5309+
// silently "in either direction" files the item under a key the
5310+
// caller never wrote, so the boot loop converges onto the
5311+
// refusal; the artifact door was not moved onto the rewrite.
5312+
//
5313+
// MEASURED, and it is why only this site changed: the second
5314+
// `toRegister` ternary in this method (the assembled
5315+
// `viewItems:` channel below) cannot carry a container at all —
5316+
// `AssembledViewArtifactSchema` is the view vocabulary MINUS the
5317+
// container branch, and every body it admits is
5318+
// `isAggregatedViewContainer === false`. Its ternary can only
5319+
// MINT a `name` onto an overlay that has none; it can never
5320+
// discard an authored one, because `resolveMetadataItemName`
5321+
// reads a non-container's own `name` FIRST.
5322+
//
5323+
// Scope is the ruling's named main risk, so the gate is three
5324+
// independent narrowings and each one is load-bearing:
5325+
// * `key === 'views'` — this is the GENERIC metadata loop; no
5326+
// other kind changes behaviour and they all keep the
5327+
// reconcile below;
5328+
// * `isAggregatedViewContainer(item)` — the CONTAINER branch
5329+
// only. A standalone ViewItem's `name` is its identity, not
5330+
// a binding, and it has no disagreement to make;
5331+
// * `name` present AND different. A container carrying no
5332+
// `name` is untouched (the reconcile below still mints the
5333+
// derived key onto it); so is one whose `name` already
5334+
// equals the derived key; and so is one that declares no
5335+
// binding anywhere else, because `deriveViewContainerObject`
5336+
// then derives the key FROM that same `name` and it cannot
5337+
// disagree with itself.
5338+
//
5339+
// The runtime string deliberately carries NO tracker id: it is read by
5340+
// authors and operators who cannot resolve `#NNNN`
5341+
// (`check:doc-authoring`, maintainer ruling 2026-08-12). The rule it
5342+
// enforces is #7378 row 1, named in this comment instead, where the
5343+
// reader who can resolve it is already looking.
5344+
//
5345+
// The ADR-0112 envelope is the artifact door's exactly —
5346+
// `VALIDATION_ERROR` / 400 — because a document refused at one
5347+
// SOURCE registrar must be refused the same way at the other,
5348+
// and that equality is asserted in
5349+
// `view-container-divergent-name-registrars.test.ts`. The prose
5350+
// is this seam's own on purpose: `assertMetadataRegisterContract`
5351+
// opens its message with `IMetadataService.register(...)`, an API
5352+
// this seam does not call, and a refusal that misnames its own
5353+
// door is the opposite of "locate the mismatch".
5354+
if (
5355+
key === 'views'
5356+
&& isAggregatedViewContainer(item)
5357+
&& typeof item.name === 'string'
5358+
&& item.name
5359+
&& item.name !== itemName
5360+
) {
5361+
const err: Error & { code?: string; status?: number } = new Error(
5362+
`Invalid \`views:\` container from ${sourceLabel} '${ownerId}': the container's own `
5363+
+ `\`name\` is '${item.name}', which disagrees with the object key it binds to, `
5364+
+ `'${itemName}' (derived from its own \`object\`, else \`list.data.object\` / `
5365+
+ '`form.data.object`). A disagreement is almost always an authoring bug, and resolving '
5366+
+ 'it silently in either direction can file the item under a key the caller never wrote '
5367+
+ '(refuse loudly, locate the mismatch) — the artifact/HMR loader refuses '
5368+
+ 'this same document. Register under one name: drop `name`, or set it to '
5369+
+ `'${itemName}'.`,
5370+
);
5371+
err.code = 'VALIDATION_ERROR';
5372+
err.status = 400;
5373+
throw err;
5374+
}
52985375
const toRegister = item.name === itemName ? item : { ...item, name: itemName };
52995376
// [#5320] The `views:` tighten — containers ONLY, the contract the
53005377
// stack schema has always declared (`stack.zod.ts`,

0 commit comments

Comments
 (0)