Repository navigation
fix(objectql): judge a parent-scoped readonlyWhen against the header the write stores (#19853) - #19877
Conversation
…the write stores A by-id or bulk update resolved the `parent` binding payload-FK-first and judged every `parent`-scoped `readonlyWhen` (and `requiredWhen`, which shares the binding) against the header the payload NAMED. Two strips can take that FK back out afterwards: the static `readonly` strip for a non-system caller, and the FK's own `readonlyWhen` lock. The row then kept its prior header while the locks had been judged against one it never reached, so naming an open invoice beside a read-only FK rewrote a paid invoice's frozen line. `settleMasterDetailLanding` now settles the FK's fate first: its own lock is judged alone against the header it names (#4889, unchanged), the static strip's verdict is asked of the same function silently, and `parent` is resolved from the view the write stores. Both update paths use it. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
…date branch The FK-landing settlement asked `context?.isSystem` itself, which added an elevation read site the system-context census page has no row for. Each update branch now reads the static strip's gate once into a const that both the settlement and the strip consume, so the census count is unchanged and the two consumers cannot disagree about whether the strip runs. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 17 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 e43cf07983260885ea161f1e6f2a0d396c01d5e1 && git checkout e43cf07983260885ea161f1e6f2a0d396c01d5e1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b940f32a568e6b07b97b1b08572c632ecfb1660f 0a538af9d76981e45ac4affafdd1db71e140ee74 && git checkout -B drift-repro b940f32a568e6b07b97b1b08572c632ecfb1660f && git merge --no-ff 0a538af9d76981e45ac4affafdd1db71e140ee74
node scripts/docs-audit/affected-docs.mjs --json b940f32a568e6b07b97b1b08572c632ecfb1660f |
Contract reviewServed-tier: ① Derived judgmentsHead = branch tip = PR head; merge-base
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
… 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>
Fixes #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 aparent-scopedreadonlyWhenby naming a different, unlocked parent in the same update. The engine resolvedparentfrom 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 ownreadonlyWhenlock 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 (andrequiredWhen, which shares the binding) against the parent the row is stored under afterwards. A legitimate move — FK writable, or anisSystem/preserveAuditwrite the static strip exempts — is still judged against the parent it moves to.A1 — reproduction on
origin/main2bbb462, engine untouchedReal
ObjectQLengine, in-memory driver (the suite's own), the card's shape:inv_line.amounthasreadonlyWhen: "parent.status == 'paid'",inv_line.invoiceismaster_detailwithreadonly: true,l1(amount 100) under paidinv_a,inv_bopen.update(l1, { amount: 999, invoice: 'inv_b' })committed and stored{ invoice: 'inv_a', amount: 999 }— reproduced.update(l1, { amount: 555 })— locked, amount stayed 100.amount: 999underinv_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 refusedpo_ref is required, and clearingpo_refon 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 (
idonly) →readonlyWhenstrip → staticreadonlystrip (skipped forisSystem) →assertNoStrictDrops→evaluateValidationRules→ reference check → driver.readonlyWhenstrip — can take the FK when the FK carries its own lock. Ran AFTERparentwas resolved from the named FK.readonlystrip — takes a non-system caller's FK when it isreadonly: true(unlesspreserveAuditkeeps it, or a hook wrote it). Ran after thereadonlyWhenstrip.PermissionDeniedError); the Update-side: a readonly field is stripped from persistence but still reaches beforeUpdate, so hook-derived columns persist values computed from data the row never contains #16344 hide pass withholds static-readonly values from hooks and restores them before any engine-owned read (net zero);beforeUpdatehooks,normalizeMultiValueFieldsandvalidateRecordall run beforeparentis resolved, so their effect is already in the payload the resolution reads; the primary-key strip touchesidonly.The fix —
packages/objectql/src/engine.tssettleMasterDetailLanding(one private method, used by the by-id and the bulk branch) runs before thereadonlyWhenstrip:readonlyWhenlock is judged ALONE (the strip'ssuppliedsubset holds only the FK) against the header the FK names — Parent-scopedreadonlyWhenis unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889's verdict for the FK itself, byte-identical to before; the strip that judges the other fields is then handed asuppliedwithout the FK, so that verdict is never re-asked against a different header;stripReadonlyFields, silently, with the samesupplied,hookWrittenKeysandpreserveAudit; itsisSystemgate 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);parentis resolved from the view the write stores — the payload, or the payload without the FK — somasterIdOffalls through to the prior row's FK when the repoint does not land.The
requiredWhennon-regression pre-check's "does this write repoint" is asked of the same stored view. The header read keepsopCtx.context(the #19837 tenant scope) exactly as it was.A3 — what moves
parentfor everyparent-scopedreadonlyWhenandrequiredWhenis the header the row is stored under (the fix).onFieldsDroppedfor the card's write:[{amount, readonly_when}, {invoice, readonly}](was[{invoice, readonly}]); astrictReadonlyWritesrefusal names['amount', 'invoice']with both drops (was['invoice']).requiredWhenmoves with the shared binding, both directions (see the declaration note below).requiredWhenpre-check no longer buys a second read for a repoint that does not land; aparent-scoped lock on the FK ITSELF that refuses the landing costs two reads (named, then kept) where it cost one.fieldsorder and message text are unchanged.isSystem/preserveAuditbehaviour; every write whose payload does not name the FK.A4 — the legitimate repoint
With
invoicewritable (inv_line_free): paid → openupdate(f1, { invoice: 'inv_b', amount: 999 })is judged againstinv_band commits both; open → paidupdate(f2, { invoice: 'inv_a', amount: 999 })is judged againstinv_aand 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
describeblock inpackages/objectql/src/engine-readonly-when-parent.test.ts(16 cases): the card, the control, the report, the strict envelope (codeERR_READONLY_FIELD_REJECTED,name,fields,drops; the engine error carries no HTTPstatus— 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, bothrequiredWhendirections (refusal asserted oncodeVALIDATION_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.engine.tsrestored to its2bbb462335blobffcda81540and verified on disk,settleMasterDetailLandingcount 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 withgit checkout HEAD --, blob624fc1e38aequalsHEAD:packages/objectql/src/engine.ts,git diff HEADempty, rerun 38/38 passed. Script carried an EXIT/INT/TERM trap.Gates on
0a538af9d7node 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-loadsandcheck:type-check-debtfirst answered PREREQUISITE NOT MET (no workspacedist); afterturbo run buildover./packages/*and./packages/*/*(72/72 tasks), both exit 0 (type-check-debtneeded an objectql rebuild after the ablation restore touchedengine.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 newisSystemread inside the helper; the second commit hoists the one read per branch instead.pnpm check:query-options-erasure→ exit 0.ESLint.calculateConfigForFile/isPathIgnored) putsengine.tsand the test file in the linted population and ignores the changeset;eslint --no-inline-config --format jsonover 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 lintitself is CI's.Neighbour PR #19728
Textually disjoint: this diff does not touch the
evaluateValidationRuleslines, the import line, the region afterresolveMasterDetailParentsorsystem-context.mdx. Driver-free bare probe (git clone --bare --shared, no merge driver registered):merge-tree --write-treeof0a538af9d7against3b9c5f2fca→ exit 0; againstorigin/mainb940f32a56→ exit 0. The merged tree carries both changes.Acceptance notes
recordroot, same defect class, not fixed here (listed for the seat to file). A record-scopedreadonlyWhenthat reads a static-readonlyfield is judged against the caller's forged value of that field, which the static strip then removes. Measured on0a538af9d7:statusreadonly: true,amountreadonlyWhen: "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.readonlyWhenis unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889's landing rule still decides the FK's OWNparent-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 aparent-scopedrequiredWhen: a write refusedVALIDATION_FAILEDagainst 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-②: nostands, andpatchis 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 bindsparenton 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