Repository navigation
fix(objectql): judge each readonlyWhen lock against the row the update stores, not a value another lock drops (#19911) - #19923
Conversation
… stores, not a value another lock drops
The conditional strip judged every lock against one view built before any
lock was judged, so a lock read a value another lock in the same pass then
dropped: on a closed row, `update(c1, { status: 'open', amount: 999 })`
dropped `status` (its own lock) but judged `amount` against the dropped
'open', and the row stayed closed with its locked amount rewritten. The same
mechanism over-locked in reverse.
The strip now settles its drops: a monotone fixpoint (drops only grow, every
kept key is unlocked on the row stored), then one exact release of keys that
were locked only by a value a later pass reverted, kept only when every
dropped key is locked and every kept key unlocked on that row. By id and
bulk; the master-detail settlement judges the FK's own lock with the rest
of the locks and re-judges a landing FK on the same landing.
Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr
Co-authored-by: Claude <noreply@anthropic.com>
… through the master-detail settlement; changeset Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
… through Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check15 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 e4b2798101416c9b090455002b43ca472c360f0e && git checkout e4b2798101416c9b090455002b43ca472c360f0e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin beac7980267a361c5979ca7aa75c034624955af0 d01b912f3da551e13c6f561eda44ac7bd1008d20 && git checkout -B drift-repro beac7980267a361c5979ca7aa75c034624955af0 && git merge --no-ff d01b912f3da551e13c6f561eda44ac7bd1008d20
node scripts/docs-audit/affected-docs.mjs --json beac7980267a361c5979ca7aa75c034624955af0 |
… the readonlyWhen claims to what the settlement guarantees The FK's own record-scoped lock, judged with the other locks on the header the update names, can now land a repoint it used to hold when the value it reads is itself locked under that header; pinned by id, bulk and under strictReadonlyWrites, and stated in the changeset. The docblocks, the test header and the changeset no longer claim every lock reads the stored row: no lock opens, and when one release step does not settle the drops (a cycle, or a cascade) the fixpoint's larger, fail-safe drop set stands. Claude-Session: 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 #19911
Clause-②: no
What a caller could do before, and what happens now
Before. When one field's
readonlyWhenread a field that carries its ownreadonlyWhen, 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 onerecordview built before any lock was judged. That view still held every value the strip then dropped. Withstatuslocked byprevious.status == 'closed'andamountbyrecord.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, forisSystemcallers (areadonlyWhenlock binds them), and for a master-detail FK's own lock judged inside the PR #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, #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/maina34c27cbe5, before any editReal
ObjectQLengine, the PR #19905 suite's in-memory driver shape. Objectcase_x:statusreadonlyWhen: "previous.status == 'closed'",amountreadonlyWhen: "record.status == 'closed'", rowsc1,c2closed withamount: 100.update(c1, { status: 'open', amount: 999 })stored{ status: 'closed', amount: 999 }, event[{ status, readonly_when }]. Controlupdate(c1, { amount: 555 })stayed 100. Reproduced.where: { note: 'k' }, multi: true): both rows storedamount: 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 #19905's body names (its own
engine-readonly-when-stored-viewincluded) and every other objectql test file that mentionsreadonlyWhen(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.{status, amount})statusunlocked, amount edited ({}){amount})status,closed+ amount ({status}){c,b}){c,a}{c,b,a}over-locksa{c,b}{c,b}{c,z,y}){c}{c,x,z,y}over-locksx{c,z,y}{c,z,y}{a}{a,b}{a,b}fail-safe{b}recordlock reads a stage its own lock keepsxis a new over-lock).recordaspreviousfor those fields. Rejected.xmovesy'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.tssettleReadonlyWhenDrops: 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.stripReadonlyWhenFieldsandstripReadonlyWhenFieldsMultinow route through it, with no new arguments at existing call sites. Bulk means "locked in at least one matched row" for every evaluation.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?".parent-root reader now caches roots per source, so it answers forrecordtoo.index.tsandcore.tsexport none of them), so no published surface moves.packages/objectql/src/engine.ts,settleMasterDetailLandingand its twojudgeFkLockclosuresonly: fk) instead of alone. This is how a value another lock drops is reverted before the FK's lock reads it.parent(as before), or readsrecordwhile another payload key's lock readsparent.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 fix(objectql): judge a parent-scoped readonlyWhen against the header the write stores (#19853) #19877's rule: its verdict is final and never re-asked against the header the row keeps.Every movement (base
a34c27cbe5to headd01b912f3d; patch round 1 changed no code, so611a2fc561answers the same)amount: 999; event[status]amount: 100; one event[status, amount]readonly_whenstrictReadonlyWritesfields: ['status']fields: ['status', 'amount'],dropsonereadonly_whenevent (a refusal before and now)isSystem/preserveAudit/ a hook echoing the caller'sstatusamount: 999amount: 100status)[status, amount]amount: 999lands; event[status]strictReadonlyWrites['status', 'amount']['status']amount: 500on a closed rowamount: 100{c: L, b: x, a: 1}{c: L, b: y, a: 2}{a}dropped{a, b}dropped (no exact set exists)requiredWhenrequiringnotewhile the amount is above 500, on the cardVALIDATION_FAILEDrequiredWhenrequiringnotewhile the amount is below 500, the card clearingnote{closed, 999, null}VALIDATION_FAILED, nothing landsinv_b, amount 999inv_a, amount 100; event[stage, invoice, amount]recordlock reads a value that is itself locked under the header the update names (invoicelocked byrecord.amount == 'big',amountbyparent.status == 'paid'; line under openinv_b, the update names paidinv_awithamount: 'big'), by id and bulkinv_band storesamount: 'big'; event[invoice]; header reads[inv_b]inv_aand keepsamount: 'small'; event[amount]; header reads[inv_a], then the repoint's reference check oninv_a. Both rows agree with their locksstrictReadonlyWritesfields: ['invoice']fields: ['amount'], nothing landsclocked byprevious.c == 'L',xbyrecord.c == 'open',ybyrecord.x == 'xv'; rowc: 'L'; the update sets all three), by id and bulk{c, x, y}{c, x, y}, unchanged; the exact set is{c, y}(#19927)parent-scoped lock (plain strip)inv_line_mooredshape (stage not in the payload)[inv_a][inv_b, inv_a]: one extra read, same row, same eventUnmoved (pinned): both controls; a hook-written
statusand a hook overwriting the caller'sstatus(both land and are read); a hook-writtenamount; 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 onlyprevious(one header read); a landing FK's fail-open fault warning, said once. PR #19905's 18 pins and PR #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
judgeFkLockand bulkjudgeFkLockall 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 neitherrecordnorparent, its verdict does not depend on the view. PR #19905'sstoredview 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 understrictReadonlyWritesin patch round 1): the card by id and bulk with both controls, the report and warn lines, the strict envelope (codeERR_READONLY_FIELD_REJECTED,name,fields,drops; the engine error carries no HTTPstatus, 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, bothrequiredWhendirections (refusal asserted oncodeVALIDATION_FAILED+fields), the settlement by id and bulk with its header reads, the plain-strip FK, the moored read count, theprevious-only FK, the landing FK's single warning, and two unit cases foronly.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 thetsconfig.test.jsonprogram (--listFiles: 1) with 0 diagnostics.pnpm --filter @objectstack/objectql test:repo: 5 passed.engine.tsandrule-validator.tsrestored to theira34c27cbe5blobse973ab50ac/2b3002b8e1withgit restore --source, tree only; on-disk hashes verified and markerssettleReadonlyWhenDrops/readonlyWhenFkJudgementReadsParentcounted 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 fix(objectql): judge a record-scoped readonlyWhen against the payload the write stores (#19887) #19905's 18 and PR fix(objectql): judge a parent-scoped readonlyWhen against the header the write stores (#19853) #19877's 38 stayed green. The subject is imported from source (./engine.js), so nodistsits on the path. Restored withgit checkout HEAD --: blobsc9b1cfc19e/f3934b80f6equalHEAD,git diff HEADempty, rerun 91/91 passed. The script carried anEXIT/INT/TERMtrap.Gates on
611a2fc561Patch 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, #19927 included),node scripts/check-changeset-no-major.mjs --base origin/main,pnpm check:nul-bytesand the narrowed eslint run: exit 0 each. The full derivation below ran on611a2fc561.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-closureandcheck:type-check-debtfirst answered PREREQUISITE NOT MET (exit 3, no workspacedist). Afterpnpm 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 newisSystemread).pnpm check:query-options-erasure: exit 0.node scripts/check-changeset-fixed.mjs,pnpm check:authz-resolver,pnpm check:filter-alias-parity, exit 0 each.ESLint.isPathIgnored/calculateConfigForFile) ignores the changeset and lints the three TypeScript files withtypescript-eslint/parserand noparserOptions.project/projectService.eslint --no-inline-config --format jsonover 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 lintitself is CI's.Neighbour PR #19728
This PR stays textually disjoint from it and does not wait on it. Driver-free bare probe (
git clone --bare --shared, nomerge.*driver registered):merge-tree --write-treeofd01b912f3d(and before it611a2fc561) against its head3b9c5f2fcaexits 0, and againstorigin/mainbeac798026exits 0. The merged tree carries both changes (readonlyWhenFkJudgementReadsParent3 inengine.ts,settleReadonlyWhenDrops6 inrule-validator.ts, #19728'sRelatedRecordBinding2 and 5).Acceptance notes
readonlyWhenreading anotherreadonlyWhenfield. Examples and platform objects were scanned: the showcaseinvoicelocks readstatus/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".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:
VALIDATION_FAILEDonly 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") saysreadonlyWhenis a "CEL predicate; field is read-only whenTRUE", and "The server enforcesrequiredWhenon submit and ignores writes to fields whosereadonlyWhenpredicate isTRUE". ("Who the lock applies to") says "A TRUEreadonlyWhenpredicate locks the field for every API-boundary caller".content/docs/data-modeling/formulas.mdxbindsrecordto "the row being evaluated". On the row the update stores, the REVERSE amount's predicate is FALSE and the card's is TRUE.strictReadonlyWrites: every shape that moves carries a conditional drop, so it was a refusal before and still is; onlyfields/dropsmove, 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