Repository navigation
fix(objectql): settle a chain of readonlyWhen locks on the drop set the stored row agrees with (#19927) - #19928
Conversation
…il the set gives back itself The conditional strip's release step ran once. In a cascade of three locks, releasing one key moved another's verdict, the check failed, and the fixpoint's larger drop set stood: a key whose own lock was FALSE on the stored row was dropped. The release now repeats (every key judged against the previous set) until a set gives back itself, at most n + 1 sets; otherwise the fixpoint's fail-safe set stands as before. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
…e answers Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
… changeset Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 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 5d1a90f6c93125b12b93c0d8853ef240f5cc5fd1 && git checkout 5d1a90f6c93125b12b93c0d8853ef240f5cc5fd1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fdeeea0cc9183f9c80343a552ef6523b1a53f99b 2c9cbe94eb2dc223cfc01ff12cd7176a5590da62 && git checkout -B drift-repro fdeeea0cc9183f9c80343a552ef6523b1a53f99b && git merge --no-ff 2c9cbe94eb2dc223cfc01ff12cd7176a5590da62
node scripts/docs-audit/affected-docs.mjs --json fdeeea0cc9183f9c80343a552ef6523b1a53f99b |
…elease; trim the #19927 entry The #19911 entry described the release as one step and listed the three-lock cascade as dropping all three fields; after the repeated release neither holds. Its residue bullet now says what a caller can still see: an over-lock only where locks read each other in a cycle. The #19927 entry drops its pointer at the old text and the facts the corrected #19911 entry already states, and states the knock-on drop and the strict fields that move with it. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
Confirmation needed: this PR corrects a pending release note
The note: What changed under it: PR #19923 settled interdependent
Every rewritten sentence was checked on measurement by the contract reviewer. The rest of that note is unchanged. Restoring the old text would publish sentences this PR makes false; leaving it and correcting it in this PR's own entry would ship a false entry beside its erratum. The one action asked of the maintainer: confirm the in-place correction (a reply on this PR is enough). On confirmation the seat marks the PR ready and queues it, with Generated by Claude Code |
Confirmed by the maintainer — landing released
Whose confirmation, verbatim, where: the maintainer ( That is the confirmation Generated by Claude Code |
Fixes #19927
Clause-②: no
What a caller saw before, and what happens now
Before. When
readonlyWhenlocks 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:clocked byprevious.c == 'L',xbyrecord.c == 'open',ybyrecord.x == 'xv'; row{ c: 'L', x: 'old', y: 'old' };update({ c: 'open', x: 'xv', y: 'yv' }). The row keepsc: 'L', sox's lock is FALSE there, yet all three fields were dropped, and astrictReadonlyWritesrefusal namedx. The settlement that PR #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' }, onereadonly_whenevent[c, y], by id, on bulk (multi: true) updates and forisSystemcallers; understrictReadonlyWritesthe refusal names[c, y]. No caller field is written while itsreadonlyWhenis 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.tsis not touched. All four conditional call sites (by-id strip, bulk strip, and the by-id and bulkjudgeFkLockof A stripped master-detail repoint unlocks a parent-scoped readonlyWhen: a by-id update judges the lock against the parent it names, not the parent it stores #19853's settlement) already route through this one function.packages/objectql/src/engine-readonly-when-exact-drop-set.test.ts(19 cases). The A record-scoped readonlyWhen reading a field that another readonlyWhen drops in the same pass is judged against the dropped value, so a closed row locked amount is rewritten #19911 suite's header comment is corrected (comment only). Onepatchchangeset, and the pending.changeset/19911-readonlywhen-interdependent-locks.mdcorrected in place (one file beyond the claim's surface, authorized by the seat in the patch round).A1: the card reproduced on
origin/mainae0c90c133, before any editReal
ObjectQLengine, the #19911 suite's in-memory driver, two rows{ c: 'L', x: 'old', y: 'old' }:r1{ L, old, old }[c, x, y]where: { tag: 't' }, multi: true)r1,r2{ L, old, old }[c, x, y]strictReadonlyWritesERR_READONLY_FIELD_REJECTED,fields: [c, x, y]strictReadonlyWritesfields: [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
recordreads 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/stripReadonlyWhenFieldsMultiof 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, overrecord.*(self-reads included),previous.*,parent.status(bound, or unbound for 10% of rows) andtrue/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 itsreadonlyWhen) 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 therecord.*reads among its judged keys form a cycle.alocked byrecord.b == 'new_b',bbyrecord.a == 'new_a'has{a}and{b}; ② alternates between{}and{a, b}and ①'s{a, b}stands.alocked byrecord.b == 'old',bbyrecord.a == 'old'has{}and{a, b}; ①'s first pass locks nothing, so{}is the answer and both edits land.A3: the same through the real engine, base and head
The #19911 contract review's probe style: 8 hand-built shapes plus 1500 seeded random ones with 2 to 5 fields,
record/previous/parentroots, self-reading locks, a staticreadonlyfield (35% of shapes) whose value the caller forges in half of them, amaster_detailFK with its own lock (45%),isSystem(25%). Each ran by id and bulk over 1 to 3 rows with random values, plain and understrictReadonlyWrites, 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, #4889).fieldsmoved in 20 (refused before and after).recordreads (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 throughparent: the FK's lock readsrecord.f1andf1's lock readsparent, 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
judgeFkLockclosures call the same strips (only: fk), which callsettleReadonlyWhenDrops, and a landing FK is re-judged by the strip that follows over the same header. Pinned by id, bulk and strict withline_cascadebelow. The rule for an FK that does not land is unchanged (its verdict is final, #19853), and the new docblock says the settlement's claims are about the views it is handed for that reason.Every movement (base
ae0c90c133to headd9af551bb5)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 understrictReadonlyWriteson bulk and forisSystem.isSystem{ c: L, x: old, y: old }; event[c, x, y]{ c: L, x: xv, y: old }; event[c, y]strictReadonlyWrites, by id and bulkfields: [c, x, y], nothing landsfields: [c, y], nothing landscbyprevious.c == 'L', eachxNby the previous one being'v', all set to'v'), by id, bulk,isSystemx1,x3,x5land; event[c, x2, x4]strictReadonlyWritesfields: [c, x1, x2, x3, x4, x5]fields: [c, x2, x4]pbyprevious.p == 'L',mbyrecord.p == 'L',jbyrecord.m == 'new',kbyrecord.p == 'L' && record.j == 'new'; all set to'new'on{ p: L, m: old, j: old, k: old }, by id, bulk,isSystemk: 'new'stored; event[p, m, j]j: 'new'stored,kkeptold; event[p, m, k]strictReadonlyWritesfields: [p, m, j]fields: [p, m, k]cbyprevious.c == 'L', FKinvoicebyrecord.c == 'open',ybyrecord.invoice == 'h_open',amtbyparent.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,isSystemh_paid,y: yvstored,amtstaysa0; event[c, invoice, amt]; by-id header reads[h_open, h_paid]h_open,amt: a1stored,ystaysold; event[c, y]; by-id header reads[h_open, h_open](the named header, then the repoint's reference check)strictReadonlyWritesfields: [c, invoice, amt]fields: [c, y]; the line stays underh_paidUnmoved (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: yover the card's cascade. PR #19923's 35 pins, PR #19905's 18 and PR #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
candyonly.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.tsandcore.tsexport neither it nor the strips).content/docs/data-modeling/fields.mdx, "Conditional Logic": "The server enforcesrequiredWhenon submit and ignores writes to fields whosereadonlyWhenpredicate isTRUE", and the table row "CEL predicate; field is read-only whenTRUE".content/docs/data-modeling/formulas.mdxbindsrecordto "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.mdxlists thereadonly_whenstrip as "A TRUEreadonlyWhenpredicate" andstrictReadonlyWritesas "refuse instead of stripping": the old refusal namedx, whose predicate was FALSE on the stored row.amtunlocked,ylocked): the same "stays to moves" class PR fix(objectql): judge each readonlyWhen lock against the row the update stores, not a value another lock drops (#19911) #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). Onlyfields/dropsmove.requiredWhenand 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 arequiredWhen.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 andisSystem(stored rows, the one event, the warn lines,codeERR_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 onstripReadonlyWhenFields(the cascade,only: x,only: y).On
2c9cbe94eb(the patch round changed only the two changesets; the code isd9af551bb5's):pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2: 308 files / 5177 tests passed, exit 0 (the same atd9af551bb5).pnpm --filter @objectstack/objectql typecheck: exit 0,check:test-typecheck: OK. Atd9af551bb5the new file was in thetsconfig.test.jsonprogram (--listFiles: 1) with 0 diagnostics.pnpm --filter @objectstack/objectql test:repo: 5 passed.b5e3de9a35(the fix and the tests committed;d9af551bb5adds 5 docblock lines).rule-validator.tsrestored to its base blobf3934b80f6withgit 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'sconst overLockedcounted 1. The four suites (new, A record-scoped readonlyWhen reading a field that another readonlyWhen drops in the same pass is judged against the dropped value, so a closed row locked amount is rewritten #19911's, A record-scoped readonlyWhen is judged against a static-readonly field value the caller forged and the strip then removes, so the lock is bypassed while the row keeps its stored value #19887's, A stripped master-detail repoint unlocks a parent-scoped readonlyWhen: a by-id update judges the lock against the parent it names, not the parent it stores #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 fix(objectql): judge each readonlyWhen lock against the row the update stores, not a value another lock drops (#19911) #19923's 35, 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. The subject is imported from source (./engine.js), so nodistsits on the path. Restored withgit checkout HEAD --: on-disk blob35fb361521equalsHEAD's,git diff HEAD0 bytes,git statusclean, rerun 110/110 passed. The script carried anEXIT/INT/TERMtrap.Gates on
2c9cbe94ebnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: the same 63 commands as atd9af551bb5, each run with its exit code captured before any pipe: 62 exit 0, andnode scripts/check-empty-changeset.mjs --base origin/mainexits 1 on the A record-scoped readonlyWhen reading a field that another readonlyWhen drops in the same pass is judged against the dropped value, so a closed row locked amount is rewritten #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 afterpnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2(72/72, all cached) and exit 0;git statusstayed 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 at2c9cbe94eb, 6 citations judged, all resolve.pnpm check:authz-resolver,pnpm check:filter-alias-parityand the narrowed eslint run below were measured atd9af551bb5; 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.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
Textually disjoint, not merged in, not waited on. Driver-free bare probe (
git clone --bare --shared, nomerge.*config):merge-tree --write-tree --name-onlyof2c9cbe94eb(and before itd9af551bb5andb5e3de9a35) against its head3b9c5f2fcaexits 0, and againstorigin/mainfdeeea0cc9exits 0. The merged tree carries both changes (that loop-bound marker 1 inrule-validator.ts; #19728'sRelatedRecordBinding5 there and 2 inengine.ts).Acceptance notes
.changeset/19911-readonlywhen-interdependent-locks.mdis 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'sreadonlyWhenfields), 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 (aparent-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/mainexits 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." SoCheck Changesetstays red,skip-changesetis not applied, and the seat holds this PR's landing for the maintainer's confirmation.readonlyWhenreading anotherreadonlyWhenfield; 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