Skip to content

Commit f334fcf

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14849-approval-error-wire-code-pins
2 parents 340d763 + 2ed6be6 commit f334fcf

27 files changed

Lines changed: 1752 additions & 80 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/metadata-protocol": minor
5+
---
6+
7+
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.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `plugins` / `devPlugins` are artifact envelope keys — excluded from the assembled package body and refused inside `packages[]` (#15219)
6+
7+
<!-- adr-0087: registered assembled-package-body-plugins-envelope -->
8+
9+
**BREAKING** accept-set narrowing on `AssembledPackageBodySchema` — the body
10+
under `packages[i].manifest` of a release artifact (ADR-0130 D4): a body that
11+
carries `plugins` or `devPlugins` is now **refused** at the manifest's strict
12+
close (`unrecognized_keys`, naming the key), where it used to parse. Shipped as
13+
`minor` under the repo's launch-window convention for breaking changes; the
14+
hand-migration prescription is registered under protocol major 18. Maintainer
15+
ruling 2026-09-04 on #15219 (director decision batch #32, verbatim 「同意」):
16+
option A for both keys.
17+
18+
`plugins` and `devPlugins` were members of the assembled-body key set by the
19+
same derivation every other collection uses (`COMPOSE_KEY_DISPOSITIONS` gives
20+
both `concat`). They are the only members whose values are **runtime assembly
21+
instructions** rather than serialisable metadata: `plugins` holds what a host
22+
hands to `kernel.use()` — live plugin instances, manifests or package names —
23+
and `devPlugins` is the `os dev` load list. Inside an artifact a package body
24+
is inert JSON, so a plugin under `packages[i].manifest` could never be
25+
constructed by a loader; every reader reads the top level. The classification
26+
is corrected rather than special-cased: an artifact carries metadata, a host
27+
assembles plugins.
28+
29+
**What changes** (`packages/spec/src/stack.zod.ts`):
30+
31+
- `plugins` / `devPlugins` are **envelope keys** — top level only, never inside
32+
`packages[]`. `ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS` (`packages`, `plugins`,
33+
`devPlugins`) is declared once and feeds both the `AssembledPackageBodyKey`
34+
derivation and `assembledPackageBodyShape()`.
35+
- Both keys stay `concat`: a live stack still concatenates its plugins to the
36+
top level under `composeStacks`, and `manifest: 'preserve'` no longer folds
37+
them into any package body.
38+
- The two declarations on the stack schema are unchanged.
39+
40+
**What does NOT change:** `os serve` / `os migrate` / `os dev` keep reading the
41+
top level (now correct by construction); no CLI, core or runtime code moves.
42+
43+
## FROM → TO
44+
45+
```ts
46+
// before — a package body inside an artifact could carry plugins nobody could load
47+
{ packages: [{ manifest: { id: 'com.example.crm', /**/ plugins: [{ name: 'plugin.x' }] } }] }
48+
49+
// after — plugins live on the artifact envelope only; the body above is refused:
50+
// packages.0.manifest: unrecognized_keys ['plugins']
51+
{ plugins: [new CrmPlugin()], packages: [{ manifest: { id: 'com.example.crm', /**/ } }] }
52+
```
53+
54+
**Migration.** Declare `plugins` / `devPlugins` at the stack top level and
55+
delete them from every `packages[i].manifest`. An existing multi-package
56+
artifact that carries `packages[i].manifest.plugins` (if `os build` ever wrote
57+
one — not directly measured) is refused on load after this change and must be
58+
rebuilt from source; a hand-written `packages[]` entry drops the keys. Stacks
59+
that only ever declared the two keys at the top level parse byte-identically.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
feat(runtime): a flow action's run context now carries `recordLoadDenied` (#15168)
7+
8+
The previous release declared `AutomationContext.recordLoadDenied?: true` and
9+
said so plainly: **declared, not yet populated on the flow face.** The
10+
script/body face of both action doors emitted the signal, but
11+
`dispatchFlowAction` handed `automation.execute` a context without it, so a
12+
`runAs: 'system'` flow that guarded on the documented key was inert — never
13+
`true`, never wrong, and indistinguishable from a flow whose caller could read
14+
the row.
15+
16+
**This release populates it, on both doors in one stroke** — REST
17+
`POST /api/v1/actions/...` and the MCP `run_action` bridge:
18+
19+
```js
20+
// a runAs:'system' flow, guarding before it acts on the subject row
21+
if (context.recordLoadDenied === true) { /* the invoker cannot read this row */ }
22+
```
23+
24+
- **The exact producer shape, unchanged.** The one shared producer
25+
(`loadActionSubjectRecord``actionRecordLoadSignal`) already returns
26+
`{ recordLoadDenied?: true }`, and the flow door now spreads it as a
27+
**sibling of `record`** — never a key on the record, and **absent**, never
28+
`false`, when nothing was refused. So a flow reads it exactly as a handler
29+
does, `recordLoadDenied === true`.
30+
- **Both doors, structurally.** `dispatchFlowAction`'s wiring now takes the
31+
load OUTCOME (`subject`) instead of a bare `record`, and derives both the
32+
record and the signal from it. A caller can no longer forward the row while
33+
dropping the verdict that says the caller could not read it — the omission is
34+
a compile error rather than a guard silently inert one door over, which is
35+
the defect the handler-face signal was filed for.
36+
- **Purely additive.** Nothing is refused that was not refused before, no
37+
existing key changes value, and the `recordId` stamp is deliberately kept:
38+
`record.id` still arrives exactly as it did, which is why the flag — and not
39+
`record.id` — is the authorization predicate. Whether the automation engine
40+
*acts* on the key (a flow-level refusal, a step condition) is a separate
41+
decision and is deliberately not part of this change.
42+
- **`@objectstack/spec` (docs only).** The contract's "not yet populated on the
43+
flow face" sentence is retired; no type changes.
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/data-modeling/validation.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,11 @@ All validation types share these base properties:
8383
| Severity | Behavior |
8484
| :--- | :--- |
8585
| `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 |
8787
| `info` | Informational message, no blocking |
8888

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+
8991
## Validation Types
9092

9193
### Script Validation
@@ -112,7 +114,8 @@ fix it. Until protocol 17 such a rule was logged at WARN and *skipped*, so the w
112114
went through while the rule stayed declared and enforced nothing; a validation exists
113115
to reject a write, and "the rule could not be checked" must never resolve to
114116
"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).
116119

117120
The record a predicate reads is the stored row overlaid with this write's payload,
118121
**total over the object's declared fields** (`null` for a declared field present in

content/docs/permissions/system-context.mdx

Lines changed: 11 additions & 11 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

@@ -193,9 +193,9 @@ assuming `isSystem` covers it is a documented source of bugs.
193193

194194
| Assumption | Reality | Anchor |
195195
|:---|:---|:---|
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` |
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` |

0 commit comments

Comments
 (0)