Skip to content

fix(objectql): judge each readonlyWhen lock against the row the update stores, not a value another lock drops (#19911) - #19923

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19911-readonlywhen-interdependent-locks
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19911-readonlywhen-interdependent-locks

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19911

Clause-②: no

What a caller could do before, and what happens now

Before. When one field's readonlyWhen read a field that carries its own readonlyWhen, a caller with edit rights could change a locked field by sending a new value for the other field in the same update. The conditional strip judged every lock against one record view built before any lock was judged. That view still held every value the strip then dropped. With status locked by previous.status == 'closed' and amount by record.status == 'closed', update(c1, { status: 'open', amount: 999 }) on a closed row stored { status: 'closed', amount: 999 }. The same happened on bulk (multi: true) updates, for isSystem callers (a readonlyWhen lock binds them), and for a master-detail FK's own lock judged inside the PR #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/main a34c27cbe5, before any edit

Real ObjectQL engine, the PR #19905 suite's in-memory driver shape. Object case_x: status readonlyWhen: "previous.status == 'closed'", amount readonlyWhen: "record.status == 'closed'", rows c1, c2 closed with amount: 100.

  • By id: update(c1, { status: 'open', amount: 999 }) stored { status: 'closed', amount: 999 }, event [{ status, readonly_when }]. Control update(c1, { amount: 555 }) stayed 100. Reproduced.
  • Bulk (where: { note: 'k' }, multi: true): both rows stored amount: 999. Control stayed 100. Reproduced.

A2: the candidates, measured

All five variants (the base and four candidates) were run on the same 20 files: the nine suites PR #19905's body names (its own engine-readonly-when-stored-view included) and every other objectql test file that mentions readonlyWhen (674 tests). Every variant passes 674/674, so the existing pins do not tell the candidates apart. They were compared on the card's shape and on a 27-row probe of every movement class instead. The expected rows were checked against a brute-force enumeration of every drop set that agrees with the stored row.

shape (exact drop sets by enumeration) base (i) monotone fixpoint (iv) fixpoint + exact release (chosen) (ii) judge against the stored row (iii) refuse when locks interact
the card, by id and bulk ({status, amount}) amount 999 under-lock 100 100 100 refused
legitimate reopen: status unlocked, amount edited ({}) both land both land both land amount dropped over-lock both land
close + edit on an open row ({amount}) amount dropped dropped dropped amount 999 lands on a closed row under-lock dropped
REVERSE: frozen status, closed + amount ({status}) amount dropped over-lock dropped over-lock amount lands lands refused
lock reading its own field, open row over the cap dropped dropped dropped 5000 lands under-lock dropped
three-lock chain ({c,b}) {c,a} {c,b,a} over-locks a {c,b} {c,b} refused
four-lock chain ({c,z,y}) {c} {c,x,z,y} over-locks x {c,z,y} {c,z,y} refused
two-lock cycle (NONE) {a} {a,b} {a,b} fail-safe {b} refused
FK's own record lock reads a stage its own lock keeps FK lands, amount 999 FK stays, 100 FK stays, 100 FK stays, 100 refused
  • (i) monotone fixpoint. Never opens a lock, and its first pass is the old single pass. It over-locks: a key dropped in an early pass whose own lock is FALSE on the final row. Measured on a two-field shape (REVERSE, where the base over-locks too, so (i) keeps that defect) and on the three- and four-lock chains (the four-lock x is a new over-lock).
  • (ii) judge every lock against the stored row. It reverts every judged key, a key's own value included. It over-locks the legitimate reopen, and it opens locks the base holds: close + edit writes the amount onto a now-closed row, and a lock that reads its own field lets 5000 past a 1000 cap. It also reads record as previous for those fields. Rejected.
  • (iii) refuse. Moves accept to refuse on every interacting shape. The documented behaviour is to ignore a locked write, not refuse it (quoted below). Rejected. Measured in its narrowest form, refusing only when a drop moves another lock's verdict; a syntactic "reads a dropped field" rule would refuse a superset.
  • (iv) chosen. (i), then ONE release: every dropped key that is unlocked on (i)'s row is released at once. The result is kept only if it is exact (every dropped key locked, every kept key unlocked, on the row it stores). Otherwise (i)'s answer stands. It never opens a lock: no field is written while its lock is TRUE on the row the update stores. It reaches the unique exact drop set on every measured shape except the three-lock cascade (A three-lock readonlyWhen cascade drops a write whose own lock is FALSE on the stored row: a legitimate edit is silently ignored #19927): there, one release step does not settle the set (releasing x moves y's verdict), so the fixpoint's larger drop set {c, x, y} stands instead of {c, y}, and a field whose own lock is FALSE on the stored row is still dropped, as on base. The cycle has no exact set at all and takes the same fail-safe answer. It changes no documented behaviour: every movement below brings the stored row into line with the documented rule. It keeps every existing pin, as all candidates do.

The fix

  • packages/objectql/src/validation/rule-validator.ts
    • settleReadonlyWhenDrops: the fixpoint and the exact release. Each key is judged with its own incoming value (a lock reading its own field judges the write) and the other drops reverted. Views are memoised per drop set.
    • stripReadonlyWhenFields and stripReadonlyWhenFieldsMulti now route through it, with no new arguments at existing call sites. Bulk means "locked in at least one matched row" for every evaluation.
    • Warnings come from each key's deciding evaluation and are logged once, in declaration order.
    • ReadonlyWhenStripOptions.only: the strip judges every key but takes, and speaks about, only that key, and only when it is taken.
    • readonlyWhenFkJudgementReadsParent: the settlement's question "does judging the FK's lock need the header it names?".
    • The parent-root reader now caches roots per source, so it answers for record too.
    • Neither the options type nor the strips are exported from the package (index.ts and core.ts export none of them), so no published surface moves.
  • packages/objectql/src/engine.ts, settleMasterDetailLanding and its two judgeFkLock closures

Every movement (base a34c27cbe5 to head d01b912f3d; patch round 1 changed no code, so 611a2fc561 answers the same)

write before now
the card, by id and bulk amount: 999; event [status] amount: 100; one event [status, amount] readonly_when
the card under strictReadonlyWrites refused, fields: ['status'] refused, fields: ['status', 'amount'], drops one readonly_when event (a refusal before and now)
the card, isSystem / preserveAudit / a hook echoing the caller's status amount: 999 amount: 100
REVERSE by id and bulk (frozen status) amount dropped; event [status, amount] amount: 999 lands; event [status]
REVERSE under strictReadonlyWrites refused, ['status', 'amount'] refused, ['status']
own-field lock beside a dropped reopen amount: 500 on a closed row amount: 100
three-lock chain stored {c: L, b: x, a: 1} stored {c: L, b: y, a: 2}
two-lock cycle {a} dropped {a, b} dropped (no exact set exists)
requiredWhen requiring note while the amount is above 500, on the card refused VALIDATION_FAILED commits, amount 100
requiredWhen requiring note while the amount is below 500, the card clearing note commits {closed, 999, null} refused VALIDATION_FAILED, nothing lands
settlement, FK lock reads a stage its own lock keeps, by id and bulk line moves to inv_b, amount 999 stays under inv_a, amount 100; event [stage, invoice, amount]
settlement, stays to moves: the FK's own record lock reads a value that is itself locked under the header the update names (invoice locked by record.amount == 'big', amount by parent.status == 'paid'; line under open inv_b, the update names paid inv_a with amount: 'big'), by id and bulk line stays under inv_b and stores amount: 'big'; event [invoice]; header reads [inv_b] line moves to inv_a and keeps amount: 'small'; event [amount]; header reads [inv_a], then the repoint's reference check on inv_a. Both rows agree with their locks
the same write under strictReadonlyWrites refused, fields: ['invoice'] refused, fields: ['amount'], nothing lands
three-lock cascade (c locked by previous.c == 'L', x by record.c == 'open', y by record.x == 'xv'; row c: 'L'; the update sets all three), by id and bulk drops {c, x, y} drops {c, x, y}, unchanged; the exact set is {c, y} (#19927)
same FK lock, no parent-scoped lock (plain strip) FK moves FK stays
PR #19877's inv_line_moored shape (stage not in the payload) header reads [inv_a] [inv_b, inv_a]: one extra read, same row, same event

Unmoved (pinned): both controls; a hook-written status and a hook overwriting the caller's status (both land and are read); a hook-written amount; the legitimate reopen by id, bulk and strict (nothing dropped); close + edit; an own-field lock over and under its cap; an FK lock reading only previous (one header read); a landing FK's fail-open fault warning, said once. PR #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 judgeFkLock and bulk judgeFkLock all run the same settlement; no site is exempt. The FK's verdict is consistent when it lands: the re-judge is the same computation over the same header. When the FK's lock reads neither record nor parent, its verdict does not depend on the view. PR #19905's stored view is the base every view is built from, so a value the static strip takes is never read either.

Tests

New packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts, 35 cases (32 in round 1, plus three stays-to-moves pins by id, bulk and under strictReadonlyWrites in patch round 1): the card by id and bulk with both controls, the report and warn lines, the strict envelope (code ERR_READONLY_FIELD_REJECTED, name, fields, drops; the engine error carries no HTTP status, which is mapped downstream and not measured here), isSystem, preserveAudit, four hook shapes, the legitimate reopen (by id; bulk plus strict), close + edit, REVERSE by id / bulk / strict, the own-field lock (two cases), the chain, the cycle, both requiredWhen directions (refusal asserted on code VALIDATION_FAILED + fields), the settlement by id and bulk with its header reads, the plain-strip FK, the moored read count, the previous-only FK, the landing FK's single warning, and two unit cases for only.

On d01b912f3d (patch round 1):

  • pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2: 307 files / 5158 tests passed, exit 0.
  • pnpm --filter @objectstack/objectql typecheck: exit 0, check:test-typecheck: OK. The new file is in the tsconfig.test.json program (--listFiles: 1) with 0 diagnostics. pnpm --filter @objectstack/objectql test:repo: 5 passed.
  • Ablation (fix committed; engine.ts and rule-validator.ts restored to their a34c27cbe5 blobs e973ab50ac / 2b3002b8e1 with git restore --source, tree only; on-disk hashes verified and markers settleReadonlyWhenDrops / readonlyWhenFkJudgementReadsParent counted 0 / 0). The three suites ran 23 failed / 68 passed: every new pin failed except the 12 whose behaviour is unchanged, the three stays-to-moves pins included, and PR 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 no dist sits on the path. Restored with git checkout HEAD --: blobs c9b1cfc19e / f3934b80f6 equal HEAD, git diff HEAD empty, rerun 91/91 passed. The script carried an EXIT / INT / TERM trap.

Gates on 611a2fc561

Patch round 1 (d01b912f3d) changed docblocks, the changeset and three pins over the same four paths. It re-ran the objectql tests and typecheck, node scripts/check-issue-citations.mjs (23 citations resolve, #19927 included), node scripts/check-changeset-no-major.mjs --base origin/main, pnpm check:nul-bytes and the narrowed eslint run: exit 0 each. The full derivation below ran on 611a2fc561.

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 65 commands, each run with its exit code captured before any pipe. --ran: 65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero …), exit 0. check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3, no workspace dist). After pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 (72/72 tasks) all three exit 0, and the record carries the reruns.
  • node scripts/check-issue-citations.mjs (live, the verdict CI blocks on): exit 0, 22 citations resolve.
  • node scripts/check-system-context-census.mjs: exit 0 (no new isSystem read). pnpm check:query-options-erasure: exit 0.
  • The three roster gates the derivation marks as keeping a roster under this diff's directories: node scripts/check-changeset-fixed.mjs, pnpm check:authz-resolver, pnpm check:filter-alias-parity, exit 0 each.
  • Lint, narrowed and proven. eslint's own config (ESLint.isPathIgnored / calculateConfigForFile) ignores the changeset and lints the three TypeScript files with typescript-eslint/parser and no parserOptions.project / projectService. eslint --no-inline-config --format json over them gives 3 results, 0 errors, 0 warnings. The config enables no type-aware linting, and its only file reads are two baselines this diff does not touch, so this diff cannot move a verdict on any untouched file. pnpm lint itself is CI's.

Neighbour PR #19728

This PR stays textually disjoint from it and does not wait on it. Driver-free bare probe (git clone --bare --shared, no merge.* driver registered): merge-tree --write-tree of d01b912f3d (and before it 611a2fc561) against its head 3b9c5f2fca exits 0, and against origin/main beac798026 exits 0. The merged tree carries both changes (readonlyWhenFkJudgementReadsParent 3 in engine.ts, settleReadonlyWhenDrops 6 in rule-validator.ts, #19728's RelatedRecordBinding 2 and 5).

Acceptance notes

  • Cycle residue. When locks read each other in a cycle, no drop set can make every dropped key locked AND every kept key unlocked on the stored row, and no rule can promise both halves. This one keeps the fail-safe half (a lock that cannot be settled is not waived), as the bulk strip's "locked in at least one row" rule already does.
  • Cost. A write where nothing locks runs one pass, as before. Each drop adds one pass over the standing keys and one re-check of the dropped keys; a full check pass runs only when something is over-locked. The settlement reads one extra header in the moored shape. Noted, not measured.
  • In-tree usage. The tree has no case of one readonlyWhen reading another readonlyWhen field. Examples and platform objects were scanned: the showcase invoice locks read status / parent.status, and neither carries a lock. The card's shape is the natural "a closed case stays closed" plus "a closed case's amount is frozen".
  • User docs are untouched and still true after the fix; see the declaration note.

Declaration note for the contract review

The claim declares no widening; copied above as given. The fix moves a refuse/accept answer only through the validation rules that run on the stripped payload, and moves strip verdicts toward the documented rule:

  • Accept direction. A write refused VALIDATION_FAILED only because an amount the lock should have held raised a requirement now commits without it. The REVERSE shape's amount, which the base silently dropped, now lands. Both remove a verdict the published text already negated. content/docs/data-modeling/fields.mdx ("Conditional Logic") says readonlyWhen is a "CEL predicate; field is read-only when TRUE", and "The server enforces requiredWhen on submit and ignores writes to fields whose readonlyWhen predicate is TRUE". ("Who the lock applies to") says "A TRUE readonlyWhen predicate locks the field for every API-boundary caller". content/docs/data-modeling/formulas.mdx binds record to "the row being evaluated". On the row the update stores, the REVERSE amount's predicate is FALSE and the card's is TRUE.
  • Refuse direction. A write that cleared a field the kept amount requires is now refused: a restored guarantee.
  • strictReadonlyWrites: every shape that moves carries a conditional drop, so it was a refusal before and still is; only fields / drops move, and they grow on the card and shrink on REVERSE.

Every other movement is a strip (the write still commits). No authorable key, export or error code moves.


Generated by Claude Code

… 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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 23, 2026
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

15 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
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 68 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json beac7980267a361c5979ca7aa75c034624955af0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e4b2798101416c9b090455002b43ca472c360f0e — the merge of head d01b912f3da551e13c6f561eda44ac7bd1008d20 into base beac7980267a361c5979ca7aa75c034624955af0, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

… 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>
@github-actions github-actions Bot added size/xl and removed size/l labels Sep 23, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 23, 2026 20:54
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit ae0c90c Sep 23, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19911-readonlywhen-interdependent-locks branch September 23, 2026 21:13
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

1 participant