You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Advisory validation rules no longer flood the startup log, and no longer count a row twice on a clean first boot.
8
+
9
+
A `severity: 'warning'` (or `'info'`) validation rule is advisory: it never blocks a write, and its message is written for a person filling in a form. Evaluated across a seed load it produced one `WARN` line per row, so a clean-database first boot opened with a wall of form hints re-cast as boot diagnostics — and an app could reach "zero warnings" only by bending its data or deleting the rule.
10
+
11
+
Two changes, and neither moves what a rule evaluates to:
12
+
13
+
-**Aggregated reporting on the seed/boot path.**`SeedLoaderService.load()` now runs inside an advisory aggregation scope, and reports one summary line per rule — the rule, the object, the row count, the rule's own message and example rows — instead of one line per row. Off that path (an ordinary interactive write) nothing changes: the same per-write line is emitted verbatim. The new scope is `runWithAdvisoryAggregation` / `recordAdvisoryHit` in `@objectstack/core`.
14
+
-**Advisory rules are counted by row, not by write.** An `update` whose payload touches only platform-injected system columns — the shape `claimSeedOwnership` writes when it hands seeded rows to the first admin, `{ owner_id }` — changes no business field, so it no longer re-evaluates the object's advisory rules. Previously a seeded row rang once on insert and again when the claim scan rewrote `owner_id`, so anyone counting startup warnings over-estimated by the number of claimed objects.
15
+
16
+
`error`-severity rules are untouched by both changes: an invariant is still enforced on every write, whoever issued it and however little it moved. Membership of the "system column" set is resolved per object by `resolveInjectedSystemColumns`, so an object that declares `ownership: 'org'` (no `owner_id`) or `systemFields: false` is judged on its own columns rather than a fixed list.
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
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). -->
Copy file name to clipboardExpand all lines: content/docs/data-modeling/validation.mdx
+5-2Lines changed: 5 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -83,9 +83,11 @@ All validation types share these base properties:
83
83
| Severity | Behavior |
84
84
| :--- | :--- |
85
85
|`error`| Prevents the record from being saved |
86
-
|`warning`|Shows a warning but allows save|
86
+
|`warning`|Allows the save; advisory only — logged server-side, not returned to the caller|
87
87
|`info`| Informational message, no blocking |
88
88
89
+
Advisory rules (`warning` / `info`) are reported, never enforced: on an ordinary write each hit is logged as it happens; on a seed/boot load a run's hits are folded into **one summary line per rule**; and an `update` touching only platform-injected system columns does not re-evaluate them at all, so a row is reported once rather than once per write (#13889).
90
+
89
91
## Validation Types
90
92
91
93
### Script Validation
@@ -112,7 +114,8 @@ fix it. Until protocol 17 such a rule was logged at WARN and *skipped*, so the w
112
114
went through while the rule stayed declared and enforced nothing; a validation exists
113
115
to reject a write, and "the rule could not be checked" must never resolve to
114
116
"allowed" (#4649). `severity` still governs blocking — an unevaluable `warning` /
115
-
`info` rule is logged and does not throw.
117
+
`info` rule is logged and does not throw, on the writes where advisory rules are
118
+
evaluated at all (see above).
116
119
117
120
The record a predicate reads is the stored row overlaid with this write's payload,
118
121
**total over the object's declared fields** (`null` for a declared field present in
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+11-11Lines changed: 11 additions & 11 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -109,18 +109,18 @@ that silently does not happen.
109
109
110
110
| # | Behaviour when `isSystem`| Package | What you get / what you lose | Anchor |
111
111
|:--|:---|:---|:---|:---|
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`|
| 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`|
| 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`|
118
118
| 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`|
| 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`|
124
124
125
125
### 3. Sharing (`plugin-sharing`)
126
126
@@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them.
180
180
| # | Site | Package | What it does |
181
181
|:--|:---|:---|:---|
182
182
| 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 |
184
184
| 64 |`plugin-reports/src/report-service.ts:556`| plugin-reports | Threads the flag into the engine call that runs a report |
185
185
| 65 |`body-runner.ts:279`| runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |
186
186
@@ -193,9 +193,9 @@ assuming `isSystem` covers it is a documented source of bugs.
193
193
194
194
| Assumption | Reality | Anchor |
195
195
|:---|:---|:---|
196
-
| "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`|
196
+
| "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:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:702`|
197
197
| "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`|
199
199
| "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) |
200
200
| "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`|
201
201
| "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`|
0 commit comments