Skip to content

fix(objectql): judge a parent-scoped readonlyWhen against the header the write stores (#19853) - #19877

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19853-readonlywhen-post-strip-parent
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19853-readonlywhen-post-strip-parent

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #19853

Clause-②: no

What a caller could do before, and what happens now

Before. On a master-detail child whose parent field is readonly: true, a caller with edit rights could change a field locked by a parent-scoped readonlyWhen by naming a different, unlocked parent in the same update. The engine resolved parent from the FK the payload named, judged the lock against that header, and only afterwards stripped the read-only FK — so the row stayed under the locked parent with the locked field rewritten. The same happened when the FK carried its own readonlyWhen lock that kept the row where it was, and on bulk (multi: true) updates for every matched row.

Now. The engine settles whether the update really moves the row BEFORE it judges any parent-scoped lock, and judges every lock (and requiredWhen, which shares the binding) against the parent the row is stored under afterwards. A legitimate move — FK writable, or an isSystem / preserveAudit write the static strip exempts — is still judged against the parent it moves to.

A1 — reproduction on origin/main 2bbb462, engine untouched

Real ObjectQL engine, in-memory driver (the suite's own), the card's shape: inv_line.amount has readonlyWhen: "parent.status == 'paid'", inv_line.invoice is master_detail with readonly: true, l1 (amount 100) under paid inv_a, inv_b open.

  • update(l1, { amount: 999, invoice: 'inv_b' }) committed and stored { invoice: 'inv_a', amount: 999 } — reproduced.
  • Control update(l1, { amount: 555 }) — locked, amount stayed 100.
  • Same run, the other arms: FK's own record-scoped lock → stored amount: 999 under inv_a; FK's own parent-scoped lock → same; bulk stripped repoint → same per row; reverse direction (naming the PAID invoice for a line under an open one) → the amount was dropped (over-lock); requiredWhen: "parent.status == 'paid'" → a note-only edit of an open line naming the paid invoice was refused po_ref is required, and clearing po_ref on a paid line by naming the open one was accepted.

A2 — every strip that can move the FK on this path, from the code

Order on both update branches, after the before-phase hooks: primary-key strip (id only) → readonlyWhen strip → static readonly strip (skipped for isSystem) → assertNoStrictDrops → evaluateValidationRules → reference check → driver.

  • readonlyWhen strip — can take the FK when the FK carries its own lock. Ran AFTER parent was resolved from the named FK.
  • Static readonly strip — takes a non-system caller's FK when it is readonly: true (unless preserveAudit keeps it, or a hook wrote it). Ran after the readonlyWhen strip.
  • Not strips: field-level security refuses a forbidden FK outright (plugin-security step 2.5 throws PermissionDeniedError); the Update-side: a readonly field is stripped from persistence but still reaches beforeUpdate, so hook-derived columns persist values computed from data the row never contains #16344 hide pass withholds static-readonly values from hooks and restores them before any engine-owned read (net zero); beforeUpdate hooks, normalizeMultiValueFields and validateRecord all run before parent is resolved, so their effect is already in the payload the resolution reads; the primary-key strip touches id only.

The fix — packages/objectql/src/engine.ts

settleMasterDetailLanding (one private method, used by the by-id and the bulk branch) runs before the readonlyWhen strip:

  1. the FK's own readonlyWhen lock is judged ALONE (the strip's supplied subset holds only the FK) against the header the FK names — Parent-scoped readonlyWhen is unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889's verdict for the FK itself, byte-identical to before; the strip that judges the other fields is then handed a supplied without the FK, so that verdict is never re-asked against a different header;
  2. the static strip's verdict on the FK is asked of the same stripReadonlyFields, silently, with the same supplied, hookWrittenKeys and preserveAudit; its isSystem gate is read once per branch into a const that both the settlement and the real strip consume (keeps the system-context census at 110 sites, and the two cannot disagree);
  3. parent is resolved from the view the write stores — the payload, or the payload without the FK — so masterIdOf falls through to the prior row's FK when the repoint does not land.

The requiredWhen non-regression pre-check's "does this write repoint" is asked of the same stored view. The header read keeps opCtx.context (the #19837 tenant scope) exactly as it was.

A3 — what moves

  • parent for every parent-scoped readonlyWhen and requiredWhen is the header the row is stored under (the fix).
  • onFieldsDropped for the card's write: [{amount, readonly_when}, {invoice, readonly}] (was [{invoice, readonly}]); a strictReadonlyWrites refusal names ['amount', 'invoice'] with both drops (was ['invoice']).
  • Over-lock removed: naming a locked parent beside a stripped FK no longer drops a field of a row that stays under an open parent.
  • requiredWhen moves with the shared binding, both directions (see the declaration note below).
  • Header reads: a stripped repoint reads the header the row keeps instead of the named one (same count, pinned); the requiredWhen pre-check no longer buys a second read for a repoint that does not land; a parent-scoped lock on the FK ITSELF that refuses the landing costs two reads (named, then kept) where it cost one.
  • Warn-line order only: when the FK's own lock fires, its line now prints before the other fields' lines. Report order, strict fields order and message text are unchanged.
  • Unmoved: the FK's own verdict; strip order; isSystem / preserveAudit behaviour; every write whose payload does not name the FK.

A4 — the legitimate repoint

With invoice writable (inv_line_free): paid → open update(f1, { invoice: 'inv_b', amount: 999 }) is judged against inv_b and commits both; open → paid update(f2, { invoice: 'inv_a', amount: 999 }) is judged against inv_a and drops the amount while the move lands. In both the judged header is the one the row is stored under because the FK lands: no strip takes it.

Tests

New describe block in packages/objectql/src/engine-readonly-when-parent.test.ts (16 cases): the card, the control, the report, the strict envelope (code ERR_READONLY_FIELD_REJECTED, name, fields, drops; the engine error carries no HTTP status — that mapping is downstream and not measured here), the reverse over-lock, the one-header read, isSystem, preserveAudit, both legitimate repoint directions, the FK's own record- and parent-scoped locks, both requiredWhen directions (refusal asserted on code VALIDATION_FAILED + fields), and bulk in both directions.

Measured on 0a538af9d7:

  • pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 → 305 files / 5105 tests passed (exit 0).
  • pnpm --filter @objectstack/objectql test:repo → 1 file / 5 tests passed; pnpm --filter @objectstack/objectql typecheck → exit 0, check:test-typecheck: OK.
  • Ablation (committed fix; engine.ts restored to its 2bbb462335 blob ffcda81540 and verified on disk, settleMasterDetailLanding count 0): the parent suite ran 11 failed / 27 passed — every new pin except the five whose behaviour is unchanged (control, isSystem, preserveAudit, both legitimate repoints). Restored with git checkout HEAD --, blob 624fc1e38a equals HEAD:packages/objectql/src/engine.ts, git diff HEAD empty, rerun 38/38 passed. Script carried an EXIT/INT/TERM trap.

Gates on 0a538af9d7

  • node scripts/pm/dispatch-gates.mjs --commands → 64 commands; all run with exit codes captured before any pipe; --ran → 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED, exit 0.
  • check:dual-build-cjs-loads and check:type-check-debt first answered PREREQUISITE NOT MET (no workspace dist); after turbo run build over ./packages/* and ./packages/*/* (72/72 tasks), both exit 0 (type-check-debt needed an objectql rebuild after the ablation restore touched engine.ts's mtime).
  • node scripts/check-issue-citations.mjs (live, the verdict CI blocks on) → exit 0, 13 citations resolve.
  • node scripts/check-system-context-census.mjs → exit 0 (110 sites). It was red on the first commit (d4496752a1) for a new isSystem read inside the helper; the second commit hoists the one read per branch instead.
  • pnpm check:query-options-erasure → exit 0.
  • Lint, narrowed and proven: eslint's own config (ESLint.calculateConfigForFile / isPathIgnored) puts engine.ts and the test file in the linted population and ignores the changeset; eslint --no-inline-config --format json over the three paths → 3 results, 0 errors, 0 warnings on the two linted files; the config enables no type-aware linting and no cross-file rule (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

Textually disjoint: this diff does not touch the evaluateValidationRules lines, the import line, the region after resolveMasterDetailParents or system-context.mdx. Driver-free bare probe (git clone --bare --shared, no merge driver registered): merge-tree --write-tree of 0a538af9d7 against 3b9c5f2fca → exit 0; against origin/main b940f32a56 → exit 0. The merged tree carries both changes.

Acceptance notes

  • Sibling on the record root, same defect class, not fixed here (listed for the seat to file). A record-scoped readonlyWhen that reads a static-readonly field is judged against the caller's forged value of that field, which the static strip then removes. Measured on 0a538af9d7: status readonly: true, amount readonlyWhen: "record.status == 'closed'", row closed; update(t1, { amount: 555 }) stays 100, update(t1, { status: 'open', amount: 999 }) stores { status: 'closed', amount: 999 }. Judging the conditional strip over the post-static view would close both roots at once, but it moves reason attribution for fields declaring both locks and the strict message text, so it is a separate decision.
  • Parent-scoped readonlyWhen is unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889's landing rule still decides the FK's OWN parent-scoped lock: invoice: { readonlyWhen: "parent.status == 'paid'" } judges a move off a paid invoice against the invoice it moves to. Documented behaviour, unchanged here; noted, not filed.

Declaration note for the contract review

The claim declares no widening; copied above as given. Measured movement in update()'s refuse/accept answer, only when a payload names an FK that a strip then takes back out and the object has a parent-scoped requiredWhen: a write refused VALIDATION_FAILED against the named header now commits (the row stays under a header that does not require the field), and a write that cleared a required field under a header that does require it is now refused. Every other movement is a strip (the write still commits) or a strict refusal that was already a refusal. Whether that requirement movement counts as an accept-set change is the review's call.

Seat's disposition (after the contract review): Clause-②: no stands, and patch is right. The accept-direction movement removes a refusal the published text already negated. content/docs/data-modeling/fields.mdx ("Locking a detail from its master: parent") says: "parent = the record on the other end of this object's master_detail field", and "The server binds parent on the update path by reading the master the row points at (a repointing write is judged against the master it lands on)". A row whose repoint never lands points at, and lands on, its stored master, so refusing it against the named header contradicted that text. The refuse-direction movement is a restored guarantee (a requirement bypass now refused). Neither moves an authorable key, an export or an error code.


Generated by Claude Code

…the write stores

A by-id or bulk update resolved the `parent` binding payload-FK-first and
judged every `parent`-scoped `readonlyWhen` (and `requiredWhen`, which shares
the binding) against the header the payload NAMED. Two strips can take that
FK back out afterwards: the static `readonly` strip for a non-system caller,
and the FK's own `readonlyWhen` lock. The row then kept its prior header while
the locks had been judged against one it never reached, so naming an open
invoice beside a read-only FK rewrote a paid invoice's frozen line.

`settleMasterDetailLanding` now settles the FK's fate first: its own lock is
judged alone against the header it names (#4889, unchanged), the static strip's
verdict is asked of the same function silently, and `parent` is resolved from
the view the write stores. Both update paths use it.

Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr
Co-authored-by: Claude <noreply@anthropic.com>
…date branch

The FK-landing settlement asked `context?.isSystem` itself, which added an
elevation read site the system-context census page has no row for. Each update
branch now reads the static strip's gate once into a const that both the
settlement and the strip consume, so the census count is unchanged and the
two consumers cannot disagree about whether the strip runs.

Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 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)
  • 1 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 b940f32a568e6b07b97b1b08572c632ecfb1660f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e43cf07983260885ea161f1e6f2a0d396c01d5e1 — the merge of head 0a538af9d76981e45ac4affafdd1db71e140ee74 into base b940f32a568e6b07b97b1b08572c632ecfb1660f, 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 e43cf07983260885ea161f1e6f2a0d396c01d5e1 && git checkout e43cf07983260885ea161f1e6f2a0d396c01d5e1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b940f32a568e6b07b97b1b08572c632ecfb1660f 0a538af9d76981e45ac4affafdd1db71e140ee74 && git checkout -B drift-repro b940f32a568e6b07b97b1b08572c632ecfb1660f && git merge --no-ff 0a538af9d76981e45ac4affafdd1db71e140ee74

node scripts/docs-audit/affected-docs.mjs --json b940f32a568e6b07b97b1b08572c632ecfb1660f

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0a538af9d76981e45ac4affafdd1db71e140ee74

① Derived judgments

Head = branch tip = PR head; merge-base 2bbb462335; three files. Measured in a detached throwaway worktree, removed afterwards.

② Semver level

Clause-②: no and patch are right. Nothing authorable, exported or in the error-code ledger moves. The refuse-direction movement is a restored guarantee. The accept-direction movement removes a refusal the published text already negated; per execution-duties such a no must cite that text: content/docs/data-modeling/fields.mdx, "parent = the record on the other end of this object's master_detail field" and "The server binds parent on the update path by reading the master the row points at (a repointing write is judged against the master it lands on)". A row whose repoint never lands points at, and lands on, its stored master. (The seat has added this citation to the PR body.)

③ Boundary flags

  1. The class (a) sibling on the record root: REAL, reproduced at head and on the base blob. status readonly: true, amount readonlyWhen: "record.status == 'closed'": update(t1, { status: 'open', amount: 999 }) stores { status: 'closed', amount: 999 } while the control { amount: 555 } stays 100. The same shape through the FK: amount readonlyWhen: "record.invoice == 'inv_a'" with a read-only invoice: update(r1, { invoice: 'inv_b', amount: 999 }) stores { invoice: 'inv_a', amount: 999 }. Pre-existing and outside this card's property. FILE SEPARATELY (priority:p1, security suggested); it must not block this PR.
  2. The Parent-scoped readonlyWhen is unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889 landing rule on the FK's own parent-scoped lock: documented and pinned, unchanged. ACCEPTABLE.
  3. Neighbour PR feat(formula,objectql): read one hop through a lookup in a validation predicate #19728: merge-tree clean against its tip 3b9c5f2fca and against origin/main 1f89ba0d70. ACCEPTABLE.
  4. The read-count note in the docblock and the PR body: ACCEPTABLE; a one-clause correction can ride a later touch.
  5. User docs are untouched and still true after the fix. ACCEPTABLE.

Implemented-by: claude/issue-19853-readonlywhen-post-strip-parent
Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 23, 2026 14:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 3bd221d Sep 23, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19853-readonlywhen-post-strip-parent branch September 23, 2026 15:09
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… the write stores (objectstack-ai#19887) (objectstack-ai#19905)

Fixes objectstack-ai#19887

Clause-②: no

## What a caller could do before, and what happens now

**Before.** A caller with edit rights could change a field locked by a
`record`-scoped `readonlyWhen` by putting a forged value for a
statically `readonly` field in the same update. On `UPDATE` the
conditional strip runs before the static one, and it built `record` from
the payload it was handed, which still held the forged value. The lock
was judged against that value, the static strip then removed it, and the
row committed with its stored state kept and its locked field rewritten.
The same happened through a read-only master-detail FK read as
`record.invoice`, on bulk (`multi: true`) updates for every matched row,
and for an FK's own `readonlyWhen` lock (judged inside objectstack-ai#19853's
settlement) that reads a read-only field.

**Now.** Every `readonlyWhen` predicate on the update path reads the
payload the write STORES: a value the static strip will take back out is
replaced by the row's stored value before any predicate is evaluated.
The strips keep their order, so each still reports its own fields under
its own reason. Where the static strip keeps the value (an `isSystem`
caller, a `preserveAudit` write of a preservable field, a value a
`beforeUpdate` hook wrote or overwrote), the value is stored, and the
lock reads it exactly as before.

## A1 — reproduction on `origin/main` `dabf8d795e` (includes
`3bd221dfe1`, PR objectstack-ai#19877), engine untouched

Real `ObjectQL` engine, the suites' in-memory driver shape.

- **Shape 1** — `status` `readonly: true`, `amount` `readonlyWhen:
"record.status == 'closed'"`, `t1` = `{ status: 'closed', amount: 100
}`: `update(t1, { status: 'open', amount: 999 })` stored `{ status:
'closed', amount: 999 }`; `onFieldsDropped` reported only `[{ status,
readonly }]`; control `update(t1, { amount: 555 })` stayed 100. Bulk
(`where: { status: 'closed' }, multi: true`): both matched rows stored
`amount: 999`. **Reproduced.**
- **Shape 2** — `invoice` read-only `master_detail`, `amount`
`readonlyWhen: "record.invoice == 'inv_a'"`, `r1` under `inv_a`:
`update(r1, { invoice: 'inv_b', amount: 999 })` stored `{ invoice:
'inv_a', amount: 999 }`; control stayed 100; bulk the same. **Reproduced
— PR objectstack-ai#19877 does not reach it**: its settlement runs only when the
payload touches a `parent`-scoped lock or the schema has a
`parent`-scoped `requiredWhen`, and this lock is `record`-scoped, so
`landing` is undefined and the strip judged the pre-static payload as
before.

## A2 — the choice: the post-static VIEW, not a reordered strip

Two candidates, both measured on this branch.

- **(A) Reorder** — run the static strip first and judge the conditional
one over what it leaves. Measured as a variant of this head (by-id
branch reordered, no view option) over the nine readonly suites
(`engine-readonly-when-parent`, this PR's suite,
`engine-readonly-strict-writes`, `engine-readonly-strip-signal`,
`engine-strict-readonly-warning-truthful`,
`engine-readonly-strip-caller-values`,
`engine-readonly-when-derived-writes`, `engine-readonly-hook-input`,
`hook-withheld-readonly-fault`): **6 failed / 161 passed**, including
**2 of PR objectstack-ai#19877's 16 pins** (`reports both strips, each under its own
reason` and the `strictReadonlyWrites` refusal: the `readonly` event now
arrives first, so the event order and the strict `fields` / `drops`
order flip). It also reports a field carrying both locks as `readonly`
whenever the static strip takes it, where a TRUE lock used to report
`readonly_when`, and (by reading, not measured) the FK's own lock would
stop being judged when the static strip takes the FK.
- **(B) The stored view (chosen)** — keep the order; hand the
conditional strip the payload the static strip leaves, and build
`record` from that. Same nine suites: **167 / 167 passed**. This is the
"smaller alternative" of the dispatch, analogous to
`staticReadonlyStripTakes`: the view is asked of the SAME
`stripReadonlyFields` verdict, with the same `supplied` snapshot,
`hookWrittenKeys`, `preserveAudit` and `isSystem` gate.

### The fix

- `packages/objectql/src/engine.ts`: `staticReadonlyStoredView(schema,
data, supplied, strip)` — `data` itself when the static strip does not
run, else `stripReadonlyFields(...)` run silently with the same
arguments the real strip gets. `staticReadonlyStripTakes` (objectstack-ai#19853) now
delegates to it, so the one-key question and the whole-view question are
one call. Each branch describes the static strip once (`staticStrip` /
`staticStripMulti`, reusing the one `isSystem` read objectstack-ai#19853 hoisted, so
the system-context census stays at 110 sites) and passes the view as
`stored` to all four `readonlyWhen` strip calls: the by-id and bulk
strips, and the by-id and bulk `judgeFkLock` of the settlement.
- `packages/objectql/src/validation/rule-validator.ts` — outside the
claimed file surface, and the reason it is the landing site:
`readonlyWhenBindings` builds `record` from the strip's `data` argument,
which is also the set of keys judged and the payload returned.
Separating "what is judged" from "what the predicate reads" needs one
optional key, `ReadonlyWhenStripOptions.stored`, read by
`stripReadonlyWhenFields` and `stripReadonlyWhenFieldsMulti`. Neither
function nor the options type is exported from the package (`index.ts`
exports `evaluateValidationRules`, `needsPriorRecord`, `legalNextStates`
from that file), so no published surface moves. Unlike `supplied`,
omitting `stored` is not fail-safe (the view is then `data`); the
docblock says so, and every engine call site passes it.

### Every movement, measured (base `dabf8d795e` → this head
`1afbcef386`)

| write | before | now |
|:---|:---|:---|
| shape 1 `update(t1, { status: 'open', amount: 999 })` | stored `{
closed, 999 }`; events `[{status, readonly}]` | stored `{ closed, 100
}`; events `[{amount, readonly_when}, {status, readonly}]` |
| shape 1 under `strictReadonlyWrites` | refused, `fields: ['status']` |
refused, `fields: ['amount', 'status']`, `drops` both (already a
refusal) |
| shape 2 by-id and bulk | stored `amount: 999` | stored `amount: 100` |
| shape 1 bulk | both rows `amount: 999` | both rows `amount: 100` |
| REVERSE: forged `status: 'closed'` on an open row | amount dropped
(over-lock), stored `{ open, 100 }` | amount lands, stored `{ open, 999
}` |
| field with BOTH locks, own predicate reads itself (`update(b1, {
status: 'open', amount: 999 })`) | events `[{status, readonly}]`, amount
stored 999 | events `[{amount, status: readonly_when}]` (one event),
amount stored 100 — the both-lock field is still dropped; only its
reason moves |
| FK's own record-scoped lock reads a forged read-only `stage`
(settlement composes) | FK landed on `inv_b`, amount 999 | FK stays on
`inv_a`, amount judged against the paid header, 100 (by-id and bulk) |
| `requiredWhen: "record.amount > 500"` on `note`, forged status +
amount 999 | refused `VALIDATION_FAILED` (note required) | commits; the
row keeps amount 100 and note null |
| `requiredWhen` requiring `note` while the amount is below 500, forged
status + amount 999 + `note: null` | accepted, stored `{ closed, 999,
null }` | refused `VALIDATION_FAILED` (note required); nothing lands |

**Unmoved (pinned):** the control writes; `isSystem` (the static strip
does not run, the forged status lands and is read); `preserveAudit` (the
static strip keeps the status); a hook-written status and a hook
overwriting a forged status (both land and are read); every write whose
payload holds no value the static strip takes (the view is then the
payload itself). Warn-line wording is unchanged; lines follow the strip
verdicts. Strip order, `reportDroppedFields` and the strict envelope's
shape are unchanged.

## A3 — bulk

`stripReadonlyWhenFieldsMulti` builds each matched row's `record` from
the same `stored` view over THAT row (the static strip is not per-row,
so one view serves every row). Pinned: shape 1 and shape 2 in bulk, and
the settlement composition in bulk.

## Tests

New `packages/objectql/src/engine-readonly-when-stored-view.test.ts`, 18
cases: both card shapes with their controls, the reverse over-lock, the
report, the strict envelope (`code` `ERR_READONLY_FIELD_REJECTED`,
`name`, `fields`, `drops`; the engine error carries no HTTP `status` —
that mapping is downstream and not measured here), the both-lock
attribution, `isSystem`, `preserveAudit`, the two hook pins, the
settlement composition (by-id with its events, and bulk), bulk shapes 1
and 2, and the two `requiredWhen` directions (refusal asserted on `code`
`VALIDATION_FAILED` + `fields`).

All on `1afbcef386`:

- `pnpm --filter @objectstack/objectql exec vitest run --project local
--maxWorkers=2` → 306 files / 5123 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 by `--listFiles`, 0 diagnostics in it); `pnpm --filter
@objectstack/objectql test:repo` → 5 passed.
- PR objectstack-ai#19877's 16 pins (`engine-readonly-when-parent.test.ts`, 38 cases
in the file) → all green.
- **Ablation** (fix committed; `engine.ts` and `rule-validator.ts`
restored to their `dabf8d795e` blobs `624fc1e38a` / `bb83519608` with
`git restore --source` (tree only), verified on disk by blob hash and by
marker counts 0 / 0): the two suites ran **12 failed / 44 passed** —
every new pin except the six whose behaviour is unchanged (both
CONTROLs, `isSystem`, `preserveAudit`, both hook pins), and all 38 of
the objectstack-ai#19853 file green. Restored with `git checkout HEAD --`; blobs
`e973ab50ac` / `2b3002b8e1` equal `HEAD`, `git diff HEAD` empty; rerun
56 / 56 passed. The script carried an `EXIT` / `INT` / `TERM` trap.

## Gates on `1afbcef386`

- `node scripts/pm/dispatch-gates.mjs --commands` → 65 commands, every
one run with its exit code captured before any pipe; `--ran` → `65
derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED`, exit 0.
`check:type-check-debt` first answered PREREQUISITE NOT MET (the
ablation restore left `engine.ts` newer than objectql's `dist`); after
`pnpm --filter @objectstack/objectql build` it exits 0, and the record
carries that rerun. The workspace `dist` came from `turbo run build`
over `./packages/*` and `./packages/*/*` (72 / 72 tasks).
- `check:objectql-double-limit` was red on the first commit for this
suite's copied driver double (its `find` ignored `limit`); the second
commit applies the bound after the filter, and the gate is green.
- `node scripts/check-issue-citations.mjs` (live, the verdict CI blocks
on) → exit 0, 10 citations resolve.
- `node scripts/check-system-context-census.mjs` → exit 0 (110 sites).
`pnpm check:query-options-erasure` → exit 0.
- The three roster gates the derivation marks as keeping a roster under
one of 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 the typescript-eslint parser and no
`parserOptions.project` / `projectService`; `eslint --no-inline-config
--format json` over the four paths → 4 results, 0 errors, 0 warnings on
the three linted files. 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: this diff does not touch the
`evaluateValidationRules` calls, the import line, or any hunk objectstack-ai#19728
edits in `rule-validator.ts` (its hunks are the related-record bindings;
none touches the `readonlyWhen` strips). Driver-free bare probe (`git
clone --bare`, refs fetched in, no `merge.*` driver registered):
`merge-tree --write-tree` of `1afbcef386` against `3b9c5f2fca` → exit 0;
against `origin/main` `44ce049a8c` → exit 0. The merged tree carries
both changes (marker counts: `staticReadonlyStoredView` 6, `stored?:
Readonly` 1, objectstack-ai#19728's `RelatedRecordBinding` 2 in `engine.ts` and 5 in
`rule-validator.ts`).

## Acceptance notes

- **Sibling in the same class, not fixed here (listed for the seat to
file, class (a)).** The conditional strip still judges one
`readonlyWhen` field against another field's value that the SAME
conditional strip drops. Measured on `dabf8d795e` and on this head:
`status` `readonlyWhen: "previous.status == 'closed'"`, `amount`
`readonlyWhen: "record.status == 'closed'"`, row closed; `update(c1, {
status: 'open', amount: 999 })` stores `{ status: 'closed', amount: 999
}`. Not mechanical: two conditional locks can each read the other's
field, so "judge against what the conditional strip itself keeps" is a
fixpoint whose verdicts can move in both directions, which is a decision
rather than this card's shape. Dedupe words: `readonlyWhen reads value
conditional strip drops` · `readonlyWhen interdependent locks same pass`
· `record binding includes readonlyWhen-dropped value`.
- The by-id branch now runs one extra silent `stripReadonlyFields` pass
per update (a loop over the declared fields, a copy only when a key is
taken). The by-id strip already materialises its bindings on every
update, so the cost profile is unchanged in kind; noted, not measured.
- 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:

- **Accept direction.** A write refused `VALIDATION_FAILED` only because
a forged-through locked value raised a requirement now commits without
that value. This removes a refusal the published text already negated:
`content/docs/data-modeling/fields.mdx` ("Conditional Logic") says "The
server enforces `requiredWhen` on submit and ignores writes to fields
whose `readonlyWhen` predicate is `TRUE`", and ("Who the lock applies
to") "A TRUE `readonlyWhen` predicate locks the field for **every
API-boundary caller**". On the stored row the predicate IS true, so the
write to the locked field was one the server is documented to ignore,
and refusing the update on the strength of it contradicted that text.
- **Refuse direction.** A write that cleared a field the kept value
requires is now refused: a restored guarantee (the stored row would have
violated the requirement).
- `strictReadonlyWrites`: a write whose view differs from its payload
always carries a static strip, so it was already refused; only `fields`
/ `drops` grow.

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

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…e stores, not a value another lock drops (objectstack-ai#19911) (objectstack-ai#19923)

Fixes objectstack-ai#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 objectstack-ai#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, objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 (objectstack-ai#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
- The FK's own lock is judged with every caller-supplied lock (`only:
fk`) instead of alone. This is how a value another lock drops is
reverted before the FK's lock reads it.
- The named header is read when the FK's lock reads `parent` (as
before), or reads `record` while another payload key's lock reads
`parent`.
- A landing FK now stays in `supplied`, so the strip that judges the
rest re-judges it on the same landing and settles the same drop set. An
FK that does not land keeps PR objectstack-ai#19877's rule: its verdict is final and
never re-asked against the header the row keeps.
- One new import line, placed away from the rule-validator import line
PR objectstack-ai#19728 edits.

## 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}` (objectstack-ai#19927) |
| same FK lock, no `parent`-scoped lock (plain strip) | FK moves | FK
stays |
| PR objectstack-ai#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 objectstack-ai#19905's 18 pins and PR
objectstack-ai#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 objectstack-ai#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 objectstack-ai#19905's 18 and PR objectstack-ai#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, objectstack-ai#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 objectstack-ai#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`, objectstack-ai#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](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/m tests tooling

Projects

None yet

1 participant