From 74d81895e87844240f8431e6b0d02a4c9a143615 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 19:24:05 +0000 Subject: [PATCH 1/4] fix(objectql): judge each readonlyWhen lock against the row the write 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 --- packages/objectql/src/engine.ts | 50 +-- .../objectql/src/validation/rule-validator.ts | 322 +++++++++++++++--- 2 files changed, 304 insertions(+), 68 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index e973ab50acb..a5c04ea19d9 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -229,6 +229,9 @@ import { import { UnscopedHookApi, type HookRunAs, type HookRunAsRef, type RunAsDerivableApi } from './hook-run-as.js'; import type { HookWriteRecording } from './hook-write-provenance.js'; import { resolveMasterDetailRelation } from './master-detail.js'; +// [#19911] The settlement's question "does judging the FK's own lock need the +// header it names?" — asked of the same root reader the strips use. +import { readonlyWhenFkJudgementReadsParent } from './validation/rule-validator.js'; // [#6457] The master-detail header a `parent`-scoped predicate reads is made // total over the MASTER's declared fields before it leaves this engine — the // same helper every other server seam materialises with (#1871/#4649/#4953). @@ -6909,14 +6912,17 @@ export class ObjectQL implements IObjectQLEngine { * rewritten, still under the paid invoice. The strips, in the order they run * after this point: * - * ① the FK's OWN `readonlyWhen` lock. Judged here first, ALONE (the - * `supplied` subset holds only the FK, so the strip evaluates nothing - * else and warns about nothing else) and against the header the FK names - * — #4889's verdict for the FK itself, unmoved. That verdict is final: + * ① the FK's OWN `readonlyWhen` lock. Judged here first, against the + * header the FK names — #4889's verdict for the FK itself, unmoved. + * [#19911] Judged WITH the other caller-supplied locks, not alone: its + * `record` is the row the write stores, so a value one of them drops is + * reverted before the FK's lock reads it — but only the FK is TAKEN here + * (`only`). When that verdict keeps the FK off the landing it is final: * the returned `supplied` no longer holds the FK, so the strip that * judges the other fields never re-asks it against the header the row * keeps, where it could flip and land the FK after the rest were judged - * against the header it left. + * against the header it left. When the FK lands, that strip re-judges it + * on the same landing and reaches the same verdict. * ② the static `readonly` strip (`staticReadonlyStripTakes`), which runs * after the conditional one and takes a non-system caller's forged FK. * @@ -6929,15 +6935,18 @@ export class ObjectQL implements IObjectQLEngine { * Returns: * - `subject` — the payload the `readonlyWhen` strip judges next: `data`, * or `data` without the FK when ①'s verdict locked it; - * - `supplied` — the entry snapshot that strip judges against, without the - * FK when the FK is in `data` (its verdict is in); + * - `supplied` — the entry snapshot that strip judges against: without the + * FK when the FK does not land (its verdict is in), whole when it does; * - `view` — the payload the header id is read from: `data` when the FK * lands, else `data` without it, so `masterIdOf` falls through to the * prior row's FK; * - `header` — what `resolve(view)` answers. ① needs the NAMED header only - * when the FK's own lock reads `parent`; when the FK then lands that is - * the same header and is reused, so the write still reads one header — - * two only when a parent-scoped lock on the FK itself refuses the landing. + * when judging the FK's lock reads `parent` — its own predicate, or + * [#19911] a `record`-reading one whose view another payload key's + * `parent`-scoped lock shapes (`readonlyWhenFkJudgementReadsParent`); + * when the FK then lands that is the same header and is reused, so the + * write still reads one header — two only when ① read the named header + * and the FK does not land. */ private async settleMasterDetailLanding(args: { schema: unknown; @@ -6945,7 +6954,7 @@ export class ObjectQL implements IObjectQLEngine { supplied: Readonly>; staticStrip: { runs: boolean; preserveAudit: boolean; hookWrittenKeys: ReadonlySet | undefined }; resolve: (view: Record) => Promise; - judgeFkLock: (named: H | undefined, fkSupplied: Readonly>) => Record; + judgeFkLock: (named: H | undefined, fk: string) => Record; }): Promise<{ subject: Record; supplied: Readonly>; @@ -6957,17 +6966,20 @@ export class ObjectQL implements IObjectQLEngine { if (fk === undefined || !(fk in data)) { return { subject: data, supplied, view: data, header: await resolve(data) }; } - // ① Own-property, never `in`, for the same reason the strips give: a - // field name can be `constructor`, which every plain object inherits. - const fkSupplied = Object.prototype.hasOwnProperty.call(supplied, fk) ? { [fk]: supplied[fk] } : {}; - const namedNeeded = hasParentScopedReadonlyWhenInPayload(schema as any, { [fk]: data[fk] }); + // ① Whether the FK is still the caller's is the strip's own question + // (`isCallerSuppliedValue`, own-property): a hook-written FK is not judged. + const namedNeeded = readonlyWhenFkJudgementReadsParent(schema as any, data, fk); const named = namedNeeded ? await resolve(data) : undefined; - const subject = judgeFkLock(named, fkSupplied); + const subject = judgeFkLock(named, fk); const rest = withoutKey(supplied as Record, fk); // ② const lands = fk in subject && !staticReadonlyStripTakes(schema, subject, fk, supplied, staticStrip); if (lands) { - return { subject, supplied: rest, view: data, header: namedNeeded ? (named as H) : await resolve(data) }; + // [#19911] A landing FK stays in `supplied`: the strip that judges the + // rest re-judges it on the SAME landing — the header ① read, or one its + // verdict never reads — so it reaches ①'s verdict again, now over the + // same drops as everything else, and says it once. + return { subject, supplied, view: data, header: namedNeeded ? (named as H) : await resolve(data) }; } const view = withoutKey(data, fk); return { subject, supplied: rest, view, header: await resolve(view) }; @@ -12924,7 +12936,7 @@ export class ObjectQL implements IObjectQLEngine { schema: updateSchema, data: preRoWhen, supplied: suppliedValues, staticStrip, resolve: (view) => this.resolveMasterDetailParent(updateSchema, view, priorRecord, opCtx.context), - judgeFkLock: (named, fkSupplied) => stripReadonlyWhenFields(updateSchema as any, preRoWhen, priorRecord, this.logger, named, { supplied: fkSupplied, stored: staticReadonlyStoredView(updateSchema, preRoWhen, suppliedValues, staticStrip) }) as Record, + judgeFkLock: (named, fk) => stripReadonlyWhenFields(updateSchema as any, preRoWhen, priorRecord, this.logger, named, { supplied: suppliedValues, only: fk, stored: staticReadonlyStoredView(updateSchema, preRoWhen, suppliedValues, staticStrip) }) as Record, }) : undefined; const roWhenParent = landing?.header; @@ -13156,7 +13168,7 @@ export class ObjectQL implements IObjectQLEngine { schema: updateSchema, data: preRoWhenMulti, supplied: suppliedValues, staticStrip: staticStripMulti, resolve: (view) => this.resolveMasterDetailParents(updateSchema, view, priorRows, opCtx.context), - judgeFkLock: (named, fkSupplied) => stripReadonlyWhenFieldsMulti(updateSchema as any, preRoWhenMulti, priorRows, this.logger, named, { supplied: fkSupplied, stored: staticReadonlyStoredView(updateSchema, preRoWhenMulti, suppliedValues, staticStripMulti) }) as Record, + judgeFkLock: (named, fk) => stripReadonlyWhenFieldsMulti(updateSchema as any, preRoWhenMulti, priorRows, this.logger, named, { supplied: suppliedValues, only: fk, stored: staticReadonlyStoredView(updateSchema, preRoWhenMulti, suppliedValues, staticStripMulti) }) as Record, }) : undefined; const parentForRow = landingMulti?.header; diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 2b3002b8e18..0c44ecfd858 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -587,6 +587,17 @@ interface ReadonlyWhenStripOptions { * Every engine call site passes it. */ stored?: Readonly>; + /** + * [#19911] Take only this key, while every judged key is still judged with + * it, so its lock reads the row the write stores. The engine's master-detail + * settlement judges the FK's own lock ahead of the rest; judged alone, the + * FK's predicate read every other key's incoming value, including one the + * rest's own locks then took back out. The key is spoken about (its warnings + * and the strip's line) only when it is taken: a key left standing is judged + * again, with the rest, by the strip that follows. Omit to take every key + * that locks. + */ + only?: string; } /** @@ -681,7 +692,9 @@ function isCallerSuppliedValue( * The `record` / `previous` bindings are TOTAL over the object's declared * fields (#4953) — see {@link readonlyWhenBindings}. `record` is the payload * the write STORES over the prior row (`options.stored`, #19887), so a - * predicate never reads a value the static `readonly` strip takes back out. + * predicate never reads a value the static `readonly` strip takes back out — + * nor, since #19911, one this strip takes back out + * ({@link settleReadonlyWhenDrops}). */ export function stripReadonlyWhenFields( objectSchema: { fields?: Record } | undefined | null, @@ -694,20 +707,183 @@ export function stripReadonlyWhenFields( const fields = objectSchema?.fields; if (!fields || !data) return data; const supplied = options?.supplied ?? data; - const view = readonlyWhenBindings(options?.stored ?? data, previous, fields); - let result = data; + const stored = options?.stored ?? data; + const judged = judgedReadonlyWhenKeys(fields, data, supplied); + if (judged.length === 0 || !judgesOnly(judged, options?.only)) return data; + const settled = settleReadonlyWhenDrops( + judged, + (dropped) => readonlyWhenBindings(withoutKeys(stored, dropped), previous, fields), + (name, view, warn) => isReadonlyWhenLocked(fields[name]!, view.merged, view.previous, name, { warn }, parent), + ); + return applyReadonlyWhenDrops(data, judged, settled, options?.only, logger, (name) => + `Field '${name}' is read-only (readonlyWhen) — ignoring incoming change`, + ); +} + +/** + * [#19911] The keys the conditional strip JUDGES: every field in the payload + * that declares a `readonlyWhen` and still holds the caller's value (#9107, + * {@link isCallerSuppliedValue}) — in declaration order, which is the order + * the strips have always evaluated, warned and reported in. + */ +function judgedReadonlyWhenKeys( + fields: Record, + data: Record, + supplied: Readonly>, +): string[] { + const judged: string[] = []; for (const [name, def] of Object.entries(fields)) { if (!def?.readonlyWhen || !(name in data)) continue; - // [#9107] Asked BEFORE the predicate runs, not after: a hook-written value + // [#9107] Asked BEFORE any predicate runs, not after: a hook-written value // is not this strip's business at all, so there is nothing to evaluate and // nothing to warn about — including the unbound-root LOCKED branch, whose // fail-CLOSED verdict exists to protect against an unjudgeable CALLER write. if (!isCallerSuppliedValue(data, supplied, name)) continue; - if (isReadonlyWhenLocked(def, view.merged, view.previous, name, logger, parent)) { - if (result === data) result = { ...data }; - delete (result as Record)[name]; - logger?.warn?.(`Field '${name}' is read-only (readonlyWhen) — ignoring incoming change`); + judged.push(name); + } + return judged; +} + +/** + * [#19911] Is `only` (when given) among the keys judged? When it is not — no + * `readonlyWhen`, absent, or no longer the caller's — the strip has nothing to + * take, and returns before judging the rest just to read their verdicts. + */ +function judgesOnly(judged: readonly string[], only: string | undefined): boolean { + return only === undefined || judged.includes(only); +} + +/** `data` without `keys`, as a copy; `data` itself when there is none to take. */ +function withoutKeys( + data: Readonly>, + keys: ReadonlySet, +): Readonly> { + if (keys.size === 0) return data; + const out: Record = { ...data }; + for (const key of keys) delete out[key]; + return out; +} + +/** + * [#19911] Which judged keys the conditional strip drops — each lock judged + * against the row this write STORES, never against a value another lock in + * the same strip takes back out. + * + * ## Why one pass was not enough + * + * Every predicate read ONE `record` view, built before any lock was judged — + * so it still held every value the pass itself went on to drop. With `status` + * locked by `previous.status == 'closed'` and `amount` by `record.status == + * 'closed'`, `update(c1, { status: 'open', amount: 999 })` on a closed row + * dropped `status` and judged `amount` against the dropped `'open'`: the row + * stayed closed and its locked amount was rewritten. The same mechanism locks + * instead of opening when the dropped value is the one that would lock: with + * `status` frozen by `previous.frozen == true`, `update(r, { status: + * 'closed', amount: 999 })` dropped `status` and then `amount` too, judged + * against a `'closed'` the row never took. `stored` (#19887) closed the static + * strip's twin of both; this is the conditional strip's own. + * + * ## The rule: the drop set that agrees with the row it stores + * + * A key is judged with its OWN incoming value (a lock that reads its own field + * judges the write) and every OTHER dropped key reverted to the prior row's + * value. A drop set is EXACT when every key it drops is locked, and every key + * it keeps is unlocked, on that view. Reached in two steps: + * + * ① A monotone fixpoint. Judge every key; drop the ones that lock; re-judge + * the rest with those reverted; repeat until a pass locks nothing new. + * Drops only grow, so it ends (one pass per key at most), and every key it + * keeps is unlocked on the row it stores: it never opens a lock. Its first + * pass IS the single pass it replaced, so a write whose drops move no + * other lock's verdict is judged exactly as before. + * ② An exact release. A key ① dropped but that is unlocked on ①'s row was + * locked by a value a later pass reverted. Every such key is released at + * once, and the result is kept only if it is exact; otherwise ①'s answer + * stands. + * + * Locks that read one another in a cycle can leave no exact set at all — `a` + * locked by `record.b == 'x'`, `b` by `record.a == 'old'`, a write setting + * both — and then ①'s answer stands: the fail-safe direction, where a lock + * that cannot be settled is not waived (#4889's frozen lines depend on that). + * + * Each key's warnings come from the evaluation that decided it and are handed + * back rather than logged, so a key judged more than once still warns once. + */ +function settleReadonlyWhenDrops( + judged: readonly string[], + viewFor: (dropped: ReadonlySet) => V, + isLocked: (name: string, view: V, warn: (message: string) => void) => boolean, +): { dropped: Set; warnings: Map } { + // Views are memoised per set of OTHER dropped keys: ① and ② ask for the + // same few again and again. + const views = new Map(); + const judge = (name: string, dropped: ReadonlySet): { locked: boolean; said: string[] } => { + const others = judged.filter((key) => key !== name && dropped.has(key)); + const signature = others.join(','); + let view = views.get(signature); + if (view === undefined) { + view = viewFor(new Set(others)); + views.set(signature, view); + } + const said: string[] = []; + return { locked: isLocked(name, view, (message) => said.push(message)), said }; + }; + // ① Every standing key is judged against the same drop set, and a drop + // found in this pass reaches the others in the next one. + const dropped = new Set(); + const warnings = new Map(); + let standing: readonly string[] = judged; + for (;;) { + const locked: string[] = []; + for (const name of standing) { + const verdict = judge(name, dropped); + warnings.set(name, verdict.said); + if (verdict.locked) locked.push(name); } + if (locked.length === 0) break; + for (const name of locked) dropped.add(name); + standing = standing.filter((name) => !dropped.has(name)); + } + // ② Every standing key is unlocked on ①'s row; a dropped key unlocked there + // too is over-locked. + const overLocked = [...dropped].filter((name) => !judge(name, dropped).locked); + if (overLocked.length === 0) return { dropped, warnings }; + const released = new Set([...dropped].filter((name) => !overLocked.includes(name))); + const releasedWarnings = new Map(); + for (const name of judged) { + const verdict = judge(name, released); + if (verdict.locked !== released.has(name)) return { dropped, warnings }; + releasedWarnings.set(name, verdict.said); + } + return { dropped: released, warnings: releasedWarnings }; +} + +/** + * [#19911] Apply a settled verdict: say what each judged key's deciding + * evaluation said, in declaration order, then the strip's own line for each + * key it takes, and return `data` without those keys — the same object when + * none is taken. `only` narrows what is TAKEN and SAID to one key, while every + * judged key still shaped the view that key was judged against + * ({@link ReadonlyWhenStripOptions.only}). + */ +function applyReadonlyWhenDrops( + data: Record, + judged: readonly string[], + settled: { dropped: ReadonlySet; warnings: ReadonlyMap }, + only: string | undefined, + logger: EvaluateRulesOptions['logger'] | undefined, + line: (name: string) => string, +): Record { + let result = data; + for (const name of judged) { + // `only`, standing, says nothing either: its caller re-judges it with the + // rest when it lands, and that is where its verdict is spoken. + if (only !== undefined && (name !== only || !settled.dropped.has(name))) continue; + for (const message of settled.warnings.get(name) ?? []) logger?.warn?.(message); + if (!settled.dropped.has(name)) continue; + if (result === data) result = { ...data }; + delete result[name]; + logger?.warn?.(line(name)); } return result; } @@ -840,25 +1016,71 @@ export function hasParentScopedRequiredWhen( } /** Parsed-root memo — metadata predicates are a small, fixed set of sources. */ -const parentRootCache = new Map(); +const celRootsCache = new Map(); /** Does this predicate's CEL source reference the `parent` root? */ function readsParentRoot(cond: string | Expression): boolean { + return readsCelRoot(cond, PARENT_ROOT); +} + +/** + * Does this predicate's CEL source reference `root`? A predicate that does not + * parse reads nothing here — it faults at evaluation, where the fail-open / + * fail-closed judgment already lives. + */ +function readsCelRoot(cond: string | Expression, root: string): boolean { const expr = toExpression(cond); if (expr.dialect !== 'cel') return false; const source = typeof expr.source === 'string' ? expr.source : ''; if (!source) return false; - const cached = parentRootCache.get(source); - if (cached !== undefined) return cached; - const roots = collectCelRootIdentifiers(source); - const answer = roots.ok && roots.roots.includes(PARENT_ROOT); - parentRootCache.set(source, answer); - return answer; + let roots = celRootsCache.get(source); + if (roots === undefined) { + const parsed = collectCelRootIdentifiers(source); + roots = parsed.ok ? parsed.roots : []; + celRootsCache.set(source, roots); + } + return roots.includes(root); } /** The CEL scope root a master-detail header is bound under (`cel-engine.ts`). */ const PARENT_ROOT = 'parent'; +/** The CEL scope root a field rule reads the row the write stores under. */ +const RECORD_ROOT = 'record'; + +/** + * [#19911] Does judging the master-detail FK's OWN `readonlyWhen` lock need the + * header the FK NAMES? The engine's settlement asks this before it judges that + * lock, and reads the named header only when the answer is yes. + * + * #4889's rule judges the FK's lock against the master it lands on, so a lock + * that reads `parent` needs that header — the one question asked before this. + * The second is new: the FK's lock is judged against the row the write stores + * ({@link settleReadonlyWhenDrops}), so when it reads `record` it depends on + * which OTHER payload keys their own locks drop — and a `parent`-scoped one + * among those is judged, on the landing the FK's verdict is deciding, against + * the header the FK names. Left unbound it would read as LOCKED (#4889's + * fail-closed exit) and revert a value the write may yet store. + * + * A yes that turns out unneeded costs one header read and moves no verdict. + */ +export function readonlyWhenFkJudgementReadsParent( + objectSchema: { fields?: Record } | undefined | null, + data: Record | undefined | null, + fk: string, +): boolean { + const fields = objectSchema?.fields; + const own = fields?.[fk]?.readonlyWhen; + if (!fields || !data || !own || !(fk in data)) return false; + if (readsCelRoot(own, PARENT_ROOT)) return true; + if (!readsCelRoot(own, RECORD_ROOT)) return false; + for (const [name, def] of Object.entries(fields)) { + if (name === fk || !def?.readonlyWhen || !(name in data)) continue; + if (readsCelRoot(def.readonlyWhen, PARENT_ROOT)) return true; + } + return false; +} + /** * True when the UPDATE payload writes at least one field that declares a * `readonlyWhen` predicate. A cheap gate the engine uses to decide whether the @@ -903,10 +1125,11 @@ export function hasReadonlyWhenInPayload( * declared fields (#4953, {@link readonlyWhenBindings}) exactly as on the * single-id path — a bulk write must not judge the same predicate by a * different record shape than a one-row write does, nor over a different payload - * (`options.stored`, #19887). The views are built ONCE - * per row (they do not depend on which field is being judged) and only when a - * `readonlyWhen` field is actually in the payload, so a batch that touches none - * still pays nothing. + * (`options.stored`, #19887), nor settle its drops differently (#19911, + * {@link settleReadonlyWhenDrops}). The row views are built once per drop set + * the settlement asks about — once in all when nothing locks — and only when a + * caller-supplied `readonlyWhen` field is actually in the payload, so a batch + * that touches none still pays nothing. * * Only keys the CALLER supplied at engine entry are judged (#9107), off the SAME * entry snapshot the single-id strip uses — one payload, one authorship, so a @@ -931,37 +1154,38 @@ export function stripReadonlyWhenFieldsMulti( // row — the same `stored` view the single-id strip reads. const stored = options?.stored ?? data; const rows = priorRows ?? []; - // Built lazily: a payload writing no `readonlyWhen` field never reaches the - // `.some()` below, and then no row view is materialised at all. - let views: Array> | null = null; - const rowViews = () => (views ??= rows.map((row) => readonlyWhenBindings(stored, row, fields))); - let result = data; - for (const [name, def] of Object.entries(fields)) { - if (!def?.readonlyWhen || !(name in data)) continue; - // [#9107] Same authorship gate as the single-id strip, asked before any row - // is judged — a hook-written key is exempt for the whole batch, not per row. - if (!isCallerSuppliedValue(data, supplied, name)) continue; - const lockedInSomeRow = rowViews().some((view, i) => - isReadonlyWhenLocked( - def, - view.merged, - view.previous, - name, - logger, - // Resolved per (field, row) exactly as before — the header lookup is - // the caller's, and its call pattern is not this change's business. - parentForRow?.(rows[i] ?? undefined), + // [#9107] Same authorship gate as the single-id strip, asked before any row + // is judged — a hook-written key is exempt for the whole batch, not per row. + // A payload that judges no key returns here, before any row view is built. + const judged = judgedReadonlyWhenKeys(fields, data, supplied); + if (judged.length === 0 || !judgesOnly(judged, options?.only)) return data; + // [#19911] The single-id strip's settlement, per matched row: a key is judged + // in every row against THAT row's view with the other drops reverted, and is + // locked when it locks in ≥1 row — so an exact set drops only keys locked in + // some row and keeps only keys unlocked in every row. + const settled = settleReadonlyWhenDrops( + judged, + (dropped) => { + const payload = withoutKeys(stored, dropped); + return rows.map((row) => readonlyWhenBindings(payload, row, fields)); + }, + (name, views, warn) => + views.some((view, i) => + isReadonlyWhenLocked( + fields[name]!, + view.merged, + view.previous, + name, + { warn }, + // Resolved per (field, row) exactly as before — the header lookup is + // the caller's, and its call pattern is not this change's business. + parentForRow?.(rows[i] ?? undefined), + ), ), - ); - if (lockedInSomeRow) { - if (result === data) result = { ...data }; - delete (result as Record)[name]; - logger?.warn?.( - `Field '${name}' is read-only (readonlyWhen) in ≥1 matched row — ignoring incoming change on bulk update`, - ); - } - } - return result; + ); + return applyReadonlyWhenDrops(data, judged, settled, options?.only, logger, (name) => + `Field '${name}' is read-only (readonlyWhen) in ≥1 matched row — ignoring incoming change on bulk update`, + ); } /** From 6325c8d9b73f55d0da46f0534fa4054da088bd80 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 19:26:33 +0000 Subject: [PATCH 2/4] test(objectql): pin interdependent readonlyWhen locks by id, bulk and through the master-detail settlement; changeset Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude --- ...19911-readonlywhen-interdependent-locks.md | 46 ++ ...readonly-when-interdependent-locks.test.ts | 538 ++++++++++++++++++ 2 files changed, 584 insertions(+) create mode 100644 .changeset/19911-readonlywhen-interdependent-locks.md create mode 100644 packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts diff --git a/.changeset/19911-readonlywhen-interdependent-locks.md b/.changeset/19911-readonlywhen-interdependent-locks.md new file mode 100644 index 00000000000..8158d859c34 --- /dev/null +++ b/.changeset/19911-readonlywhen-interdependent-locks.md @@ -0,0 +1,46 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): a `readonlyWhen` lock is judged against the values the update STORES, not a value another `readonlyWhen` lock drops (#19911) + +**What a caller could do 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 one in the same update. With +`status: { readonlyWhen: "previous.status == 'closed'" }` and `amount: { +readonlyWhen: "record.status == 'closed'" }`, `update(c1, { status: 'open', +amount: 999 })` on a CLOSED row committed `amount = 999`: `status` was dropped +by its own lock, but `amount` was judged against the dropped `'open'`, so the +row stayed closed with its frozen amount rewritten. The same happened on bulk +(`multi: true`) updates for every matched row, and for a master-detail field's +own `readonlyWhen` lock that reads such a field — the row moved to another +header although its lock held on the row it kept. `isSystem` callers were +affected too (a `readonlyWhen` lock binds them). + +**What happens now.** Every `readonlyWhen` lock reads the update as it will be +stored: a value another lock drops is replaced by the row's stored value before +the lock is judged. In the example above `amount` is dropped as locked, exactly +as `update(c1, { amount: 999 })` on its own always was. Values a `beforeUpdate` +hook wrote are still stored and read as before. + +**What else you may see move, all in the same direction (the stored values +decide):** + +- The reverse: a value that WOULD lock another field no longer locks it when + its own lock drops it. With `status` frozen by `previous.frozen == true`, + `update(r, { status: 'closed', amount: 999 })` now stores the amount (the row + stays open, so its amount is unlocked); before, the amount was dropped too. +- `onFieldsDropped` reports every dropped field in the one `readonly_when` + event, and a `strictReadonlyWrites` refusal names the fields the update would + have dropped; it was a refusal before and still is. +- `requiredWhen` and validation rules run on the stripped update, so they see + the amount the row keeps: a requirement only the dropped-through amount raised + no longer refuses the write, and clearing a field the kept amount requires is + now refused (`VALIDATION_FAILED`). +- When locks read each other in a cycle, so that no set of drops agrees with + the stored row, every lock involved holds: the fields are dropped, never + written. +- A master-detail field whose own lock reads `record`, on an object where + another field in the update has a `parent`-scoped lock, now reads the named + header before deciding whether the row moves: one more header read when it + does not. diff --git a/packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts b/packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts new file mode 100644 index 00000000000..311c6c7e139 --- /dev/null +++ b/packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts @@ -0,0 +1,538 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #19911 — a `readonlyWhen` lock is judged against the row the write STORES, +// never against a value ANOTHER `readonlyWhen` lock drops in the same strip. +// +// The conditional strip judged every lock against one `record` view built +// before any lock was judged. `status` locked by `previous.status == +// 'closed'`, `amount` by `record.status == 'closed'`: on a closed row, +// `update(c1, { status: 'open', amount: 999 })` dropped `status` but judged +// `amount` against the dropped `'open'`, and stored `{ status: 'closed', +// amount: 999 }` — the row stayed closed and its locked amount was rewritten. +// The same mechanism over-locked in reverse: a dropped value that WOULD lock +// another field locked it, though the row never took that value. +// +// The strip now settles its drops (`settleReadonlyWhenDrops`): a monotone +// fixpoint, then one exact release. The drop set it keeps is the one in which +// every dropped key is locked, and every kept key unlocked, on the row the +// write stores; where locks read each other in a cycle and no such set exists, +// the fixpoint's fail-safe answer stands. Every expected row below was checked +// against a brute-force enumeration of the drop sets that agree with the row. +// +// #19887's `stored` view (`engine-readonly-when-stored-view.test.ts`) and +// #19853's master-detail settlement (`engine-readonly-when-parent.test.ts`) +// compose with this; their suites are unchanged. +// +// Real engine, the #19887 suite's in-memory driver shape; by-id and bulk. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; +import { stripReadonlyWhenFields } from './validation/rule-validator.js'; + +function makeDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const checkOp = (value: any, cond: any): boolean => { + if (cond === null || typeof cond !== 'object' || Array.isArray(cond) || cond instanceof Date) { + return value === cond; + } + return Object.entries(cond).every(([op, target]: [string, any]) => { + switch (op) { + case '$eq': return value === target; + case '$ne': return value !== target; + case '$in': return Array.isArray(target) && target.includes(value); + default: return true; + } + }); + }; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]: [string, any]) => { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k === '$or') return (v as any[]).some((w) => matches(row, w)); + if (k === '$not') return !matches(row, v); + return checkOp(row?.[k], v); + }); + }; + let n = 0; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + n += 1; + const id = (data.id as string) ?? `r_${n}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const row = { ...s.get(id), ...data, id }; + s.set(id, row); + return row; + }, + async updateMany(object: string, ast: any, data: Record) { + const s = storeFor(object); + let count = 0; + for (const row of [...s.values()]) { + if (!matches(row, ast?.where)) continue; + s.set(row.id, { ...row, ...data, id: row.id }); + count += 1; + } + return count; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count() { return 0; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r, undefined))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor }; +} + +describe('a readonlyWhen lock is judged against the row the write STORES (#19911)', () => { + let engine: ObjectQL; + let storeFor: ReturnType['storeFor']; + let warns: string[]; + + beforeEach(async () => { + warns = []; + const logger: any = { + warn: (m: string) => warns.push(String(m)), + debug() {}, info() {}, error() {}, trace() {}, fatal() {}, + child() { return logger; }, + }; + engine = new ObjectQL({ logger }); + const d = makeDriver(); + storeFor = d.storeFor; + engine.registerDriver(d.driver, true); + await engine.init(); + // The card: a closed case stays closed, and its amount is frozen. + engine.registry.registerObject({ + name: 'case_x', + fields: { + status: { type: 'text', readonlyWhen: "previous.status == 'closed'" }, + amount: { type: 'number', readonlyWhen: "record.status == 'closed'" }, + note: { type: 'text' }, + }, + } as any); + // Two interacting locks where nothing should drop: an unarchived closed + // case may be reopened, and a reopened case's amount is writable. + engine.registry.registerObject({ + name: 'case_reopen', + fields: { + archived: { type: 'boolean' }, + status: { type: 'text', readonlyWhen: 'previous.archived == true' }, + amount: { type: 'number', readonlyWhen: "record.status == 'closed'" }, + }, + } as any); + // The reverse: a frozen case's status cannot move, so a `closed` the row + // never takes must not lock its amount. + engine.registry.registerObject({ + name: 'case_frozen', + fields: { + frozen: { type: 'boolean' }, + status: { type: 'text', readonlyWhen: 'previous.frozen == true' }, + amount: { type: 'number', readonlyWhen: "record.status == 'closed'" }, + tag: { type: 'text' }, + }, + } as any); + // A lock that also reads its OWN field judges the write's own value. + engine.registry.registerObject({ + name: 'case_capped', + fields: { + status: { type: 'text', readonlyWhen: "previous.status == 'closed'" }, + amount: { type: 'number', readonlyWhen: "record.amount > 1000 || record.status == 'closed'" }, + }, + } as any); + // A three-lock chain with exactly one drop set that agrees with its row. + engine.registry.registerObject({ + name: 'chain', + fields: { + c: { type: 'text', readonlyWhen: "previous.c == 'L'" }, + b: { type: 'text', readonlyWhen: "record.c == 'L'" }, + a: { type: 'number', readonlyWhen: "record.b == 'x'" }, + }, + } as any); + // Two locks reading each other: no drop set agrees with its row. + engine.registry.registerObject({ + name: 'cycle', + fields: { + a: { type: 'text', readonlyWhen: "record.b == 'x'" }, + b: { type: 'text', readonlyWhen: "record.a == 'old_a'" }, + }, + } as any); + // `requiredWhen` runs on the stripped payload, so it answers for the + // amount the row keeps — in both directions. + engine.registry.registerObject({ + name: 'case_big_note', + fields: { + status: { type: 'text', readonlyWhen: "previous.status == 'closed'" }, + amount: { type: 'number', readonlyWhen: "record.status == 'closed'" }, + note: { type: 'text', requiredWhen: 'record.amount > 500' }, + }, + } as any); + engine.registry.registerObject({ + name: 'case_small_note', + fields: { + status: { type: 'text', readonlyWhen: "previous.status == 'closed'" }, + amount: { type: 'number', readonlyWhen: "record.status == 'closed'" }, + note: { type: 'text', requiredWhen: 'record.amount < 500' }, + }, + } as any); + // #19853's settlement: the FK's OWN record-scoped lock reads a field that + // its own conditional lock keeps, and the amount's lock reads `parent`. + engine.registry.registerObject({ name: 'inv', fields: { status: { type: 'text' } } } as any); + engine.registry.registerObject({ + name: 'line_staged', + fields: { + stage: { type: 'text', readonlyWhen: "previous.stage == 'submitted'" }, + invoice: { type: 'master_detail', reference: 'inv', readonlyWhen: "record.stage == 'submitted'" }, + amount: { type: 'number', readonlyWhen: "parent.status == 'paid'" }, + tag: { type: 'text' }, + }, + } as any); + // The same FK lock with no `parent`-scoped lock: no settlement, one strip. + engine.registry.registerObject({ + name: 'line_staged_flat', + fields: { + stage: { type: 'text', readonlyWhen: "previous.stage == 'submitted'" }, + invoice: { type: 'master_detail', reference: 'inv', readonlyWhen: "record.stage == 'submitted'" }, + amount: { type: 'number' }, + }, + } as any); + // #19853's own `inv_line_moored` shape (the stage is not in the payload), + // and its twin whose FK lock reads only `previous`. + engine.registry.registerObject({ + name: 'line_moored', + fields: { + stage: { type: 'text' }, + invoice: { type: 'master_detail', reference: 'inv', readonlyWhen: "record.stage == 'submitted'" }, + amount: { type: 'number', readonlyWhen: "parent.status == 'paid'" }, + }, + } as any); + engine.registry.registerObject({ + name: 'line_moored_prev', + fields: { + stage: { type: 'text' }, + invoice: { type: 'master_detail', reference: 'inv', readonlyWhen: "previous.stage == 'submitted'" }, + amount: { type: 'number', readonlyWhen: "parent.status == 'paid'" }, + }, + } as any); + // An FK whose own lock faults (text compared with a number): fail-open. + engine.registry.registerObject({ + name: 'line_fault', + fields: { + stage: { type: 'text' }, + invoice: { type: 'master_detail', reference: 'inv', readonlyWhen: 'record.stage > 5' }, + amount: { type: 'number', readonlyWhen: "parent.status == 'paid'" }, + }, + } as any); + + storeFor('case_x').set('c1', { id: 'c1', status: 'closed', amount: 100, note: 'k' }); + storeFor('case_x').set('c2', { id: 'c2', status: 'closed', amount: 100, note: 'k' }); + storeFor('case_x').set('o1', { id: 'o1', status: 'open', amount: 100, note: 'o' }); + storeFor('case_reopen').set('r1', { id: 'r1', archived: false, status: 'closed', amount: 100 }); + storeFor('case_frozen').set('z1', { id: 'z1', frozen: true, status: 'open', amount: 100, tag: 't' }); + storeFor('case_frozen').set('z2', { id: 'z2', frozen: false, status: 'open', amount: 100, tag: 't' }); + storeFor('case_capped').set('q1', { id: 'q1', status: 'open', amount: 100 }); + storeFor('case_capped').set('q2', { id: 'q2', status: 'closed', amount: 100 }); + storeFor('chain').set('h1', { id: 'h1', c: 'L', b: 'y', a: 1 }); + storeFor('cycle').set('y1', { id: 'y1', a: 'old_a', b: 'y' }); + storeFor('case_big_note').set('g1', { id: 'g1', status: 'closed', amount: 100, note: null }); + storeFor('case_small_note').set('m1', { id: 'm1', status: 'closed', amount: 100, note: 'kept' }); + storeFor('inv').set('inv_a', { id: 'inv_a', status: 'paid' }); + storeFor('inv').set('inv_b', { id: 'inv_b', status: 'open' }); + storeFor('line_staged').set('s1', { id: 's1', stage: 'submitted', invoice: 'inv_a', amount: 100, tag: 'x' }); + storeFor('line_staged').set('s2', { id: 's2', stage: 'submitted', invoice: 'inv_a', amount: 100, tag: 'x' }); + storeFor('line_staged_flat').set('k1', { id: 'k1', stage: 'submitted', invoice: 'inv_a', amount: 100 }); + storeFor('line_moored').set('m1', { id: 'm1', stage: 'submitted', invoice: 'inv_a', amount: 100 }); + storeFor('line_moored_prev').set('m1', { id: 'm1', stage: 'submitted', invoice: 'inv_a', amount: 100 }); + storeFor('line_fault').set('f1', { id: 'f1', stage: 'draft', invoice: 'inv_a', amount: 100 }); + }); + + const row = (object: string, id: string) => storeFor(object).get(id); + + async function rejection(p: Promise): Promise { + try { + await p; + } catch (err) { + return err; + } + throw new Error('expected the write to be refused, but it resolved'); + } + + function dropEvents(): { events: any[]; options: any } { + const events: any[] = []; + return { events, options: { onFieldsDropped: (e: any) => events.push(e) } }; + } + + /** Every header id the write reads, in order (single-id and batched reads). */ + function recordHeaderReads(): unknown[] { + const reads: unknown[] = []; + const findOne = (engine as any).findOne.bind(engine); + (engine as any).findOne = async (name: string, q: any, o?: any) => { + if (name === 'inv') reads.push(q?.where?.id); + return findOne(name, q, o); + }; + return reads; + } + + // ── The card ────────────────────────────────────────────────────────── + + it('THE CARD: the dropped reopen no longer unlocks the closed case\'s amount', async () => { + await engine.update('case_x', { id: 'c1', status: 'open', amount: 999 }); + expect(row('case_x', 'c1')).toMatchObject({ status: 'closed', amount: 100 }); + }); + + it('CONTROL: the amount alone on a closed case, unchanged', async () => { + await engine.update('case_x', { id: 'c1', amount: 555 }); + expect(row('case_x', 'c1')).toMatchObject({ status: 'closed', amount: 100 }); + }); + + it('THE CARD in BULK: no matched closed case takes the amount', async () => { + await engine.update('case_x', { status: 'open', amount: 999 }, { where: { note: 'k' }, multi: true } as any); + expect(row('case_x', 'c1')).toMatchObject({ status: 'closed', amount: 100 }); + expect(row('case_x', 'c2')).toMatchObject({ status: 'closed', amount: 100 }); + }); + + it('CONTROL in BULK: the amount alone, unchanged', async () => { + await engine.update('case_x', { amount: 555 }, { where: { note: 'k' }, multi: true } as any); + expect(row('case_x', 'c1')).toMatchObject({ amount: 100 }); + expect(row('case_x', 'c2')).toMatchObject({ amount: 100 }); + }); + + it('reports both drops as ONE readonly_when event, and warns once per field', async () => { + const { events, options } = dropEvents(); + await engine.update('case_x', { id: 'c1', status: 'open', amount: 999 }, options); + expect(events).toEqual([{ object: 'case_x', fields: ['status', 'amount'], reason: 'readonly_when' }]); + expect(warns.filter((w) => w.includes('is read-only (readonlyWhen)'))).toEqual([ + "Field 'status' is read-only (readonlyWhen) — ignoring incoming change", + "Field 'amount' is read-only (readonlyWhen) — ignoring incoming change", + ]); + }); + + it('under strictReadonlyWrites the write is still refused, now naming BOTH fields, and nothing lands', async () => { + const err = await rejection(engine.update( + 'case_x', + { id: 'c1', status: 'open', amount: 999, note: 'edited' }, + { strictReadonlyWrites: true } as any, + )); + expect(err.code).toBe('ERR_READONLY_FIELD_REJECTED'); + expect(err.name).toBe('ReadonlyFieldRejectedError'); + expect(err.fields).toEqual(['status', 'amount']); + expect(err.drops).toEqual([{ object: 'case_x', fields: ['status', 'amount'], reason: 'readonly_when' }]); + expect(row('case_x', 'c1')).toMatchObject({ status: 'closed', amount: 100, note: 'k' }); + }); + + it('isSystem: a conditional lock binds a system caller too, so the amount stays', async () => { + await engine.update('case_x', { id: 'c1', status: 'open', amount: 999 }, { context: { isSystem: true } } as any); + expect(row('case_x', 'c1')).toMatchObject({ status: 'closed', amount: 100 }); + }); + + it('preserveAudit: exempts no conditional lock, so the amount stays', async () => { + await engine.update('case_x', { id: 'c1', status: 'open', amount: 999 }, { context: { preserveAudit: true } } as any); + expect(row('case_x', 'c1')).toMatchObject({ status: 'closed', amount: 100 }); + }); + + // ── Hook-written values are not the strip's: they land, and are read ── + + it('a hook-written status is not judged, lands, and the amount\'s lock reads it', async () => { + engine.registerHook('beforeUpdate', async (ctx: any) => { + if ('amount' in ctx.input.data) ctx.input.data.status = 'open'; + }, { object: 'case_x', priority: 50 }); + await engine.update('case_x', { id: 'c1', amount: 999 }); + expect(row('case_x', 'c1')).toMatchObject({ status: 'open', amount: 999 }); + }); + + it('a hook overwriting the caller\'s status lands, and the amount\'s lock reads the hook\'s value', async () => { + engine.registerHook('beforeUpdate', async (ctx: any) => { + ctx.input.data.status = 'reopened'; + }, { object: 'case_x', priority: 50 }); + await engine.update('case_x', { id: 'c1', status: 'open', amount: 999 }); + expect(row('case_x', 'c1')).toMatchObject({ status: 'reopened', amount: 999 }); + }); + + it('a hook echoing the caller\'s own status is still the caller\'s (#14259), so both drop', async () => { + engine.registerHook('beforeUpdate', async (ctx: any) => { + ctx.input.data.status = 'open'; + }, { object: 'case_x', priority: 50 }); + await engine.update('case_x', { id: 'c1', status: 'open', amount: 999 }); + expect(row('case_x', 'c1')).toMatchObject({ status: 'closed', amount: 100 }); + }); + + it('a hook-written amount is not judged, and lands', async () => { + engine.registerHook('beforeUpdate', async (ctx: any) => { + ctx.input.data.amount = 777; + }, { object: 'case_x', priority: 50 }); + await engine.update('case_x', { id: 'c1', status: 'open' }); + expect(row('case_x', 'c1')).toMatchObject({ status: 'closed', amount: 777 }); + }); + + // ── Interacting locks where nothing, or less, should drop ───────────── + + it('LEGITIMATE REOPEN: two interacting locks, neither locked, and both fields land', async () => { + const { events, options } = dropEvents(); + await engine.update('case_reopen', { id: 'r1', status: 'open', amount: 999 }, options); + expect(row('case_reopen', 'r1')).toMatchObject({ status: 'open', amount: 999 }); + expect(events).toEqual([]); + }); + + it('LEGITIMATE REOPEN in BULK, and under strictReadonlyWrites: nothing dropped, nothing refused', async () => { + await engine.update('case_reopen', { status: 'open', amount: 999 }, { where: { archived: false }, multi: true, strictReadonlyWrites: true } as any); + expect(row('case_reopen', 'r1')).toMatchObject({ status: 'open', amount: 999 }); + }); + + it('CLOSE AND EDIT on an open case: the closing lands, so the amount is locked by it', async () => { + await engine.update('case_x', { id: 'o1', status: 'closed', amount: 999 }); + expect(row('case_x', 'o1')).toMatchObject({ status: 'closed', amount: 100 }); + }); + + it('REVERSE: a dropped `closed` no longer locks the frozen case\'s amount', async () => { + const { events, options } = dropEvents(); + await engine.update('case_frozen', { id: 'z1', status: 'closed', amount: 999 }, options); + expect(row('case_frozen', 'z1')).toMatchObject({ status: 'open', amount: 999 }); + expect(events).toEqual([{ object: 'case_frozen', fields: ['status'], reason: 'readonly_when' }]); + }); + + it('REVERSE in BULK: locked in ≥1 row, the status drops for all, and the amount lands in every row', async () => { + await engine.update('case_frozen', { status: 'closed', amount: 999 }, { where: { tag: 't' }, multi: true } as any); + expect(row('case_frozen', 'z1')).toMatchObject({ status: 'open', amount: 999 }); + expect(row('case_frozen', 'z2')).toMatchObject({ status: 'open', amount: 999 }); + }); + + it('REVERSE under strictReadonlyWrites: still refused, now naming only the status', async () => { + const err = await rejection(engine.update('case_frozen', { id: 'z1', status: 'closed', amount: 999 }, { strictReadonlyWrites: true } as any)); + expect(err.code).toBe('ERR_READONLY_FIELD_REJECTED'); + expect(err.fields).toEqual(['status']); + expect(err.drops).toEqual([{ object: 'case_frozen', fields: ['status'], reason: 'readonly_when' }]); + expect(row('case_frozen', 'z1')).toMatchObject({ status: 'open', amount: 100 }); + }); + + it('a lock reading its OWN field judges the write\'s value: over the cap stays locked', async () => { + await engine.update('case_capped', { id: 'q1', amount: 5000 }); + expect(row('case_capped', 'q1')).toMatchObject({ amount: 100 }); + await engine.update('case_capped', { id: 'q1', amount: 500 }); + expect(row('case_capped', 'q1')).toMatchObject({ amount: 500 }); + }); + + it('a lock reading its OWN field and a dropped one: the reopen drops, and the amount is judged closed', async () => { + await engine.update('case_capped', { id: 'q2', status: 'open', amount: 500 }); + expect(row('case_capped', 'q2')).toMatchObject({ status: 'closed', amount: 100 }); + }); + + it('CHAIN: of three interacting locks, only the two locked on the stored row drop', async () => { + const { events, options } = dropEvents(); + await engine.update('chain', { id: 'h1', c: 'open', b: 'x', a: 2 }, options); + expect(row('chain', 'h1')).toMatchObject({ c: 'L', b: 'y', a: 2 }); + expect(events).toEqual([{ object: 'chain', fields: ['c', 'b'], reason: 'readonly_when' }]); + }); + + it('CYCLE: with no drop set that agrees with its row, both locks hold (fail-safe)', async () => { + const { events, options } = dropEvents(); + await engine.update('cycle', { id: 'y1', a: 'new_a', b: 'x' }, options); + expect(row('cycle', 'y1')).toMatchObject({ a: 'old_a', b: 'y' }); + expect(events).toEqual([{ object: 'cycle', fields: ['a', 'b'], reason: 'readonly_when' }]); + }); + + // ── requiredWhen answers for the amount the row keeps ───────────────── + + it('requiredWhen: a requirement the forged-through amount raised no longer refuses the write', async () => { + await engine.update('case_big_note', { id: 'g1', status: 'open', amount: 999 }); + expect(row('case_big_note', 'g1')).toMatchObject({ status: 'closed', amount: 100, note: null }); + }); + + it('requiredWhen: clearing a field the kept amount requires is now refused', async () => { + const err = await rejection(engine.update('case_small_note', { id: 'm1', status: 'open', amount: 999, note: null })); + expect(err.code).toBe('VALIDATION_FAILED'); + expect(err.fields).toEqual([expect.objectContaining({ field: 'note', code: 'required' })]); + expect(row('case_small_note', 'm1')).toMatchObject({ status: 'closed', amount: 100, note: 'kept' }); + }); + + // ── #19853's settlement: the FK's own lock is judged with the rest ──── + + it('SETTLEMENT: the FK\'s own lock reads the stage the row keeps, so the line stays home under the paid invoice', async () => { + const { events, options } = dropEvents(); + const reads = recordHeaderReads(); + await engine.update('line_staged', { id: 's1', stage: 'draft', invoice: 'inv_b', amount: 999 }, options); + expect(row('line_staged', 's1')).toMatchObject({ stage: 'submitted', invoice: 'inv_a', amount: 100 }); + expect(events).toEqual([{ object: 'line_staged', fields: ['stage', 'invoice', 'amount'], reason: 'readonly_when' }]); + // The FK's lock reads `record`, and the amount's lock reads `parent`, so + // the FK is judged on the landing it names; it does not land, so the rest + // are judged against the header the row keeps. + expect(reads).toEqual(['inv_b', 'inv_a']); + }); + + it('SETTLEMENT in BULK: every matched line stays home, and its amount is judged there', async () => { + await engine.update('line_staged', { stage: 'draft', invoice: 'inv_b', amount: 999 }, { where: { tag: 'x' }, multi: true } as any); + expect(row('line_staged', 's1')).toMatchObject({ stage: 'submitted', invoice: 'inv_a', amount: 100 }); + expect(row('line_staged', 's2')).toMatchObject({ stage: 'submitted', invoice: 'inv_a', amount: 100 }); + }); + + it('with no parent-scoped lock (no settlement) the FK\'s lock reads the kept stage too', async () => { + await engine.update('line_staged_flat', { id: 'k1', stage: 'draft', invoice: 'inv_b', amount: 5 }); + expect(row('line_staged_flat', 'k1')).toMatchObject({ stage: 'submitted', invoice: 'inv_a', amount: 5 }); + }); + + it('#19853\'s moored shape: same row and report, and one more header read (the named one)', async () => { + const { events, options } = dropEvents(); + const reads = recordHeaderReads(); + await engine.update('line_moored', { id: 'm1', invoice: 'inv_b', amount: 999 }, options); + expect(row('line_moored', 'm1')).toMatchObject({ invoice: 'inv_a', amount: 100 }); + expect(events).toEqual([{ object: 'line_moored', fields: ['invoice', 'amount'], reason: 'readonly_when' }]); + expect(reads).toEqual(['inv_b', 'inv_a']); + }); + + it('an FK lock reading only `previous` needs no named header: one read, as before', async () => { + const reads = recordHeaderReads(); + await engine.update('line_moored_prev', { id: 'm1', invoice: 'inv_b', amount: 999 }); + expect(row('line_moored_prev', 'm1')).toMatchObject({ invoice: 'inv_a', amount: 100 }); + expect(reads).toEqual(['inv_a']); + }); + + it('a landing FK is re-judged with the rest, and its fail-open fault is said ONCE', async () => { + await engine.update('line_fault', { id: 'f1', invoice: 'inv_b', amount: 5 }); + expect(row('line_fault', 'f1')).toMatchObject({ invoice: 'inv_b', amount: 5 }); + expect(warns.filter((w) => w.includes("readonlyWhen for 'invoice' failed to evaluate"))).toHaveLength(1); + }); +}); + +describe('stripReadonlyWhenFields `only` (#19911)', () => { + const fields = { + fields: { + status: { type: 'text', readonlyWhen: "previous.status == 'closed'" }, + amount: { type: 'number', readonlyWhen: "record.status == 'closed'" }, + }, + } as any; + const closed = { status: 'closed', amount: 100 }; + + it('takes only the named key, judged over the other keys\' drops', () => { + const warned: string[] = []; + const out = stripReadonlyWhenFields(fields, { status: 'open', amount: 999 }, closed, { warn: (m: string) => warned.push(m) }, undefined, { only: 'amount' }); + expect(out).toEqual({ status: 'open' }); + expect(warned).toEqual(["Field 'amount' is read-only (readonlyWhen) — ignoring incoming change"]); + }); + + it('says nothing about a named key left standing, and returns the payload itself', () => { + const warned: string[] = []; + const data = { status: 'open', amount: 999 }; + const out = stripReadonlyWhenFields(fields, data, { status: 'open', amount: 100 }, { warn: (m: string) => warned.push(m) }, undefined, { only: 'amount' }); + expect(out).toBe(data); + expect(warned).toEqual([]); + }); +}); From 611a2fc56101eea3e1cbfd24461401cc46ffd861 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 19:27:34 +0000 Subject: [PATCH 3/4] test(objectql): name the requiredWhen pin for the amount the lock let through Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude --- .changeset/19911-readonlywhen-interdependent-locks.md | 2 +- .../src/engine-readonly-when-interdependent-locks.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/19911-readonlywhen-interdependent-locks.md b/.changeset/19911-readonlywhen-interdependent-locks.md index 8158d859c34..0662c17ca1a 100644 --- a/.changeset/19911-readonlywhen-interdependent-locks.md +++ b/.changeset/19911-readonlywhen-interdependent-locks.md @@ -34,7 +34,7 @@ decide):** event, and a `strictReadonlyWrites` refusal names the fields the update would have dropped; it was a refusal before and still is. - `requiredWhen` and validation rules run on the stripped update, so they see - the amount the row keeps: a requirement only the dropped-through amount raised + the amount the row keeps: a requirement only the let-through amount raised no longer refuses the write, and clearing a field the kept amount requires is now refused (`VALIDATION_FAILED`). - When locks read each other in a cycle, so that no set of drops agrees with diff --git a/packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts b/packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts index 311c6c7e139..02b9880b8ac 100644 --- a/packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts +++ b/packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts @@ -452,7 +452,7 @@ describe('a readonlyWhen lock is judged against the row the write STORES (#19911 // ── requiredWhen answers for the amount the row keeps ───────────────── - it('requiredWhen: a requirement the forged-through amount raised no longer refuses the write', async () => { + it('requiredWhen: a requirement only the let-through amount raised no longer refuses the write', async () => { await engine.update('case_big_note', { id: 'g1', status: 'open', amount: 999 }); expect(row('case_big_note', 'g1')).toMatchObject({ status: 'closed', amount: 100, note: null }); }); From d01b912f3da551e13c6f561eda44ac7bd1008d20 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:27:12 +0000 Subject: [PATCH 4/4] fix(objectql): pin the settlement's stays-to-moves repoint and narrow 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 --- ...19911-readonlywhen-interdependent-locks.md | 44 +++++++++---- ...readonly-when-interdependent-locks.test.ts | 63 ++++++++++++++++--- packages/objectql/src/engine.ts | 15 +++-- .../objectql/src/validation/rule-validator.ts | 41 +++++++----- 4 files changed, 125 insertions(+), 38 deletions(-) diff --git a/.changeset/19911-readonlywhen-interdependent-locks.md b/.changeset/19911-readonlywhen-interdependent-locks.md index 0662c17ca1a..9cc5f5feafa 100644 --- a/.changeset/19911-readonlywhen-interdependent-locks.md +++ b/.changeset/19911-readonlywhen-interdependent-locks.md @@ -2,7 +2,7 @@ "@objectstack/objectql": patch --- -fix(objectql): a `readonlyWhen` lock is judged against the values the update STORES, not a value another `readonlyWhen` lock drops (#19911) +fix(objectql): a value one `readonlyWhen` lock drops can no longer unlock another `readonlyWhen` lock (#19911) **What a caller could do before.** When one field's `readonlyWhen` read a field that carries its own `readonlyWhen`, a caller with edit rights could change a @@ -17,14 +17,16 @@ own `readonlyWhen` lock that reads such a field — the row moved to another header although its lock held on the row it kept. `isSystem` callers were affected too (a `readonlyWhen` lock binds them). -**What happens now.** Every `readonlyWhen` lock reads the update as it will be -stored: a value another lock drops is replaced by the row's stored value before -the lock is judged. In the example above `amount` is dropped as locked, exactly -as `update(c1, { amount: 999 })` on its own always was. Values a `beforeUpdate` -hook wrote are still stored and read as before. +**What happens now.** A value one `readonlyWhen` lock drops can no longer +unlock another: no field is written while its `readonlyWhen` is TRUE on the row +the update stores. The locks are judged together, again with each dropped +value put back to the row's stored one, until no further field locks; then a +field that was held only by a value that was later put back is released, if +the result agrees with the stored row. In the example above `amount` is +dropped as locked, exactly as `update(c1, { amount: 999 })` on its own always +was. Values a `beforeUpdate` hook wrote are still stored and read as before. -**What else you may see move, all in the same direction (the stored values -decide):** +**What else you may see move:** - The reverse: a value that WOULD lock another field no longer locks it when its own lock drops it. With `status` frozen by `previous.frozen == true`, @@ -37,9 +39,29 @@ decide):** the amount the row keeps: a requirement only the let-through amount raised no longer refuses the write, and clearing a field the kept amount requires is now refused (`VALIDATION_FAILED`). -- When locks read each other in a cycle, so that no set of drops agrees with - the stored row, every lock involved holds: the fields are dropped, never - written. +- The release is one step, not a search. When it does not settle the drops, + every lock involved holds and the field is dropped, never written — so a + field whose own lock is FALSE on the stored row can still be dropped. That + happens when locks read each other in a cycle (no set of drops agrees with + the stored row), and in a cascade where releasing one field changes another's + verdict: with `c` locked by `previous.c == 'L'`, `x` by `record.c == 'open'` + and `y` by `record.x == 'xv'`, `update(r, { c: 'open', x: 'xv', y: 'yv' })` + on a row with `c: 'L'` drops all three, although `x` is unlocked on the + stored row. That was dropped before this change too; it is tracked as + #19927. +- A master-detail repoint that the field's own `record`-scoped lock used to + hold can now land. Its lock is judged together with the other locks on the + header the update names, so when the value that lock reads is itself locked + under that header, the value is dropped, the repoint lands, and the edit is + dropped under the header the row lands on. With `invoice: { readonlyWhen: + "record.amount == 'big'" }` and `amount: { readonlyWhen: "parent.status == + 'paid'" }`, `update(line, { invoice: 'inv_a', amount: 'big' })` on a line + under an open invoice, naming a paid one, used to keep the line where it was + and store `amount: 'big'` (`onFieldsDropped` reported `invoice`); it now + moves the line onto the paid invoice and keeps its old amount + (`onFieldsDropped` reports `amount`), by id and on bulk updates. A + `strictReadonlyWrites` refusal of that write now names `amount` instead of + `invoice`. Both outcomes agree with the locks on the row they store. - A master-detail field whose own lock reads `record`, on an object where another field in the update has a `parent`-scoped lock, now reads the named header before deciding whether the row moves: one more header read when it diff --git a/packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts b/packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts index 02b9880b8ac..1230677fdfe 100644 --- a/packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts +++ b/packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// #19911 — a `readonlyWhen` lock is judged against the row the write STORES, -// never against a value ANOTHER `readonlyWhen` lock drops in the same strip. +// #19911 — a value one `readonlyWhen` lock drops can no longer unlock another +// `readonlyWhen` lock in the same strip. // // The conditional strip judged every lock against one `record` view built // before any lock was judged. `status` locked by `previous.status == @@ -13,10 +13,11 @@ // another field locked it, though the row never took that value. // // The strip now settles its drops (`settleReadonlyWhenDrops`): a monotone -// fixpoint, then one exact release. The drop set it keeps is the one in which -// every dropped key is locked, and every kept key unlocked, on the row the -// write stores; where locks read each other in a cycle and no such set exists, -// the fixpoint's fail-safe answer stands. Every expected row below was checked +// fixpoint, which never opens a lock, then one release step, kept only when +// every dropped key is locked and every kept key unlocked on the row the write +// stores. When that one step does not settle the set — a cycle, or a cascade +// where releasing one key moves another's verdict (#19927) — the fixpoint's +// larger, fail-safe drop set stands. Every expected row below was checked // against a brute-force enumeration of the drop sets that agree with the row. // // #19887's `stored` view (`engine-readonly-when-stored-view.test.ts`) and @@ -105,7 +106,7 @@ function makeDriver() { return { driver, storeFor }; } -describe('a readonlyWhen lock is judged against the row the write STORES (#19911)', () => { +describe('a value one readonlyWhen lock drops no longer unlocks another (#19911)', () => { let engine: ObjectQL; let storeFor: ReturnType['storeFor']; let warns: string[]; @@ -234,6 +235,16 @@ describe('a readonlyWhen lock is judged against the row the write STORES (#19911 amount: { type: 'number', readonlyWhen: "parent.status == 'paid'" }, }, } as any); + // The FK's own lock reads a value that is itself locked under the header + // the update names — the settlement's stays → moves direction. + engine.registry.registerObject({ + name: 'line_big', + fields: { + invoice: { type: 'master_detail', reference: 'inv', readonlyWhen: "record.amount == 'big'" }, + amount: { type: 'text', readonlyWhen: "parent.status == 'paid'" }, + tag: { type: 'text' }, + }, + } as any); // An FK whose own lock faults (text compared with a number): fail-open. engine.registry.registerObject({ name: 'line_fault', @@ -264,6 +275,8 @@ describe('a readonlyWhen lock is judged against the row the write STORES (#19911 storeFor('line_moored').set('m1', { id: 'm1', stage: 'submitted', invoice: 'inv_a', amount: 100 }); storeFor('line_moored_prev').set('m1', { id: 'm1', stage: 'submitted', invoice: 'inv_a', amount: 100 }); storeFor('line_fault').set('f1', { id: 'f1', stage: 'draft', invoice: 'inv_a', amount: 100 }); + storeFor('line_big').set('b1', { id: 'b1', invoice: 'inv_b', amount: 'small', tag: 'y' }); + storeFor('line_big').set('b2', { id: 'b2', invoice: 'inv_b', amount: 'small', tag: 'y' }); }); const row = (object: string, id: string) => storeFor(object).get(id); @@ -510,6 +523,42 @@ describe('a readonlyWhen lock is judged against the row the write STORES (#19911 expect(row('line_fault', 'f1')).toMatchObject({ invoice: 'inv_b', amount: 5 }); expect(warns.filter((w) => w.includes("readonlyWhen for 'invoice' failed to evaluate"))).toHaveLength(1); }); + + // ── The settlement in the other direction: stays → moves ───────────── + // + // The FK's own lock reads `record.amount`, and `amount` is locked under the + // paid invoice the update names. Judged ALONE, the FK read the incoming + // `'big'` and held the line home, and the edit landed under the open header. + // Judged WITH the amount's lock on the landing it names, the amount drops, + // the FK reads the stored `'small'` and lands, and the edit is dropped under + // the paid header. Both rows agree with their locks; the second is the one + // the landing-first rule (#4889) now reaches. + + it('STAYS → MOVES: the repoint lands on the paid invoice and the edit under it is dropped', async () => { + const { events, options } = dropEvents(); + const reads = recordHeaderReads(); + await engine.update('line_big', { id: 'b1', invoice: 'inv_a', amount: 'big' }, options); + expect(row('line_big', 'b1')).toMatchObject({ invoice: 'inv_a', amount: 'small' }); + expect(events).toEqual([{ object: 'line_big', fields: ['amount'], reason: 'readonly_when' }]); + // The header the update names, then the repoint's reference check. + expect(reads).toEqual(['inv_a', 'inv_a']); + }); + + it('STAYS → MOVES in BULK: every matched line lands on the paid invoice, every edit is dropped', async () => { + const { events, options } = dropEvents(); + await engine.update('line_big', { invoice: 'inv_a', amount: 'big' }, { ...options, where: { tag: 'y' }, multi: true } as any); + expect(row('line_big', 'b1')).toMatchObject({ invoice: 'inv_a', amount: 'small' }); + expect(row('line_big', 'b2')).toMatchObject({ invoice: 'inv_a', amount: 'small' }); + expect(events).toEqual([{ object: 'line_big', fields: ['amount'], reason: 'readonly_when' }]); + }); + + it('STAYS → MOVES under strictReadonlyWrites: still refused, now naming the amount, and nothing lands', async () => { + const err = await rejection(engine.update('line_big', { id: 'b1', invoice: 'inv_a', amount: 'big' }, { strictReadonlyWrites: true } as any)); + expect(err.code).toBe('ERR_READONLY_FIELD_REJECTED'); + expect(err.fields).toEqual(['amount']); + expect(err.drops).toEqual([{ object: 'line_big', fields: ['amount'], reason: 'readonly_when' }]); + expect(row('line_big', 'b1')).toMatchObject({ invoice: 'inv_b', amount: 'small' }); + }); }); describe('stripReadonlyWhenFields `only` (#19911)', () => { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index a5c04ea19d9..c9b1cfc19e8 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -6913,11 +6913,16 @@ export class ObjectQL implements IObjectQLEngine { * after this point: * * ① the FK's OWN `readonlyWhen` lock. Judged here first, against the - * header the FK names — #4889's verdict for the FK itself, unmoved. - * [#19911] Judged WITH the other caller-supplied locks, not alone: its - * `record` is the row the write stores, so a value one of them drops is - * reverted before the FK's lock reads it — but only the FK is TAKEN here - * (`only`). When that verdict keeps the FK off the landing it is final: + * header the FK names — #4889's rule for the FK itself, unmoved. + * [#19911] Judged WITH the other caller-supplied locks, not alone, and + * settled with them (`settleReadonlyWhenDrops`), so a value one of them + * drops can no longer unlock it — but only the FK is TAKEN here (`only`). + * That moves the FK's verdict in BOTH directions: a repoint its lock + * used to let through can now stay home, and a repoint it used to hold + * can now LAND, when the value its `record` lock reads is itself locked + * under the header the FK names — that value is dropped, the FK's lock + * reads the stored one, and the rest are then judged under the header + * the row lands on. When that verdict keeps the FK off the landing it is final: * the returned `supplied` no longer holds the FK, so the strip that * judges the other fields never re-asks it against the header the row * keeps, where it could flip and land the FK after the rest were judged diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 0c44ecfd858..f3934b80f68 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -589,10 +589,11 @@ interface ReadonlyWhenStripOptions { stored?: Readonly>; /** * [#19911] Take only this key, while every judged key is still judged with - * it, so its lock reads the row the write stores. The engine's master-detail - * settlement judges the FK's own lock ahead of the rest; judged alone, the - * FK's predicate read every other key's incoming value, including one the - * rest's own locks then took back out. The key is spoken about (its warnings + * it, so its verdict is settled together with theirs + * ({@link settleReadonlyWhenDrops}). The engine's master-detail settlement + * judges the FK's own lock ahead of the rest; judged alone, the FK's + * predicate read every other key's incoming value, including one the rest's + * own locks then took back out. The key is spoken about (its warnings * and the strip's line) only when it is taken: a key left standing is judged * again, with the rest, by the strip that follows. Omit to take every key * that locks. @@ -692,9 +693,10 @@ function isCallerSuppliedValue( * The `record` / `previous` bindings are TOTAL over the object's declared * fields (#4953) — see {@link readonlyWhenBindings}. `record` is the payload * the write STORES over the prior row (`options.stored`, #19887), so a - * predicate never reads a value the static `readonly` strip takes back out — - * nor, since #19911, one this strip takes back out - * ({@link settleReadonlyWhenDrops}). + * predicate never reads a value the static `readonly` strip takes back out; + * and since #19911 a value this strip takes back out can no longer unlock a + * lock ({@link settleReadonlyWhenDrops}, which says where it can still + * over-lock). */ export function stripReadonlyWhenFields( objectSchema: { fields?: Record } | undefined | null, @@ -765,9 +767,10 @@ function withoutKeys( } /** - * [#19911] Which judged keys the conditional strip drops — each lock judged - * against the row this write STORES, never against a value another lock in - * the same strip takes back out. + * [#19911] Which judged keys the conditional strip drops — so that a value + * another lock in the same strip takes back out can never unlock a lock, and, + * where one release step can settle it, no key is dropped that is unlocked on + * the row the write stores. * * ## Why one pass was not enough * @@ -783,7 +786,7 @@ function withoutKeys( * against a `'closed'` the row never took. `stored` (#19887) closed the static * strip's twin of both; this is the conditional strip's own. * - * ## The rule: the drop set that agrees with the row it stores + * ## The rule: never open a lock, then agree with the stored row where one step can * * A key is judged with its OWN incoming value (a lock that reads its own field * judges the write) and every OTHER dropped key reverted to the prior row's @@ -801,10 +804,18 @@ function withoutKeys( * once, and the result is kept only if it is exact; otherwise ①'s answer * stands. * - * Locks that read one another in a cycle can leave no exact set at all — `a` - * locked by `record.b == 'x'`, `b` by `record.a == 'old'`, a write setting - * both — and then ①'s answer stands: the fail-safe direction, where a lock + * ① alone is guaranteed; ② is one step, not a search. When it does not settle + * the set, ①'s larger drop set stands, so a key whose own lock is FALSE on + * the stored row can still be dropped — the fail-safe direction, where a lock * that cannot be settled is not waived (#4889's frozen lines depend on that). + * Two shapes, measured: + * - a CYCLE has no exact set at all — `a` locked by `record.b == 'x'`, `b` by + * `record.a == 'old'`, a write setting both; + * - a CASCADE can have one that ② misses (#19927) — `c` locked by + * `previous.c == 'L'`, `x` by `record.c == 'open'`, `y` by `record.x == + * 'xv'`, a write setting all three on a row with `c: 'L'`. The exact set is + * `{c, y}`, but releasing `x` moves `y`'s verdict, so the release fails its + * check and `{c, x, y}` stands, as it did before this function existed. * * Each key's warnings come from the evaluation that decided it and are handed * back rather than logged, so a key judged more than once still warns once. @@ -1055,7 +1066,7 @@ const RECORD_ROOT = 'record'; * * #4889's rule judges the FK's lock against the master it lands on, so a lock * that reads `parent` needs that header — the one question asked before this. - * The second is new: the FK's lock is judged against the row the write stores + * The second is new: the FK's lock is settled together with the other locks * ({@link settleReadonlyWhenDrops}), so when it reads `record` it depends on * which OTHER payload keys their own locks drop — and a `parent`-scoped one * among those is judged, on the landing the FK's verdict is deciding, against