Repository navigation
feat(formula,objectql): read one hop through a lookup in a validation predicate - #19728
objectstack-fleet[bot] merged 49 commits into
Conversation
Static analysis only — no I/O, no schema, no query function. Answers which reference fields an authored predicate reads through (one hop), which it uses as a plain value, and which it reads deeper than one hop, so a call site that owns a query engine can preload exactly those fields BEFORE evaluation and stdlib's purity invariant is untouched. Also reports the one shape that cannot be served as written: a field both traversed and used as a value. Hydrating it serves the traversal and silently turns the value comparison false, and a validation predicate expresses the FAILURE condition, so a silent false is a rule that stops firing. Measured on this front end, that shape cannot work today — the traversal faults on every row that reaches it — so refusing it removes nothing an author has working. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
`checkPredicate` evaluated with `{ record, previous }` only, so a lookup field
carried its id and `record.account.type` faulted with `No such key: type` —
and a faulting validation predicate rejects the write, so the rule could not
be authored at all.
The engine resolves the related rows (it owns the driver) and hands them over
as `EvaluateRulesOptions.related`, the same division of labour `parent`
follows. Two deliberate differences from `parent`: the rows are read under the
ACTING USER so the referenced object's RLS and FLS apply, and an unresolved
row is left absent on purpose — the traversal then faults and the write is
rejected, which is the loud failure an unreadable related field owes.
Hydration is per RULE and onto a COPY. Per rule, because hydrating a field
replaces its stored id with the related record and a sibling rule comparing
the bare id must keep seeing the id. Onto a copy, because the record handed to
a rule is the real write payload.
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
One batched resolver serves the insert, single-update and bulk-update seams: collect the reference fields the object's predicate rules traverse, read the related rows ONCE per field under the CALLER's context so the referenced object's CRUD gate, RLS and FLS apply, and hand them to the evaluator. Free when no rule traverses — no query and no closure work. `needsPriorRecord` now counts a traversing rule. The hop is taken from the foreign KEY and a PATCH that does not touch that key does not carry it, so without the prior row there is no id to resolve and the rule would fault and reject a write it should have accepted. Counting it keeps the bulk path's no-prior branch unreachable for such an object, the same argument #4977 makes for `parent`. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…ritten The authoring-time half. Two shapes are refused with a prescription rather than served half-right: a reference field read BOTH through the relationship and as a plain value (hydrating it silently turns the value comparison false), and a read deeper than the one hop predicates resolve. Neither removes anything an author has working — measured on this front end, both fault at evaluation today, and a faulting validation predicate rejects the write. The refusal replaces a per-row runtime fault with one loud message naming the repair. Gated on `fieldTypes`, which is what tells a REFERENCE field from an object-valued one: `record.address.city` traverses today and keeps traversing. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…ust the picker The invoice `account` picker already scopes churned accounts out of the dropdown with `lookupFilters`, which is exactly why the rule earns its place: a picker filter is a UI affordance, not a server guarantee. A write that never touches the picker — REST, an import, a flow, an agent — reached the same column with no scoping at all. Adds the ADR-0136 D2.4 acceptance item under `records-forms` covering the three ruled outcomes: the write passes when the parent field matches, is refused with the AUTHORED message when it does not, and faults loudly when the acting user cannot read the parent. Plus the changeset and both locales' bundle entries for the new rule message. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…ow read The new batched read went through `as any`, which grew the query-options-erasure ratchet on engine.ts from 8 to 9. The options bag is declared — `EngineQueryOptions` — so it is named rather than erased. The ratchet's unswept count drops 68 to 67. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 34 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 21c6bb3905239d2933ec4f31827cd370abfab67b && git checkout 21c6bb3905239d2933ec4f31827cd370abfab67b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 615c0856efd26f42579bf7ff27297c05bdfd11fb 3e8b3c3a22a6a0b67e77668d9220d6cf7cce0c31 && git checkout -B drift-repro 615c0856efd26f42579bf7ff27297c05bdfd11fb && git merge --no-ff 3e8b3c3a22a6a0b67e77668d9220d6cf7cce0c31
node scripts/docs-audit/affected-docs.mjs --json 615c0856efd26f42579bf7ff27297c05bdfd11fb
|
Contract reviewServed-tier: Read at 2026-09-22T13:48Z. Every reading below was re-derived from the diff ① Derived judgmentsQ1-A (hydrate in place + refuse the mixed shape at authoring, prescription
Q2-A ( The ruling's ground ("a mixed expression already throws today, so refusing it removes nothing that works") — verified with one nuance, and one consequence the ruling did not foresee. Probe P2: Accept/reject delta, enumerated. Accepted before, refused after:
Refused before, accepted after:
Unchanged: non-reference object-valued fields ( Hard constraint (per-evaluation copy) — holds, at two layers, and the ruling's premise is inexact. Permission semantics — read under the acting user: holds. Unreadable → loud: holds for a plain member access, with three real holes.
Changeset honesty. Model identifier (AGENTS.md:446). PR title, PR body, all 6 commit messages (trailer pair Also red, and caused by the diff: required context ② Semver level
③ Boundary flagsBlocking (the FAIL rests on these; each names its fix):
Non-blocking:
Implemented-by: VERDICT: FAIL Generated by Claude Code |
… decide readability first Contract-review rework, findings 2-7. ADR: the acceptance cited ADR-0136, which names no record. ADR-0137 (predicate fault semantics are contract) is what governs the fault this adds a cause to. Fail closed in the ENGINE, not only in lint (ADR-0137 D1, ADR-0124). No runtime package imports lint, so the mixed shape — a reference field read both through the relationship and as a value — was rejected before this capability and would have been accepted after it, with the bare arm silently false, on every path that authors metadata without lint. `checkPredicate` now refuses it itself. The comment claiming an engine publish path already refused is deleted; there is none. Name the RELATED object in the refusal (ADR-0137 D2). The generic prescription said the field "this object does not declare" — on a traversal the field IS declared, on another object, and an author following it added a bogus column to the wrong file. Decide readability BEFORE evaluation. A related column the caller may read but which is empty now materialises to null and evaluates; one the caller may not read refuses. A driver returns both as the same missing key, so the engine asks `getReadableFields` through a new seam the security plugin fills. This also stops the verdict depending on which operator was written: `has()`, `.?` and `[?]` read a missing key as an ordinary false, and all three are now seen by the analysis and refused the same way. Scope the authoring refusal to where hydration happens. Gating on `fieldTypes` reached nine seams — field requiredWhen/readonlyWhen, option visibleWhen, action visible/disabled, sharing (RLS), hooks, flow node and edge conditions — where nothing hydrates and the prescription is false. Now an explicit opt-in, set by lint at the validation-rule condition alone. Also: the dry-run validate() seam resolves related rows, so preview and write agree; and the relationship collector checks the dialect. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
The rule-validator tests hand `related` in by hand, so none of the engine behaviour was pinned. Driven end-to-end through the real engine and a real driver: the related read runs under the acting user and never as system, the projection is id plus only the fields the rules name, a batch costs one read, the foreign key is read off the prior row when a patch omits it, the driver's write payload carries the key and not the expanded record, a readable-but-empty column evaluates as null, an unreadable field refuses under every operator spelling, the dry run agrees with the write, and a non-traversing object pays no read at all. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…lidation rules Ruled: a validation rule's output is a pass/fail the SYSTEM enforces, not data handed to the caller — categorically unlike an access-control rule, which is why RLS predicates are out of this capability entirely. Reading as the acting user made the rule unauthorable for exactly the persona it exists to constrain: a member with CRUD on the child and no read on the parent faulted on every write, which the dogfood gate measured. What bounds the elevation is the PROJECTION, not the caller: only the columns the predicate names, intersected with the related object's declared fields. A column the related object does not declare never enters the query and is refused as the authoring fault it is — and stays distinguishable from a column that exists and is empty, which materialises to null and evaluates. The refusal names the field and the rule, never the value. A caller can infer a value by observing which writes refuse; that channel was accepted knowingly and is kept no wider than "this rule refused this write". Confined to `checkPredicate`'s seam. The readable-fields seam the acting-user model needed is removed again, with it. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
… not by position `showcase_invoice` now carries a real business rule — no invoice against a churned account — so the matrix's "admin control payload is VALID" assertion depended on which account the seed happened to return first. The control is chosen to satisfy the rule instead of by position; the rule is untouched. Also states the checklist's known gap as what CI actually measured: the dogfood persona holds invoice CRUD and no grant on showcase_account, which is now the point of the item rather than a gap, because the related read is system-authority. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…bound in the test double Two gate findings from this diff. The new unevaluable log line copied a tracker id into prose an operator reads; ids belong in the lesson, not the sentence. And the new engine test's `find` double ignored the caller's `limit`, which is how a real double-limit defect rides through unnoticed. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
The acting-user model needed a readable-fields resolver wired here; the ruled system-authority read does not. Removing the wiring left one blank line behind, which kept a package this card no longer touches inside its diff. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
席位改写了本 PR 正文的三处 —— 谁改的、改了什么、为什么,写在这里
为什么必须改,而不是留一条评论了事本 PR 是契约面 PR。正文里那三处在返工后已经为假,而正文是复核者、队列记录与将来读 CHANGELOG 的人都会读的东西。⇒ 留着假陈述、另发一条更正,等于让最权威的那份文本继续说错话。
⭐ 第 3 处为什么单独说:它曾经断言了一件没有发生过的事上一轮的正文把 dogfood 用例写成已验证,而那个门根本没跑;同一时刻该 head 上有三条必需检查是红的。施工轮这一轮主动认了这件事,⛔ 没有辩解。 ⇒ 新的 ⛔ 本席没有动正文的任何其它部分:技术方案叙述、两种被拒形状、Acceptance、Acceptance notes、以及施工轮自己的署名页脚,一字未改。 状态
Generated by Claude Code |
Contract reviewServed-tier: Read at 2026-09-22T15:41Z. This record replaces ① Derived judgmentsThe maintainer's ruling — 「校验规则在读关联记录时改用系统权限,但只读规则表达式自己点名的字段」 — bound by bound, from the code.
Engine-side fail-closed for the mixed shape (prior finding 4) — CLOSED, transition re-measured on both sides. Rule
The refusal arm does not leak past the validation seam (prior finding 5) — HOLDS, both directions. Fault prescription (prior finding 6) — HOLDS. Four reasons, each naming the related object: Absence vs null (prior finding 7) — genuinely distinguishable. A declared column the driver did not echo is materialised to ⭐ Optional-access re-measurement — VERIFIED, no hole found. The raw AST was inspected:
Body vs code. The rewritten body's claims about the seam, the read authority, the projection, the accepted cost and Verification hold. Three passages contradict the code: the repair column of the "Two shapes refused" table ( CI, read at the head against GitHub, not from the report: 42 check runs, every one of the seven required contexts Model identifier (AGENTS.md:446): title, body, 12 commit messages (trailer pair ② Semver level
③ Boundary flagsBlocking — the FAIL rests on these; each names its fix:
Non-blocking:
Implemented-by: VERDICT: FAIL Generated by Claude Code |
… retire the acting-user copy Three review findings. The repair the conflict refusal prescribes did not exist. It tells an author to compare `record.<fk>.id`, and the engine refused that too — the primary key is declared by the platform, not the author, so it is absent from every object's field map — then prescribed declaring `id` on the related object, which is equally impossible. The PK spelling now counts as declared for traversal resolution; the read set widens by nothing, since `id` was already unconditionally in the projection. Pinned end to end: take the message the refusal emits, author the rule it asks for, require it accepted, and require it to fire for real so `.id` is proven to resolve. The dry run handed the accepted inference channel to non-writers. On the write path the CRUD gate runs in middleware, so only someone who could write could observe a rule's verdict — which is the bound the channel was accepted under. `validate()` runs no middleware for its target and its import ingress checks no caller CRUD, so the elevated read reached any authenticated caller. It is now behind the caller's own create/update gate, answered by the security plugin with the same evaluator the CRUD gate uses. A caller who could not perform the write gets no elevated read at all and the predicate refuses. The docblock no longer claims nothing is executed. The superseded acting-user semantics survived in the code after the visible copy was corrected, including surfaces that ship: the exported `EvaluateRulesOptions.related` docblock, the resolver header and both call sites, the reason union, both test headers, the checklist source and history, an orphaned docblock with two dangling links, and — worst — the showcase example telling reference-app readers the opposite of what the dogfood on this head measures. All swept. Also: lint owes a changeset line, the no-reference sentence now names all three stored shapes that reach it, and the computed-receiver traversal is documented as deliberately unprescribed. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…-rule elevation The related-record read for a traversing validation rule is a new elevation site, so the census page's six declared counts moved 110 to 111. Mechanical, regenerated with `gen:system-context-census`. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…edicate-relationship-traversal Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
…red by the caller's own read A validation rule reading one hop through a reference field reads the related row under system authority, scoped by the caller's organization. A related object with no tenant column (sys_user), `tenancy.enabled: false` or `external` is not scoped by any organization, so a rule could be evaluated on a user the caller cannot read, and its pass/fail revealed that user's field. For a user caller, such a related object's rows are now the ones the caller's OWN read returns, through every enforcement layer (CRUD, row-level security, sharing). Any other stored id binds as 'unreadable' and the rule refuses the write with the not-readable prescription; it is never evaluated on that row and its columns are never read. Tenant-scoped related objects and system callers are unchanged. Pinned on the real SecurityPlugin + SqlDriver with the shipped member_default sys_user wall, on insert, validate() and update. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
…ion wall scopes Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 149/149 Isolated at-tier reviewer subagent, run by the Read: card #18682 body plus all 42 comments (rulings ① Derived judgmentsRound shape. (a) Gate set — RIGHT. engine.ts:7399 at head: (b) Own read — RIGHT for a caller carrying (c) Refusal identity — RIGHT for (d) Pins — weight-bearing by reading. predicate-related-read-tenant-scope.test.ts:309–345: pin (a) asserts (e) Prose. Changeset lines 61–65, ② Semver level
③ Boundary flagsBlocking:
CI, stated plainly: Non-blocking:
Implemented-by: VERDICT: FAIL Generated by Claude Code |
…r that is not system Both bounds in resolvePredicateRelated keyed on `caller.userId`, so a caller that is neither system nor a user (the public-form submitter, a principal-less context) got the bare system read, bounded by neither organization nor row. They now key on "not isSystem": under a walled posture such a caller with no organization reads nothing, and on a related object no organization wall scopes it is bound by its own read. System callers are unchanged. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
…ess "not found" case
The changeset said the own-read row bound applies to "a user caller"; it
applies to every caller that is not system. It now also states that under a
walled posture an org-less caller gets no related read and is refused as not
found. The exported RelatedFieldBinding docblock names the row bound beside
the projection, and `row` no longer claims an FLS-filtered ("readable") set.
Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
…rsing rule's related read `resolvePredicateRelated` reads `ExecutionContext.isSystem` to decide which callers its two bounds hold, so it is an elevation read site: row 29b anchors it, and the declared counts move 111 -> 112 (regenerated by check-system-context-census --fix). Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 76/76 Isolated at-tier reviewer subagent, run by the Read: card #18682 body plus all 43 comments (maintainer ruling ① Derived judgmentsRound shape. (a) Round-16 ①(b) — CLOSED. engine.ts:7317 at head
(b) Regressions — none against the rulings; one undeclared behaviour move. FK clear: (c) Census row 29b — TRUE and complete. The one new (d) Pins — weight-bearing by reading. New describe at test file 352–384. Pin (a) 355–366: both callers under (e) Prose. Changeset 61–68: true (any non-system caller; "not found" under a walled posture). Engine docblock 7277–7278 and comments 7313–7315, 7397–7400: true. rule-validator.ts:546 ( (f) Budget. CI at the head, stated plainly: 35 check-runs, 35 names, 33 success, 2 skipped (Console Pin Gate; Packed-tarball smoke (opt-in)), 0 failure, 0 cancelled. Lint & Repo Gates, TypeScript Type Check, Test Core (all six shards), Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard: success. Matches the dev's 33/2/7. Intermediate heads: ② Semver level
③ Boundary flagsBlocking: none. Non-blocking:
Implemented-by: VERDICT: PASS Generated by Claude Code |
…ization (objectstack-ai#19836) Fixes objectstack-ai#19808 Clause-②: no ## What this changes `ObjectQL.referenceExists`, the probe behind `assertReferencesResolve` (the write-path lookup existence check), read under a bare `{ isSystem: true }`. That context has no `tenantId`, so `buildDriverOptions` sent the driver no tenant and the check looked in every organization. The probe now runs under `ObjectQL.referenceCheckContext(context)`. This is the `sudo()`-shaped `{ ...callerContext, isSystem: true }` that the pre-delete reference check already uses, so both reference checks now build their elevated context the same way: - `isSystem` still skips RBAC/RLS/FLS. That is why the original check was elevated: a user may link to a record they are not allowed to read. - The caller's `tenantId` now reaches the driver. Under the `group` posture, `accessible_org_ids` reaches it too and is widened into `tenantIds`. - A record in another organization now gets the same `reference_not_found` refusal as a record that exists nowhere. - `buildDriverOptions` still sends no tenant for `tenancy.enabled: false` objects and for federated objects. References to those objects keep working from any organization. `assertReferencesResolve` now passes its `context` (already its 4th parameter) to the probe. All three call sites (insert, update by id, bulk update) already passed `opCtx.context`, so no call site changed. The dangling-reference audit calls the probe without a context. It gets `referenceCheckContext(undefined)`, which is `{ isSystem: true }`, the same as before. The audit's behaviour does not change. Code changes are confined to `assertReferencesResolve` and `referenceExists` (one argument, one context expression, rewritten docblocks). The docblock section that explained why the probe ignored tenancy now says why it skips RLS but keeps the tenant filter. ## The card's first step: measured before the fix Question from triage: after the cross-organization reference is stored, can the org-X caller read any field of the org-Y row through any read path? Measured on `origin/main` c118524. The setup was a real `SecurityPlugin` (posture `isolated`, `org-scoping` on) over a real `ObjectQL` over `SqlDriver` (better-sqlite3 `:memory:`). The test was a scratch file under `plugin-security`, which aliases `@objectstack/objectql` and `@objectstack/driver-sql` to source. The file was deleted after the run and never committed. | read path, org-X member, stored contact.account = org-Y account | result | |:--|:--| | `find` with `expand: { account: {} }` | `account` stays the bare id `acc_y`, no fields | | `find` with `expand: { account: { fields: [name, secret, status] } }` | bare id `acc_y`, no fields | | `findOne` with `expand` | bare id `acc_y`, no fields | | direct `find` of the org-Y account | `[]` | | roll-up `summary` on the org-Y parent (count of contacts) | org-Y `contact_count` stays 0. The org-X insert threw `SummaryRecomputeError` after the row was written, because the recompute's update is tenant-scoped and cannot find the parent | | `count` of the org-Y account | 0 | | master-detail header with a `requiredWhen: parent.status == 'locked'` detail field, note omitted | header = org-Y locked row: `note: required`. Header = org-Y open row: **write committed** | So no read path returned an org-Y field value. The last row is different: a parent-scoped predicate over an org-Y header can be observed. `resolveMasterDetailParents` / `resolveMasterDetailParent` read the header under a bare `{ isSystem: true }` too, and this oracle **survives this fix**. After the fix: locked header → `note: required`, open header → `reference_not_found`. The two answers still differ, because `evaluateValidationRules` runs before `assertReferencesResolve`. This is reported to the PM as a separate finding. It is not changed here: it is outside this card's two methods, and open PR objectstack-ai#19728 also edits `engine.ts`. ## After the fix: same harness | write, org-X member | before | after | |:--|:--|:--| | lookup to a row only in org Y | committed (plus `SummaryRecomputeError` on the roll-up) | `VALIDATION_FAILED` / `reference_not_found` | | lookup to an id that exists nowhere | `VALIDATION_FAILED` / `reference_not_found` | same | | lookup to an org-X row | commits | commits | | lookup to a `tenancy: { enabled: false }` row | commits | commits | | lookup to an org-X row of an object the member may NOT read (per-object `allowRead: false`, direct read = 403 `PERMISSION_DENIED`) | commits | commits (RLS still skipped under the real SecurityPlugin) | | lookup to an org-Y row of that unreadable object | commits | `reference_not_found` | | lookup to a NULL-organization row of an object exempted only by the deployment's `platformGlobalObjects` | commits | commits | | lookup to an org-Y-stamped row of that deployment-exempted object | commits | `reference_not_found` | The last row is a behaviour change, and it is the hazard the dispatch asked me to measure. The driver still filters a `platformGlobalObjects`-exempted object by organization (objectstack-ai#15831, open, `pm:blocked`). So the probe now agrees with what the caller's own `find` of that object already returns (measured: only the NULL-organization row). This PR does not work around it. The changeset tells deployments about it. ## Tests New file `packages/objectql/src/engine-reference-tenant-scope.test.ts`, 15 cases. objectql cannot import `driver-sql`, so the test driver copies `SqlDriver.applyTenantScope`: it filters on `DriverOptions.tenantId`, keeps `OR organization_id IS NULL`, and honours the `tenantIds` union. It also records every call's options. - Refusal, on all three doors (insert, update by id, bulk update): `ValidationError`. `resolveThrownHttpError` reads `{ status: 400, code: 'VALIDATION_FAILED' }` and the fields are `[{ field: 'account', code: 'reference_not_found' }]`. Nothing is written or repointed. - Oracle shut, on all three doors: "only in another organization" and "exists nowhere" give the same envelope. The messages are also identical once the caller's own id is removed. - Controls: a same-org reference commits on all three doors. A `tenancy.enabled: false` target commits, and its probe's driver options carry no `tenantId`; the row is stamped with another org on purpose, so it passes only because the engine withholds the tenant. An `isSystem` write stays unchecked and runs no probe. - Wiring checks: the probe's operation context is `{ isSystem: true, tenantId, userId }` and the driver sees `tenantId`. Under the `group` posture the membership union reaches the probe. The audit's unscoped probe is pinned, so any change to its behaviour has to be a deliberate decision. Ablation, run on committed HEAD 92662fe through `scripts/ablation-replace.mjs`, with an EXIT/INT/TERM trap that restores and checks the blob hash. The test imports `./engine.js` from source, so no dist build was involved. - Leg A: the probe context was changed back to the pre-fix bare `{ isSystem: true }`, with an injected marker. On disk: anchor 1 → 0, marker 0 → 1. Result: **7 failed / 8 passed**. Failed: refusal ×3, oracle ×3, probe wiring. - Leg B: a hand-picked `{ isSystem: true, tenantId }` with no spread. Result: **2 failed**: probe wiring (`userId` missing) and `group` posture (a legitimate reference refused). - Restore after each leg: blob equals HEAD (`4ac24d149603`) and `git diff HEAD` is empty. Control run afterwards: 15/15. ## Local verification (final head 19624a5) - `pnpm --filter @objectstack/objectql test`: 304 files / 5072 tests passed. The trailing `-- --maxWorkers=2` in my command was dropped by vitest; the whole package ran, which is what I intended. - `pnpm --filter @objectstack/objectql typecheck`: exit 0. `check:test-typecheck` OK with the ledger unchanged (40 files / 234 errors / 65 signatures). `tsc --listFilesOnly -p tsconfig.test.json` includes the new test file. - Downstream suites that combine a real engine, tenants and lookups (objectql `dist` rebuilt, or aliased to source): plugin-security 6 files / 88 tests, plugin-sharing 4 / 284, plugin-audit 4 / 83, service-automation 1 / 6, service-settings 1 / 18. All passed. - `node scripts/pm/dispatch-gates.mjs --commands` listed 64 commands. All were run on 19624a5, with each exit code captured before any pipe. 62 exited 0. `--ran` verdict: `64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`. - NOT MEASURED: `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET). It needs every package's `dist`, and a full workspace build does not fit the 10-minute foreground limit. Narrower check instead: `require()` of objectql's `dist/index.js` and `dist/core.js` both load (exit 0). - NOT MEASURED: `check:type-check-debt` (exit 3, PREREQUISITE NOT MET). It needs 14 unbuilt workspace packages for the same reason. objectql's own test layer is covered by `check:test-typecheck` above. - `check:query-options-erasure` failed on the first pass: test surface 236 → 238, from two `as any` option bags in the new test. Fixed in 19624a5 by typing them. It is now at the ceiling (236). - `check-engine-split-ratio --days 90` first refused on the shallow clone. It passed after `git fetch --shallow-since=2026-06-18 origin main`. ## Acceptance notes - The `inspectDanglingReferences` docblock still says the audit "can never be more or less strict than the rule it reports on". That is no longer true for cross-organization references: the write check refuses them, and the audit's unscoped probe does not report them. The `referenceExists` docblock states this gap. The audit's docblock and its behaviour are left alone, because both are outside this card's region and the audit question is open with the PM. - The docblock of `referenceCheckContext` still describes only the pre-delete check. The write-path probe now uses it too. Left as is (outside the region). - Before the fix, a cross-organization child write under a roll-up parent was written and then threw `SummaryRecomputeError`: HTTP 500 after commit. Non-system writes are now refused before the write. `isSystem` writes that name another organization's parent were not measured. Issue not filed; no current owner. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…g-reference audit to the organization (objectstack-ai#19854) Fixes objectstack-ai#19837 Clause-②: no ## What this changes **Leg 1: the master-detail parent binding.** `resolveMasterDetailParent` (update by id) and `resolveMasterDetailParents` (insert and bulk update) read the header that a detail object's `parent.*` predicates (`requiredWhen`, `readonlyWhen`) are judged against. Both read it under a bare `{ isSystem: true }`. That context has no `tenantId`, so the driver found the header in any organization. Both now read under `ObjectQL.referenceCheckContext(context)`, the `sudo()`-shaped `{ ...context, isSystem: true }` that both reference checks already use (claim-seat ruling): - RLS and FLS are still bypassed, because a master-detail lock is a property of the header's state. - The caller's `tenantId` now reaches the driver, and so does the `group` posture's membership union. - A header outside the caller's tenant scope (another organization; under `group`, one outside the caller's membership set) binds as absent. That is the same binding a header that exists nowhere gets. Both methods take a new `context` parameter. The five call sites pass `opCtx.context`. **Leg 2: the dangling-reference audit, option B (claim-seat ruling, objectstack-ai#19808's ACCEPT).** `inspectDanglingReferences` used to probe through `referenceExists` with no context, so the check ran across every organization. Now each scanned row's reference is probed under that row's own organization: - The audit reads the column the object is tenant-scoped by (`resolveTenantFieldName`), next to the references. - It hands that value to the port's `probe` as a new optional third argument. - The engine's port turns the value into the probe's `tenantId`. - A NULL-organization row, a `tenancy.enabled: false` object, and a federated object's phantom (injected) organization column all probe unscoped, as before. - The run's probe memo is keyed per organization. - **Except under a union posture.** Under `group` (`postureUsesUnionScope(this.resolveEnginePosture())`, read once per run) the probe stays unscoped, as before this PR. That is the seat's decision from the patch round (see "The `group` posture" below). The per-row organization applies in every other posture. ## A1: the oracle on insert, re-measured on `origin/main` after objectstack-ai#19836 This uses the harness shape objectstack-ai#19808's dev used: a real `SecurityPlugin` (posture `isolated`, `org-scoping` on) over a real `ObjectQL` over `SqlDriver` (better-sqlite3 `:memory:`). The detail field `note` has `requiredWhen: "parent.status == 'locked'"`. The caller is bound to org X and omits `note`. The harness was a scratch file under `plugin-security/src`. It was never committed and was removed after every run. "Pre" means `engine.ts` at `afc3b64928`, and "post" means this branch at `5930c9898a`. | write names… | pre | post | |:--|:--|:--| | org-Y header, `locked` | `VALIDATION_FAILED` · `note` `required` | `VALIDATION_FAILED` · `header` `reference_not_found` | | org-Y header, `open` | `VALIDATION_FAILED` · `header` `reference_not_found` | same | | a header id that exists nowhere | `VALIDATION_FAILED` · `header` `reference_not_found` | same | | control: org-X header, `locked` | `note` `required` | same | | control: org-X header, `open` | commits | commits | | control: NULL-organization header, `locked` | `note` `required` | same | | control: `tenancy: { enabled: false }` master, `locked` | `note` `required` | same | ## A2: the update doors (NOT MEASURED on the card) The caller is bound to org X and updates their own org-X detail rows. "Stored" means that the row already holds an org-Y header, as a row written before objectstack-ai#19808 or by an `isSystem` write would. "Bulk" uses `where: { id: { $in: [...] } }, multi: true`. A scalar `where.id` routes to the by-id branch even under `multi: true`. | door, predicate | pre: org-Y `locked` | pre: org-Y `open` | post: both | |:--|:--|:--|:--| | by id, repoint + write `memo` (`readonlyWhen`) | `reference_not_found`; `onFieldsDropped` reports `memo` | `reference_not_found`; no drop | `reference_not_found`; `memo` dropped (`readonly_when`) | | same, `strictReadonlyWrites` | `ERR_READONLY_FIELD_REJECTED` | `reference_not_found` | `ERR_READONLY_FIELD_REJECTED` | | by id, stored header, write `memo` | commits, `memo` kept, drop reported | commits, `memo` **written** | commits, `memo` kept, drop reported | | same, `strictReadonlyWrites` | `ERR_READONLY_FIELD_REJECTED` | commits | `ERR_READONLY_FIELD_REJECTED` | | bulk, stored header, write `memo` | `memo` kept | `memo` **written** | `memo` kept | | by id, repoint, `note` stays null (`requiredWhen`) | `note` `required` | `reference_not_found` | `reference_not_found` | | by id, stored header, clear `note` | `note` `required` | commits | commits | | bulk, stored header, clear `note` | `note` `required` | commits | commits | Before the fix, every pair differed on at least one channel a caller can see. After it, every pair answers the same. ## A3: does "absent" close the oracle on every door? Yes, measured on every door. An org-Y header now takes the same path as a header that does not exist. The two predicate kinds already handle an unbound `parent`: - `requiredWhen` is **fail-open** (objectstack-ai#4977 ruling: logged, skipped). On insert and on a repoint, the header id is in the payload, so `assertReferencesResolve` (objectstack-ai#19808) refuses it with `reference_not_found`. On a stored header, the id is not in the payload, so nothing is refused and the write commits. Either way, locked and open give the same answer. - `readonlyWhen` is **fail-closed** (objectstack-ai#4889: LOCKED). The field is dropped, or refused under strict mode, whatever state the header is in. The only differences left are server-side `warn` lines ("requiredWhen … not bound … skipped", "readonlyWhen parent lookup…"). They do not reach the caller, and they read the same for both states. What this costs, stated in the changeset: a row that already stores a header from another organization now edits as if its header were missing. Its `parent`-scoped `readonlyWhen` fields stay locked, and its `parent`-scoped `requiredWhen` rules are not enforced. Outside `group`, leg 2 now reports exactly these rows. ## A4: the audit (leg 2) Rows were inserted under `isSystem`, because the write path now refuses a non-system cross-organization reference. `inspectDanglingReferences({ objects: ['m_contact'] })` was run over 6 rows in the same harness (posture `isolated`; under `group` the audit probes unscoped, see below): | stored row | pre | post | |:--|:--|:--| | org-X row → org-Y account | not reported | **reported** in `dangling` | | org-X row → an id that exists nowhere | reported | reported | | org-X row → org-X account | not reported | not reported | | org-X row → a `tenancy.enabled: false` row | not reported | not reported | | org-Y row → org-Y account | not reported | not reported | | NULL-organization row → org-Y account | not reported | not reported | ## A5: class sweep of `{ isSystem: true }` reads in `engine.ts` `check-system-context-census` counts READS of `isSystem` (110 sites, unchanged by this diff), not constructions, so I enumerated every non-comment `isSystem: true` construction in `engine.ts` (12 sites) and checked whether its target id comes from the caller: | site (symbol) | shape | same defect? | |:--|:--|:--| | `resolveMasterDetailParent` / `resolveMasterDetailParents` | bare, header id from the caller's payload or row | **yes, fixed here** | | `referenceExists` via `referenceCheckContext`, and `cascadeDeleteRelations` | spread (`sudo()`-shaped) | no, tenant kept | | `recomputeSummaries` | `{ ...(execCtx ?? {}), isSystem: true }` | no, tenant kept | | `ObjectRepository.execute`, `ScopedContext.sudo()` | spread of the caller's or scoped context | no, tenant kept | | `buildSession` (`...(execCtx.isSystem ? { isSystem: true } : {})`) | copies the caller's own flag onto the hook session | no, not an elevation | | `probeInstallOrganizations` | `sys_organization`, `limit: 2`, no id | no, no caller id | | `buildHookApi` fallback | used only when there is no caller context | no, no caller | | `readMigrationFlagVerified`, `recordObservedDeviation` (read + write), `retractCreationAttestation` (read + write) | `sys_migration` flag row by migration id | no, engine-owned id | No other site in `engine.ts` has this shape, so nothing else is fixed here and no class (a) item is filed from the sweep. For completeness I also looked at the bare constructions elsewhere in `packages/objectql/src`: the audit's own row read, `scan-value-shapes`, the lifecycle sweep and `summary-backfill`. All of them are boot or sweep reads with no caller id. ## Tests `packages/objectql/src/engine-reference-tenant-scope.test.ts` (the objectstack-ai#19808 file, with the same tenant-scoping driver double): - **Leg 1**, new `describe`, 9 cases: - Insert: org-Y `locked`, org-Y `open` and a header that exists nowhere give the same envelope: `resolveThrownHttpError` reads `{ status: 400, code: 'VALIDATION_FAILED' }` and the fields are `[{ field: 'header', code: 'reference_not_found' }]`. The messages also match once the id is removed. - Update by id, repoint: same envelope for both headers. - `readonlyWhen` on a stored org-Y header, by id and bulk: the stored value, the `onFieldsDropped` report and the strict envelope `{ status: 500, code: 'ERR_READONLY_FIELD_REJECTED' }` are all equal for both headers. - `requiredWhen` on a stored org-Y header, by id and bulk: both commit. - Lit controls: an org-X `locked` header still gives `note` `required`, and an org-X `open` one commits. An org-X `locked` header still locks `readonlyWhen`, and an `open` one lets the write through. A NULL-organization header binds. A tenancy-disabled master stamped with another organization binds, and its read carries no `tenantId`. - `group`: a member of org X and org Y inserting against the org-Y `locked` header gets `note` `required` (the header binds inside the membership set), and the header read reaches the driver with `tenantIds` = both organizations. The lit control, a member of org X alone, gets `header` `reference_not_found`. - Wiring: the header reads are `find` (insert) and `findOne` (update). Their operation context is `{ isSystem: true, tenantId, userId }` and the driver sees `tenantId`. - **Leg 2**: objectstack-ai#19808's pin, "the audit keeps its unscoped probe", was written so that it moves only by decision, and the decision is now made. It is replaced by three cases: - A stored cross-organization reference is reported, and the probe's driver `tenantId` is the row's organization. - Lit controls: same-organization, NULL-organization, tenancy-disabled and own-organization references are not reported, and in the same run an id that exists nowhere is reported. - `group`: a cross-organization reference to an EXISTING row is not reported, because the probe is unscoped (no `rts_account` probe carries a `tenantId`), while an id that exists nowhere still is. A lit control runs the same stored rows under `isolated`, where both are reported. The "audit REPORTS" case now names `isolated` explicitly instead of inheriting the posture from the environment. `packages/objectql/src/integrity/dangling-reference-audit.row-organization.test.ts` (new, 5 cases) covers the audit-module half: - the row's organization reaches the port, and `null` or `''` probes unscoped; - the memo is keyed per organization; - a `tenancy.enabled: false` object probes unscoped; - a declared `tenancy.tenantField` is read, and projected when nothing else asked for it; - a federated phantom organization column is not projected (objectstack-ai#8414's projection is unchanged). **Ablation.** Run on committed HEAD `5930c9898a` through `scripts/ablation-replace.mjs` (WRAP mode), inside a script with its own `EXIT`/`INT`/`TERM` trap that restores from `HEAD` and checks the blob hash. The tests import `./engine.js` from source, so no `dist` build is involved. The markers are comments, so they cannot change behaviour. - **1a**: `resolveMasterDetailParent` back to the bare `{ isSystem: true }`. The anchor went 1 → 0 on disk and the marker 0 → 1. Result: **4 failed / 27 passed** (update-by-id repoint, stored `readonlyWhen`, stored `requiredWhen`, wiring). - **1b**: `resolveMasterDetailParents` back to the bare elevation. The anchor went 1 → 0 and the marker 0 → 1. Result: **3 failed / 28 passed** (insert, bulk stored `requiredWhen`, wiring). - **2** (re-run on `26421788b4` after the patch round): the engine's probe port back to no per-row context in every posture. The anchor went 1 → 0 and the marker 0 → 1. Result: **2 failed / 29 passed**: the `isolated` "audit REPORTS" case, and the `group` case's `isolated` lit control (`:424`, expected `['ct_grp','ct_gone']`, received `['ct_gone']`). The `group` half stays green under this mutation, as it should, because the mutation is the decided `group` behaviour. The blob was restored to `a69246368bc5`. Legs 1a and 1b were not re-run, because the leg-1 code is unchanged since `5930c9898a`. - **1c** (the `group` pin, run on `8b5c2cee52`): `resolveMasterDetailParents` back to the bare elevation. The anchor went 1 → 0 and the marker 0 → 1. The new pin went red at its `tenantIds` assertion (`:656`, expected both organizations, received `[]`). Its `note` `required` envelope stays green under the mutation, because an unscoped read also finds the org-Y header, so the `tenantIds` assertion is the one that discriminates. The blob was restored to `956383b5ce5a`. - After each leg the blob equals HEAD (`dce546fe7f5d`) and `git diff HEAD` is empty. The control run afterwards passed 31/31. Every leg went red, as predicted. ## Local verification (head `5930c9898a`) **Re-run on `8b5c2cee52` after the second patch round:** the two touched test files passed (32 tests), the objectql suite passed (305 files / 5089 tests), `typecheck` exited 0 with the ledger unchanged, `check-system-context-census` exited 0, `check:query-options-erasure` exited 0, and `node scripts/check-issue-citations.mjs` exited 0 (14 citations, all resolve), and `node scripts/check-changeset-no-major.mjs --base origin/main` exited 0. The full 64-command sweep and the `--ran` verdict below are from `5930c9898a`; they were not re-run. - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: **305 files / 5088 tests passed**. - `pnpm --filter @objectstack/objectql typecheck`: exit 0. `check:test-typecheck` is OK with the ledger unchanged (40 files / 234 errors / 65 signatures). `tsc --listFilesOnly -p tsconfig.test.json` lists both test files. - Downstream: the plugin-security master-detail / `controlled_by_parent` suites (8 files / 154 tests) passed against source objectql (aliased). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **64** commands from the tree at `5930c9898a` (merge base `afc3b6492`). All were run, with each exit code captured before any pipe; 62 exited 0. The `--ran` verdict: `64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`. - Named by the dispatch: `check-system-context-census` exit 0 (`110 elevation read sites in 20 packages across 45 files`), `check-platform-object-tenancy-census` exit 0 (`84 platform-namespace objects`), `check:org-identifier` exit 0, and `check:query-options-erasure` exit 0 (test surface 236, at the ceiling; non-test 67 sites, none new). - NOT MEASURED: `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: it needs every package's `dist`). As a narrower check, `require()` of the rebuilt `packages/objectql/dist/index.js` and `dist/core.js` loads (exit 0). - NOT MEASURED: `check:type-check-debt` (exit 3, PREREQUISITE NOT MET, for the same reason). objectql's own layers are covered by `typecheck` above. - Lint, narrowed and proven: `eslint --no-inline-config --format json` over the 4 touched `.ts` files counted 4 files linted, 0 errors and 0 warnings, and none of the files is ignored. `eslint.config.mjs` sets no `parserOptions.project` and registers no typed rule, so the lint is not type-aware and this diff cannot change the verdict on any file it does not touch. The repo-wide `pnpm lint` is CI's. - Dogfood (`showcase-readonly-when-parent`, `federated-sweep-projections`): NOT MEASURED locally, because they need the full showcase build. The `Dogfood Regression Gate` in CI runs them. ## Deviations from the declared file surface - **Five call sites** (insert ×1, update by id ×2, bulk ×2) gained `, opCtx.context`. The two methods had no context at all, so the ruled cure could not be written inside them alone. Each edit is one argument on a line that already called the method. - **`referenceExists`'s docblock**: one comment paragraph was rewritten. It said that the audit calls the probe with no context, which leg 2 makes false. No code changed there. - **`postureUsesUnionScope`** is imported into `engine.ts` from `@objectstack/spec/security` (patch round), placed at the end of that import list so it does not collide with objectstack-ai#19728. - **`integrity/dangling-reference-audit.ts`**: the port interface, one helper and the scan loop. This is "the audit's probe port" that the claim names. - **Neighbours**: `git merge-tree --write-tree` was run in a throwaway bare repo that shares the object store and has no `os-regen` driver registered. At `8b5c2cee52` it is **clean** against objectstack-ai#19728's head `3b9c5f2fca` (exit 0) and against `origin/main` `2548ba57de` (exit 0). The first patch-round push collided textually with objectstack-ai#19728 on the `@objectstack/spec/security` import, so `26421788b4` puts `postureUsesUnionScope` at the end of that list. objectstack-ai#19840 has merged. - The scratch measurement harness lived under `packages/plugins/plugin-security/src` (outside the surface) for the length of each run. It was never committed, and a trap removed it. ## The `group` posture (decided) Under `group`, the write rule's reach is the **writer's** whole membership set (`accessible_org_ids` becomes `tenantIds`), and the stored row does not record it. A probe scoped to the row's own organization would therefore be stricter than the rule. It would report every cross-organization reference a member of both organizations legitimately wrote, on every lifecycle sweep of healthy data. The seat decided: under a union posture the probe stays unscoped, as before this PR, and every other posture keeps option B. The first contract review (FAIL, comment 5794714685) found the changeset's audit sentences unqualified by posture; the second patch round (`8b5c2cee52`) qualifies them. The blind spot this leaves is stated in the `inspectDanglingReferences` docblock and the changeset. Under `group`, a stored reference into an organization that no writer of that row could reach (a write made before objectstack-ai#19808's guard existed, an `isSystem` write, or a membership since revoked) resolves and is not reported. Only a reference that resolves nowhere is. ## Acceptance notes - `referenceCheckContext`'s docblock still describes only the pre-delete check, even though it now serves four sites. This is left alone because it is outside the region. - The audit keys the union exception on `resolveEnginePosture()` (the injected provider, then `OS_TENANCY_POSTURE`). `buildDriverOptions` widens to the union only when an injected provider returns `'group'`. So in a lean embedding with an env-only `group` posture, the audit probes unscoped while the write rule stays equality-scoped: less strict, never stricter. This is not changed here, and nothing is filed, because the reach is not measured. - The engine envelope for `ERR_READONLY_FIELD_REJECTED` resolves to status 500 through `resolveThrownHttpError`, because the error declares no status. The REST mapping was not measured, and nothing is filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…the write stores (objectstack-ai#19853) (objectstack-ai#19877) Fixes objectstack-ai#19853 Clause-②: no ## What a caller could do before, and what happens now **Before.** On a master-detail child whose parent field is `readonly: true`, a caller with edit rights could change a field locked by a `parent`-scoped `readonlyWhen` by naming a different, unlocked parent in the same update. The engine resolved `parent` from the FK the payload named, judged the lock against that header, and only afterwards stripped the read-only FK — so the row stayed under the locked parent with the locked field rewritten. The same happened when the FK carried its own `readonlyWhen` lock that kept the row where it was, and on bulk (`multi: true`) updates for every matched row. **Now.** The engine settles whether the update really moves the row BEFORE it judges any `parent`-scoped lock, and judges every lock (and `requiredWhen`, which shares the binding) against the parent the row is stored under afterwards. A legitimate move — FK writable, or an `isSystem` / `preserveAudit` write the static strip exempts — is still judged against the parent it moves to. ## A1 — reproduction on `origin/main` 2bbb462, engine untouched Real `ObjectQL` engine, in-memory driver (the suite's own), the card's shape: `inv_line.amount` has `readonlyWhen: "parent.status == 'paid'"`, `inv_line.invoice` is `master_detail` with `readonly: true`, `l1` (amount 100) under paid `inv_a`, `inv_b` open. - `update(l1, { amount: 999, invoice: 'inv_b' })` committed and stored `{ invoice: 'inv_a', amount: 999 }` — reproduced. - Control `update(l1, { amount: 555 })` — locked, amount stayed 100. - Same run, the other arms: FK's own record-scoped lock → stored `amount: 999` under `inv_a`; FK's own parent-scoped lock → same; bulk stripped repoint → same per row; reverse direction (naming the PAID invoice for a line under an open one) → the amount was dropped (over-lock); `requiredWhen: "parent.status == 'paid'"` → a note-only edit of an open line naming the paid invoice was refused `po_ref is required`, and clearing `po_ref` on a paid line by naming the open one was accepted. ## A2 — every strip that can move the FK on this path, from the code Order on both update branches, after the before-phase hooks: primary-key strip (`id` only) → `readonlyWhen` strip → static `readonly` strip (skipped for `isSystem`) → `assertNoStrictDrops` → `evaluateValidationRules` → reference check → driver. - **`readonlyWhen` strip** — can take the FK when the FK carries its own lock. Ran AFTER `parent` was resolved from the named FK. - **Static `readonly` strip** — takes a non-system caller's FK when it is `readonly: true` (unless `preserveAudit` keeps it, or a hook wrote it). Ran after the `readonlyWhen` strip. - Not strips: field-level security refuses a forbidden FK outright (plugin-security step 2.5 throws `PermissionDeniedError`); the objectstack-ai#16344 hide pass withholds static-readonly values from hooks and restores them before any engine-owned read (net zero); `beforeUpdate` hooks, `normalizeMultiValueFields` and `validateRecord` all run before `parent` is resolved, so their effect is already in the payload the resolution reads; the primary-key strip touches `id` only. ## The fix — `packages/objectql/src/engine.ts` `settleMasterDetailLanding` (one private method, used by the by-id and the bulk branch) runs before the `readonlyWhen` strip: 1. the FK's own `readonlyWhen` lock is judged ALONE (the strip's `supplied` subset holds only the FK) against the header the FK names — objectstack-ai#4889's verdict for the FK itself, byte-identical to before; the strip that judges the other fields is then handed a `supplied` without the FK, so that verdict is never re-asked against a different header; 2. the static strip's verdict on the FK is asked of the same `stripReadonlyFields`, silently, with the same `supplied`, `hookWrittenKeys` and `preserveAudit`; its `isSystem` gate is read once per branch into a const that both the settlement and the real strip consume (keeps the system-context census at 110 sites, and the two cannot disagree); 3. `parent` is resolved from the view the write stores — the payload, or the payload without the FK — so `masterIdOf` falls through to the prior row's FK when the repoint does not land. The `requiredWhen` non-regression pre-check's "does this write repoint" is asked of the same stored view. The header read keeps `opCtx.context` (the objectstack-ai#19837 tenant scope) exactly as it was. ## A3 — what moves - `parent` for every `parent`-scoped `readonlyWhen` and `requiredWhen` is the header the row is stored under (the fix). - `onFieldsDropped` for the card's write: `[{amount, readonly_when}, {invoice, readonly}]` (was `[{invoice, readonly}]`); a `strictReadonlyWrites` refusal names `['amount', 'invoice']` with both drops (was `['invoice']`). - Over-lock removed: naming a locked parent beside a stripped FK no longer drops a field of a row that stays under an open parent. - `requiredWhen` moves with the shared binding, both directions (see the declaration note below). - Header reads: a stripped repoint reads the header the row keeps instead of the named one (same count, pinned); the `requiredWhen` pre-check no longer buys a second read for a repoint that does not land; a `parent`-scoped lock on the FK ITSELF that refuses the landing costs two reads (named, then kept) where it cost one. - Warn-line order only: when the FK's own lock fires, its line now prints before the other fields' lines. Report order, strict `fields` order and message text are unchanged. - Unmoved: the FK's own verdict; strip order; `isSystem` / `preserveAudit` behaviour; every write whose payload does not name the FK. ## A4 — the legitimate repoint With `invoice` writable (`inv_line_free`): paid → open `update(f1, { invoice: 'inv_b', amount: 999 })` is judged against `inv_b` and commits both; open → paid `update(f2, { invoice: 'inv_a', amount: 999 })` is judged against `inv_a` and drops the amount while the move lands. In both the judged header is the one the row is stored under because the FK lands: no strip takes it. ## Tests New `describe` block in `packages/objectql/src/engine-readonly-when-parent.test.ts` (16 cases): the card, the control, the report, the strict envelope (`code` `ERR_READONLY_FIELD_REJECTED`, `name`, `fields`, `drops`; the engine error carries no HTTP `status` — that mapping is downstream and not measured here), the reverse over-lock, the one-header read, `isSystem`, `preserveAudit`, both legitimate repoint directions, the FK's own record- and parent-scoped locks, both `requiredWhen` directions (refusal asserted on `code` `VALIDATION_FAILED` + `fields`), and bulk in both directions. Measured on `0a538af9d7`: - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2` → 305 files / 5105 tests passed (exit 0). - `pnpm --filter @objectstack/objectql test:repo` → 1 file / 5 tests passed; `pnpm --filter @objectstack/objectql typecheck` → exit 0, `check:test-typecheck: OK`. - Ablation (committed fix; `engine.ts` restored to its `2bbb462335` blob `ffcda81540` and verified on disk, `settleMasterDetailLanding` count 0): the parent suite ran 11 failed / 27 passed — every new pin except the five whose behaviour is unchanged (control, `isSystem`, `preserveAudit`, both legitimate repoints). Restored with `git checkout HEAD --`, blob `624fc1e38a` equals `HEAD:packages/objectql/src/engine.ts`, `git diff HEAD` empty, rerun 38/38 passed. Script carried an EXIT/INT/TERM trap. ## Gates on `0a538af9d7` - `node scripts/pm/dispatch-gates.mjs --commands` → 64 commands; all run with exit codes captured before any pipe; `--ran` → `64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED`, exit 0. - `check:dual-build-cjs-loads` and `check:type-check-debt` first answered PREREQUISITE NOT MET (no workspace `dist`); after `turbo run build` over `./packages/*` and `./packages/*/*` (72/72 tasks), both exit 0 (`type-check-debt` needed an objectql rebuild after the ablation restore touched `engine.ts`'s mtime). - `node scripts/check-issue-citations.mjs` (live, the verdict CI blocks on) → exit 0, 13 citations resolve. - `node scripts/check-system-context-census.mjs` → exit 0 (110 sites). It was red on the first commit (`d4496752a1`) for a new `isSystem` read inside the helper; the second commit hoists the one read per branch instead. - `pnpm check:query-options-erasure` → exit 0. - Lint, narrowed and proven: eslint's own config (`ESLint.calculateConfigForFile` / `isPathIgnored`) puts `engine.ts` and the test file in the linted population and ignores the changeset; `eslint --no-inline-config --format json` over the three paths → 3 results, 0 errors, 0 warnings on the two linted files; the config enables no type-aware linting and no cross-file rule (its only file reads are two baselines this diff does not touch), so this diff cannot move a verdict on any untouched file. `pnpm lint` itself is CI's. ## Neighbour PR objectstack-ai#19728 Textually disjoint: this diff does not touch the `evaluateValidationRules` lines, the import line, the region after `resolveMasterDetailParents` or `system-context.mdx`. Driver-free bare probe (`git clone --bare --shared`, no merge driver registered): `merge-tree --write-tree` of `0a538af9d7` against `3b9c5f2fca` → exit 0; against `origin/main` `b940f32a56` → exit 0. The merged tree carries both changes. ## Acceptance notes - **Sibling on the `record` root, same defect class, not fixed here (listed for the seat to file).** A record-scoped `readonlyWhen` that reads a static-`readonly` field is judged against the caller's forged value of that field, which the static strip then removes. Measured on `0a538af9d7`: `status` `readonly: true`, `amount` `readonlyWhen: "record.status == 'closed'"`, row closed; `update(t1, { amount: 555 })` stays 100, `update(t1, { status: 'open', amount: 999 })` stores `{ status: 'closed', amount: 999 }`. Judging the conditional strip over the post-static view would close both roots at once, but it moves reason attribution for fields declaring both locks and the strict message text, so it is a separate decision. - objectstack-ai#4889's landing rule still decides the FK's OWN `parent`-scoped lock: `invoice: { readonlyWhen: "parent.status == 'paid'" }` judges a move off a paid invoice against the invoice it moves to. Documented behaviour, unchanged here; noted, not filed. ## Declaration note for the contract review The claim declares no widening; copied above as given. Measured movement in `update()`'s refuse/accept answer, only when a payload names an FK that a strip then takes back out and the object has a `parent`-scoped `requiredWhen`: a write refused `VALIDATION_FAILED` against the named header now commits (the row stays under a header that does not require the field), and a write that cleared a required field under a header that does require it is now refused. Every other movement is a strip (the write still commits) or a strict refusal that was already a refusal. Whether that requirement movement counts as an accept-set change is the review's call. **Seat's disposition (after the contract review):** `Clause-②: no` stands, and `patch` is right. The accept-direction movement removes a refusal the published text already negated. `content/docs/data-modeling/fields.mdx` ("Locking a detail from its master: `parent`") says: "`parent` = the record on the other end of this object's master_detail field", and "The server binds `parent` on the update path by reading the master the row points at (a repointing write is judged against the master it lands on)". A row whose repoint never lands points at, and lands on, its stored master, so refusing it against the named header contradicted that text. The refuse-direction movement is a restored guarantee (a requirement bypass now refused). Neither moves an authorable key, an export or an error code. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… the write stores (objectstack-ai#19887) (objectstack-ai#19905) Fixes objectstack-ai#19887 Clause-②: no ## What a caller could do before, and what happens now **Before.** A caller with edit rights could change a field locked by a `record`-scoped `readonlyWhen` by putting a forged value for a statically `readonly` field in the same update. On `UPDATE` the conditional strip runs before the static one, and it built `record` from the payload it was handed, which still held the forged value. The lock was judged against that value, the static strip then removed it, and the row committed with its stored state kept and its locked field rewritten. The same happened through a read-only master-detail FK read as `record.invoice`, on bulk (`multi: true`) updates for every matched row, and for an FK's own `readonlyWhen` lock (judged inside objectstack-ai#19853's settlement) that reads a read-only field. **Now.** Every `readonlyWhen` predicate on the update path reads the payload the write STORES: a value the static strip will take back out is replaced by the row's stored value before any predicate is evaluated. The strips keep their order, so each still reports its own fields under its own reason. Where the static strip keeps the value (an `isSystem` caller, a `preserveAudit` write of a preservable field, a value a `beforeUpdate` hook wrote or overwrote), the value is stored, and the lock reads it exactly as before. ## A1 — reproduction on `origin/main` `dabf8d795e` (includes `3bd221dfe1`, PR objectstack-ai#19877), engine untouched Real `ObjectQL` engine, the suites' in-memory driver shape. - **Shape 1** — `status` `readonly: true`, `amount` `readonlyWhen: "record.status == 'closed'"`, `t1` = `{ status: 'closed', amount: 100 }`: `update(t1, { status: 'open', amount: 999 })` stored `{ status: 'closed', amount: 999 }`; `onFieldsDropped` reported only `[{ status, readonly }]`; control `update(t1, { amount: 555 })` stayed 100. Bulk (`where: { status: 'closed' }, multi: true`): both matched rows stored `amount: 999`. **Reproduced.** - **Shape 2** — `invoice` read-only `master_detail`, `amount` `readonlyWhen: "record.invoice == 'inv_a'"`, `r1` under `inv_a`: `update(r1, { invoice: 'inv_b', amount: 999 })` stored `{ invoice: 'inv_a', amount: 999 }`; control stayed 100; bulk the same. **Reproduced — PR objectstack-ai#19877 does not reach it**: its settlement runs only when the payload touches a `parent`-scoped lock or the schema has a `parent`-scoped `requiredWhen`, and this lock is `record`-scoped, so `landing` is undefined and the strip judged the pre-static payload as before. ## A2 — the choice: the post-static VIEW, not a reordered strip Two candidates, both measured on this branch. - **(A) Reorder** — run the static strip first and judge the conditional one over what it leaves. Measured as a variant of this head (by-id branch reordered, no view option) over the nine readonly suites (`engine-readonly-when-parent`, this PR's suite, `engine-readonly-strict-writes`, `engine-readonly-strip-signal`, `engine-strict-readonly-warning-truthful`, `engine-readonly-strip-caller-values`, `engine-readonly-when-derived-writes`, `engine-readonly-hook-input`, `hook-withheld-readonly-fault`): **6 failed / 161 passed**, including **2 of PR objectstack-ai#19877's 16 pins** (`reports both strips, each under its own reason` and the `strictReadonlyWrites` refusal: the `readonly` event now arrives first, so the event order and the strict `fields` / `drops` order flip). It also reports a field carrying both locks as `readonly` whenever the static strip takes it, where a TRUE lock used to report `readonly_when`, and (by reading, not measured) the FK's own lock would stop being judged when the static strip takes the FK. - **(B) The stored view (chosen)** — keep the order; hand the conditional strip the payload the static strip leaves, and build `record` from that. Same nine suites: **167 / 167 passed**. This is the "smaller alternative" of the dispatch, analogous to `staticReadonlyStripTakes`: the view is asked of the SAME `stripReadonlyFields` verdict, with the same `supplied` snapshot, `hookWrittenKeys`, `preserveAudit` and `isSystem` gate. ### The fix - `packages/objectql/src/engine.ts`: `staticReadonlyStoredView(schema, data, supplied, strip)` — `data` itself when the static strip does not run, else `stripReadonlyFields(...)` run silently with the same arguments the real strip gets. `staticReadonlyStripTakes` (objectstack-ai#19853) now delegates to it, so the one-key question and the whole-view question are one call. Each branch describes the static strip once (`staticStrip` / `staticStripMulti`, reusing the one `isSystem` read objectstack-ai#19853 hoisted, so the system-context census stays at 110 sites) and passes the view as `stored` to all four `readonlyWhen` strip calls: the by-id and bulk strips, and the by-id and bulk `judgeFkLock` of the settlement. - `packages/objectql/src/validation/rule-validator.ts` — outside the claimed file surface, and the reason it is the landing site: `readonlyWhenBindings` builds `record` from the strip's `data` argument, which is also the set of keys judged and the payload returned. Separating "what is judged" from "what the predicate reads" needs one optional key, `ReadonlyWhenStripOptions.stored`, read by `stripReadonlyWhenFields` and `stripReadonlyWhenFieldsMulti`. Neither function nor the options type is exported from the package (`index.ts` exports `evaluateValidationRules`, `needsPriorRecord`, `legalNextStates` from that file), so no published surface moves. Unlike `supplied`, omitting `stored` is not fail-safe (the view is then `data`); the docblock says so, and every engine call site passes it. ### Every movement, measured (base `dabf8d795e` → this head `1afbcef386`) | write | before | now | |:---|:---|:---| | shape 1 `update(t1, { status: 'open', amount: 999 })` | stored `{ closed, 999 }`; events `[{status, readonly}]` | stored `{ closed, 100 }`; events `[{amount, readonly_when}, {status, readonly}]` | | shape 1 under `strictReadonlyWrites` | refused, `fields: ['status']` | refused, `fields: ['amount', 'status']`, `drops` both (already a refusal) | | shape 2 by-id and bulk | stored `amount: 999` | stored `amount: 100` | | shape 1 bulk | both rows `amount: 999` | both rows `amount: 100` | | REVERSE: forged `status: 'closed'` on an open row | amount dropped (over-lock), stored `{ open, 100 }` | amount lands, stored `{ open, 999 }` | | field with BOTH locks, own predicate reads itself (`update(b1, { status: 'open', amount: 999 })`) | events `[{status, readonly}]`, amount stored 999 | events `[{amount, status: readonly_when}]` (one event), amount stored 100 — the both-lock field is still dropped; only its reason moves | | FK's own record-scoped lock reads a forged read-only `stage` (settlement composes) | FK landed on `inv_b`, amount 999 | FK stays on `inv_a`, amount judged against the paid header, 100 (by-id and bulk) | | `requiredWhen: "record.amount > 500"` on `note`, forged status + amount 999 | refused `VALIDATION_FAILED` (note required) | commits; the row keeps amount 100 and note null | | `requiredWhen` requiring `note` while the amount is below 500, forged status + amount 999 + `note: null` | accepted, stored `{ closed, 999, null }` | refused `VALIDATION_FAILED` (note required); nothing lands | **Unmoved (pinned):** the control writes; `isSystem` (the static strip does not run, the forged status lands and is read); `preserveAudit` (the static strip keeps the status); a hook-written status and a hook overwriting a forged status (both land and are read); every write whose payload holds no value the static strip takes (the view is then the payload itself). Warn-line wording is unchanged; lines follow the strip verdicts. Strip order, `reportDroppedFields` and the strict envelope's shape are unchanged. ## A3 — bulk `stripReadonlyWhenFieldsMulti` builds each matched row's `record` from the same `stored` view over THAT row (the static strip is not per-row, so one view serves every row). Pinned: shape 1 and shape 2 in bulk, and the settlement composition in bulk. ## Tests New `packages/objectql/src/engine-readonly-when-stored-view.test.ts`, 18 cases: both card shapes with their controls, the reverse over-lock, the report, the strict envelope (`code` `ERR_READONLY_FIELD_REJECTED`, `name`, `fields`, `drops`; the engine error carries no HTTP `status` — that mapping is downstream and not measured here), the both-lock attribution, `isSystem`, `preserveAudit`, the two hook pins, the settlement composition (by-id with its events, and bulk), bulk shapes 1 and 2, and the two `requiredWhen` directions (refusal asserted on `code` `VALIDATION_FAILED` + `fields`). All on `1afbcef386`: - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2` → 306 files / 5123 tests passed (exit 0). - `pnpm --filter @objectstack/objectql typecheck` → exit 0, `check:test-typecheck: OK` (the new file is in the `tsconfig.test.json` program by `--listFiles`, 0 diagnostics in it); `pnpm --filter @objectstack/objectql test:repo` → 5 passed. - PR objectstack-ai#19877's 16 pins (`engine-readonly-when-parent.test.ts`, 38 cases in the file) → all green. - **Ablation** (fix committed; `engine.ts` and `rule-validator.ts` restored to their `dabf8d795e` blobs `624fc1e38a` / `bb83519608` with `git restore --source` (tree only), verified on disk by blob hash and by marker counts 0 / 0): the two suites ran **12 failed / 44 passed** — every new pin except the six whose behaviour is unchanged (both CONTROLs, `isSystem`, `preserveAudit`, both hook pins), and all 38 of the objectstack-ai#19853 file green. Restored with `git checkout HEAD --`; blobs `e973ab50ac` / `2b3002b8e1` equal `HEAD`, `git diff HEAD` empty; rerun 56 / 56 passed. The script carried an `EXIT` / `INT` / `TERM` trap. ## Gates on `1afbcef386` - `node scripts/pm/dispatch-gates.mjs --commands` → 65 commands, every one run with its exit code captured before any pipe; `--ran` → `65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED`, exit 0. `check:type-check-debt` first answered PREREQUISITE NOT MET (the ablation restore left `engine.ts` newer than objectql's `dist`); after `pnpm --filter @objectstack/objectql build` it exits 0, and the record carries that rerun. The workspace `dist` came from `turbo run build` over `./packages/*` and `./packages/*/*` (72 / 72 tasks). - `check:objectql-double-limit` was red on the first commit for this suite's copied driver double (its `find` ignored `limit`); the second commit applies the bound after the filter, and the gate is green. - `node scripts/check-issue-citations.mjs` (live, the verdict CI blocks on) → exit 0, 10 citations resolve. - `node scripts/check-system-context-census.mjs` → exit 0 (110 sites). `pnpm check:query-options-erasure` → exit 0. - The three roster gates the derivation marks as keeping a roster under one of this diff's directories: `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:filter-alias-parity` → exit 0 each. - Lint, narrowed and proven: eslint's own config (`ESLint.isPathIgnored` / `calculateConfigForFile`) ignores the changeset and lints the three TypeScript files with the typescript-eslint parser and no `parserOptions.project` / `projectService`; `eslint --no-inline-config --format json` over the four paths → 4 results, 0 errors, 0 warnings on the three linted files. The config enables no type-aware linting and its only file reads are two baselines this diff does not touch, so this diff cannot move a verdict on any untouched file. `pnpm lint` itself is CI's. ## Neighbour PR objectstack-ai#19728 Textually disjoint: this diff does not touch the `evaluateValidationRules` calls, the import line, or any hunk objectstack-ai#19728 edits in `rule-validator.ts` (its hunks are the related-record bindings; none touches the `readonlyWhen` strips). Driver-free bare probe (`git clone --bare`, refs fetched in, no `merge.*` driver registered): `merge-tree --write-tree` of `1afbcef386` against `3b9c5f2fca` → exit 0; against `origin/main` `44ce049a8c` → exit 0. The merged tree carries both changes (marker counts: `staticReadonlyStoredView` 6, `stored?: Readonly` 1, objectstack-ai#19728's `RelatedRecordBinding` 2 in `engine.ts` and 5 in `rule-validator.ts`). ## Acceptance notes - **Sibling in the same class, not fixed here (listed for the seat to file, class (a)).** The conditional strip still judges one `readonlyWhen` field against another field's value that the SAME conditional strip drops. Measured on `dabf8d795e` and on this head: `status` `readonlyWhen: "previous.status == 'closed'"`, `amount` `readonlyWhen: "record.status == 'closed'"`, row closed; `update(c1, { status: 'open', amount: 999 })` stores `{ status: 'closed', amount: 999 }`. Not mechanical: two conditional locks can each read the other's field, so "judge against what the conditional strip itself keeps" is a fixpoint whose verdicts can move in both directions, which is a decision rather than this card's shape. Dedupe words: `readonlyWhen reads value conditional strip drops` · `readonlyWhen interdependent locks same pass` · `record binding includes readonlyWhen-dropped value`. - The by-id branch now runs one extra silent `stripReadonlyFields` pass per update (a loop over the declared fields, a copy only when a key is taken). The by-id strip already materialises its bindings on every update, so the cost profile is unchanged in kind; noted, not measured. - User docs are untouched and still true after the fix; see the declaration note. ## Declaration note for the contract review The claim declares no widening; copied above as given. The fix moves a refuse/accept answer only through the validation rules that run on the stripped payload: - **Accept direction.** A write refused `VALIDATION_FAILED` only because a forged-through locked value raised a requirement now commits without that value. This removes a refusal the published text already negated: `content/docs/data-modeling/fields.mdx` ("Conditional Logic") says "The server enforces `requiredWhen` on submit and ignores writes to fields whose `readonlyWhen` predicate is `TRUE`", and ("Who the lock applies to") "A TRUE `readonlyWhen` predicate locks the field for **every API-boundary caller**". On the stored row the predicate IS true, so the write to the locked field was one the server is documented to ignore, and refusing the update on the strength of it contradicted that text. - **Refuse direction.** A write that cleared a field the kept value requires is now refused: a restored guarantee (the stored row would have violated the requirement). - `strictReadonlyWrites`: a write whose view differs from its payload always carries a static strip, so it was already refused; only `fields` / `drops` grow. Every other movement is a strip (the write still commits). No authorable key, export or error code moves. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…e stores, not a value another lock drops (objectstack-ai#19911) (objectstack-ai#19923) Fixes objectstack-ai#19911 Clause-②: no ## What a caller could do before, and what happens now **Before.** When one field's `readonlyWhen` read a field that carries its own `readonlyWhen`, a caller with edit rights could change a locked field by sending a new value for the other field in the same update. The conditional strip judged every lock against one `record` view built before any lock was judged. That view still held every value the strip then dropped. With `status` locked by `previous.status == 'closed'` and `amount` by `record.status == 'closed'`, `update(c1, { status: 'open', amount: 999 })` on a closed row stored `{ status: 'closed', amount: 999 }`. The same happened on bulk (`multi: true`) updates, for `isSystem` callers (a `readonlyWhen` lock binds them), and for a master-detail FK's own lock judged inside the PR objectstack-ai#19877 settlement: the line moved to another invoice although its own lock held on the row it kept. The mechanism also over-locked in reverse: a dropped value that WOULD lock another field locked it, though the row never took that value. **Now.** Each lock is judged with its own incoming value and every OTHER dropped key reverted to the stored value. No field is written while its lock is TRUE on the row the update stores. The strip then releases, in one step, every dropped key that is unlocked on that row, and keeps the result only when every dropped key is locked and every kept key is unlocked on the row it stores. When that step does not settle the set (locks that read each other in a cycle, or a cascade where releasing one key moves another's verdict, objectstack-ai#19927), the larger fail-safe drop set stands. This applies on all four conditional call sites (by-id and bulk strips, and the by-id and bulk FK judgements of the settlement). ## A1: reproduction on `origin/main` `a34c27cbe5`, before any edit Real `ObjectQL` engine, the PR objectstack-ai#19905 suite's in-memory driver shape. Object `case_x`: `status` `readonlyWhen: "previous.status == 'closed'"`, `amount` `readonlyWhen: "record.status == 'closed'"`, rows `c1`, `c2` closed with `amount: 100`. - By id: `update(c1, { status: 'open', amount: 999 })` stored `{ status: 'closed', amount: 999 }`, event `[{ status, readonly_when }]`. Control `update(c1, { amount: 555 })` stayed 100. **Reproduced.** - Bulk (`where: { note: 'k' }, multi: true`): both rows stored `amount: 999`. Control stayed 100. **Reproduced.** ## A2: the candidates, measured All five variants (the base and four candidates) were run on the same 20 files: the nine suites PR objectstack-ai#19905's body names (its own `engine-readonly-when-stored-view` included) and every other objectql test file that mentions `readonlyWhen` (674 tests). **Every variant passes 674/674**, so the existing pins do not tell the candidates apart. They were compared on the card's shape and on a 27-row probe of every movement class instead. The expected rows were checked against a brute-force enumeration of every drop set that agrees with the stored row. | shape (exact drop sets by enumeration) | base | (i) monotone fixpoint | **(iv) fixpoint + exact release (chosen)** | (ii) judge against the stored row | (iii) refuse when locks interact | |:---|:---|:---|:---|:---|:---| | the card, by id and bulk (`{status, amount}`) | amount 999 **under-lock** | 100 | 100 | 100 | refused | | legitimate reopen: `status` unlocked, amount edited (`{}`) | both land | both land | both land | amount dropped **over-lock** | both land | | close + edit on an open row (`{amount}`) | amount dropped | dropped | dropped | amount 999 lands on a closed row **under-lock** | dropped | | REVERSE: frozen `status`, `closed` + amount (`{status}`) | amount dropped **over-lock** | dropped **over-lock** | amount lands | lands | refused | | lock reading its own field, open row over the cap | dropped | dropped | dropped | 5000 lands **under-lock** | dropped | | three-lock chain (`{c,b}`) | `{c,a}` | `{c,b,a}` over-locks `a` | `{c,b}` | `{c,b}` | refused | | four-lock chain (`{c,z,y}`) | `{c}` | `{c,x,z,y}` over-locks `x` | `{c,z,y}` | `{c,z,y}` | refused | | two-lock cycle (NONE) | `{a}` | `{a,b}` | `{a,b}` fail-safe | `{b}` | refused | | FK's own `record` lock reads a stage its own lock keeps | FK lands, amount 999 | FK stays, 100 | FK stays, 100 | FK stays, 100 | refused | - **(i) monotone fixpoint.** Never opens a lock, and its first pass is the old single pass. It **over-locks**: a key dropped in an early pass whose own lock is FALSE on the final row. Measured on a two-field shape (REVERSE, where the base over-locks too, so (i) keeps that defect) and on the three- and four-lock chains (the four-lock `x` is a new over-lock). - **(ii) judge every lock against the stored row.** It reverts every judged key, a key's own value included. It over-locks the legitimate reopen, and it **opens locks the base holds**: close + edit writes the amount onto a now-closed row, and a lock that reads its own field lets 5000 past a 1000 cap. It also reads `record` as `previous` for those fields. Rejected. - **(iii) refuse.** Moves accept to refuse on every interacting shape. The documented behaviour is to *ignore* a locked write, not refuse it (quoted below). Rejected. Measured in its narrowest form, refusing only when a drop moves another lock's verdict; a syntactic "reads a dropped field" rule would refuse a superset. - **(iv) chosen.** (i), then ONE release: every dropped key that is unlocked on (i)'s row is released at once. The result is kept only if it is exact (every dropped key locked, every kept key unlocked, on the row it stores). Otherwise (i)'s answer stands. It never opens a lock: no field is written while its lock is TRUE on the row the update stores. It reaches the unique exact drop set on every measured shape except the three-lock cascade (objectstack-ai#19927): there, one release step does not settle the set (releasing `x` moves `y`'s verdict), so the fixpoint's larger drop set `{c, x, y}` stands instead of `{c, y}`, and a field whose own lock is FALSE on the stored row is still dropped, as on base. The cycle has no exact set at all and takes the same fail-safe answer. It changes no documented behaviour: every movement below brings the stored row into line with the documented rule. It keeps every existing pin, as all candidates do. ## The fix - `packages/objectql/src/validation/rule-validator.ts` - `settleReadonlyWhenDrops`: the fixpoint and the exact release. Each key is judged with its own incoming value (a lock reading its own field judges the write) and the other drops reverted. Views are memoised per drop set. - `stripReadonlyWhenFields` and `stripReadonlyWhenFieldsMulti` now route through it, with no new arguments at existing call sites. Bulk means "locked in at least one matched row" for every evaluation. - Warnings come from each key's deciding evaluation and are logged once, in declaration order. - `ReadonlyWhenStripOptions.only`: the strip judges every key but takes, and speaks about, only that key, and only when it is taken. - `readonlyWhenFkJudgementReadsParent`: the settlement's question "does judging the FK's lock need the header it names?". - The `parent`-root reader now caches roots per source, so it answers for `record` too. - Neither the options type nor the strips are exported from the package (`index.ts` and `core.ts` export none of them), so no published surface moves. - `packages/objectql/src/engine.ts`, `settleMasterDetailLanding` and its two `judgeFkLock` closures - The FK's own lock is judged with every caller-supplied lock (`only: fk`) instead of alone. This is how a value another lock drops is reverted before the FK's lock reads it. - The named header is read when the FK's lock reads `parent` (as before), or reads `record` while another payload key's lock reads `parent`. - A landing FK now stays in `supplied`, so the strip that judges the rest re-judges it on the same landing and settles the same drop set. An FK that does not land keeps PR objectstack-ai#19877's rule: its verdict is final and never re-asked against the header the row keeps. - One new import line, placed away from the rule-validator import line PR objectstack-ai#19728 edits. ## Every movement (base `a34c27cbe5` to head `d01b912f3d`; patch round 1 changed no code, so `611a2fc561` answers the same) | write | before | now | |:---|:---|:---| | the card, by id and bulk | `amount: 999`; event `[status]` | `amount: 100`; one event `[status, amount]` `readonly_when` | | the card under `strictReadonlyWrites` | refused, `fields: ['status']` | refused, `fields: ['status', 'amount']`, `drops` one `readonly_when` event (a refusal before and now) | | the card, `isSystem` / `preserveAudit` / a hook echoing the caller's `status` | `amount: 999` | `amount: 100` | | REVERSE by id and bulk (frozen `status`) | amount dropped; event `[status, amount]` | `amount: 999` lands; event `[status]` | | REVERSE under `strictReadonlyWrites` | refused, `['status', 'amount']` | refused, `['status']` | | own-field lock beside a dropped reopen | `amount: 500` on a closed row | `amount: 100` | | three-lock chain | stored `{c: L, b: x, a: 1}` | stored `{c: L, b: y, a: 2}` | | two-lock cycle | `{a}` dropped | `{a, b}` dropped (no exact set exists) | | `requiredWhen` requiring `note` while the amount is above 500, on the card | refused `VALIDATION_FAILED` | commits, amount 100 | | `requiredWhen` requiring `note` while the amount is below 500, the card clearing `note` | commits `{closed, 999, null}` | refused `VALIDATION_FAILED`, nothing lands | | settlement, FK lock reads a stage its own lock keeps, by id and bulk | line moves to `inv_b`, amount 999 | stays under `inv_a`, amount 100; event `[stage, invoice, amount]` | | settlement, stays to moves: the FK's own `record` lock reads a value that is itself locked under the header the update names (`invoice` locked by `record.amount == 'big'`, `amount` by `parent.status == 'paid'`; line under open `inv_b`, the update names paid `inv_a` with `amount: 'big'`), by id and bulk | line stays under `inv_b` and stores `amount: 'big'`; event `[invoice]`; header reads `[inv_b]` | line moves to `inv_a` and keeps `amount: 'small'`; event `[amount]`; header reads `[inv_a]`, then the repoint's reference check on `inv_a`. Both rows agree with their locks | | the same write under `strictReadonlyWrites` | refused, `fields: ['invoice']` | refused, `fields: ['amount']`, nothing lands | | three-lock cascade (`c` locked by `previous.c == 'L'`, `x` by `record.c == 'open'`, `y` by `record.x == 'xv'`; row `c: 'L'`; the update sets all three), by id and bulk | drops `{c, x, y}` | drops `{c, x, y}`, unchanged; the exact set is `{c, y}` (objectstack-ai#19927) | | same FK lock, no `parent`-scoped lock (plain strip) | FK moves | FK stays | | PR objectstack-ai#19877's `inv_line_moored` shape (stage not in the payload) | header reads `[inv_a]` | `[inv_b, inv_a]`: one extra read, same row, same event | **Unmoved (pinned):** both controls; a hook-written `status` and a hook overwriting the caller's `status` (both land and are read); a hook-written `amount`; the legitimate reopen by id, bulk and strict (nothing dropped); close + edit; an own-field lock over and under its cap; an FK lock reading only `previous` (one header read); a landing FK's fail-open fault warning, said once. PR objectstack-ai#19905's 18 pins and PR objectstack-ai#19877's 38 (its whole file) are green. **Warn lines:** a newly dropped key gains its drop line and a released key loses it. A key re-judged in a later pass speaks from its deciding evaluation, once. A landing FK's fault warnings now print with the rest, in declaration order, instead of first. When the FK stands on its own lock but the static strip takes it, its conditional fault warning is no longer printed. ## A3: all four call sites By-id strip, bulk strip, by-id `judgeFkLock` and bulk `judgeFkLock` all run the same settlement; no site is exempt. The FK's verdict is consistent when it lands: the re-judge is the same computation over the same header. When the FK's lock reads neither `record` nor `parent`, its verdict does not depend on the view. PR objectstack-ai#19905's `stored` view is the base every view is built from, so a value the static strip takes is never read either. ## Tests New `packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts`, 35 cases (32 in round 1, plus three stays-to-moves pins by id, bulk and under `strictReadonlyWrites` in patch round 1): the card by id and bulk with both controls, the report and warn lines, the strict envelope (`code` `ERR_READONLY_FIELD_REJECTED`, `name`, `fields`, `drops`; the engine error carries no HTTP `status`, which is mapped downstream and not measured here), `isSystem`, `preserveAudit`, four hook shapes, the legitimate reopen (by id; bulk plus strict), close + edit, REVERSE by id / bulk / strict, the own-field lock (two cases), the chain, the cycle, both `requiredWhen` directions (refusal asserted on `code` `VALIDATION_FAILED` + `fields`), the settlement by id and bulk with its header reads, the plain-strip FK, the moored read count, the `previous`-only FK, the landing FK's single warning, and two unit cases for `only`. On `d01b912f3d` (patch round 1): - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: 307 files / 5158 tests passed, exit 0. - `pnpm --filter @objectstack/objectql typecheck`: exit 0, `check:test-typecheck: OK`. The new file is in the `tsconfig.test.json` program (`--listFiles`: 1) with 0 diagnostics. `pnpm --filter @objectstack/objectql test:repo`: 5 passed. - **Ablation** (fix committed; `engine.ts` and `rule-validator.ts` restored to their `a34c27cbe5` blobs `e973ab50ac` / `2b3002b8e1` with `git restore --source`, tree only; on-disk hashes verified and markers `settleReadonlyWhenDrops` / `readonlyWhenFkJudgementReadsParent` counted 0 / 0). The three suites ran **23 failed / 68 passed**: every new pin failed except the 12 whose behaviour is unchanged, the three stays-to-moves pins included, and PR objectstack-ai#19905's 18 and PR objectstack-ai#19877's 38 stayed green. The subject is imported from source (`./engine.js`), so no `dist` sits on the path. Restored with `git checkout HEAD --`: blobs `c9b1cfc19e` / `f3934b80f6` equal `HEAD`, `git diff HEAD` empty, rerun 91/91 passed. The script carried an `EXIT` / `INT` / `TERM` trap. ## Gates on `611a2fc561` Patch round 1 (`d01b912f3d`) changed docblocks, the changeset and three pins over the same four paths. It re-ran the objectql tests and typecheck, `node scripts/check-issue-citations.mjs` (23 citations resolve, objectstack-ai#19927 included), `node scripts/check-changeset-no-major.mjs --base origin/main`, `pnpm check:nul-bytes` and the narrowed eslint run: exit 0 each. The full derivation below ran on `611a2fc561`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 65 commands, each run with its exit code captured before any pipe. `--ran`: `65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero …)`, exit 0. `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` first answered PREREQUISITE NOT MET (exit 3, no workspace `dist`). After `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2` (72/72 tasks) all three exit 0, and the record carries the reruns. - `node scripts/check-issue-citations.mjs` (live, the verdict CI blocks on): exit 0, 22 citations resolve. - `node scripts/check-system-context-census.mjs`: exit 0 (no new `isSystem` read). `pnpm check:query-options-erasure`: exit 0. - The three roster gates the derivation marks as keeping a roster under this diff's directories: `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:filter-alias-parity`, exit 0 each. - Lint, narrowed and proven. eslint's own config (`ESLint.isPathIgnored` / `calculateConfigForFile`) ignores the changeset and lints the three TypeScript files with `typescript-eslint/parser` and no `parserOptions.project` / `projectService`. `eslint --no-inline-config --format json` over them gives 3 results, 0 errors, 0 warnings. The config enables no type-aware linting, and its only file reads are two baselines this diff does not touch, so this diff cannot move a verdict on any untouched file. `pnpm lint` itself is CI's. ## Neighbour PR objectstack-ai#19728 This PR stays textually disjoint from it and does not wait on it. Driver-free bare probe (`git clone --bare --shared`, no `merge.*` driver registered): `merge-tree --write-tree` of `d01b912f3d` (and before it `611a2fc561`) against its head `3b9c5f2fca` exits 0, and against `origin/main` `beac798026` exits 0. The merged tree carries both changes (`readonlyWhenFkJudgementReadsParent` 3 in `engine.ts`, `settleReadonlyWhenDrops` 6 in `rule-validator.ts`, objectstack-ai#19728's `RelatedRecordBinding` 2 and 5). ## Acceptance notes - **Cycle residue.** When locks read each other in a cycle, no drop set can make every dropped key locked AND every kept key unlocked on the stored row, and no rule can promise both halves. This one keeps the fail-safe half (a lock that cannot be settled is not waived), as the bulk strip's "locked in at least one row" rule already does. - **Cost.** A write where nothing locks runs one pass, as before. Each drop adds one pass over the standing keys and one re-check of the dropped keys; a full check pass runs only when something is over-locked. The settlement reads one extra header in the moored shape. Noted, not measured. - **In-tree usage.** The tree has no case of one `readonlyWhen` reading another `readonlyWhen` field. Examples and platform objects were scanned: the showcase `invoice` locks read `status` / `parent.status`, and neither carries a lock. The card's shape is the natural "a closed case stays closed" plus "a closed case's amount is frozen". - User docs are untouched and still true after the fix; see the declaration note. ## Declaration note for the contract review The claim declares no widening; copied above as given. The fix moves a refuse/accept answer only through the validation rules that run on the stripped payload, and moves strip verdicts toward the documented rule: - **Accept direction.** A write refused `VALIDATION_FAILED` only because an amount the lock should have held raised a requirement now commits without it. The REVERSE shape's amount, which the base silently dropped, now lands. Both remove a verdict the published text already negated. `content/docs/data-modeling/fields.mdx` ("Conditional Logic") says `readonlyWhen` is a "CEL predicate; field is read-only when `TRUE`", and "The server enforces `requiredWhen` on submit and ignores writes to fields whose `readonlyWhen` predicate is `TRUE`". ("Who the lock applies to") says "A TRUE `readonlyWhen` predicate locks the field for **every API-boundary caller**". `content/docs/data-modeling/formulas.mdx` binds `record` to "the row being evaluated". On the row the update stores, the REVERSE amount's predicate is FALSE and the card's is TRUE. - **Refuse direction.** A write that cleared a field the kept amount requires is now refused: a restored guarantee. - `strictReadonlyWrites`: every shape that moves carries a conditional drop, so it was a refusal before and still is; only `fields` / `drops` move, and they grow on the card and shrink on REVERSE. Every other movement is a strip (the write still commits). No authorable key, export or error code moves. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…he stored row agrees with (objectstack-ai#19927) (objectstack-ai#19928) Fixes objectstack-ai#19927 Clause-②: no ## What a caller saw before, and what happens now **Before.** When `readonlyWhen` locks read each other in a chain, an update could ignore an edit whose own lock was FALSE on the row the update stored. The card's cascade: `c` locked by `previous.c == 'L'`, `x` by `record.c == 'open'`, `y` by `record.x == 'xv'`; row `{ c: 'L', x: 'old', y: 'old' }`; `update({ c: 'open', x: 'xv', y: 'yv' })`. The row keeps `c: 'L'`, so `x`'s lock is FALSE there, yet all three fields were dropped, and a `strictReadonlyWrites` refusal named `x`. The settlement that PR objectstack-ai#19923 added released every over-locked key once and, when that one step did not settle the set, kept the fixpoint's larger drop set. **Now.** The release repeats. The stored row for the card's update is `{ c: 'L', x: 'xv', y: 'old' }`, one `readonly_when` event `[c, y]`, by id, on bulk (`multi: true`) updates and for `isSystem` callers; under `strictReadonlyWrites` the refusal names `[c, y]`. No caller field is written while its `readonlyWhen` is TRUE on the row the update stores (0 opened in 3016 engine runs and 5662 strip runs, below). A cycle with no exact drop set keeps the fail-safe drop. ## The change - `packages/objectql/src/validation/rule-validator.ts`, `settleReadonlyWhenDrops`, step ② only. Step ① (the monotone fixpoint) is untouched. Step ② used to release every over-locked key once and keep the result only if it was exact. Now the first set releases them all at once, each later set is every judged key that locks when judged against the set before it, and the first set that gives back itself is the answer. After n + 1 sets (n = judged keys) with none giving back itself, ①'s set stands, as before. The docblock is rewritten to say this and what it guarantees. - `packages/objectql/src/engine.ts` is not touched. All four conditional call sites (by-id strip, bulk strip, and the by-id and bulk `judgeFkLock` of objectstack-ai#19853's settlement) already route through this one function. - New `packages/objectql/src/engine-readonly-when-exact-drop-set.test.ts` (19 cases). The objectstack-ai#19911 suite's header comment is corrected (comment only). One `patch` changeset, and the pending `.changeset/19911-readonlywhen-interdependent-locks.md` corrected in place (one file beyond the claim's surface, authorized by the seat in the patch round). ## A1: the card reproduced on `origin/main` `ae0c90c133`, before any edit Real `ObjectQL` engine, the objectstack-ai#19911 suite's in-memory driver, two rows `{ c: 'L', x: 'old', y: 'old' }`: | write | stored | report | |:---|:---|:---| | by id | `r1` `{ L, old, old }` | event `[c, x, y]` | | bulk (`where: { tag: 't' }, multi: true`) | `r1`, `r2` `{ L, old, old }` | event `[c, x, y]` | | by id, `strictReadonlyWrites` | untouched | refused `ERR_READONLY_FIELD_REJECTED`, `fields: [c, x, y]` | | bulk, `strictReadonlyWrites` | untouched | refused, `fields: [c, x, y]` | Brute-force enumeration of the 8 drop sets: the only exact one is `{c, y}`. **Reproduced.** ## A2: the search, its bound, and its answers against a brute-force oracle **Why this search.** Every step judges every key against one set, so no order enters the answer: field declaration order and payload key order cannot move it (measured below). A one-key-at-a-time release needs an order: either a read graph built from the CEL source, which the strip does not have, or declaration order, which would make the answer order-dependent on a cycle. It was not built. **Termination and cost.** ① makes at most n(n + 1) / 2 key judgements; ② at most n + n² (the first release judges ①'s dropped keys once; each of at most n further sets judges every key once). Worst case 3n(n + 1) / 2 key judgements; a bulk write evaluates each over its matched rows. Measured below as CEL evaluations: the highest ratio to that bound was 1.0 (9 of 9 at n = 2, the two-lock cycle with no exact set); at n = 7 the highest was 70 of 84 by id and 144 of 252 on a 3-row bulk write. On every run where ① or its first release settled the set, the evaluation count equals the base's exactly (5535 of 5535 runs); on the 127 runs where it did not, head evaluates at least as many. **Exactness without a cycle.** A key's verdict depends only on the judged keys its `record` reads name. After t sets, every key at depth below t in that read graph holds its final verdict, so the n-th set is exact and the (n + 1)-th gives it back. Measured: every acyclic run reached its exact set (below). **Probe.** The real `stripReadonlyWhenFields` / `stripReadonlyWhenFieldsMulti` of the base tree (`ae0c90c133`) and of this head, over 9 hand-built shapes plus 3000 seeded random ones: 2 to 7 text fields, predicates of one or two atoms joined by `&&` / `||`, sometimes negated, over `record.*` (self-reads included), `previous.*`, `parent.status` (bound, or unbound for 10% of rows) and `true` / `false`; 1 to 3 prior rows; by id and bulk. 178 shapes judged fewer than two keys and were skipped: **5662 runs**. The oracle: a key's lock on a drop set D is the one-key strip (only that key carries its `readonlyWhen`) over the payload with D minus that key removed, i.e. the same evaluator, bindings and fault rules with no settlement. Every one of the 2^n drop sets was enumerated. A shape is cyclic when the `record.*` reads among its judged keys form a cycle. | class (runs) | exact sets (enumerated) | head reaches one | base reaches one | |:---|:---|:---|:---| | acyclic (4720) | exactly 1 in every run | 4720 | 4673 | | cyclic, one exact set (846) | 1 | 846 | 823 | | cyclic, two exact sets (64) | 2 | 39 (the same answer as base in all 64) | 39 | | cyclic, no exact set (32) | 0 | n/a: fail-safe drop, equal to base in 32 of 32 | n/a | - **Locks opened:** head 0, base 0. - **Order:** 3 permutations of field declaration order and payload key order per run, 16986 permutation runs, 0 changed the head's answer. - **Two exact sets (64 runs):** head's answer equals base's in all 64: one of the exact sets in 39 (① or its first release had already reached it), the fail-safe drop in 25. Pinned: `a` locked by `record.b == 'new_b'`, `b` by `record.a == 'new_a'` has `{a}` and `{b}`; ② alternates between `{}` and `{a, b}` and ①'s `{a, b}` stands. `a` locked by `record.b == 'old'`, `b` by `record.a == 'old'` has `{}` and `{a, b}`; ①'s first pass locks nothing, so `{}` is the answer and both edits land. - **Movements against base: 70 runs.** In every one, head's answer is exact and each key base dropped but head keeps lands with its lock FALSE on the row head stores. In 4 of them (hand-built, by id and bulk) head also drops a key base had written: that key's lock is TRUE on the row head stores (the knock-on class below). ## A3: the same through the real engine, base and head The objectstack-ai#19911 contract review's probe style: 8 hand-built shapes plus 1500 seeded random ones with 2 to 5 fields, `record` / `previous` / `parent` roots, self-reading locks, a static `readonly` field (35% of shapes) whose value the caller forges in half of them, a `master_detail` FK with its own lock (45%), `isSystem` (25%). Each ran by id and bulk over 1 to 3 rows with random values, plain and under `strictReadonlyWrites`, at base and at head: **3016 plain and 3016 strict runs per tree**, plus 2 permutations per plain run at head. An independent oracle re-evaluated every caller-supplied field's predicate on the row the driver holds after the write (the FK's own lock against the header it names, objectstack-ai#4889). - **Locks opened:** head 0, base 0. - **Order:** 6032 permutation runs at head, 0 differ in stored rows or reported drops. - **Strict:** the refusal went refuse to accept or accept to refuse in 0 of 3016 pairs; its `fields` moved in 20 (refused before and after). - **Movements: 20 plain runs** (12 hand-built, 8 random). 14 only release keys; 6 also drop a knock-on key. Every released key lands with its lock FALSE on the stored row; every knock-on key is locked on it. Two random runs (seed 413, by id and bulk) release a master-detail FK: the line moves to the header the update names, as in the settlement row below. - **Exactness at head:** 3002 of 3016 runs store a row on which every dropped key is locked and every kept key unlocked (base: 2982). The 14 others are unmoved from base: 13 have a cycle among `record` reads (4 hand-built: the two-exact-set pair and the no-exact-set cycle, by id and bulk); 1 (seed 371, bulk) has its only cycle through `parent`: the FK's lock reads `record.f1` and `f1`'s lock reads `parent`, which the FK decides. Enumerating its 4 sets of `{inv, f1}` by hand, none is exact, and the fail-safe drop stands. ## A4: the FK settlement The search applies there: both `judgeFkLock` closures call the same strips (`only: fk`), which call `settleReadonlyWhenDrops`, and a landing FK is re-judged by the strip that follows over the same header. Pinned by id, bulk and strict with `line_cascade` below. The rule for an FK that does not land is unchanged (its verdict is final, objectstack-ai#19853), and the new docblock says the settlement's claims are about the views it is handed for that reason. ## Every movement (base `ae0c90c133` to head `d9af551bb5`) Measured through the real engine at both trees. The new test file pins each row by id, on bulk and by id under `strictReadonlyWrites`, except the six-lock cascade (by id only); the card is also pinned under `strictReadonlyWrites` on bulk and for `isSystem`. | write | before | now | |:---|:---|:---| | the card by id, bulk (2 rows) and `isSystem` | every row `{ c: L, x: old, y: old }`; event `[c, x, y]` | every row `{ c: L, x: xv, y: old }`; event `[c, y]` | | the card under `strictReadonlyWrites`, by id and bulk | refused, `fields: [c, x, y]`, nothing lands | refused, `fields: [c, y]`, nothing lands | | six-lock cascade (`c` by `previous.c == 'L'`, each `xN` by the previous one being `'v'`, all set to `'v'`), by id, bulk, `isSystem` | all six dropped | `x1`, `x3`, `x5` land; event `[c, x2, x4]` | | the same under `strictReadonlyWrites` | `fields: [c, x1, x2, x3, x4, x5]` | `fields: [c, x2, x4]` | | KNOCK-ON: `p` by `previous.p == 'L'`, `m` by `record.p == 'L'`, `j` by `record.m == 'new'`, `k` by `record.p == 'L' && record.j == 'new'`; all set to `'new'` on `{ p: L, m: old, j: old, k: old }`, by id, bulk, `isSystem` | `k: 'new'` stored; event `[p, m, j]` | `j: 'new'` stored, `k` kept `old`; event `[p, m, k]` | | the same under `strictReadonlyWrites` | refused, `fields: [p, m, j]` | refused, `fields: [p, m, k]` | | SETTLEMENT: `c` by `previous.c == 'L'`, FK `invoice` by `record.c == 'open'`, `y` by `record.invoice == 'h_open'`, `amt` by `parent.status == 'paid'`; line `{ c: L, invoice: h_paid, y: old, amt: a0 }`; update `{ c: open, invoice: h_open, y: yv, amt: a1 }`, by id, bulk, `isSystem` | line stays under `h_paid`, `y: yv` stored, `amt` stays `a0`; event `[c, invoice, amt]`; by-id header reads `[h_open, h_paid]` | line moves to `h_open`, `amt: a1` stored, `y` stays `old`; event `[c, y]`; by-id header reads `[h_open, h_open]` (the named header, then the repoint's reference check) | | the same under `strictReadonlyWrites` | refused, `fields: [c, invoice, amt]` | refused, `fields: [c, y]`; the line stays under `h_paid` | **Unmoved (pinned):** the two-lock cycle with no exact set by id and bulk (`{a, b}` dropped); the pair with exact sets `{a}` and `{b}` (`{a, b}` dropped); the pair with exact sets `{}` and `{a, b}` (both land); `only: y` over the card's cascade. PR objectstack-ai#19923's 35 pins, PR objectstack-ai#19905's 18 and PR objectstack-ai#19877's 38 are green. **Warn lines:** a released key loses its drop line and a knock-on key gains one; the card by id now warns for `c` and `y` only. ## Declaration note for the contract review The claim carries `Clause-②: no`, copied above as given. No authorable key, export or error code moves; the function is not exported (`index.ts` and `core.ts` export neither it nor the strips). - **Accept direction (a dropped edit now lands).** `content/docs/data-modeling/fields.mdx`, "Conditional Logic": "The server enforces `requiredWhen` on submit and ignores writes to fields whose `readonlyWhen` predicate is `TRUE`", and the table row "CEL predicate; field is read-only when `TRUE`". `content/docs/data-modeling/formulas.mdx` binds `record` to "the row being evaluated". Every released key's predicate is FALSE on the row the update stores (checked on every moved run of A2 and A3), so the old drop is one the text negates. `content/docs/kernel/contracts/data-engine.mdx` lists the `readonly_when` strip as "A TRUE `readonlyWhen` predicate" and `strictReadonlyWrites` as "refuse instead of stripping": the old refusal named `x`, whose predicate was FALSE on the stored row. - **Drop direction (the knock-on class: a written field is now ignored).** On the row the update now stores, that field's predicate is TRUE, so ignoring it is what the same sentence prescribes. The base wrote it onto a different row, one where another field was dropped with its own predicate FALSE; only the new answer agrees with every field's lock on the row it stores. This moves a write to a drop and does not move any refusal. - **Settlement.** A master-detail repoint the chain used to hold now lands where its lock is FALSE on the stored row, and the other fields are then judged under the header it lands on (`amt` unlocked, `y` locked): the same "stays to moves" class PR objectstack-ai#19923 declared, reached now through a chain. - **`strictReadonlyWrites`.** Whether a write is refused does not move: the answer is empty only when ①'s first pass locks nothing, exactly as before, and a set that gives back itself is never empty otherwise (0 of 3016 A3 pairs moved). Only `fields` / `drops` move. - **Validation.** `requiredWhen` and validation rules run on the stripped payload, as before, so a field that now lands or is now ignored reaches them as stored. Declared by class; no shape here carries a `requiredWhen`. - **Cycles.** Where no exact set exists the docs are silent and the fail-safe drop stands, as before (binding per the dispatch). ## Tests New `packages/objectql/src/engine-readonly-when-exact-drop-set.test.ts`, 19 cases: the card by id, bulk, strict by id, strict bulk and `isSystem` (stored rows, the one event, the warn lines, `code` `ERR_READONLY_FIELD_REJECTED` + `fields` + `drops`); the six-lock cascade; the knock-on by id, bulk and strict; the no-exact-set cycle by id and bulk; the two two-exact-set cycles; the settlement by id (with header reads), bulk and strict; three unit cases on `stripReadonlyWhenFields` (the cascade, `only: x`, `only: y`). On `2c9cbe94eb` (the patch round changed only the two changesets; the code is `d9af551bb5`'s): - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: 308 files / 5177 tests passed, exit 0 (the same at `d9af551bb5`). - `pnpm --filter @objectstack/objectql typecheck`: exit 0, `check:test-typecheck: OK`. At `d9af551bb5` the new file was in the `tsconfig.test.json` program (`--listFiles`: 1) with 0 diagnostics. `pnpm --filter @objectstack/objectql test:repo`: 5 passed. - **Ablation**, run at `b5e3de9a35` (the fix and the tests committed; `d9af551bb5` adds 5 docblock lines). `rule-validator.ts` restored to its base blob `f3934b80f6` with `git restore --source`, tree only; on-disk hash verified equal to it, the fix's marker (the loop bound: `step`, a less-than sign, `judged.length`) counted 0 and the base's `const overLocked` counted 1. The four suites (new, objectstack-ai#19911's, objectstack-ai#19887's, objectstack-ai#19853's) ran **14 failed / 96 passed**: every movement pin in the new file failed, and its 5 unchanged-behaviour pins (both no-exact-set cycle pins, both two-exact-set pins, `only: y`) passed with PR objectstack-ai#19923's 35, PR objectstack-ai#19905's 18 and PR objectstack-ai#19877's 38. The subject is imported from source (`./engine.js`), so no `dist` sits on the path. Restored with `git checkout HEAD --`: on-disk blob `35fb361521` equals `HEAD`'s, `git diff HEAD` 0 bytes, `git status` clean, rerun 110/110 passed. The script carried an `EXIT` / `INT` / `TERM` trap. ## Gates on `2c9cbe94eb` - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: the same 63 commands as at `d9af551bb5`, each run with its exit code captured before any pipe: 62 exit 0, and `node scripts/check-empty-changeset.mjs --base origin/main` exits 1 on the objectstack-ai#19911 note this PR corrects (the refusal's DELIBERATE CORRECTION class, declared below). `--ran`: `63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3)`, exit 0. The three dist-reading gates ran after `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2` (72/72, all cached) and exit 0; `git status` stayed clean. - `node scripts/check-changeset-no-major.mjs --base origin/main`: exit 0. `pnpm check:nul-bytes`: exit 0. `node scripts/check-changeset-fixed.mjs`: exit 0. - `node scripts/check-issue-citations.mjs` (live, the verdict CI blocks on): exit 0 at `2c9cbe94eb`, 6 citations judged, all resolve. `pnpm check:authz-resolver`, `pnpm check:filter-alias-parity` and the narrowed eslint run below were measured at `d9af551bb5`; the patch round touched only the two changesets, which eslint's config ignores. - `node scripts/check-system-context-census.mjs`: exit 0. `pnpm check:query-options-erasure`: exit 0. - The three roster gates the derivation marks as keeping a roster under this diff's directories: `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:filter-alias-parity`, exit 0 each. - Lint, narrowed and proven. eslint's own config (`ESLint.isPathIgnored` / `calculateConfigForFile`) ignores the changeset and lints the three TypeScript files with `typescript-eslint/parser` and no `parserOptions.project` / `projectService`. `eslint --no-inline-config --format json` over them gives 3 results, 0 errors, 0 warnings. The config enables no type-aware linting, and its only file reads are two baselines this diff does not touch, so this diff cannot move a verdict on any untouched file. `pnpm lint` itself is CI's. ## Neighbour PR objectstack-ai#19728 Textually disjoint, not merged in, not waited on. Driver-free bare probe (`git clone --bare --shared`, no `merge.*` config): `merge-tree --write-tree --name-only` of `2c9cbe94eb` (and before it `d9af551bb5` and `b5e3de9a35`) against its head `3b9c5f2fca` exits 0, and against `origin/main` `fdeeea0cc9` exits 0. The merged tree carries both changes (that loop-bound marker 1 in `rule-validator.ts`; objectstack-ai#19728's `RelatedRecordBinding` 5 there and 2 in `engine.ts`). ## Acceptance notes - **The pending objectstack-ai#19911 release note is corrected in place.** `.changeset/19911-readonlywhen-interdependent-locks.md` is unreleased and compiles into the same CHANGELOG as this PR's entry. This PR made two of its passages false, so both are rewritten in that file and nothing else there moves: the "What happens now" sentences on the release (it now repeats round after round until the dropped fields are exactly the locked ones, and the first, larger set stands after one round more than the caller's `readonlyWhen` fields), and the residue bullet that said "The release is one step, not a search" with the three-lock cascade as its example. That bullet now says a field whose lock is FALSE on the stored row can still be dropped only where locks read each other in a cycle (a `parent`-scoped lock counting as a read of the master-detail field), gives the two cycle shapes this PR pins, and says some other updates with two agreeing drop sets store one of them. This PR's own entry no longer repeats what that entry states (the cycle residue, validation on the stripped update, the no-lock-opens rule) and no longer points at the old text. The seat chose this correction in the patch-round order (option A). `node scripts/check-empty-changeset.mjs --base origin/main` exits 1 on it by design: "Correcting a pending release note is a decision about a release rather than a refactor -- say so on the PR, naming the note and what changed under it, and get it confirmed." So `Check Changeset` stays red, `skip-changeset` is not applied, and the seat holds this PR's landing for the maintainer's confirmation. - **Cycles.** A cycle can have no exact set, one, or several. With none, the fail-safe drop stands; with several, the answer is the one the iteration reaches, or the fail-safe drop (measured: equal to base on all 64 such runs). - **In-tree usage.** PR objectstack-ai#19923 scanned examples and platform objects and found no `readonlyWhen` reading another `readonlyWhen` field; this change moves nothing where no such chain exists (on every run where ① or its first release settled, head's answer and evaluation count equal base's). --- _Generated by [Claude Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ger over-locks the rest of the update (objectstack-ai#19979) Fixes objectstack-ai#19929 Clause-②: no It removes a drop that published text already rules out: `content/docs/data-modeling/fields.mdx` says the server "ignores writes to fields whose `readonlyWhen` predicate is `TRUE`". The dropped `x` below has a FALSE predicate on the row the update stores. ## What changes `settleReadonlyWhenDrops` (`packages/objectql/src/validation/rule-validator.ts`) searched for ONE drop set across every `readonlyWhen` key in the update. When a cycle stopped that search from settling, it kept the fixpoint's larger set for the WHOLE update. Now the keys are settled in groups: the strongly connected components of "the predicate of k reads j through `record`", taken in dependency order. - A key in no cycle is judged once, after every key it reads, against their final drops. - Keys that read each other in a cycle are settled together, with the existing fixpoint, release rounds and fail-safe fallback. The bound is now the cycle's size, and the fallback covers only that cycle's keys. - What a predicate reads comes from the AST of the canonical parse (`parseCelToAst`). Only a `.` or `.?` select on the bare `record` root counts as reading a named field. Any other use of `record` (`record['b']`, `'b' in record`, `size(record)`, a macro-bound `record`), a non-CEL predicate, or one that does not parse counts as reading every judged key. `previous` and `parent` are fixed within one strip call, so they add no edge. `engine.ts` is unchanged (H4). The master-detail settlement calls the same strips; the new `line_beside` pins show its FK landing beside a cycle with the same header reads as before (`['h_open', 'h_open']`). I chose strongly connected components over the card's connected components on measurement (table below). Connected components still over-lock a key that is in no cycle but is connected to one, for example a cycle that reads `x`. The per-field sentence the triage asked to make true ("only where locks read each other in a cycle") holds only with the per-cycle grouping. ## Measurements **H1** holds on `main` `2c1011b01b`, read from the code: the function's last line returns the fixpoint's set for the whole judged list. **H2** holds on `main`. The new suite, run before the fix, was 17/17 red. In both card shapes the update stored `x: 'old'`, so all five fields dropped. The pure strip returned `{ x: 'xv' }` for the chain alone and `{}` beside the cycle. The strict refusal named `['c', 'x', 'y', 'a', 'b']`. **H3** was tested with a brute-force probe: a temporary vitest file, not committed, run against the new strips and the BASE strips copied from `2c1011b`. It covered 16,500 random lock systems, 12,000 single-row and 4,500 bulk with 2 or 3 matched rows, some with index reads. Every check came back with 0 violations: - (S) no kept key is locked on the row the update stores (a key is judged with its own incoming value); - (E) every dropped key that is unlocked there is in a cycle, under the reading above; - (L) each key's verdict is unchanged when the payload is cut down to the keys that key transitively reads; - (D) shuffling field declaration order and payload key order never changes the drops; - (C) an acyclic system gives the same answer as base; - (R) the update drops nothing exactly when base drops nothing, so `strictReadonlyWrites` refuses the same writes. | over-locked keys (dropped while unlocked on the stored row) | base | connected components | per cycle (this PR) | |---|---|---|---| | total | 664 | 661 | 575 | | outside any cycle | 90 | 87 | 0 | Base and this PR agree on 16,405 of the 16,500 systems. They differ in 95: - 86 where both fall back and this PR over-locks fewer keys; - 5 where both reach an agreeing set, but a different one; - 4 where base reached an agreeing set and this PR keeps the cycle's fail-safe drops (see the acceptance notes). The rule-validator blob the probe measured is `87b0b5a7`, unchanged since `ebb8a761` through this head. **Ablation of the conservative reader.** I committed first, then ran `node scripts/ablation-replace.mjs` in WRAP mode to change `every = true;` to `every = false;` (anchor 1 → 0, blob `87b0b5a7` → `9b744861`). Exactly the two `index_read` pins went red. The ablated update stored `w: 'wv'` although `w`'s lock (`record['a'] == 'old_a'`) is TRUE on the stored row, so the lock opens. The restore was proven: blob equal to HEAD, and `git diff HEAD` empty. The suite imports `./validation/rule-validator.js` through a relative path, so the subject resolves to `src`, not `dist`, and no dist preflight applies. The fixtures declare `z`/`w` ahead of the cycle. Groups with no read between them come out in declaration order, so a cycle declared first would have hidden a missed read. ## Tests (head `2d75a593`) - `pnpm --filter @objectstack/objectql typecheck`: exit 0, test layer included (234 errors, 65 signatures held in the ledger, unchanged). - `pnpm --filter @objectstack/objectql test`: 309 files, 5195 tests, all passed. Every existing `readonlyWhen` pin is unchanged and green, including the objectstack-ai#19911 and objectstack-ai#19927 suites and the parent, stored-view and derived-writes suites. - New `engine-readonly-when-cycle-isolation.test.ts`: 18 pins. They cover both card shapes (by id, bulk, strict, `isSystem`), declaration and payload order, a cycle that reads the chain, locks that read the cycle (select and index), the master-detail FK beside a cycle (by id and bulk, header reads), and pure-strip isolation including `only`. - eslint `--no-inline-config` on the 2 changed `.ts` files: 2 files, 0 errors, 0 warnings. The config never enables type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any file it does not touch. The 2 `.changeset/*.md` files fall outside the lint `files` globs. ## Gates I ran `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from this diff. It derived 63 commands; I ran those plus the 2 families only the dispatch named (`check:stack-collection-maps`, `check:swallow-census-controls`). `--ran` reconciles: 63 derived, 61 run, 2 NOT-MEASURED, 0 UNRUN. - NOT MEASURED, PREREQUISITE NOT MET (exit 3): `check:dual-build-cjs-loads` and `check:type-check-debt`. Both need every workspace package's `dist/`, and only the objectql closure is built here. As a narrower check, `@objectstack/objectql`'s own build succeeded (ESM, CJS and DTS), and its CJS entry loads (`require` exit 0, 176 exports). - `check:engine-split-ratio --days 90` refused on the shallow clone (exit 2). After `git fetch --shallow-since=2026-06-19 origin main` it passed (exit 0). - **Red by design: `check-empty-changeset --base origin/main` (exit 1), the DELIBERATE CORRECTION class.** This PR rewrites the pending `.changeset/19911-readonlywhen-interdependent-locks.md`, whose sentences this PR made false: - its mechanism paragraph said the release rounds stop "after one round more than the caller sent fields" and that the larger set "stands instead" for the whole update; - its cycle bullet said a FALSE-lock field is dropped "only where locks read each other in a cycle", which was true for the update and false per field. Both now describe per-cycle settlement. The pending `.changeset/19927-readonlywhen-exact-drop-set.md` carries no such sentence, and every example in it still holds on the new pins, so it is untouched. Neither changeset had been consumed by a release at base `2c1011b`. **This correction of a pending release note needs a maintainer's confirmation on this PR;** the gate stays red until then. I did not add a `skip-changeset` label. - All other commands exit 0. ## Serial neighbour Draft PR objectstack-ai#19728 is still open. I fetched its head `3b9c5f2f` into `refs/issue-19929/pr19728` and ran `git merge-tree --write-tree --name-only` of this head against it from a throwaway bare clone with no merge driver: exit 0, tree `80a7d59f`, no conflicted paths (merge base `6eaa0f4a`). That PR rewrites the `@objectstack/formula` import on line 195, so this PR imports `parseCelToAst` in a separate statement further down the import block. ## Acceptance notes - **A delta inside cycles with two or more agreeing sets.** In 4 of the 16,500 probe systems, base reached one of a cycle's agreeing sets through the whole-update trajectory, and this PR keeps that cycle's fail-safe drops instead. Example: `k1: previous.k1 == 'q'`, `k2: record.k1 == 'q'`, and the cycle `k0: record.k3 == 'p' && record.k2 == 'q'` / `k3: record.k0 == 'q'`, on the row `{k0: p, k1: q, k2: q, k3: q}` with `{k0: q, k1: p, k2: p, k3: p}`. With `k2` settled first the cycle agrees with both `{k0}` and `{k3}`, and the release rounds alternate between `{}` and `{k0, k3}`. Base's first pass judged `k0` against a `k2` it had not dropped yet and landed on `{k3}`. Such a cycle's pick was always an artifact of the iteration (the documented rule is "the one ② reaches, or ①'s when it reaches none"). It now depends only on the locks the cycle reads. The 19929 changeset states this. No pin flipped. - A predicate that reads `record` other than as a field select counts as reading every judged key. In the probe that created a cycle that was not really there 9 times, each over-locking in the fail-safe direction. This is the conservative direction the ablation above proves is load-bearing. - `fields.mdx` never mentions the residual over-lock inside a cycle. The docs are incomplete there, not wrong, so I noted it and filed nothing. Carrier: none. - The `onFieldsDropped` event lists fields in the payload's key order (`reportDroppedFields` walks the payload). That is existing behaviour, and the order pin in the new suite states it. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ds for the row it stores (objectstack-ai#20012) Fixes objectstack-ai#19989 Clause-②: no (narrowing) ## What this fixes A row-level security `check` (declared on a policy, or defaulted from its `using`) is a guarantee about the row that is stored (`RowLevelSecurityPolicySchema.check`; ADR-0058 D4). An insert and a predicate update are judged on the row the driver stores, through the engine-run `OperationContext.postHookWriteImageCheck` seam. A by-id update was judged only inside the security middleware, on the caller's pre-image merged with the change set as sent, before `next()` runs the `beforeUpdate` chain. A value a hook wrote into a checked field after that point was never judged, so the row could be stored outside the policy, including in an organization the caller does not hold. A by-id update is now also judged on the row it stores, with the one existing refusal (`PERMISSION_DENIED` / 403, nothing stored). The middleware's existing judgement of the change set as sent stays, so this change only ever refuses more. ## Landing: two packages - `packages/objectql/src/engine.ts` (`domain:engine`, declared cross-seat in the claim). The by-id branch of `update()` runs the installed seam right after `assertNoStrictDrops()`, the same point the predicate path uses. By then the `beforeUpdate` chain, the hand-back of withheld read-only values, both readonly strips and the strict-drop refusal have run, and nothing below changes a value before `driver.update`. The image is the prior row (already read under the not-found gate) merged with the final payload, coerced as the predicate path's images are. The seam's doc comments now name the by-id path. - `packages/plugins/plugin-security/src/security-plugin.ts`, step 3.6. The seam is installed for every update, not only a predicate update. The by-id change-set judgement is unchanged. The post-`next()` fail-closed guard therefore covers a by-id update too. **Why the engine is the producer (measured).** The middleware's only image of a by-id update is taken before the hooks run. The engine is the one place that holds the final payload and the prior row together, and it already runs this seam for the other two write shapes. **One adjacent edge, closed so that the change never admits more.** The middleware reads a falsy scalar payload `id` as a row address; the engine does not, and binds a truthy scalar `where.id` instead. In that shape the middleware's by-id gates judged a different row than the one written. Before this change the write reached the driver and was refused only afterwards, by the fail-closed guard, because the seam had been installed for a predicate path the engine never took (measured: 403 with the row already stored). With the engine now running the seam on the by-id path, that guard would stop firing and the 403 would become a 200. So step 3.6 asks the engine's own dispatch predicate (`resolveEngineUpdateDispatch`, from `@objectstack/metadata-core`, already a runtime dependency) and refuses that shape before `next()`, with nothing stored. It applies only where a `check` applies, which is the set the old guard covered. ## Mechanism hypotheses (dispatch Section 2), measured Base: `origin/main` `e8f163fc3a` at worktree creation. It is one unrelated commit (service-analytics) after the dispatch's `009da14713`. 1. **Held.** Step 3.6's by-id branch, verbatim on the base: `// BY-ID UPDATE — unchanged. Build the post-image: the caller's pre-image merged with the change set`, then `let postImage = { ...(opCtx.data) }`, `if (pre) postImage = { ...pre, ...(opCtx.data) }`, `if (!satisfiesCheck(postImage)) denyCheck();`. All of it runs before `next()`. 2. **Reproduced, both drivers (driver-sql on better-sqlite3, driver-sqlite-wasm), through the real `SecurityPlugin` and `ObjectQL`.** - The organization shape: admitted, and the stored row carried an organization the caller does not hold. - The simpler shape: admitted, and the stored row carried a value the check refuses. - Step 3.7 does not catch the organization shape independently: it judges only an `organization_id` supplied in the change set as sent. On the tenant-wall harness, re-pointing to a parent in another tenant is refused earlier, by the reference check (`VALIDATION_FAILED`), not by step 3.7. A related Layer 0 reading is handed to the seat as an out-of-scope finding; it is not fixed here. 3. **Found.** The by-id branch of `update()` has the equivalent point: after `assertNoStrictDrops()` and before `evaluateValidationRules`. The predicate path's seam call sits in the same position. ## Tests (code head `7efc9eb010`; round 2 head `0474ea38f3`) New: `packages/plugins/plugin-security/src/rls-check-by-id-update-post-hook.test.ts`, 24 cells (12 per driver). It uses the real `SecurityPlugin` and `ObjectQL` on both SQL drivers. Every refusal asserts `code` `PERMISSION_DENIED` and `status` 403 plus the gate's developer half, then reads the stored rows straight off the driver's table. - The negative cells: the organization shape; the simpler shape; a hook-closed row failing on a field the update never carried (the stored row, not the change set); fail-closed on a host that strips the installed seam; a falsy payload `id` beside a truthy `where.id` (for both `''` and `0`). - The controls: an in-scope by-id update is admitted and stored; a hook that touches no checked field is unchanged; the change set as sent is still refused; a change set the check refuses stays refused when a hook would replace it with an admitted value; the predicate twin gives the same answer; a falsy payload `id` with no `where.id` is still a judged predicate update. - **Failing first.** The file was committed first (`36f1dff9d6`). Then both source files were set back to the base blobs and restored from `HEAD` (blobs equal HEAD, `git diff HEAD` empty). The run gave 12 red (the six negative cells × two drivers) and 12 green controls. Ablations were run on the final head, and the counts were identical on the pre-merge head `414909be35`. Each is one mutation through `scripts/ablation-replace.mjs`: the anchor hit 1 → 0 and the blob changed, then the file was restored with blob == HEAD and an empty `git diff HEAD`. On resolution, the plugin is imported relatively and `@objectstack/objectql` is aliased to `src/index.ts` in this package's `vitest.config.ts`, so no `dist/` sits between a mutation and the run. | # | mutation | red | |---|---|---| | A1 | the engine never runs the seam on the by-id path | 10: the three by-id negative cells (organization, plain, unchanged field) are refused only by the fail-closed guard, after the row is stored, and the two admitted controls are refused, × 2 | | A2 | the plugin installs the seam only for a predicate update (the old condition) | 8: org, plain, unchanged-field, fail-closed, × 2 | | A3 | the by-id change-set judgement dropped | 2: the only-refuses-more cell, × 2 | | A4 | the falsy-id refusal dropped | 4: both falsy-id cells, × 2 | | A5 | the engine's image is the payload alone, not merged with the prior row | 2: the unchanged-field cell, × 2 | | A6 | the post-`next()` fail-closed guard dropped | 2: the fail-closed cell, × 2 | | A7 | the engine never runs the seam on the predicate path | 4: the predicate twin and the falsy-id predicate control, × 2 | Suites on `7efc9eb010`, after merging `origin/main`, with the closure rebuilt: - plugin-security: 128 files, 2501 tests passed. - objectql local project: two halves, 156 files / 2454 tests and 155 files / 2784 tests, all passed. - objectql repo project: 1 file / 5 tests. - Round 2, on `0474ea38f3`: plugin-auth 114 files / 2439 tests passed (CI Test Core 6/6 had 8 red in `sys-user-self-service-route.test.ts` on `7efc9eb010`; see the surface section); runtime 277 files / 3911 passed, 1 skipped; plugin-dev 8 files / 80 passed; plugin-auth `typecheck` exit 0 (test-layer ledger held at 10 files / 94 errors). - Round 2, real engine: a member updating their OWN `sys_user` row (real `ObjectQL` on driver-sql, the real `SysUser` schema, the shipped permission sets, the real identity write guard) is admitted and stored for `locale` and `name`, with the seam installed and honoured. The peer row is refused by the by-id pre-image gate, and `role` by the identity guard. So the plugin-auth red was the harness, not the product path. - `typecheck` green for both packages. The plugin-security test layer is at 0 files / 0 errors; objectql holds its ledger unchanged at 40 files / 234 errors. ## Gates - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` derived 69 families from this diff on `0474ea38f3` (68 on `7efc9eb010`, plus `check-dev-prereqs --self-test` for the plugin-auth file). All 69 ran and `--ran` reconciled 69 derived / 69 run / 0 NOT-MEASURED. 68 exit 0; `check-empty-changeset` exits 1 by design (see Deliberate correction). That list is a superset of the dispatch-time list; the additions include `check:engine-double-contract`, `check:adr-0087-registration`, `check:empty-changeset`, `check:durability-log-level` and the type-check ratchets. - `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt` first answered PREREQUISITE NOT MET (exit 3) and were re-run green after a full package-closure build. - The board-probing `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs` passed: 15 citations resolve. The dead tracker the in-tree note cited is removed, and is referred to in words. - Narrowed lint: `eslint --no-inline-config --format json` over the 13 touched `.ts` files gave 13 files, 0 errors and 0 warnings. Three facts make it a full measurement for these files. The population is the `**/*.{ts,…}` and `packages/**` blocks of `eslint.config.mjs`. The count, 13, comes from the JSON output. The config never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move the verdict for any untouched file. The repo-wide `pnpm lint` is left to CI. ## Surface beyond the claim, with reasons - Eight existing plugin-security test files. Their engine doubles now run the stored-row check on a by-id update the way the real engine does; otherwise the fail-closed guard refuses them, which is the intended behaviour. The files are `authored-row-write-verdict`, `authz-matrix-gate`, `check-only-write-scope`, `controlled-by-parent-detail-write-authority`, `controlled-by-parent-master-widener`, `rls-check-membership-staging` (its double had judged the payload alone), `security-denial-user-copy` and `select-only-write-visibility`. `security-plugin.test.ts` changes a comment only. - `insert-check-post-image.test.ts`: the in-tree note that recorded this gap as open now points at the new pins. - Round 2: `packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts`. Its terminal `next()` stood in for the engine without running the seam, so the middleware's fail-closed guard refused every own-row write the file pins as admitted. The failing run's developer message was the guard's own ("the update on 'sys_user' was executed without the row-level CHECK being evaluated"), not the check refusal. The terminal now runs the seam on the fixture row merged with the payload, through the producer's dispatch predicate, without adding a read to the recorded pre-image wheres. The census of `SecurityPlugin` hosts outside its own package found one other hand-made engine double, `runtime/src/domains/share-links-enforcement-context.test.ts`. It passes unchanged (runtime suite green), so it is untouched. - Round 2: the two pending changesets named under Deliberate correction. - A merge of `origin/main` brought PR objectstack-ai#19728, which touched both files. It left one conflict in `engine.ts`, at this exact point: the seam and objectstack-ai#19728's related-record binding for validation both follow `assertNoStrictDrops()`. The resolution keeps both, with the seam first, as on the predicate path. ## Behaviour that changes (all in the refusing direction) - A by-id update whose `beforeUpdate` chain leaves a checked field at a value the check refuses is refused, and nothing is stored. - A by-id update on a host that installs the judgement and never runs it is refused (403, `error` log), as inserts and predicate updates already are. - An update whose payload `id` addresses no row while `where.id` addresses one, under a policy with a `check`, is refused before anything runs. It used to be written and then refused. ## Deliberate correction This PR rewrites one sentence in each of two pending release notes it did not add. Both sentences read as release-level claims, and this PR makes them false in a release that ships it. Every other byte of both files is unchanged (reversing each replacement reproduces the base blob exactly). - `.changeset/19950-rls-check-multi-row-writes.md`, under "What does not change". - Old: "A by-id update and a single-row insert are judged exactly as before." - New: "A single-row insert is judged exactly as before. A by-id update is not changed by this entry; its judgement on the row it stores is its own entry (objectstack-ai#19989)." - Why: this PR changes how a by-id update is judged, so "judged exactly as before" would be false in the release. - `.changeset/insert-check-post-image.md`, under "What changed, mechanically". - Old clause: "`update` is unchanged." - New clause: "this entry leaves `update` unchanged, and the predicate and by-id updates move onto the same seam in their own entries (objectstack-ai#19950, objectstack-ai#19989)." - Why: the by-id update now uses the same seam, so a bare "`update` is unchanged" would be false in the release. `check-empty-changeset` goes red on this PR, and that is expected. It refuses any change to a changeset present on the merge base, and names both files with the DELIBERATE CORRECTION class. Its remedy for that class is to keep the correction and have it confirmed on the PR, not to restore the old text. Local run on `0474ea38f3`: exit 1, "This PR changes a changeset it did not add", listing both files as "present on the merge base and CHANGED by this PR". ## Acceptance notes - The change-set judgement is kept, so that this change only refuses more. As a consequence, a change set the check refuses as sent stays refused even when a hook would overwrite the refused value with one the check admits (pinned). Dropping it would match the insert's ruled "judge the stored row only" semantics, but it would widen, so it is a separate decision. - The seam's position is pinned relative to the hooks: the refusal cells can only pass on post-hook values. Its position relative to the readonly strips is not separately pinned on this path. - Ablation A3 reading: the "change set as sent still refuses" cell stays green without the middleware judgement, because the seam refuses the same value after the hooks. It is a behaviour control, not a pin of the middleware judgement; the only-refuses-more cell is that pin. - The doubles in the two controlled-by-parent harnesses and in `authz-matrix-gate` model only the by-id path, through the producer's dispatch predicate. The `security-plugin.test.ts` double holds no table and judges no rows on an update; this is documented there. --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18682
Clause-②: yes
5790480639); the next at-tier record judges the whole PR.What changes
A
script/cross_fieldvalidation rule can read one hop through a lookup:record.account.typeon an opportunity resolves the related account's field. Before this change the expression faulted, and because a broken validation fails closed, every write on the object was refused.id. When the caller has an active organization, the read carries it: underisolated, a reference to another organization's row reads 0 rows (measured ondriver-sqlanddriver-sqlite-wasm). Two bounds hold every caller that is not system (a user, a public-form submitter, or a caller with no principal); they never apply to a system caller. One consequence for system callers is stated rather than left implicit: a system context that carries auserIdbut no active organization now gets the system read under a walled posture, where it read nothing before round 17. Under a walled posture (grouporisolated), such a caller with no active organization gets no related read, and every stored reference stays unresolved. A related object that no organization wall scopes (no tenant column, assys_userbehind auserfield;tenancy.enabled: false; orexternal) is read only for the rows the caller's own read of that object returns; a reference to any other row refuses the write as not readable, whatever that row holds, and its columns are never read. A public-form submitter's grant admits a read of the form's own object only, so its own read of any other such object is refused; a caller with no principal reads through the security middleware's fall-open. The organization scope, the org-less no-read (user, public-form and principal-less callers) and thesys_userown-read bound (user and public-form callers) are pinned inpackages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts, the user case against the shippedmember_defaultwall; the principal-less own read and thetenancy.enabled: false/externalarms are covered by reading, not by a pin.could not read 'sys_user'), with one text; so is a related read that is itself refused.The write preview (
ObjectQL.validate())The preview resolves related rows only for a caller that
@objectstack/plugin-security'scanWriteObjectadmits; the checks it runs are listed in its docblock, and include the organization wall (ADR-0123 D2).can-write-object-admission.test.tspins the method against the registered middleware on its equivalence block's cases, one D12 delegate UPDATE as a direction case, and the D12 arm receiving copies.write-preview-field-gate-parity.test.tspins the preview against the write path for the field-level gate. ⛔ The preview does not promise that the write would succeed.Cascade delete
The foreign-key clear a cascade delete issues does not resolve relations, which is
main's behaviour. Known limit: a traversing rule on a child that clears its reference to the deleted parent refuses that delete, and the refusal's prescription points at the child object rather than the related one.Not in this PR
requiredWhen/readonlyWhen/ optionvisibleWhenpredicates and RLS predicates (the UI seams are [#18682 v1 切出] UI 谓词三缝(visibleWhen / readonlyWhen / requiredWhen)在关联字段不可读时 fail-open —— 父卡裁定的「不可读即响亮报错、⛔ 绝不静默为真」在这三缝上今天做不到 #19727). Depth is one hop.parentseam resolves a by-id update's parent before read-only strips. It is the same order this PR fixes on its own seam, predates this PR, and is filed separately.Acceptance
docs/qa/platform-checklist/areas/records-forms.jsongainsrecords-forms.predicate-relationship-traversal. On the showcase app,showcase_invoicerefuses an invoice against a churned account on the server.Changeset
minoron@objectstack/formula,@objectstack/lint,@objectstack/objectqland@objectstack/plugin-security.维护者速读
isolated下读不到别的组织的记录);在有组织墙的部署里,没有当前组织的非系统调用者(包括公开表单的匿名提交者)完全不做关联读取。两条都有测试钉住。user字段指向的sys_user),只读调用者自己读得到的那一行;读不到(比如别的组织的用户)就按「不可读」响亮拒绝,不管那一行存的是什么,也不读它的字段。这两条约束对所有非系统调用者都成立:登录用户、公开表单的匿名提交者、没有身份的内部调用;系统调用不受这两条约束(带userId但没有当前组织的系统调用,在有组织墙的部署里现在会做系统读取,之前不读)。main一致;已知局限:这时带关联读取的规则会拒绝那次删除,提示语指向的对象不对。🤖 Generated with Claude Code
https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1