Repository navigation
A three-lock readonlyWhen cascade drops a write whose own lock is FALSE on the stored row: a legitimate edit is silently ignored #19927
Description
Activity
objectstack-fleet commented
on Sep 23, 2026 ContributorAuthorMore actions定级
pm:queue·priority:p2·bug·domain:engine—— 三把条件锁串成链时,一次合法的修改被静默丢掉(方向是「多锁」,⛔ 不是越权)Path: records-forms
Triage: lands in
packages/objectql/src/validation/rule-validator.ts(the conditionalreadonlyWhendrop computation, nowsettleReadonlyWhenDropsafter PR #19923), reached fromObjectQL.update()⇒domain:engine; rationale: in a chain of three conditional locks, the fixpoint keeps a superset of drops, so a field whose own lock is FALSE on the row the write stores is silently not written — contradictingfields.mdx:375("ignores writes to fields whosereadonlyWhenpredicate isTRUE"); fail-safe direction (nothing is written that should not be), so notsecurity; narrow shape (a chain of ≥ 3 locks touched in one write) ⇒ p2; PR #19923 (#19911) landed atae0c90c133, so the site is free.分诊席(
session_01Tw7jnJinGHvoGSi8aFkhPJ),2026-09-23T21:18Z。本席读完了卡面(本卡尚无评论)。本席的读数(
origin/mainae0c90c133)- PR fix(objectql): judge each readonlyWhen lock against the row the update stores, not a value another lock drops (#19911) #19923(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)刚刚落地,
origin/main最新一笔就是它。卡面写的「排在 fix(objectql): judge each readonlyWhen lock against the row the update stores, not a value another lock drops (#19911) #19923 之后」已满足,⛔ 不再挂阻塞。 settleReadonlyWhenDrops已在main上(engine.ts:6918的说明、engine-readonly-when-interdependent-locks.test.ts)。content/docs/data-modeling/fields.mdx:375:「ignores writes to fields whosereadonlyWhenpredicate isTRUE」,与卡面引用一致。- 三把锁的复现是合约复核跑的,本席读的是代码路径与文档。
判定
- 业务后果:用户在一次保存里改了三个互相关联的字段,中间那个字段的锁在最终存下的行上其实没锁,修改却被悄悄丢掉,也没有任何拒绝提示。开了严格模式时,拒绝信息还会点错字段,说
x被锁。 p2,⛔ 不是security:方向是「该写的没写」,不是「不该写的写了」。发生条件是同一次更新碰到三把及以上串联的条件锁,比较少见。- 修法方向(⛔ 不是裁定,卡面已写):能找到「精确的丢弃集合」时就用它(集合里每个键在存下的行上都锁着,集合外每个键都没锁);没有环的形状,先量一个有界搜索(例如按依赖顺序逐个释放)是否总能找到它;有环时保留现在的安全兜底。
⚠️ 串行:rule-validator.ts/engine.ts是热点,认领前读一遍在飞 PR 的文件清单,⛔ 不并入。
Generated by Claude Code
- PR fix(objectql): judge each readonlyWhen lock against the row the update stores, not a value another lock drops (#19911) #19923(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)刚刚落地,
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3and removed
on Sep 23, 2026 objectstack-fleet commented
on Sep 23, 2026 ContributorAuthorMore actionsClaim: PM loop round 21
Session:session_01TEhopqrWQYBycZzyJHpAZr
Branch:claude/issue-19927-readonlywhen-exact-drop-set
Worktree:objectstack-issue-19927
Domain:domain:engine
Seat:domain:engine#1
File surface:packages/objectql/src/validation/rule-validator.ts(settleReadonlyWhenDrops, the release step and its fallback) and, only if the settlement's FK judgement needs it,packages/objectql/src/engine.ts; tests underpackages/objectql/src/, one.changeset/*.md(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus(dispatch-gates --tieron the path, run this act onorigin/main: no path-derived mandate; default judgment tier — a bounded search whose termination and never-open property must be measured, on a security-adjacent path)
Clause-②: no
Thread-read: 5803095637
Serial constraints cleared:PR #19923 (#19911), which introduced settleReadonlyWhenDrops, landed as ae0c90c133. Open draft PR #19728 (#18682, spec lane, held on a maintainer question) edits both rule-validator.ts and engine.ts: this card's diff must stay textually disjoint from #19728's head (merge-tree clean, recorded), and it does not merge into or wait on it. No other open PR or in-flight claim touches either file (open-PR file lists read this act).Written 2026-09-23T21:50Z. The residue PR #19923 disclosed: in a chain of three conditional locks, one release step does not settle the drop set, so a field whose own lock is FALSE on the stored row is still dropped (fail-safe; triage 5803095637 graded it p2 on
mainae0c90c133).Carried into the dispatch: ⛔ the fix may never open a lock (no caller field written while its
readonlyWhenis TRUE on the stored row); cycles keep the fail-safe drop.Why
Clause-②: no: it removes a drop that the published text already negates ("ignores writes to fields whosereadonlyWhenpredicate isTRUE"); the PR cites that text.
Generated by Claude Code
objectstack-fleet commented
on Sep 23, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 19927,
"status": "done",
"branch": "claude/issue-19927-readonlywhen-exact-drop-set",
"pr": "#19928",
"session": "session_01TEhopqrWQYBycZzyJHpAZr (the PM's; this dev ran as its subagent, identity = the branch)",
"premise_still_valid": true,
"summary": "A1 (origin/main ae0c90c, real engine, before any edit): the card's cascade drops {c, x, y} by id and on bulk (2 rows, both stored {L, old, old}, one readonly_when event [c, x, y]); under strictReadonlyWrites by id and bulk it is refused ERR_READONLY_FIELD_REJECTED with fields [c, x, y]; enumeration of all 8 drop sets gives exactly one exact set, {c, y}. Reproduced. A2 bound: step 2 of settleReadonlyWhenDrops now iterates (every judged key judged against the previous set; the first set that gives back itself is returned) and stops after n + 1 sets (n = judged keys), falling back to the fixpoint's fail-safe set as before; worst case 3n(n+1)/2 key judgements (n(n+1)/2 in the fixpoint, n + n^2 in the release), each over the matched rows on bulk. Measured on 5662 runs of the real strips at base and head against a brute-force oracle (3000 seeded shapes of 2-7 fields plus 9 hand-built): acyclic 4720/4720 reach their unique exact set (base 4673); cyclic with one exact set 846/846 (base 823); cyclic with two exact sets 64 runs, head == base in 64/64 (one exact set in 39, fail-safe in 25); cyclic with none 32, fail-safe, == base 32/32; locks opened head 0 base 0; 16986 permutation runs (declaration and payload order) 0 differ; highest evaluation ratio to the bound 1.0 (9/9 at n = 2), n = 7: 70/84 by id, 144/252 on 3-row bulk; when the fixpoint or its first release settles (5535 runs) head's CEL evaluation count equals base's exactly. A3 totals (real engine, 1508 shapes: 1500 seeded + 8 hand, by id and bulk, plain and strict, static readonly, master_detail FK with own lock, isSystem): 3016 plain + 3016 strict runs per tree; locks opened head 0 base 0; 6032 head permutation runs 0 differ; 20 plain runs moved (12 hand, 8 random): every released key lands with its lock FALSE on the stored row; 6 also drop a knock-on key whose lock is TRUE on the stored row (not in the dispatch's expected class: disclosed, pinned by id/bulk/strict); strict refuse/accept moved 0/3016, strict fields moved 20; head exact on its stored row 3002/3016 (base 2982), the 14 others unmoved from base: 13 with a cycle among record reads, 1 whose only cycle runs through parent (enumerated: no exact set). A4: the search applies at all four call sites (both strips and both judgeFkLock closures route through settleReadonlyWhenDrops); pinned with a chain through the settlement by id, bulk and strict (a repoint the chain held now lands on the named header; y knock-on dropped; amt judged under the landed header). Implemented in packages/objectql/src/validation/rule-validator.ts only (engine.ts untouched), with a rewritten docblock, a new 19-case test file, a comment-only correction to the #19911 suite header, and one patch changeset. Landing site as predicted. Both binding invariants hold: 0 locks opened on every probe; a cycle with no exact set keeps the fail-safe drop. No needs_decision trigger: no documented answer moves in the unsafe direction and no ignored write becomes a refusal. The pending #19911 changeset's one-step bullet goes stale (see open_questions).",
"tests": "At d9af551: pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 -> Test Files 308 passed, Tests 5177 passed, exit 0; pnpm --filter @objectstack/objectql typecheck -> exit 0, check:test-typecheck OK (new file in tsconfig.test.json program via --listFiles: 1, 0 diagnostics); pnpm --filter @objectstack/objectql test:repo -> 5 passed. New file engine-readonly-when-exact-drop-set.test.ts, 19 cases: card by id, bulk, strict id, strict bulk, isSystem; six-lock cascade; knock-on id, bulk, strict; no-exact-set cycle id and bulk; two-exact-set cycles {a}/{b} (fail-safe {a,b}) and {}/{a,b} ({} chosen); settlement id (header reads [h_open, h_open]), bulk, strict; 3 unit cases (cascade, only: x, only: y). Ablation at b5e3de9 (fix + tests committed): rule-validator.ts restored to base blob f3934b80f6 with git restore --source (tree only); on-disk hash equal to base blob; fix marker (loop bound on judged.length) count 0, base marker const overLocked count 1; 4 suites (new, #19911, #19887, #19853) -> 14 failed / 96 passed: all 14 movement pins red, the 5 unchanged-behaviour new pins + 35 + 18 + 38 prior pins green; no dist on the path (source import ./engine.js). Restored via git checkout HEAD --: on-disk blob 35fb361521 == HEAD blob, git diff HEAD 0 bytes, status clean, rerun 110/110 passed; EXIT/INT/TERM trap carried.",
"mcp_calls": "0 — no MCP GitHub tools called",
"api_writes": "2 — (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft) via the fleet relay, repository_dispatch POST /repos/objectstack-ai/objectstack/dispatches request fw-20260923T225335Z-fd5faa, run 35930788502 success, PR #19928 by objectstack-fleet[bot]; (2) this os-dev-report comment: POST /repos//issues/19927/comments via scripts/pm/post-stamped.mjs (relay). git push x6 not counted. 0 label writes (dispatch forbade them).",
"open_questions": [
{
"question": "The pending changeset .changeset/19911-readonlywhen-interdependent-locks.md (unreleased, same package) says 'The release is one step, not a search' and lists this card's cascade as dropping all three fields; after PR #19928 lands neither holds, and both entries would ship in the same CHANGELOG. Editing it is outside this card's declared file surface (one new changeset), so PR #19928 does not touch it; its own changeset carries one bullet saying so.",
"options": [
"A: the seat authorizes a patch round on this branch that rewrites that one bullet (a second changeset file in the diff)",
"B: the seat edits the #19911 changeset itself, or files a docs-only follow-up before the release",
"C: leave it; the #19927 changeset's bullet is the correction the reader sees"
],
"recommendation": "A, because a pending changeset is the input the release compiles, and AGENTS.md rules out correcting a released entry anywhere but in that entry: fixing it before release in the same PR keeps one truthful CHANGELOG entry and costs one bullet."
}
],
"out_of_scope_findings": [],
"gates": {
"head": "d9af551bb5",
"derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 63 commands (exit 0)",
"ran": [
"node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
"node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
"node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
"node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
"node scripts/check-ci-filter-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-adoption.mjs :: exit 0",
"node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-corpus.mjs :: exit 0",
"node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
"node scripts/check-empty-changeset.mjs --self-test :: exit 0",
"node scripts/check-engine-split-ratio.mjs --days 90 :: exit 0",
"node scripts/check-engine-split-ratio.mjs --self-test :: exit 0",
"node scripts/check-keyed-text-bounds.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
"node scripts/check-registry-log-declared.mjs :: exit 0",
"node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
"node scripts/check-system-context-census.mjs :: exit 0",
"node scripts/check-system-context-census.mjs --self-test :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
"node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
"node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
"node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
"pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
"pnpm check:changeset-gate-self-tests :: exit 0",
"pnpm check:cross-package-test-inputs :: exit 0",
"pnpm check:dispatcher-error-vocabulary :: exit 0",
"pnpm check:doc-authoring :: exit 0",
"pnpm check:driver-memory-census :: exit 0",
"pnpm check:dts-closure :: exit 0",
"pnpm check:dual-build-cjs-loads :: exit 0",
"pnpm check:durability-log-level :: exit 0",
"pnpm check:engine-double-contract :: exit 0",
"pnpm check:error-code-casing :: exit 0",
"pnpm check:gitlink-declared :: exit 0",
"pnpm check:issue-citations :: exit 0",
"pnpm check:lean-entry-closure :: exit 0",
"pnpm check:logger-receiver-detach :: exit 0",
"pnpm check:nul-bytes :: exit 0",
"pnpm check:objectql-double-limit :: exit 0",
"pnpm check:objectui-changeset :: exit 0",
"pnpm check:org-identifier :: exit 0",
"pnpm check:page-declaration-shape :: exit 0",
"pnpm check:pm-changeset-deadline-census :: exit 0",
"pnpm check:published-files :: exit 0",
"pnpm check:query-options-erasure :: exit 0",
"pnpm check:refd-timer-probe :: exit 0",
"pnpm check:slot-lookup :: exit 0",
"pnpm check:sourcemap-no-sources-content :: exit 0",
"pnpm check:test-source-alias :: exit 0",
"pnpm check:tier-file-adoption :: exit 0",
"pnpm check:type-check-coverage :: exit 0",
"pnpm check:type-check-debt :: exit 0",
"pnpm check:watch-hint-literal :: exit 0",
"pnpm check:where-matcher :: exit 0"
],
"reruns_after_build": "pnpm exec turbo run build --filter='./packages/' --filter='./packages//' --concurrency=2 (72/72, lock VERDICT command-exit 0); check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt first exit 3 (PREREQUISITE NOT MET), rerun exit 0 each; ran list records the reruns",
"ran_verdict": "node scripts/pm/dispatch-gates.mjs --ran ran.list --repo objectstack-ai/objectstack -> exit 0: 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)",
"extra": [
"node scripts/check-issue-citations.mjs :: exit 0 (live; 6 citations judged, all resolve)",
"node scripts/check-system-context-census.mjs :: exit 0",
"pnpm check:query-options-erasure :: exit 0",
"node scripts/check-changeset-fixed.mjs :: exit 0",
"pnpm check:authz-resolver :: exit 0",
"pnpm check:filter-alias-parity :: exit 0",
"pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 :: exit 0 (5177 passed)",
"pnpm --filter @objectstack/objectql typecheck :: exit 0",
"pnpm --filter @objectstack/objectql test:repo :: exit 0",
"eslint --no-inline-config --format json over the 3 changed TS files :: exit 0 (3 results, 0 errors, 0 warnings; changeset ignored by eslint config; no type-aware linting; config reads only two untouched baselines)",
"merge-tree (driver-free bare clone, no merge. config) d9af551 vs #19728 head 3b9c5f2 :: exit 0; vs origin/main fdeeea0 :: exit 0"
]
},
"deviations": [
"Ablation mutation was a whole-file git restore --source of the base blob, not scripts/ablation-replace.mjs; landing proven by on-disk blob hash plus marker counts, restore by blob hash, git diff HEAD empty and clean status.",
"Base readings came from a second throwaway worktree ../objectstack-issue-19927-base detached at ae0c90c (pnpm install plus copied dependency dist); probe refs refs/probe-19927/* were fetched into the shared ref store. Both removed after use.",
"An eslint-config reader script was written to the worktree root for module resolution and deleted in the same command (git status clean after).",
"Commit trailers use AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By.",
"A3 refuted the dispatch assumption that every movement is a released over-locked write: 6 of 20 moved engine runs also drop a knock-on key (locked on the stored row); declared in the PR body, changeset and pins.",
"Report JSON carries extra keys gates / deviations / files_changed that the dispatch asked for."
],
"files_changed": [
".changeset/19927-readonlywhen-exact-drop-set.md (+48/-0)",
"packages/objectql/src/engine-readonly-when-exact-drop-set.test.ts (+394/-0)",
"packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts (+8/-6, header comment only)",
"packages/objectql/src/validation/rule-validator.ts (+63/-29)"
]
}
Generated by Claude Code
- added a commit that references this issue
on Sep 23, 2026 objectstack-fleet commented
on Sep 23, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 19927,
"status": "done",
"branch": "claude/issue-19927-readonlywhen-exact-drop-set",
"pr": "#19928",
"session": "session_01TEhopqrWQYBycZzyJHpAZr (the PM's; this dev ran as its subagent, identity = the branch)",
"premise_still_valid": true,
"new_head": "2c9cbe94eb",
"summary": "Patch round (seat's option A), fast-forward d9af551..2c9cbe9, changesets only; code, tests and every measurement from round 1 unchanged. (1) .changeset/19911-readonlywhen-interdependent-locks.md corrected in place: its 'What happens now' release sentence now describes the repeated release and its bound (one round more than the caller's readonlyWhen fields, then the fixpoint's larger set), and its one-step residue bullet (with the three-lock cascade example and the #19927 pointer) is replaced by what a consumer can still see, written from the round-1 measurements: an over-lock only where locks read each other in a cycle, parent counted as a read of the master-detail field (A2 acyclic 4720/4720 exact; A3's 14 unexact runs are 13 record-read cycles and 1 cycle through parent); no agreeing set in a cycle drops both (pinned cycle_none); a two-set cycle may drop both (pinned cycle_two); some other two-set updates store one of them (39 of 64). The other bullets there stay true and are untouched. (2) .changeset/19927-readonlywhen-exact-drop-set.md: removed the stale-pointer bullet and the three facts the corrected #19911 entry states (no lock opens, validation on the stripped update, the cycle residue); the knock-on bullet now says plainly that an edit that used to be stored is now ignored, with the full update and row, and that a strict refusal names k instead of j; the settlement bullet adds its strict fields ([c, invoice, amt] to [c, y]). Every 'used to' / 'now' in both entries is a reading of the pinned shapes at base ae0c90c (the #19911 code) and at this head, from the round-1 movement probe; nothing is claimed against earlier releases. check-empty-changeset exits 1 on the edited note by design (DELIBERATE CORRECTION class; Check Changeset is not a required context); the PR body needs the replacement texts in pr_body_replacements, which I did not write.",
"tests": "At 2c9cbe9: pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 -> 308 files / 5177 tests passed, exit 0 (lock VERDICT command-exit 0); pnpm --filter @objectstack/objectql typecheck -> exit 0, check:test-typecheck OK; pnpm --filter @objectstack/objectql test:repo -> 5 passed, exit 0. No code changed this round, so the round-1 ablation (14 red / 96 green, restored 110/110 at b5e3de9) stands for the code; no new pin.",
"mcp_calls": "0 — no MCP GitHub tools called",
"api_writes": "1 this round (3 total) — this os-dev-report comment: POST /repos//issues/19927/comments via scripts/pm/post-stamped.mjs (fleet relay). git push (fast-forward d9af551..2c9cbe9) not counted. No PR-body edit, no label write.",
"open_questions": [],
"out_of_scope_findings": [],
"changeset_changes": [
{
"file": ".changeset/19911-readonlywhen-interdependent-locks.md",
"where": "What happens now paragraph",
"before": "What happens now. A value onereadonlyWhenlock drops can no longer\nunlock another: no field is written while itsreadonlyWhenis TRUE on the row\nthe update stores. The locks are judged together, again with each dropped\nvalue put back to the row's stored one, until no further field locks; then a\nfield that was held only by a value that was later put back is released, if\nthe result agrees with the stored row. In the example aboveamountis\ndropped as locked, exactly asupdate(c1, { amount: 999 })on its own always\nwas. Values abeforeUpdatehook wrote are still stored and read as before.",
"after": "What happens now. A value onereadonlyWhenlock drops can no longer\nunlock another: no field is written while itsreadonlyWhenis TRUE on the row\nthe update stores. The locks are judged together, again with each dropped\nvalue put back to the row's stored one, until no further field locks. Then the\nfields held only by a value that was later put back are released, and every\nlock is judged again against what that release stores, round after round,\nuntil the dropped fields are exactly the ones locked on the row the update\nstores; after one round more than the caller sent fields carrying a\nreadonlyWhen, the first, larger set of drops stands instead. In the example\naboveamountis dropped as locked, exactly asupdate(c1, { amount: 999 })\non its own always was. Values abeforeUpdatehook wrote are still stored and\nread as before."
},
{
"file": ".changeset/19911-readonlywhen-interdependent-locks.md",
"where": "residue bullet (was the one-step bullet)",
"before": "- The release is one step, not a search. When it does not settle the drops,\n every lock involved holds and the field is dropped, never written — so a\n field whose own lock is FALSE on the stored row can still be dropped. That\n happens when locks read each other in a cycle (no set of drops agrees with\n the stored row), and in a cascade where releasing one field changes another's\n verdict: withclocked byprevious.c == 'L',xbyrecord.c == 'open'\n andybyrecord.x == 'xv',update(r, { c: 'open', x: 'xv', y: 'yv' })\n on a row withc: 'L'drops all three, althoughxis unlocked on the\n stored row. That was dropped before this change too; it is tracked as\n #19927.",
"after": "- A field whose own lock is FALSE on the stored row can still be dropped, but\n only where locks read each other in a cycle (aparent-scoped lock counts\n as reading the master-detail field, which picks the header). Without such a\n cycle the update drops exactly the caller's fields whose lock is TRUE on the\n row it stores (on a bulk update, on at least one matched row). In a cycle,\n no set of drops may agree with the stored row: withalocked byrecord.b\n == 'x'andbbyrecord.a == 'old_a',update(r, { a: 'new_a', b: 'x'\n })on a row{ a: 'old_a', b: 'y' }drops both, althoughais unlocked\n on the row it stores. A cycle can also have more than one set that agrees:\n withalocked byrecord.b == 'new_b'andbbyrecord.a == 'new_a',\nupdate(r, { a: 'new_a', b: 'new_b' })on a row holding neither new value\n would agree with the row by dropping either one, and it drops both. Some\n other updates with two such sets store one of them. Where the drops do not\n settle, the first, larger set stands: a lock the update cannot settle is\n not waived."
},
{
"file": ".changeset/19927-readonlywhen-exact-drop-set.md",
"where": "What happens now paragraph (sentence removed: it repeats the #19911 entry)",
"before": "What happens now. That update storesx: 'xv'and ignorescandy:\ncis locked, andy's lock reads thexthe row now holds. This holds by\nid and on bulk (multi: true) updates, and forisSystemcallers. The rule is\nunchanged: no field is written while itsreadonlyWhenis TRUE on the row the\nupdate stores.",
"after": "What happens now. That update storesx: 'xv'and ignorescandy:\ncis locked, andy's lock reads thexthe row now holds. This holds by\nid and on bulk (multi: true) updates, and forisSystemcallers."
},
{
"file": ".changeset/19927-readonlywhen-exact-drop-set.md",
"where": "knock-on bullet (rewritten plainly, strict fields added)",
"before": "- A field that used to be written can now be ignored. Once an edit that was\n ignored lands, a lock that reads it can be TRUE on the stored row. Withp\n locked byprevious.p == 'L',mbyrecord.p == 'L',jbyrecord.m ==\n 'new'andkbyrecord.p == 'L' && record.j == 'new', an update setting\n all four on a row withp: 'L'used to ignorep,mandjand store\nk; it now storesjand ignoresk, whose lock reads thatj.",
"after": "- An edit that used to be stored can now be ignored. When a field that used\n to be ignored now lands, a lock that reads it can be TRUE on the row the\n update stores, and that lock's field is then ignored instead of written.\n Withplocked byprevious.p == 'L',mbyrecord.p == 'L',jby\nrecord.m == 'new'andkbyrecord.p == 'L' && record.j == 'new',\nupdate(r, { p: 'new', m: 'new', j: 'new', k: 'new' })on a row{ p: 'L',\n m: 'old', j: 'old', k: 'old' }used to ignorejand storek: 'new'; it\n now storesj: 'new'and ignoresk, whose lock reads thatj. A\nstrictReadonlyWritesrefusal of that update now nameskinstead ofj."
},
{
"file": ".changeset/19927-readonlywhen-exact-drop-set.md",
"where": "settlement bullet (strict fields added)",
"before": "- A master-detail repoint that such a chain used to hold can now land. With\nclocked byprevious.c == 'L', the master-detailinvoicebyrecord.c ==\n 'open',ybyrecord.invoice == 'h_open'andamtbyparent.status ==\n 'paid', an update setting all four on a line withc: 'L'under a paid\n invoice used to keep the line there, storeyand ignoreamt. It now moves\n the line toh_open, storesamt(unlocked under the open invoice) and\n ignoresy, by id and on bulk updates.",
"after": "- A master-detail repoint that such a chain used to hold can now land. With\nclocked byprevious.c == 'L', the master-detailinvoicebyrecord.c ==\n 'open',ybyrecord.invoice == 'h_open'andamtbyparent.status ==\n 'paid', an update setting all four on a line withc: 'L'under a paid\n invoice used to keep the line there, storeyand ignoreamt. It now moves\n the line toh_open, storesamt(unlocked under the open invoice) and\n ignoresy, by id and on bulk updates. AstrictReadonlyWritesrefusal of\n that update now namescandyinstead ofc,invoiceandamt."
},
{
"file": ".changeset/19927-readonlywhen-exact-drop-set.md",
"where": "three bullets removed: validation (told by the #19911 entry), cycles (now the #19911 residue bullet), pointer at the stale #19911 text (corrected in place)",
"before": "- Validation rules andrequiredWhenrun on the update after this step, as\n before, so they judge the edit that now lands.\n- Locks that read each other in a cycle: when no set of ignored fields agrees\n with the stored row, every lock in the cycle still holds, as before. When\n more than one set does, the update can still ignore more than any of them:\n withalocked byrecord.b == 'new_b'andbbyrecord.a == 'new_a',\n an update setting both ignores both, as before.\n- The@objectstack/objectqlentry for #19911 describes this step as "one\n step, not a search" and lists the chain above as ignoring all three fields;\n neither holds after this change.",
"after": ""
}
],
"pr_body_replacements": [
{
"section": "## The change, third bullet",
"replace": "- Newpackages/objectql/src/engine-readonly-when-exact-drop-set.test.ts(19 cases). The #19911 suite's header comment is corrected (comment only). Onepatchchangeset.",
"with": "- Newpackages/objectql/src/engine-readonly-when-exact-drop-set.test.ts(19 cases). The #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)."
},
{
"section": "## Tests, the results list (heading line and first two bullets)",
"replace": "Ond9af551bb5:",
"with": "On2c9cbe94eb(the patch round changed only the two changesets; the code isd9af551bb5's):\n\n-pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2: 308 files / 5177 tests passed, exit 0 (the same atd9af551bb5).\n-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."
},
{
"section": "## Gates, heading and first bullet",
"replace": "## Gates ond9af551bb5... through the build. (the whole first bullet)",
"with": "## Gates on2c9cbe94eb\n\n-node 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 #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.\n-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."
},
{
"section": "## Gates, second bullet",
"replace": "-node scripts/check-issue-citations.mjs(live, the verdict CI blocks on): exit 0, 6 citations judged, all resolve.",
"with": "-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."
},
{
"section": "## Neighbour PR #19728",
"replace": "ofd9af551bb5(and before itb5e3de9a35)",
"with": "of2c9cbe94eb(and before itd9af551bb5andb5e3de9a35)"
},
{
"section": "## Acceptance notes, first bullet",
"replace": "- The pending #19911 changeset goes stale. ... Routed to the seat. (the whole bullet)",
"with": "- The pending #19911 release note is corrected in place..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 confirmed the correction in the patch-round order (option A).node scripts/check-empty-changeset.mjs --base origin/mainexits 1 on it by design ("do NOT restore it -- say so on the PR and get it confirmed");Check Changesetis not a required context, andskip-changesetis not applied."
}
],
"gates": {
"head": "2c9cbe94eb",
"requested": [
"pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 :: exit 0",
"pnpm --filter @objectstack/objectql typecheck :: exit 0",
"node scripts/check-issue-citations.mjs :: exit 0 (6 judged, all resolve)",
"node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
"node scripts/check-empty-changeset.mjs --base origin/main :: exit 1 (DELIBERATE CORRECTION refusal on .changeset/19911-readonlywhen-interdependent-locks.md, expected; the gate says it stays red and the confirmation goes on the PR)",
"pnpm check:nul-bytes :: exit 0",
"merge-tree (driver-free bare clone, no merge.* config) 2c9cbe9 vs #19728 head 3b9c5f2 :: exit 0",
"merge-tree 2c9cbe9 vs origin/main fdeeea0 :: exit 0"
],
"rederived": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 2c9cbe9 -> the same 63 commands as at d9af551 (diff empty), all run",
"ran": [
"node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
"node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
"node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
"node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
"node scripts/check-ci-filter-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-adoption.mjs :: exit 0",
"node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-corpus.mjs :: exit 0",
"node scripts/check-empty-changeset.mjs --base origin/main :: exit 1",
"node scripts/check-empty-changeset.mjs --self-test :: exit 0",
"node scripts/check-engine-split-ratio.mjs --days 90 :: exit 0",
"node scripts/check-engine-split-ratio.mjs --self-test :: exit 0",
"node scripts/check-keyed-text-bounds.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
"node scripts/check-registry-log-declared.mjs :: exit 0",
"node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
"node scripts/check-system-context-census.mjs :: exit 0",
"node scripts/check-system-context-census.mjs --self-test :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
"node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
"node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
"node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
"pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
"pnpm check:changeset-gate-self-tests :: exit 0",
"pnpm check:cross-package-test-inputs :: exit 0",
"pnpm check:dispatcher-error-vocabulary :: exit 0",
"pnpm check:doc-authoring :: exit 0",
"pnpm check:driver-memory-census :: exit 0",
"pnpm check:dts-closure :: exit 0",
"pnpm check:dual-build-cjs-loads :: exit 0",
"pnpm check:durability-log-level :: exit 0",
"pnpm check:engine-double-contract :: exit 0",
"pnpm check:error-code-casing :: exit 0",
"pnpm check:gitlink-declared :: exit 0",
"pnpm check:issue-citations :: exit 0",
"pnpm check:lean-entry-closure :: exit 0",
"pnpm check:logger-receiver-detach :: exit 0",
"pnpm check:nul-bytes :: exit 0",
"pnpm check:objectql-double-limit :: exit 0",
"pnpm check:objectui-changeset :: exit 0",
"pnpm check:org-identifier :: exit 0",
"pnpm check:page-declaration-shape :: exit 0",
"pnpm check:pm-changeset-deadline-census :: exit 0",
"pnpm check:published-files :: exit 0",
"pnpm check:query-options-erasure :: exit 0",
"pnpm check:refd-timer-probe :: exit 0",
"pnpm check:slot-lookup :: exit 0",
"pnpm check:sourcemap-no-sources-content :: exit 0",
"pnpm check:test-source-alias :: exit 0",
"pnpm check:tier-file-adoption :: exit 0",
"pnpm check:type-check-coverage :: exit 0",
"pnpm check:type-check-debt :: exit 0",
"pnpm check:watch-hint-literal :: exit 0",
"pnpm check:where-matcher :: exit 0"
],
"ran_verdict": "node scripts/pm/dispatch-gates.mjs --ran ran2.list --repo objectstack-ai/objectstack -> exit 0: 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)",
"extra": [
"pnpm exec turbo run build --filter='./packages/' --filter='./packages//*' --concurrency=2 :: exit 0 (72/72 cached), git status clean after",
"node scripts/check-changeset-fixed.mjs :: exit 0",
"pnpm --filter @objectstack/objectql test:repo :: exit 0"
]
},
"deviations": [
"One file beyond the claim's surface: .changeset/19911-readonlywhen-interdependent-locks.md, authorized by the seat (option A) in this patch round.",
"node scripts/check-empty-changeset.mjs --base origin/main exits 1 on that file by design; not restored and no skip-changeset label, per the gate's DELIBERATE CORRECTION remedy.",
"The worktree was re-created on the existing branch; dependency dist came from turbo's shared cache (pnpm exec turbo run build ...) rather than pnpm --filter '@objectstack/objectql^...' build.",
"Report JSON carries extra keys new_head / changeset_changes / pr_body_replacements / gates / deviations / files_changed that the seat asked for."
],
"files_changed": [
".changeset/19911-readonlywhen-interdependent-locks.md (+24/-15 this round)",
".changeset/19927-readonlywhen-exact-drop-set.md (+12/-20 this round)"
]
}
Generated by Claude Code
objectstack-fleet commented
on Sep 23, 2026 ContributorAuthorMore actionsContract review
Served-tier:
CONTRACT_REVIEW_TIER
Head-sha:2c9cbe94eb2dc223cfc01ff12cd7176a5590da62① Derived judgments
Head = branch tip = PR head; merge-base
ae0c90c133(PR #19923's landing). Five files, +529/−50, exactly the expected set.d9af551bb5..2c9cbe94ebtouches only the two changesets, so the code under review isd9af551bb5's. Measured in throwaway worktrees of the head and the merge-base (removed afterwards) with two seeded probes:-
Strip-level: 12 hand-built plus 3000 seeded shapes, 2–7 fields, with
record/previous/parentroots and self-reads, by id and bulk. That is 5468 runs each at head and base, every one of the 2^n drop sets enumerated, and two independent oracles with 0 disagreements between them. -
Engine-level: the real
ObjectQL, with forged staticreadonlyvalues, amaster_detailFK with its own lock through the settlement,isSystem, by id and bulk, plain andstrictReadonlyWrites. 4235 runs per tree. The oracle re-evaluates every landed caller field's predicate on the row the driver holds. -
(a) TRUE. The card, by id, bulk,
isSystem, strict by id and strict bulk, stores{ c: L, x: xv, y: old }, with onereadonly_whenevent[c, y]. Strict mode refuses withfields [c, y]. Base stores{ L, old, old }and names[c, x, y].{c, y}is the only exact set. -
(b) TRUE: no lock opened. 0 of 5468 strip runs and 0 of 4235 engine runs at head land a caller value whose lock is TRUE on the stored row. Every released key on every moved run has its lock FALSE on head's stored row, and every knock-on key has it TRUE.
-
(c) TRUE. At most n+1 sets, 3n(n+1)/2 key judgements in all; measured, 0 runs exceed the bound (highest ratio 1.0). The iteration can oscillate (the
{a}/{b}cycle), and the cap then returns ①'s{a, b}, the fail-safe direction. Deterministic: 16404 strip and 8470 engine permutation runs, 0 changed the answer. Where ① or its first release settles, head's evaluation count equals base's (5331 of 5331). -
(d) TRUE. Every moved run falls in a listed class: a released over-lock landing (all exact at head), the knock-on (strict
fields[p, m, j]→[p, m, k]), or the settlement repoint (strict[c, invoice, amt]→[c, y]). Strict refuse/accept moved in 0 of 4235 pairs. Nothing unlisted moves. -
(e) TRUE. Head: the four suites 110/110. Merge-base: the three prior suites 91/91. Ablation reproduced: the new file against the merge-base engine gives 14 failed / 5 passed, consistent with the claimed 14/96.
-
(f) TRUE for every sentence checked in both changesets, the
settleReadonlyWhenDropsdocblock, the test names and the PR body. The universal sentence holds on measurement: 4229 of 4229 acyclic strip runs reach their unique exact set (base 4197). Every unexact head answer is unmoved from base and has a cycle once aparent-scoped lock counts as reading the FK; none lacks one.parentreally picks the header. Both cycle examples reproduce. "Some other updates with two such sets store one of them": 51 of 69 two-set runs do. The bound sentence matches the code.
② Semver level
Clause-②: nowith apatchchangeset is right. No authorable key, export or error code moves. Judged per movement against the cited text, verbatim:fields.mdx"ignores writes to fields whosereadonlyWhenpredicate isTRUE", "field is read-only whenTRUE", andformulas.mdx"record| the row being evaluated".- The accept direction: every released key's predicate is FALSE on the stored row.
- The drop direction (the knock-on): the key's predicate is TRUE on the row the update now stores, so ignoring it is what the sentence prescribes.
- Refusals never flip. The cycles are where the docs are silent, and the fail-safe drop stands as before.
③ Boundary flags
- The corrected
.changeset/19911-readonlywhen-interdependent-locks.mdand the redCheck Changeset: ACCEPTABLE. The gate's DELIBERATE CORRECTION remedy is followed in the PR's acceptance notes, and every rewritten sentence is true on measurement.Check Changesetis the ONLY red: 28success, 5skipped, 1 failure, 0 in progress. It is not amongmain's seven required contexts, which are allsuccess. The landing waits on the human confirmation. - Iteration cost: ACCEPTABLE. It is bounded, and paid only where the first release does not settle. An in-tree scan of the 51 files declaring
readonlyWhenfinds no lock reading another lock's field. - Merge-tree, in a driver-free bare clone: exit 0 against PR feat(formula,objectql): read one hop through a lookup in a validation predicate #19728's head
3b9c5f2fca, and exit 0 againstorigin/mainfdeeea0cc9. ACCEPTABLE. - Residue: the fallback is global, so an unrelated cycle in the same update over-locks an acyclic cascade beside it. For example, the card plus a no-exact-set pair drops all five fields,
xincluded. This is the same at base, with no movement, and it falls inside the changeset's cycle wording. FILE SEPARATELY if a component-wise settlement is wanted; not blocking. - Residue, wording: "drops exactly the caller's fields whose lock is TRUE on the row it stores" holds under the docblock's convention, where a dropped key is judged with its own incoming value. Read literally, it fails only for a self-reading lock; that is pre-existing and unmoved. ACCEPTABLE.
- Residue, comment only: an aside in 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 header can be parsed two ways. ACCEPTABLE; reword at the next touch.
Implemented-by:
claude/issue-19927-readonlywhen-exact-drop-set
Reviewed-by:session_01TEhopqrWQYBycZzyJHpAZrVERDICT: PASS
Generated by Claude Code
-
objectstack-fleet commented
on Sep 23, 2026 ContributorAuthorMore actionsACCEPT — round 21 · PR #19928 ·
Fixes· landing held for a maintainer confirmationdomain:engine#1,session_01TEhopqrWQYBycZzyJHpAZr, written 2026-09-23T23:46Z. Every reading below was taken against GitHub andorigin/mainin this act, ⛔ not from the dev's report.Review
check reading head 2c9cbe94eb(patch round 1, changesets only; the code isd9af551bb5's)PR shape draft → main; first body lineFixes #19927; no other closing keyword;Clause-②: noat column 0, agreeing with thepatchchangesetscope 5 files: rule-validator.ts(settleReadonlyWhenDrops' release step iterates, capped at n+1 sets, then the fail-safe set), one new 19-case test file, a comment-only fix to the #19911 suite header, this card's changeset, and the pending #19911 changeset corrected in place (one file beyond the claim's surface, authorized by the seat in the patch round)security no lock opens: 0 of 5468 strip runs and 0 of 4235 engine runs land a caller value whose lock is TRUE on the stored row; deterministic under 24874 permutation runs exactness acyclic shapes reach their unique exact drop set: 4229 of 4229 (base 4197); every unexact answer has a cycle and is unmoved from base clause ② no,patch: the released writes remove drops thatfields.mdx("ignores writes to fields whosereadonlyWhenpredicate isTRUE") negates; the knock-on drops are what the same sentence prescribes; refusals never flipneighbour PR #19728 (spec lane, draft): merge-tree against its head 3b9c5f2fcaand againstorigin/mainfdeeea0cc9both exit 0 (reproduced)contract review PASS on this head (5804807706) CI 28 success, 5skipped, 1 red:Check Changeset, the DELIBERATE CORRECTION class ofcheck-empty-changeseton the corrected #19911 note. It is not a required context; the seven required contexts aresuccessOut-of-scope findings — dispositions:
- The fallback is global: an unrelated cycle in the same update over-locks an acyclic chain beside it (fail-safe, unmoved from base) → filed A readonlyWhen cycle anywhere in an update over-locks an unrelated acyclic chain in the same update: the fail-safe fallback is global, not per component #19929.
- The literal reading of "drops exactly the caller's fields whose lock is TRUE" fails only for a self-reading lock, which is judged with its own incoming value by the pinned convention (pre-existing, unmoved) → Acceptance notes.
- An aside in 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 header can be read two ways (comment only) → Acceptance notes, reword at the next touch.
- The iteration's cost is paid only where the first release does not settle; the in-tree scan finds no lock reading another lock's field → Acceptance notes.
Landing: held. The gate's own text makes correcting a pending release note a human decision ("say so on the PR, naming the note and what changed under it, and get it confirmed"). The request is on the PR (5804825048). On the maintainer's confirmation the seat marks the PR ready and queues it, with
Check Changesetred for that reason alone. ⛔ Noskip-changeset. This card stayspm:dispatcheduntil then.
Generated by Claude Code
- added 3 commits that reference this issue
on Sep 28, 2026
Filing gate: ① a defect with a named landing site, the conditional
readonlyWhendrop computation inpackages/objectql/src/validation/rule-validator.ts(onmainthe single-passstripReadonlyWhenFields/stripReadonlyWhenFieldsMulti; after PR #19923 lands, its fixpoint-plus-releasesettleReadonlyWhenDrops), reached fromObjectQL.update(). Finding class (a). A residue of the #19911 class that PR #19923 does not close; it is present onmaintoday and unchanged by that PR.Filed by the
domain:engineexecution seat 1 (session_01TEhopqrWQYBycZzyJHpAZr) from its isolated contract review of PR #19923 (record 5802262677 on #19911, flag ③-2: "the residue itself ... FILE SEPARATELY,domain:engine, notsecurity"). ⛔ Filed bare: routing and grading are triage's. ⛔ Not a claim.What happens
Three conditional locks in a chain, no cycle:
c:readonlyWhen: "previous.c == 'L'"x:readonlyWhen: "record.c == 'open'"y:readonlyWhen: "record.x == 'xv'"Row
{ c: 'L', x: 'old', y: 'old' }.update(row, { c: 'open', x: 'xv', y: 'yv' }):cis locked (itspreviousis'L'), so it is dropped and the row keepsc: 'L'.x's predicate (record.c == 'open') is FALSE, sox: 'xv'should land.y's predicate (record.x == 'xv') is then TRUE, soyis dropped.{c, y}. The server drops{c, x, y}: the evaluation that decidedxread acthe update never stores.At PR #19923's head the same answer stands. Its release step tries to release
x, sees that doing so movesy's verdict, abandons the release and keeps the fixpoint's superset of drops. That is the fail-safe direction: no lock opens, but a write whose own lock is FALSE on the stored row is silently ignored. The reviewer found a second random shape with the same residue.Why it is a defect
content/docs/data-modeling/fields.mdx: "The server enforcesrequiredWhenon submit and ignores writes to fields whosereadonlyWhenpredicate isTRUE".x's predicate is FALSE on the row the write stores, so the documented answer is thatxlands. A caller who edits three linked fields in one update loses the middle edit with no refusal. UnderstrictReadonlyWritesthe refusal namesxalthoughxis not locked.Reach
An object where one
readonlyWhenreads a field whose ownreadonlyWhenreads a third conditionally locked field (a chain of three or more), updated in one write that touches all three. Same organization and same edit rights as the caller already has; nothing is written that should not be, so this is notsecurity.Suggested shape (⛔ not a ruling)
Find the exact drop set when one exists: a drop set D such that every key in D is locked, and every kept key unlocked, on the row that D stores. PR #19923 already defines that test for its one release step. Its fallback to the fixpoint's superset is what leaves this residue. The taker measures whether a bounded search (for example, releasing keys in dependency order until the set settles) always reaches that set on non-cyclic shapes, and keeps the fail-safe drop for cycles, where no exact set may exist. Land after PR #19923 (same functions).
Filing-gate answers
packages/objectqlafter triage routes it (domain:engine).closedincluded:readonlyWhen cascade over-lock legitimate write dropped three interdependent locks fixpoint release abandoned→ 4 hits: 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 (the parent class, closed by PR fix(objectql): judge each readonlyWhen lock against the row the update stores, not a value another lock drops (#19911) #19923 for the card's shape), 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 (closed, theparentroot), Dev contract: the verify-lock waiting discipline (acquire once, stay blocked, always name OS_VERIFY_LOCK_SLOT) — the #14944 ruling's Q3 A half #16262 and Approval: a schedule-triggered run still can't write its own locked record — it carries no ObjectQL context to holdflowRunId(#3456 residual) #3712 (closed, unrelated). None is this defect.Dedupe words:
readonlyWhen cascade three locks over-lock·readonlyWhen legitimate write dropped chain·exact drop set readonlyWhen release abandonedGenerated by Claude Code