Skip to content

Commit 5050442

Browse files
fix(objectql,plugin-security): an update's organization_id re-point the Layer 0 wall admits is stored as sent (#25094)
Fixes #24822 Clause-②: yes (widening) — an update re-point of `organization_id` the Layer 0 wall admits is now stored instead of silently dropped, and an in-process `strictReadonlyWrites: true` update naming an organization the wall admits is now stored instead of refused `ERR_READONLY_FIELD_REJECTED`. This is half 2 of the card: UPDATE. Half 1 (insert) landed as PR #25048 (`2b1382a5b3`). This half reuses its member, `PostHookWriteImageCheck.judgesOrganization`, and its F4 provenance rule, through one helper shared by both write verbs. The card's last half, so this PR closes it. ## What was wrong Under `group`, take a member whose active organization is A and whose membership set includes C. It updates a row of an app object, which carries the platform-injected tenant column, naming `organization_id: C`. - **Answer:** 2xx, and the row stayed in A, by id and by predicate alike. - **Mechanism:** - The Layer 0 write wall (plugin-security step 3.7) judged C and admitted it: on the payload as sent, and on the stored image (by id the prior row merged with the final payload; by predicate every matched row merged with it). - The engine's static-`readonly` update strip then took the value, because `TENANT_SCOPE_FIELD_DEF` declares `readonly: true`, and the row kept its own organization. - **Visibility:** a `droppedFields` `readonly` entry and a WARN; `beforeUpdate` hooks were not shown the value. - **The inconsistent half.** An object that declares its own writable `organization_id` stored the same re-point, by id and by predicate (premise U2, measured below), so honouring it on the injected column is a fix, not a new capability. The governing text is the organizations runtime's Middleware A comment: a supplied value "goes on to the Layer 0 write wall … the same wall an UPDATE that re-points `organization_id` meets, and gets the same answer: admitted where the caller's organization scope … admits it, otherwise refused `403 PERMISSION_DENIED`". The seat call `6305891812` carried the UPDATE half on this card. ## What changed - **`packages/objectql/src/engine.ts`.** - `staticReadonlySparesOrganization(schema, wallJudgesOrganization)`, new: the ONE answer both write verbs read. It spares the column only when the wall's seam declares the member, the object is one the INSERT strip judges (`staticReadonlyInsertSubject` holds the column: never `sys_`, never a platform-internal `managedBy` bucket), and the column's provenance is not `'author'` (F4). - `insertStaticReadonlySubject` now reads it. Its behaviour is unchanged (half 1's pins are green, and ablation B1-F4 turns its F4 pin red through the shared helper). - `updateStaticReadonlySubject(schema, wallJudgesOrganization)`, new: the update strip's schema view, without `organization_id` where the helper spares it; `schema` itself otherwise. - In `update()`, `wallJudgesOrganization` is read ONCE, inside the middleware chain's final handler (`!isSystem` and the seam's member `=== true`), and `readonlyUpdateSubject` is built once from it. Every static-`readonly` pass reads that one view: the pre-hook hide pass (what `beforeUpdate` is shown), the by-id strip and the predicate strip (what is stored). - `staticReadonlyStoredView` / `staticReadonlyStripTakes` (the views the `readonlyWhen` strips and the master-detail settlement read) take the same flag on their descriptor and apply the same helper, so a `readonlyWhen` predicate reading `record.organization_id` reads the organization the row is stored in. - The `PostHookWriteImageCheck.judgesOrganization` doc now covers updates. No member is added. - `validate()` is unchanged. - **`packages/plugins/plugin-security/src/security-plugin.ts`.** - Step 3.7's setter: the `operation === 'insert'` conjunct is replaced by `operation === 'insert' || (operation === 'update' && !Array.isArray(data))`, still ANDed with `tenantParts.length > 0`. The array clause restates the enclosing condition on purpose: the member is the seam's own claim about what it judges. - The private `WriteImageCheckSeam` doc, the setter's comment, and step 3.7's "effectively immutable" paragraph are rewritten to state the stored re-point (seat ruling 6). - **Tests:** - `objectql`: `engine-update-wall-judged-organization.test.ts` (new, stand-in installer), and census rows in `federated-injected-column-readers.test.ts` for the shared helper. - `plugin-security`: `update-seam-judges-organization.test.ts` (new, real ObjectQL + SqlDriver + SecurityPlugin, real grant tables); in `insert-seam-judges-organization.test.ts` the cell "an update's seam does not carry the member" flips to the update half's answer; `tenant-wall-organization-removal.test.ts` header prose only. - `organizations`: `update-repoint-organization-wall.test.ts` (new, the real stack half 1 measured on). - **Changeset:** `.changeset/24822-update-repoint-honoured.md`: `@objectstack/objectql` minor, `@objectstack/plugin-security` patch. ## Public surface Every change below is a widening. None admits a `(caller, organization)` pair the wall does not already admit, and the engine's own gates (the wall, the strip, the strict flag) refuse nothing they accepted before. - `PostHookWriteImageCheck.judgesOrganization` (published, `OperationContext.postHookWriteImageCheck`): its contract now covers single-payload updates, and `ObjectQL.update` reads it. No new member. - A walled update's admitted re-point is stored as sent, by id and by predicate. - It is no longer reported as dropped: not in `droppedFields`, not through `onFieldsDropped`, not by the read-only WARN. - An in-process `strictReadonlyWrites: true` update naming an admitted organization is stored, where it used to be refused `ERR_READONLY_FIELD_REJECTED`. - `beforeUpdate` hooks see the caller's `organization_id` on a walled update. - Under `group`, a member with NO active organization re-points within its membership set the same way (before: taken and reported). - An on-behalf-of update by a crossing operator (ADR-0090 D10) is held to the delegator's wall: stored as sent where it admits the re-point (before: taken), refused where it does not. - **N2, stated:** outcomes that depend on the stored organization now follow the admitted value. An organization-scoped unique key, a validation rule, a `readonlyWhen` / `requiredWhen` predicate and a hook reading `organization_id` see the organization the row moves to. So a re-point that collides with an organization-scoped unique key in its new organization is refused there, exactly as on a declared-column object today; before, the re-point was dropped and the write reported success. - Unchanged, by ruling or by measurement: `sys_` and platform-internal objects, an AUTHORED `readonly: true` `organization_id`, an emptied value, an array payload, every no-wall path, `preserveAudit`, `isSystem`, and the wall-blind `update` preview. ## Seam - **Who sets the member on an update, and when.** Only step 3.7, on the seam it composes for a single-payload update, and only with the wall's own parts in it (`tenantParts` non-empty: the caller's Layer 0 and the ADR-0090 D10 delegator's). That seam's loop then holds every stored image's organization to the wall and refuses an emptied one; the payload half of the same step has already refused every supplied value outside the wall, and every emptied one, before the engine runs. - **Left unset:** step 3.6's business-`check`-only update seam (how `single` gets a seam with no wall); a crossing platform operator's update (it gets no step 3.7 seam: `tenantParts` is empty and the no-active-organization arm is insert-only); an array update payload (step 3.7 composes nothing for it); `isSystem` (the middleware returns before steps 3.6 and 3.7). - **What the engine reads.** The member, once, and nothing else. It never derives the answer from the posture or the context: that would be a mirror of the wall, wider than the wall wherever the wall is not mounted (half 1's measured P5). `tenantLayer0Verdict` is untouched. ## Boundary statement The set of `(caller, organization)` pairs an update can move a row into is the set the Layer 0 wall admits, before and after. A, C and D are organizations: A is the active one and the row's own, C is in the membership set, D is outside it. - **Stacks.** Rows marked (org) are ObjectQL + SqliteWasmDriver + the real `OrganizationsPlugin` + the real `SecurityPlugin`, the stack half 1 used. Rows marked (sec) are ObjectQL + SqlDriver + `SecurityPlugin` with the real grant tables and a delegator rebuilt from stored `sys_user`, `sys_member` and `sys_user_permission_set` rows. - **Before** is measured twice: by a premise probe on base `2b1382a5b`, and by the final pins with `main`'s two source files (`c67f244f5`) restored over the tree (tree only; restored to blob == HEAD after). **After** is the probe at `22e257dfe` and the pins at the final head. | posture · caller | update on the injected column | before | after | |:--|:--|:--|:--| | `group` member (org) | re-point A→C, by id | 2xx, row stays **A**, `droppedFields` readonly + WARN, hook sees none | 2xx, row moves to **C**, nothing reported, hook sees C | | `group` member (org) | re-point A→C, by predicate | 2xx, the matched row stays **A**, reported + WARN | 2xx, the matched row moves to **C**, nothing reported | | `group` member (org) | re-point to D, by id / by predicate | 403 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED` | | `group` member (org) | names A (its own), by id | stays A, reported + WARN | stays A, nothing reported | | `group` member, `strictReadonlyWrites` (org, in process) | re-point to C, by id / by predicate | `ERR_READONLY_FIELD_REJECTED` | stored C | | `group` member with NO active organization (org) | re-point to C / to D | C: stays A, reported; D: 403 | C: stored C; D: 403 | | `isolated` member (org) | names A, by id / by predicate | stays A, reported + WARN | stays A, nothing reported | | `isolated` member (org) | re-point to C, by id / by predicate | 403 | 403 | | `group` / `isolated` member (org) | emptied (`null`, `''`), by id / by predicate | 403 (#22278) | 403 (#22278), nothing stored | | `group` member (org, sec) | ARRAY payload | refused, no step 3.7 seam | refused, no step 3.7 seam | | `group` crossing operator for a delegator holding {A, C} (sec) | re-point to C, by id / by predicate | stays A, reported | stored C | | same (sec) | re-point to D | 403 (the delegator's wall) | 403 (the delegator's wall) | | `isolated` crossing operator for a delegator (sec) | names A / re-point to C | A: stays A, reported; C: 403 | A: nothing reported; C: 403 | | `group` member, AUTHORED `readonly: true` `organization_id` (org, F4) | re-point to C, by id / by predicate | stays A, reported + WARN; strict refuses | unchanged (the seam now declares the member; the author's declaration keeps the strip) | | `group` member, `sys_` object on the injected column (sec) | re-point to C, by id / by predicate | stays A, reported; strict refuses | unchanged (the seam declares the member; the engine keeps the strip) | | `group` member, a `managedBy: 'system-data'` app object (sec probe) | re-point to C | stays A, reported | stored C (an app object the insert strip judges, as on insert) | - **What the wall admits is unchanged:** `group` {A, C}, `isolated` {A}, a delegated update the delegator's own set. B2-WALL shows the refusals are the wall's own. - **Through an explicit re-point on the injected column of an app object:** `group` goes from {A} to {A, C}, the wall's set; `isolated` stays {A}. - **Through a declared-column object, already {A, C} under `group` before this change** (U2, the control cell in the organizations file). - **No pair becomes writable that the wall does not admit.** Unchanged before and after, measured at base and pinned at the head; on each the re-point is taken, reported and refused under strict, or refused earlier, as before: | path | re-point | stored | seam | |:--|:--|:--|:--| | `single`, no organizations runtime, by id / by predicate | C | A, reported | none | | `single` with a business update `check`, by id / by predicate | C | A, reported | installed, member unset | | `single`, `strictReadonlyWrites` | C | refused `ERR_READONLY_FIELD_REJECTED` | none | | `group` / `isolated`, security plugin absent, by id / by predicate | C | A, reported | none | | platform operator crossing on a private object, `group` / `isolated`, by id / by predicate | D | A, reported | none | | `tenancy.enabled: false` object, authored read-only `organization_id` | C | A, reported | none | | `single`, `preserveAudit: true` | C | A, reported | none | | system context, by id / by predicate | D | as sent | none | | `sys_user_position` / `sys_user_permission_set`, a member | C | refused 403 by the RBAC administration gate (ADR-0090 D12) | none | | `sys_user_position` / `sys_user_permission_set`, a platform administrator (walled there) | C (grantee a member of C) / D | C: stored as sent (the column is declared WRITABLE; the strip never took it); D: 403 | installed | | `managedBy: 'engine-owned'` / `'append-only'` object (sec probe; the engine-level cell is pinned in oql) | C | refused 403 by the ADR-0103 engine-owned guard | none | | a by-id update of a row outside the scope (org, `group`) | C | refused `404 RECORD_NOT_FOUND` (step 2.7, #21771) | — | | a falsy payload id beside `where.id` (org, `group`) | C | refused 403 (step 2.7) | — | ## Must-not-regress Each point of both contract reviews' ③ lists for half 2, with its pin and the ablation that turns that pin red (the Ablations table below has the counts). From `6308267963` ③: 1. **The setter stays on non-empty `tenantParts`; never on step 3.6's check seam or the crossing operator's seam; the `insert` conjunct replaced deliberately.** The setter ANDs `tenantParts.length > 0` with `insert || (update && !Array.isArray(data))`. Pins: sec "`single` with a business `check`: … UNSET", org "`single` with a business update `check`, by id / by predicate" (B2-SET1b red); sec and org operator-crossing cells, NO SEAM; the positive control B2-SETOFF. 2. **No wall seam for an ARRAY update payload, so no spare there.** Pins: org and sec array cells (B2-ARR1 red). Removing the setter's own array clause alone is an equivalent mutant (B2-ARR2, all green): the enclosing condition never reaches the composition for an array update. 3. **#22278 on both halves, and the payload-half refusal of D.** Pins: the org `[#22278]` block, eight cells (B2-E22278 red); the org D cells and sec D cells (B2-WALL red). `tenant-wall-organization-removal.test.ts` stays green in the full suite. 4. **One read for the by-id and predicate seams, so the hook view and the stored row agree; `isSystem` and update-mode `validate()` unchanged; the census row.** One `wallJudgesOrganization` read, one `readonlyUpdateSubject`. Pins: org "shown to beforeUpdate" by id / by predicate and oql "SET, by id" (B1-HIDE red); oql `readonlyWhen` cell (B1-VIEW red); system-context cells in all three files; the preview cells in org and oql; census rows. The `!isSystem` conjunct is an equivalent mutant (the strips never run for a system caller). 5. **The `honoured` fail-closed guard.** Untouched; its pin `tenant-wall-post-hook-image.test.ts` "a host that never runs the installed wall judgement …", both drivers (B2-HON red). 6. **Step 3.7's "effectively immutable" comment.** Rewritten. From `6309495055` ③: 1. **Where the member is set: only where step 3.7 composes the wall's parts into the by-id or predicate seam; never step 3.6's update seam, an array payload, or `isSystem`.** As points 1, 2 and 4 above; sec "a system context installs no seam". 2. **#22278: emptied refused, no stamp refills it; F3 does not carry across.** The org `[#22278]` block (B2-E22278 red: with the gate off, the spare stores the emptied value). 3. **F4: an authored `readonly: true` `organization_id` keeps the update strip.** Org F4 by id / by predicate, oql "SET, but an AUTHORED …" (B1-F4 red, which also turns half 1's insert F4 pins red through the shared helper). 4. **`sys_` objects: measure and rule.** Measured as premise U3, ruled by the seat (ruling 1: the update spare covers exactly the objects the insert spare covers). Pins: oql `sys_` and `engine-owned` cells, sec `sys_` cell (B1-SYS red). The grant tables declare a WRITABLE `organization_id`, so the strip never took a re-point there: their cells are controls, measured identical before and after (the before run with `main`'s sources passes them), and no mutation of the spare can reach them. 5. **The existing gates: the by-id falsy-id refusal (#19989), the step 2.7 pre-image selection, the predicate seam's judgement of every matched row.** Org falsy-id cell (B2-PRE27 red); org out-of-scope row cell (B2-PRE27P red: it needs step 2.7 AND the engine's scoped prior read off, two independent gates); oql "SET, by predicate … the seam judges every one" (B1-PRED red). 6. **`preserveAudit`'s update-only exemption must not widen to `organization_id`.** Org `preserveAudit` cell, oql "preserveAudit does not spare it" x3 (B1-PA2 red). 7. **One helper, and the preview.** `staticReadonlySparesOrganization` is shared by insert and update. The preview: see Acceptance notes. ## Pins - **`organizations/src/update-repoint-organization-wall.test.ts`** (real stack; an innermost probe middleware records each update's seam and the organizations it judged): every row of both tables above that is marked (org), as `group` / `isolated`, by id and by predicate. - **`plugin-security/src/update-seam-judges-organization.test.ts`**: the member SET on a walled update (`isolated`, `group` by id and by predicate, composed with a business `check`); UNSET on `single`'s check seam; NO SEAM for a crossing operator, a system context and an array payload; the `sys_` scope (a `sys_` object, both grant tables); the delegated update under `group` and `isolated`. - **`objectql/src/engine-update-wall-judged-organization.test.ts`** (stand-in installer): with the member SET, by id and by predicate, the re-point is stored, shown to `beforeUpdate`, handed to the seam on every stored image, not reported, not refused under strict; `created_by` is still taken; an AUTHORED column, a `sys_` object and an `engine-owned` bucket keep the strip; a `readonlyWhen` lock reads the stored organization. With the member absent, `false`, or no seam: taken, withheld, reported, WARNed, refused under strict, and `preserveAudit` does not spare it. A system context is unchanged. The update preview still reports the drop. ## Ablations Every leg went through `node scripts/ablation-replace.mjs` (WRAP mode) inside a script carrying its own `trap … EXIT INT TERM` restore against the absolute path, with the restore proven by blob == HEAD and an empty `git diff HEAD`. Each ran the six pin files (org 88, sec 28, oql 42 cases), and each predicted direction was written down before the run. objectql, plugin-security and driver-sqlite-wasm / driver-sql are vitest source aliases in these suites, so there is no `dist/` leg. | id | mutation | predicted red | observed (org / sec / oql failed) | |:--|:--|:--|:--| | B1-A4u | the update spares without the member (flag = `!isSystem`) | every no-wall update cell | 14 / 2 / 12 | | B1-A6u | the update ignores the member (positive control) | every stored-as-sent update cell | 9 / 4 / 5 | | B1-SYS | the spare ignores the insert subject's `sys_` / bucket exclusions | the `sys_` and `engine-owned` cells only | 0 / 1 / 2 | | B1-F4 | the provenance narrowing off | the F4 cells only, insert and update | 3 / 0 / 2 | | B1-VIEW | the stored view loses the flag | the `readonlyWhen` cell only | 0 / 0 / 1 | | B1-HIDE | the hide pass reads the whole schema | the "shown to beforeUpdate" cells only | 2 / 0 / 1 | | B1-PRED | the predicate seam judges only the first matched row | the every-matched-row cell only | 0 / 0 / 1 | | B1-PA2 | `preserveAudit`'s exemption widened to `organization_id` | the `preserveAudit` cells | 1 / 0 / 4 (the 4th is the census test, which sees the mutation's own `'organization_id'` literal in `rule-validator.ts`) | | B2-SET1b | step 3.6's update check seam declares the member | the `single` + check cells | 2 / 1 / 0 | | B2-SETOFF | step 3.7's update arm off (insert only; positive control) | every stored-as-sent update cell and every update sighting | 11 / 7 / 0 | | B2-ARR1 | step 3.7 composes for an array update too | the array cells (sighting) | 1 / 1 / 0 | | B2-ARR2 | the setter's own array clause removed alone | none: equivalent mutant | 0 / 0 / 0 | | B2-E22278 | `emptiesOrganization` always false | the eight `[#22278]` cells | 8 / 0 / 0 | | B2-WALL | `clearsWall` admits every row | every refusal outside the scope, insert and update | 16 / 7 / 0 | | B2-PRE27 | step 2.7's pre-image check skipped | the falsy-id cell (and the out-of-scope cell, unless another gate holds it) | 1 / 0 / 0: only the falsy-id cell; the engine's own scoped prior read still answered 404 | | B2-PRE27P | B2-PRE27 plus the engine's prior read unscoped | the falsy-id and out-of-scope cells | 2 / 0 / 0 | | B2-HON | the `honoured` fail-closed guard off (run on `tenant-wall-post-hook-image.test.ts` + the sec update file) | the fail-closed cell, both drivers | sec 2 / 48 | Two first attempts were no-ops that `ablation-replace` refused before running anything (exit 1, "the anchor count moved 1 -> 1"): B1-PA and B2-SET1, whose replacements still contained their anchors. Their re-runs are B1-PA2 and B2-SET1b. ## Tests and gates (final head `784b1f9f3`) - **Premises, measured before any edit** (temporary probe files in `organizations/src` and `plugin-security/src`, run under the verify lock and deleted, never committed; base `2b1382a5b`): U1 true (by id and by predicate: 2xx, row stays A, `droppedFields` readonly + 1 WARN; D refused 403). U2 true (the declared-column object stores C by id and by predicate, the seam judging C; D refused 403). U3 measured (the update strip runs on `sys_` objects and step 3.7 composes its wall there; the grant tables declare a writable column; a member's update of them meets the RBAC administration gate; `engine-owned` / `append-only` objects meet the ADR-0103 guard). U4 true. U5 measured: every no-wall path takes the re-point. - **Suites** (one lock run, each exit captured before any pipe; `VERDICT command-exit 0`): - `objectql` (`vitest run --project local`): 458 files, 8610 passed. - `plugin-security`: 205 files, 4193 passed, 45 skipped. - `organizations`: 15 files, 233 passed. - **Typecheck:** `typecheck` exits 0 for all three, and `check:test-typecheck` is OK for each. `objectql`'s ledger is unchanged at 39 files / 233 errors / 64 pinned signatures; no signature was added. - **`@objectstack/spec` `check:generated`:** exit 0 (`packages/spec` moved on `main`'s side of the merge). - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths, derived 76 commands on `784b1f9f3`. All 76 exit 0, each exit captured before any pipe, after a full `turbo run build` of `./packages/*` and `./packages/*/*` (75 tasks). `--ran` reads "76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN" and exits 0. That includes `check-engine-split-ratio` (98.6%; the shallow clone's oldest visible commit predates the window), `check:dual-build-cjs-loads`, `check:type-check-debt` and `check:i18n`. - **`origin/main`:** merged twice before opening (`c67f244f5`, then `f46ef4d70`, which brought #25066's `security-plugin.ts` region; no conflict). Every number above is on the second merge. `main` has since moved to `4feccc268` (#25076, #25081, #25080, #25064: driver-sql, platform-objects, service-messaging, PM tooling), none sharing a file with this PR. - **CI:** not yet read; this PR is opened as a draft. ## Acceptance notes - **The `update`-mode `validate()` preview is wall-blind** (seat ruling 7). It runs no middleware, so it holds no seam. After this change, a `group` update preview naming C still reports `droppedFields: organization_id readonly` while the write stores C; a preview naming D answers the same drop while the write answers 403 (before and after). Pinned in the org and oql files. #25062 carries the preview, insert and update alike; this PR does not change it. - **`sys_` objects on the injected column keep the update strip, by seat ruling 1.** A source scan of this tree lists the `sys_` objects that declare no `organization_id` of their own, and so carry the injected one wherever they are tenant-scoped: `sys_attachment`, `sys_comment`, `sys_comment_reaction`, `sys_business_unit_member` (`platform`), `sys_email_template`, `sys_webhook` (`config`), `sys_user_preference`, `sys_notification_template`, `sys_notification_preference` (`system-data`), `sys_file`, `sys_upload_session`, `sys_inbox_message`. Where the wall applies to one of them, a re-point is still taken and reported, exactly as an insert's organization there is (pinned on a `sys_` object in the sec file); the seam declares the member there without effect. - **An array update payload beside `where.id`** reaches the driver and fails with a raw SQL error (`no such column: 0`), before and after this change; measured in process only, no public door measured. Observation, not filed (carrier: none). - **N1 (half 1's review), not in this claim:** the insert-side comments in step 3.7's ADR-0123 D2 arm ("the column is read-only to a session payload") and the engine's insert post-image comment, and ADR-0123 amendment item 1, which is the maintainer's pointer. --- _Generated by [Claude Code](https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d0769bf commit 5050442

9 files changed

Lines changed: 1662 additions & 85 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/plugin-security': patch
4+
---
5+
6+
fix(objectql,plugin-security): an update's re-point of `organization_id` that the Layer 0 wall admits is stored as sent, not silently dropped
7+
8+
Clause-②: yes (widening) — an update re-point of `organization_id` the Layer 0 wall admits is now stored instead of silently dropped, and an in-process `strictReadonlyWrites: true` update naming an organization the wall admits is now stored instead of refused `ERR_READONLY_FIELD_REJECTED`.
9+
10+
**What was wrong.** Under the `group` posture, a member whose active organization is A can name C, another organization it belongs to, in an update's `organization_id`. The Layer 0 write wall admitted C, on the payload and on the row about to be stored. Then the engine's static-`readonly` update strip took it, because the platform's injected tenant column is `readonly: true`, and the row stayed in A. The update answered 2xx, by id and by predicate alike, and the only signals were a `droppedFields` entry and a WARN. An object that declares its own writable `organization_id` stored the same re-point, so the app objects on the injected column were the inconsistent half. This is the update half of the insert fix shipped beside it.
11+
12+
**What changed.**
13+
14+
- **`@objectstack/objectql`.**
15+
- `ObjectQL.update` reads `PostHookWriteImageCheck.judgesOrganization` (reached as `OperationContext.postHookWriteImageCheck`) once per update, for a non-system caller, and hands that one value to every static-`readonly` pass: the pre-hook hide pass, the by-id strip, the predicate strip, and the stored view the `readonlyWhen` strips and the master-detail settlement read.
16+
- Where it is set, and the object's `organization_id` is the platform's injected column on an object the insert strip also judges, those passes leave the caller's re-point on the row. The wall then stores it as sent where it admits it and refuses it where it does not.
17+
- The decision is one helper, shared with `ObjectQL.insert`, so the two write verbs spare exactly the same objects.
18+
- The member's published doc now covers updates. No member is added.
19+
- **`@objectstack/plugin-security`.**
20+
- Step 3.7 sets `judgesOrganization` on the stored-image seam it composes for a single-payload update, only where it composes the wall's own parts in, as it already did for an insert.
21+
- It does not set the member on a business-`check`-only seam, on a crossing platform operator's update (which gets no wall seam), or on an update whose payload is an array (step 3.7 composes no seam for one).
22+
- The wall's admit/refuse set is unchanged: a re-point outside the caller's scope is still refused `403 PERMISSION_DENIED`, and so is an emptied one (`null` or `''`).
23+
24+
**What a caller sees.** Every change below is a widening. None of them admits a `(caller, organization)` pair the wall does not already admit, and the engine's own gates (the wall, the strip and the strict flag) refuse nothing they accepted before.
25+
26+
- **The re-point is stored as sent.** Under `group`, an update naming another organization of the caller's membership set moves the row there, by id and by predicate. Under `isolated`, an update naming the active organization is stored as before.
27+
- **No longer reported.** Such a re-point is no longer reported as dropped: not in `droppedFields`, not through the `onFieldsDropped` listener, and not by the read-only WARN.
28+
- **`strictReadonlyWrites` stores it.** An in-process `engine.update(..., { strictReadonlyWrites: true })` naming such an organization is now stored, where it used to be refused `ERR_READONLY_FIELD_REJECTED`: strict refuses exactly what the strip takes. No REST door can set that flag.
29+
- **`beforeUpdate` hooks see it.** On a walled update, `beforeUpdate` hooks now see the caller's `organization_id`, because the value will be stored. Before, it was withheld from them.
30+
- **A member with no active organization.** Under `group`, such a member re-points within its membership set the same way, and outside it is refused.
31+
- **An on-behalf-of update (ADR-0090 D10).** Take a platform operator who crosses the wall and acts for a walled delegator. The re-point is held to the delegator's wall: stored as sent where that wall admits it, refused `403` where it does not. Before, an admitted re-point was taken and the row kept its organization.
32+
- **Outcomes that read the stored organization now follow the admitted value.** An organization-scoped unique key, a validation rule, a `readonlyWhen` / `requiredWhen` predicate, and a hook that reads `organization_id` now see the organization the row is moved to, not the one it used to keep. So a re-point that collides with an organization-scoped unique key in its new organization is refused there, as it already was on an object that declares its own `organization_id`; before, the re-point was dropped and the write reported success.
33+
- **Only the platform's injected column on an app object is spared.** A `sys_` object, a platform-internal `managedBy` bucket (`engine-owned`, `append-only`, `better-auth`), and an object whose author declares its own `organization_id` with `readonly: true` keep the update strip: the value is still taken, reported, and refused under strict, exactly as before. The organization-scoped grant tables (`sys_user_position`, `sys_user_permission_set`) declare a writable `organization_id` the strip never took, so their re-points keep meeting their own guards exactly as before.
34+
- **Where no wall judges the column, nothing changes.** The re-point is still taken and reported, and strict still refuses it. That includes `single`, a composition without the security plugin, a tenancy-disabled object, a platform operator crossing the wall on its own behalf, and `preserveAudit`, whose update-only exemption never reaches `organization_id`.
35+
- **The `update`-mode `validate()` preview is unchanged.** It runs no middleware, so it holds no seam: it still reports an admitted re-point as dropped while the write stores it.

0 commit comments

Comments
 (0)