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
feat(security,objectql): the Layer 0 wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall (#15813) (#15878)
* wip(security): record the Layer 0 verdict on the operation; the bulk-event producer reads it (#15813)
Work in progress — spec verdict schema, plugin-security recording, engine reader,
retargeted pins, regenerated spec artifacts. Not yet verified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
* wip(security): pins, end-to-end weld, system-context census row, regenerated spec artifacts (#15813)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
* chore(docs): regenerate content/docs/references/index.mdx on the merged tree
Discharges the os-regen deferral recorded by the merge commit: the driver kept
one side of the generated index, and gen:schema + gen:docs on the merged tree
re-derives it with both sides' entries (EvaluatedExpression from main,
TenantLayer0Verdict from this branch).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(security): the Layer 0 tenant wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall
8
+
9
+
`BulkDataEventSchema.organizationId` is stamped on a `data.records.updated` / `data.records.deleted` event only when the Layer 0 tenant wall named exactly one organization for the whole predicate write. The producer (`publishBulkDataEvent`, `@objectstack/objectql`) used to decide that by re-deriving the wall's inputs — posture, context, and the object's own tenancy clauses. It could never see the third clause plugin-security folds into `tenancyDisabled`: the deployment-declared `platformGlobalObjects` carve-out (#12699). On such an object under an armed wall the producer stamped the caller's organization while Layer 0 had composed no wall at all — a wrong key asserting "every affected record belongs to this organization" over a batch that could span several, the #13566 leak shape reappearing on the bulk path (#15706).
10
+
11
+
Ruled on #15706 (seam (i), ADR-0131 D8 「一道谓词,算一次」): the wall records what it decided, and the reader composes nothing.
12
+
13
+
-**`@objectstack/spec`** — new export `TenantLayer0VerdictSchema` / `TenantLayer0Verdict` (`@objectstack/spec/security`): the four verdicts a Layer 0 wall can reach for one operation — `none`, `organization`, `organizations`, `deny`. Additive.
14
+
-**`@objectstack/objectql`** — `OperationContext` gains an optional member `tenantLayer0Verdict`, written by the enforcement layer at the moment it composes the wall onto the operation's predicate. Additive widening of a published surface, hence `minor`. `publishBulkDataEvent` now reads that member and nothing else: a recorded `organization` (or a one-member `organizations`) verdict stamps the key; `none`, `deny`, a multi-member set, a malformed value, or NO recorded verdict all omit it. The engine no longer consults the enforced posture, the execution context or the object schema to answer the question — the mirror is deleted, not moved.
15
+
-**`@objectstack/plugin-security`** — the engine middleware records `opCtx.tenantLayer0Verdict` on every operation whose predicate it composes the wall onto (reads and predicate writes); `computeTenantLayer0Filter` is now a projection of the new `computeTenantLayer0Verdict`, so the recorded verdict and the injected predicate come from one computation. An on-behalf-of write records the intersection of the caller's and the delegator's walls. System contexts and by-id writes record nothing (no wall is composed for them).
16
+
17
+
What moves, and in which direction: a deployment-exempted object under an armed wall now publishes `organizationId` ABSENT (it was wrongly present); a `PLATFORM_ADMIN` rung on a PUBLIC tenant object now publishes it PRESENT (the wall stands there; it was conservatively absent); a hand-built context with no rung is answered by the plugin's capability probe rather than conservatively absent. Every population the previous producer answered correctly is unchanged.
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+26-26Lines changed: 26 additions & 26 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -9,7 +9,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
9
9
service self-write, a migration.
10
10
11
11
This page is **the authority** for what that flag actually does. It exists
12
-
because the flag is not one concept: it is a single boolean read at **106
12
+
because the flag is not one concept: it is a single boolean read at **105
13
13
distinct sites across 19 packages**, and knowing three of those behaviours gives
14
14
no hint that the other hundred-and-three exist. Every documented app-side bug
15
15
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -87,14 +87,14 @@ that silently does not happen.
87
87
88
88
| # | Behaviour when `isSystem`| Package | What you get / what you lose | Anchor |
89
89
|:--|:---|:---|:---|:---|
90
-
| 1 |**The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page |`security-plugin.ts:1615`|
91
-
| 2 |**`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2541` (the step 3.5 block), skipped by `:1615`|
| 1 |**The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page |`security-plugin.ts:1620`|
91
+
| 2 |**`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2546` (the step 3.5 block), skipped by `:1620`|
| 7 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted |`object-schema-fls.ts:228`|
97
-
| 8 |`explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check |`security-plugin.ts:3857`|
97
+
| 8 |`explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check |`security-plugin.ts:3887`|
98
98
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId`|`anonymous-deny.ts:154`|
@@ -109,22 +109,22 @@ 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:11718`|
113
-
| 19 |**`readonly` strip bypassed — UPDATE, bulk/predicate**| objectql | Same, on the multi-row path |`objectql/src/engine.ts:11901`|
114
-
| 20 |**`readonly` strip bypassed — INSERT**| objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped |`objectql/src/engine.ts:10366`|
115
-
| 21 | 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:10499`, `readonly-strict-errors.ts:66`|
116
-
| 22 |**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:6225`|
117
-
| 23 | 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:3936`, `:3946`, `:3973`|
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:11675`|
113
+
| 19 |**`readonly` strip bypassed — UPDATE, bulk/predicate**| objectql | Same, on the multi-row path |`objectql/src/engine.ts:11858`|
114
+
| 20 |**`readonly` strip bypassed — INSERT**| objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped |`objectql/src/engine.ts:10323`|
115
+
| 21 | 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:10456`, `readonly-strict-errors.ts:66`|
116
+
| 22 |**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:6182`|
117
+
| 23 | 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:3913`, `:3923`, `:3950`|
| 25 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path |`identity-write-guard.ts:99`|
120
-
| 26 | 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:6924`|
121
-
| 27 | 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:12517`|
122
-
| 28 | 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:12446`|
123
-
| 29 |**Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" |objectql| Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: the security middleware composed no tenant wall for a system write, so the producer cannot vouch that every row a predicate write affected belongs to one organization, and the key is omitted rather than filled from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) |`objectql/src/engine.ts:2371`|
120
+
| 26 | 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:6881`|
121
+
| 27 | 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:12477`|
122
+
| 28 | 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:12406`|
123
+
| 29 |**Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" |plugin-security| Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) |`security-plugin.ts:1620`|
124
124
125
125
### 3. Sharing (`plugin-sharing`)
126
126
127
-
The largest single consumer — **17 of the 106 sites**.
127
+
The largest single consumer — **17 of the 105 sites**.
128
128
129
129
| # | Behaviour when `isSystem`| What you get / what you lose | Anchor |
130
130
|:--|:---|:---|:---|
@@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them.
179
179
180
180
| # | Site | Package | What it does |
181
181
|:--|:---|:---|:---|
182
-
| 62 |`objectql/src/engine.ts:3743`| objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
183
-
| 63 |`objectql/src/engine.ts:14961`| objectql |`ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
182
+
| 62 |`objectql/src/engine.ts:3720`| objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
183
+
| 63 |`objectql/src/engine.ts:14923`| 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
@@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs.
195
195
|:---|:---|:---|
196
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:10349`–`10366`|
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:10306`–`10323`|
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:1581` (#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`|
@@ -269,7 +269,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
269
269
independent decisions, and a seed loader plausibly wants the first two but not
270
270
the third. The concept is nevertheless **staying as one boolean**:
271
271
272
-
-**Shipped semantics.**`isSystem` is a published contract with 106 read sites
272
+
-**Shipped semantics.**`isSystem` is a published contract with 105 read sites
273
273
in 19 packages. Splitting it is a breaking contract change across all of them.
274
274
(The ruling was taken when the census read 80 sites in 18 packages; the count
275
275
has grown, which strengthens rather than weakens the argument.)
@@ -326,12 +326,12 @@ still holds equal to the census on every pull request:
326
326
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
327
327
| — parsed as a declaration | 22 | ✅ |
328
328
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
329
-
| — parsed as a property **read**|112| ✅ |
329
+
| — parsed as a property **read**|111| ✅ |
330
330
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
331
331
| — the remainder: text inside comments and string literals | 358 | — |
332
332
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
333
-
| Of those reads: reads of `ExecutionContext.isSystem`|**106**| ✅ |
334
-
| — behaviour-bearing (rows 1–61 above) |102| ✅ |
333
+
| Of those reads: reads of `ExecutionContext.isSystem`|**105**| ✅ |
334
+
| — behaviour-bearing (rows 1–61 above) |101| ✅ |
335
335
| — carry the flag onward only (rows 62–65 above) | 4 | ✅ |
336
336
| Packages containing at least one elevation read |**19**| ✅ |
337
337
| Files containing at least one elevation read | 44 | ✅ |
0 commit comments