Repository navigation
Commit 2b1382a
fix(objectql,plugin-security): an insert's explicit organization_id the Layer 0 wall judges is stored as sent (#25048)
Part of #24822
Clause-②: yes (widening) — `PostHookWriteImageCheck` gains the optional
published member `judgesOrganization`, and an in-process
`strictReadonlyWrites: true` insert naming an organization the wall
admits is now stored instead of refused `ERR_READONLY_FIELD_REJECTED`.
This is half 1 of the card: INSERT only. The UPDATE half is the card's
half 2. It reuses the member and is not in this PR. The seat call that
ruled the channel and the declaration is `6305891812` on the card (Q1 →
A, Q2 → A).
## What was wrong
Under `group`, take a member whose active organization is A and whose
membership set includes C. An insert naming `organization_id: C` on an
app object, which carries the platform-injected tenant column:
- **Answer:** 2xx, and the row was stored in A, single and array alike.
- **Mechanism:**
- The Layer 0 write wall (plugin-security step 3.7) judged C and
admitted it.
- The engine's static-`readonly` insert strip then took the value,
because `TENANT_SCOPE_FIELD_DEF` declares `readonly: true`.
- driver-sql's `injectTenantOnInsert` then filled the now-absent column
with the active organization.
- **Visibility:** the only signals were a `droppedFields` `readonly`
entry and a WARN.
ADR-0105 D5 and the organizations runtime's stamp comment ("a SUPPLIED
value is never touched here … admitted where the caller's organization
scope admits it, otherwise refused") both rule out that replacement.
## What changed
- **`packages/objectql/src/engine.ts`.**
- `PostHookWriteImageCheck` gains one optional member,
`judgesOrganization?: boolean`, with its doc.
- In `insert()`, `wallJudgesOrganization` is read once: true only for a
non-system context whose installed seam declares the member.
- The new `insertStaticReadonlySubject` returns the static-`readonly`
insert subject without `organization_id` when that holds. It is the one
function both passes use: the pre-hook withhold
(`withholdInsertReadonlyFromHooks`) and the post-hook
`staticReadonlyCreateStrip` call site.
- `validate()` is unchanged.
- **`packages/plugins/plugin-security/src/security-plugin.ts`.**
- Step 3.7 sets `judgesOrganization: true` on the stored-image seam it
composes, only for an insert with `tenantParts` non-empty.
- The private `WriteImageCheckSeam` mirror type gains the member.
- **Tests:**
- `objectql`: `engine-insert-wall-judged-organization.test.ts`, which is
new, plus one census row in `federated-injected-column-readers.test.ts`
for the new `organization_id` use.
- `plugin-security`: `insert-seam-judges-organization.test.ts`, which is
new.
- `organizations`: `create-explicit-organization-wall.test.ts`.
- **Changeset:** `.changeset/24822-wall-judged-organization-stored.md`:
`@objectstack/objectql` minor, `@objectstack/plugin-security` patch.
## Public surface
Every change below is a widening; none admits a pair the wall does not.
- `PostHookWriteImageCheck.judgesOrganization`: a new optional published
member.
- An in-process `strictReadonlyWrites: true` insert naming an admitted
organization is stored, where it used to be refused
`ERR_READONLY_FIELD_REJECTED`.
- An admitted organization is no longer reported as dropped: not in
`droppedFields`, not through `onFieldsDropped`, not by the read-only
WARN.
- `beforeInsert` hooks see the caller's `organization_id` on a walled
insert.
- **F1:** under `group`, a member with no active organization naming a
member organization is stored there. It used to be refused under
ADR-0123 D2.
- **F2:** an on-behalf-of insert by a crossing operator is held to the
delegator's wall. An admitted value is stored as sent; a refused one is
refused.
- **F3:** an array row's explicit empty `organization_id` is no longer
reported or refused under strict, and is still stored as the active
organization.
- **F4:** only the platform's injected tenant column is spared, told
apart by the registry's provenance reader
(`resolveInjectedColumnProvenance`). An AUTHORED `organization_id`
declared `readonly: true` keeps today's strip: taken, reported, and
refused under strict.
## Seam
- **Who sets the member, and when.** Only
`@objectstack/plugin-security`, at step 3.7. It sets it on the seam it
composes for an INSERT, and only when the tenant wall's own parts
(`tenantParts`: the caller's Layer 0, and the ADR-0090 D10 delegator's)
are in it. That is exactly when the seam's loop holds every stored row's
`organization_id` to the wall. The payload half of the same step has
already refused every supplied value outside it, before the engine runs.
- **When it is left unset:**
- the seam a business `check` installs alone at step 3.6, which is how
`single` gets a seam with no wall behind it;
- the crossing platform operator's no-active-organization seam (#24821),
which has no wall parts, so its loop admits every row;
- every update seam.
- **Why the engine reads nothing else.** The engine cannot tell on its
own where the wall judged the column, and seam presence is not the
answer: the business-`check` seam and the operator seam are both seams
with no organization judgement. Measured in the dev report `6305849709`,
a strip exemption the engine decides alone stored the caller's value on
five no-wall paths. Deriving the answer from the posture and the context
would be a mirror of the wall, the re-derivation the #15706 ruling
deleted. It is wider than the wall wherever the wall is not mounted. So
the wall states its verdict on the seam it owns, and the flag and the
judgement are the same object: a seam that does not judge the
organization does not carry the flag. `tenantLayer0Verdict` is
untouched.
## Boundary statement
The set of `(caller, organization)` pairs an insert can store into is
the set the Layer 0 wall admits, before and after. Every row below was
measured in process on a real stack. A, C and D are organizations: A is
the active one, C is in the membership set, D is outside it.
- **Rounds 1–2:** ObjectQL + SqliteWasmDriver + the real
`OrganizationsPlugin` (Middleware A) + the real `SecurityPlugin`. Before
is base `aaee0840b`; after is the pins.
- **F1, F3 (round 3):** the same stack. Before is the head with the
engine's member read disabled (`false &&`, through `ablation-replace`);
after is the head.
- **F2 (round 3):** ObjectQL + SqlDriver + `SecurityPlugin`, a delegator
rebuilt from stored `sys_user`, `sys_member` and
`sys_user_permission_set` rows.
| posture · caller | insert on the injected column | before | after |
|:--|:--|:--|:--|
| `group` member | names C, single and array | 2xx, stored **A**,
`droppedFields` readonly + WARN | 2xx, stored **C**, nothing reported |
| `group` member | names D, single and array | 403 `PERMISSION_DENIED` |
403 `PERMISSION_DENIED` |
| `group` member | names none | stored A, not reported | stored A, not
reported |
| `group` member, `strictReadonlyWrites` (in process) | names C |
`ERR_READONLY_FIELD_REJECTED` | stored C |
| `group` member with NO active organization (F1) | names C, single and
array | 403 `PERMISSION_DENIED` (ADR-0123 D2: the value was taken, so
the row named none) | 2xx, stored **C**, nothing reported |
| `group` member with NO active organization (F1) | names D | 403
`PERMISSION_DENIED` (the wall) | 403 `PERMISSION_DENIED` (the wall) |
| `group` member with NO active organization (F1) | names none | 403
`PERMISSION_DENIED` (D2) | 403 `PERMISSION_DENIED` (D2) |
| `group` crossing operator acting for a delegator holding {A, C} (F2,
ADR-0090 D10) | names C | 2xx, stored **A**, `droppedFields` readonly |
2xx, stored **C**, nothing reported |
| `group` crossing operator acting for that delegator (F2) | names D |
403 (the delegator's wall) | 403 (the delegator's wall) |
| `group` crossing operator acting for that delegator (F2) | names none
| stored A | stored A |
| `isolated` member | names A | stored A, reported readonly + WARN |
stored A, nothing reported |
| `isolated` member | names C or D, single and array | 403 | 403 |
| `isolated` crossing operator acting for a delegator (F2) | names A |
stored A, `droppedFields` readonly | stored A, nothing reported |
| `isolated` crossing operator acting for a delegator (F2) | names C |
403 (the delegator's wall) | 403 (the delegator's wall) |
| `isolated` member, ARRAY insert (F3) | `organization_id: null` / `''`
| stored A, `droppedFields` readonly + WARN; under strict
`ERR_READONLY_FIELD_REJECTED` | stored A, nothing reported, not refused
under strict |
| `group` member, object with an AUTHORED `readonly: true`
`organization_id` (F4) | names C | 2xx, stored A, `droppedFields`
readonly + WARN; under strict `ERR_READONLY_FIELD_REJECTED` | unchanged:
2xx, stored A, `droppedFields` readonly + WARN; under strict
`ERR_READONLY_FIELD_REJECTED` |
- **What the wall admits is unchanged:** `group` {A, C}, `isolated` {A},
and a delegated insert the delegator's own set. Ablations A3 and R3A
show the refusals are the wall's own; R3B shows the no-organization
refusal is the ADR-0123 D2 arm's.
- **Through an explicit payload on the injected column:** `group` goes
from {A} to {A, C}, the wall's set; `isolated` stays {A} to {A}.
- **F1** turns a refusal into a store only for a row that names an
organization the wall admits. That matches ADR-0123's amended scope: the
insert arms refuse a row that names no organization.
- **Through every other route, already {A, C} under `group` before this
change:** a declared-column object, and a `beforeInsert` hook's value
judged by the same seam.
- **No pair becomes writable that the wall does not admit.**
- **F4 measurement.** At `2e932a613`, before the narrowing, the head
read "2xx, stored C, nothing reported; strict stores". The narrowing
(`resolveInjectedColumnProvenance` is `'author'` ⇒ keep the strip)
restores the before column. The wall still judges an authored column and
its seam declares it; the author's read-only declaration is what keeps
the strip.
Unchanged before and after. Each path is measured at base and pinned at
the head, and on each the value is taken, reported and refused under
strict as before:
| path | explicit organization | stored | seam |
|:--|:--|:--|:--|
| `single`, no organizations runtime | C | A (the context's), reported |
none |
| `single` with a business insert `check` | C | A, reported | installed,
member unset |
| `single`, `strictReadonlyWrites` | C | refused
`ERR_READONLY_FIELD_REJECTED` | none |
| `group` / `isolated`, security plugin absent | C | A, reported | none
|
| `group` / `isolated`, security plugin absent | none | A, not reported
(#21682) | none |
| platform operator crossing on a private object, `group` / `isolated` |
D | A, reported | none |
| platform operator with no active organization, private object | D |
refused 403 (no active organization) | installed, member unset |
| `tenancy.enabled: false` object, authored read-only `organization_id`
| C | NULL, reported | none |
| system context | C, D | as sent | none |
## Pins
- **`organizations/src/create-explicit-organization-wall.test.ts`.**
This is the real stack. An innermost probe middleware records each
insert's seam: whether one is installed, and whether it carries the
member.
- The rows of both tables above, as `group` / `isolated` cells.
- The old cell "a caller that sends the readonly key itself still sees
it reported" is rewritten to `isolated`: "a caller that sends its own
active organization has it stored as sent, and nothing is reported". Its
`single` twin stays.
- **`plugin-security/src/insert-seam-judges-organization.test.ts`.**
This is real ObjectQL + SqlDriver + SecurityPlugin.
- The member is SET on a walled insert (`isolated`, `group` single and
array, composed with a business `check`).
- It is UNSET on the `single` business-`check` seam, on the operator's
no-active-organization seam, and on an update's seam.
- There is NO seam for an operator with an active organization, or for a
system context.
- **`objectql/src/engine-insert-wall-judged-organization.test.ts`.** The
engine's reading, with a stand-in installer.
- With the member SET, the value is stored as sent, shown to
`beforeInsert`, handed to the seam, not reported, and not refused under
strict. Another read-only key (`created_by`) is still taken.
- With the member absent, `false`, or no seam at all, the value is
taken, withheld from the hook, reported, WARNed, and refused under
strict.
- A system context is unchanged. The `validate()` preview is unchanged.
## Ablations
Every ablation went through `node scripts/ablation-replace.mjs` (WRAP
mode) inside a script with its own `trap` restore. Each ran the three
pin files above, and each predicted direction was stated before the run.
objectql, plugin-security and driver-sqlite-wasm are vitest src aliases
in these suites, so there is no `dist/` leg. Every restore left the blob
equal to HEAD and `git diff HEAD` empty.
Rounds 1–2 (pin files at 40 / 8 / 13 cases):
| id | mutation | predicted red | observed |
|:--|:--|:--|:--|
| A1 (ruled) | the step 3.6 business-`check` seam carries
`judgesOrganization: true` | the two `single` business-`check` pins |
org 1 failed / 40, sec 1 failed / 8, oql 0 failed / 13. The
organizations pin: `- "organization_id": "org_alpha"` / `+
"organization_id": "org_south"` |
| A2 | step 3.7 sets the member on every insert seam it composes (the
`tenantParts` conjunct dropped) | the two operator
no-active-organization pins | org 1 / 40 ("expected the
no-active-organization refusal: expected true to be false", meaning the
write was admitted), sec 1 / 8, oql 0 / 13 |
| A3 | the wall's predicate admits every row | every wall refusal | org
10 / 40, sec 0 / 8, oql 0 / 13 |
| A4 | the engine spares the column without the member | every no-wall
cell | org 10 / 40, sec 3 / 8, oql 6 / 13 |
| A5 | the #21682 caller-key record switched off | the two
security-absent "stamp not reported" cells only | org 2 / 40, sec 0, oql
0. The walled #21682 cells stay green: on a walled insert the member now
spares the column too |
| A6 | the engine ignores the member (positive control) | every
stored-as-sent cell | org 5 / 40, sec 1 / 8, oql 4 / 13 |
Round 3 (pin files at 44 / 12 / 13 cases, head `a89f493fe`):
| id | mutation | predicted red | observed |
|:--|:--|:--|:--|
| R3A | the wall's predicate admits every row | every wall refusal, now
including F1's D and F2's `group` D / `isolated` C | org 11 / 44 (the
round-2 ten plus "a create naming an organization outside the membership
set is still refused by the wall"), sec 2 / 12 (both delegated
refusals), oql 0 / 13 |
| R3B | the ADR-0123 D2 insert arm switched off (`false &&`) | F1's
no-organization cell only | org 1 / 44: `expected
'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED' to be 'PERMISSION_DENIED'`; sec
0, oql 0 |
| R3C | the engine ignores the member (positive control) | every
stored-as-sent cell, now including F1, F2 and F3 | org 7 / 44, sec 3 /
12, oql 4 / 13 |
Round 4 (pin files at 45 / 12 / 14 cases, head `534d68315`):
| id | mutation | predicted red | observed |
|:--|:--|:--|:--|
| R4 | the provenance narrowing switched off (`false &&
resolveInjectedColumnProvenance(…) === 'author'`) | the two F4 pins only
| org 1 / 45 (`- "organization_id": "org_alpha"` / `+ "organization_id":
"org_south"`), sec 0 / 12, oql 1 / 14 |
## Tests and gates (final head `64a75d2bc`)
- **Suites:**
- `objectql` full suite (`vitest run --project local`): 454 files, 8547
passed, `VERDICT command-exit 0`.
- `plugin-security` full suite: 202 files, 4151 passed, 45 skipped.
- `organizations` full suite: 14 files, 190 passed.
- The `plugin-security` and `organizations` suites and all three
typechecks ran in one `&&`-joined command, which printed `VERDICT
command-exit 0`.
- **Typecheck:** `typecheck` exits 0 for `objectql`, `plugin-security`
and `organizations`, and `check:test-typecheck` is OK for each.
`objectql`'s ledger is unchanged at 39 files / 233 errors / 64 pinned
signatures, and no signature was added.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, with no paths, derived 76 commands from 7
paths against merge base `b2890909a`. All 76 exit 0, each exit captured
before any pipe. The `--ran` reconciliation reads '76 derived, 76 run, 0
NOT-MEASURED, 0 UNRUN' and exits 0. That includes
`check-engine-split-ratio` (98.6%, on deepened history),
`check:dual-build-cjs-loads` (69 packages, 728 cjs files) and
`check:i18n` ('all bundles in sync'), each after a full `turbo run
build`.
- **CI on `64a75d2bc`:** 41 check-runs, 34 success and 7 skipped (the
opt-in and main-only jobs), 0 failed, including Test Core 1–6, Build
Core, TypeScript Type Check, Lint & Repo Gates and Governed Surface
Queue Guard. The PR is mergeable (clean).
- **`origin/main` has moved again** (to `72518d20f`: #25047, #25038,
#25051; #25051 touches `objectql`'s `rule-validator.ts`, which this PR
does not edit). It is not merged: the PR is open, mergeable and green,
so §10's merge triggers (a conflict, a `main`-caused red, a queue
removal) are absent.
## Acceptance notes
- **The `validate()` preview (import dry run, `validateData`) is
wall-blind.** This is finding 2 of the dev report. It runs no
middleware, so it holds no seam. After this change, a `group` insert
preview naming C still reports `droppedFields: organization_id
readonly`, while the write stores C. Before and after, a preview naming
D answers `valid: true` with the same drop, while the write answers 403.
`ValidateDataResponse` describes `droppedFields` as what "the write
would LEGALLY strip". Measured in process only (`engine.validate`,
pinned in the objectql file above), not at the HTTP validate door.
Dedupe words: `validate preview organization_id droppedFields wall` ·
`import dry run tenant wall not run` · `validateData Layer 0 wall
blind`.
- **The UPDATE half is not here.** It is the card's half 2: a re-point
the wall admits that the update-side strip then takes. It reuses
`judgesOrganization`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 688052e commit 2b1382a
7 files changed
Lines changed: 1370 additions & 16 deletions
File tree
- .changeset
- packages
- objectql/src
- plugins
- organizations/src
- plugin-security/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
0 commit comments