Skip to content

Commit 5dba7f3

Browse files
fix(objectql)!: a field-level requiredWhen / readonlyWhen that cannot be evaluated refuses the write (ADR-0137 D2) (#20028)
Fixes #19727 Clause-②: no (narrowing) ## What this does ADR-0137 D2 (Accepted), verbatim: "At submit time, a field-rule predicate that cannot be evaluated refuses the write and names the field and the rule. Nothing is persisted." Triage's execution reading on the card (comment 5788580082) puts the server-side `requiredWhen` / `readonlyWhen` under D2, and this PR is the server half. Its scope is the two arms ADR-0137's own Context measured as fail-open: - **`requiredWhen`** (`rule-validator.ts:3026-3044` at base `61609edf81`): the arm logged `requiredWhen for 'X' failed to evaluate — skipped` and ran `continue`, so the record saved with the field empty. **It now refuses.** Every fault takes this arm, including an unbound `parent` (a master-detail header that could not be resolved for the write). The refusal goes into the call's `ValidationError`, which is thrown before any driver call. - **`readonlyWhen`** (`rule-validator.ts:1350-1351` at base, inside `isReadonlyWhenLocked`): the non-root arm logged `change allowed through` and returned `false`, so the field was written. **It now throws a `ValidationError`** from the strip, before anything is written. The unbound-root carve-out is unchanged and still LOCKS. Both arms go through the refusal builder that `checkPredicate` has used since the #4649 work. `unevaluableRuleError` gained a `subject` parameter, so the envelope stays the same: `VALIDATION_FAILED` (served as 400), one entry with `field` set to the field, `code: 'rule_violation'`, and `constraint: { rule: 'requiredWhen' | 'readonlyWhen', reason: 'unevaluable', fault, missingKey? , hint? }`. The message reads `Field 'X' requiredWhen could not be evaluated (...) — write rejected.` The operator also gets a `warn` line saying the write was rejected. Details: - **Bulk `readonlyWhen` checks every matched row.** Before, the check stopped at the first row that locked (`some`). Now a row that faults refuses the write no matter where it sits among the matched rows, so the verdict no longer depends on the order the driver returns rows in. The refusal names the row (`(record ID)`), the same way the bulk validation refusal does. Warnings are said once per key. - **The refusal names a lookup read correctly.** A predicate that reads through a lookup (`record.account.tier`) faults as `No such key: tier`, because field-level predicates are never hydrated. The generic sentence would tell the author to declare `tier` on the wrong object. The refusal instead says the column is read through `account`, a reference to `fp_account`, and points the author at a `validations[]` `script` rule. An unbound `parent` also gets its own sentence. - **`@objectstack/lint` build-time messages.** Three author-facing strings said the server skips a faulting `requiredWhen`, and this change makes them false: the unbound-root slot consequence, the `parent`-without-a-master consequence, and the null-guard outcome, which switches from `'fail-open'` to `'fail-closed'`. All three now say the write is refused. The `doc-authoring` prose-id baseline shrank by the two tracker ids those strings dropped. That is the gate's own prescribed remedy. Not changed here, on purpose: - option-level `visibleWhen` (`evaluateOptionVisibility`), which D2 does not name and which stays fail-open; - the render side (ADR-0137 D3); - the readonlyWhen unbound-root carve-out, which LOCKS; - a predicate that evaluates, which is judged exactly as before in both directions; - the ADR-0113 legacy-row rule for an evaluated `requiredWhen`. `packages/spec` is untouched. ## Census: stored predicates that could start refusing I checked every `requiredWhen` / `readonlyWhen` under `packages/**`, `examples/**` and `apps/**`, excluding tests, at `7a6e91f5fd`. I evaluated each predicate with the real `ExpressionEngine` over normal and null bindings. | where | predicate | faults on a write it judges? | |---|---|---| | `examples/app-showcase` invoice `issued_on` | `requiredWhen: record.status in ['sent', 'paid']` | no (a null status evaluates to `false`) | | invoice `tax_rate` | `readonlyWhen: record.status == 'paid'` | no | | invoice `paid_on` | `requiredWhen: record.status == 'paid'` | no | | invoice_line `description` | `requiredWhen: record.quantity >= 100` | faults only on a null `quantity`. `quantity` is `required` with `defaultValue: 1`. Measured on the engine: an insert without it gets the default and is accepted, and an update that nulls it is refused by `required` first. So no normal write reaches the fault | | invoice_line `product` / `quantity` / `unit_price` | `readonlyWhen: parent.status == 'paid'` | only when unbound (unresolvable header), which takes the unchanged LOCKED arm | | `plugin-security` `sys_permission_set.name` | `readonlyWhen: record.id != null && record.id != ''` | no | Result: none of the in-repo predicates start refusing. `driver-sql`'s `builtin-column-collision.ts` names the keys as a classification map and holds no predicate. How many stored predicates in deployments fault is unmeasured. ADR-0137's Consequences says the loud state is what reveals them, and the changeset says so. ## Pins, and the ablation that turns each red The new file is `packages/objectql/src/engine-field-predicate-fault.test.ts`. It uses the real engine and an in-memory driver, and reads "nothing persisted" off the store. It has 13 cases. | pin | ablation (via `scripts/ablation-replace.mjs`, WRAP mode) | red | |---|---|---| | (a) requiredWhen fault refuses insert, update, supplied-field, lookup and bulk | A1: fault arm back to `continue` | 5 of 5 (a) cases | | (b) readonlyWhen non-root fault refuses single-id and bulk | A2: `return false` before the throw | 3 of 3 (b) cases | | (b) bulk refusal holds whatever the row order | A3: stop at the first locking row | the ok-row-first case only | | (a) lookup refusal names the reference | A4: traversal sentence off | the lookup case only | | (c) CONTROL requiredWhen FALSE accepted | A5a: FALSE read as TRUE | that case only | | (c) CONTROL readonlyWhen FALSE written | A5b: every lock reads LOCKED | that case only | | (c) CONTROL requiredWhen TRUE refused as `required` | A5c: TRUE routed to the fault builder | that case only | | (c) CONTROL readonlyWhen TRUE dropped, no refusal | A5d: every lock reads UNLOCKED | that case only | | (d) CONTROL option `visibleWhen` stays fail-open | A6: the option fault arm pushes an error | that case only | For every leg, the anchor hit x1 and went to x0, the blob changed, and the restore was proven: blob `5bfc176263` equals HEAD and `git diff HEAD` is empty. The script also carried an EXIT/INT/TERM trap that restores by absolute path. No build was needed: the subject resolves through relative source imports (`./engine.js` → `./validation/rule-validator.js`), not through `dist/`. Existing pins that locked in the retired fail-open branch were flipped. Each still pins its original subject: - `rule-validator.test.ts` (7 cases); - `rule-fail-closed.test.ts` (the requiredWhen "neighbour"); - `engine-required-when-parent.test.ts` (orphan header, 3 cases); - `engine-readonly-when-parent.test.ts` (undeclared header key; the three `not change allowed through` absence checks were retargeted to the refusal channel so they are not vacuous); - `engine-readonly-when-interdependent-locks.test.ts` (a faulting FK lock); - `engine-reference-tenant-scope.test.ts` (4 cases). The invariant these pin, that a locked, an open and a missing header give one answer, holds. The answer is now `note: rule_violation` for all of them. One fixture was re-spelled: `rule-validator.test.ts` "accepts the write once the field is supplied" now supplies `quantity`. Its unrelated `record.quantity >= 100` rule had been faulting silently. ## Test readings - `@objectstack/objectql` at `7a6e91f5fd`: 313 files, 5256 tests passed. `typecheck` exits 0, and `check:test-typecheck` holds its ledger. - `@objectstack/lint` at `7a6e91f5fd`: 108 files, 4138 tests passed. `typecheck` exits 0. - `@objectstack/runtime` at `7a6e91f5fd`: 276 files, 3893 passed, 1 skipped. - `@objectstack/plugin-security` at `b25e969e1d`: 128 files, 2501 passed. It reads `@objectstack/objectql` through a `dist/` built from the same `rule-validator.ts`. - `@objectstack/rest` at `b25e969e1d`: 194 files, 3265 passed, 1 skipped. Same `dist/` as above. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `7a6e91f5fd` derived 72 commands. I ran all 72: 71 exit 0. `pnpm check:dual-build-cjs-loads` exits 3 (PREREQUISITE NOT MET, needs a full `pnpm build`), so it is NOT MEASURED locally. `--ran` reconciliation: 72 derived, 71 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN. - `check:adr-0087-registration` is green. The changeset reads as `BREAKING+bang+clause-②-narrowing`, and its disposition is not-required (no-migration-prescription): nothing authored moves and no stored shape is refused. - `check:objectql-double-limit` went red once, on the new test's `find` double, and is green now that the double honours `limit`. - `check:doc-authoring` went red once, on a stale over-pin, and is green after the prescribed baseline shrink. Narrowed lint, instead of the CI-owned `pnpm lint`: I ran `eslint --no-inline-config --format json` on the 13 touched `.ts` files. Result: 13 files, 0 errors, 0 warnings, none ignored. All 13 fall under the `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` config object (`eslint.config.mjs:971`). That config never enables type-aware linting (no `parserOptions.project`, per its own note at `eslint.config.mjs:327`), so this diff cannot move the verdict on an untouched file. ## Acceptance notes - **D2 is applied literally: the submit is refused whatever the write does to the field.** A `requiredWhen` whose predicate faults refuses even a write that supplies the field, and a `readonlyWhen` fault refuses even an echoed, unchanged value. There is a measured alternative. CEL absorbs an error in `&&` (`record.statsu == 'closed' && record.reason == null` evaluates to `false` when `reason` is set). So the same rule written as a `validations[]` script refuses only when the field is empty. The report puts this to the seat as an open question and recommends staying literal. - **Error precedence moved for an unresolvable header.** `evaluateValidationRules` runs before `assertReferencesResolve`. A detail whose header FK does not resolve, and that carries a `parent`-scoped `requiredWhen`, now meets the requiredWhen refusal ("no header could be resolved") before `header: reference_not_found`. Both are loud, and the tenant-scope no-leak invariant holds. - **A field-level predicate that reads through a lookup always refuses.** The field level is never hydrated; only validation conditions are. Neither the schema nor lint refuses such a predicate at authoring (`validate-expressions.ts` passes `traversalHydration` for validation rules only). The census found none in this repository. - After this change, `NullGuardOutcome`'s `'fail-open'` member has no caller, and its clause still describes the old `requiredWhen` runtime. Its doc comment says so. It is not removed here. - The diff goes beyond the claim's declared file surface (`rule-validator.ts`, its tests, `.changeset/`). It adds `packages/lint/**`, `packages/objectql/src/{cel-fault,master-detail}.ts` (docblocks this change made false), five more `packages/objectql/src/*.test.ts` pin files, and `scripts/doc-authoring-prose-id.baseline.json`. --- _Generated by [Claude Code](https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b76aad5 commit 5dba7f3

15 files changed

Lines changed: 979 additions & 273 deletions
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/lint': patch
4+
---
5+
6+
fix(objectql)!: a field-level `requiredWhen` / `readonlyWhen` predicate that cannot be evaluated now REFUSES the write, naming the field and the rule, instead of letting it through (ADR-0137 D2)
7+
8+
Clause-②: no (narrowing)
9+
10+
**BREAKING**: shipped as `minor` under the launch-window convention
11+
(`check-changeset-no-major` refuses `major` until GA). The banner and the ADR-0087
12+
disposition below carry the breaking change, not the level.
13+
14+
**Writes that used to save now fail.** ADR-0137 D2 says: "At submit time, a
15+
field-rule predicate that cannot be evaluated refuses the write and names the
16+
field and the rule. Nothing is persisted." The server now enforces that on the
17+
two arms that let such a write through:
18+
19+
- **`requiredWhen`**: a predicate that faults used to be logged
20+
(`requiredWhen for '<field>' failed to evaluate — skipped`), and the record
21+
saved with the field empty. It now refuses the insert or update. This covers
22+
every fault, including a `parent`-scoped rule whose master-detail header
23+
could not be resolved for the write.
24+
- **`readonlyWhen`**: a predicate that faults used to be logged
25+
(`failed to evaluate — change allowed through`), and the field the author
26+
declared frozen was written. It now refuses the update. On a bulk update, a
27+
fault in any matched row refuses the whole write, and the refusal names that
28+
row. One case is unchanged: a predicate that faults because the header it
29+
reads as `parent` could not be resolved still holds the lock, as before.
30+
31+
The refusal is the same `ValidationError` a broken validation rule has thrown
32+
since #4649: `VALIDATION_FAILED`, served as `400`. Its entry for the field
33+
carries `code: 'rule_violation'` and
34+
`constraint: { rule: 'requiredWhen' | 'readonlyWhen', reason: 'unevaluable', fault }`,
35+
with `missingKey` or `hint: 'null-comparison'` when the fault is one of those.
36+
The message names the field and the rule. It is refused before anything is
37+
written, on insert, single-id update and bulk update alike. The operator also
38+
gets a `warn` line saying the write was rejected.
39+
40+
The refusal applies to the whole submit. A `requiredWhen` whose predicate
41+
faults refuses the write even when the write supplies the field, because the
42+
rule has no verdict to judge that value against.
43+
44+
**What starts refusing.** A stored predicate that faults on the writes it
45+
judges:
46+
47+
- a key the object does not declare, usually a typo (`record.statsu`);
48+
- an ordering comparison or arithmetic over a `null` (`record.amount > 100`
49+
where `amount` is empty). Guard it with `!= null`. `has(x)` is true for a
50+
declared field holding null, so it does not guard this;
51+
- a column read through a lookup (`record.account.tier`). The field level never
52+
reads the related record, so the reference holds a bare id there. The refusal
53+
says so, and names the reference and its target object;
54+
- an envelope with no evaluable `source`: blank, or `ast`-only.
55+
56+
Nothing in this repository's own metadata is affected. A census of every
57+
`requiredWhen` / `readonlyWhen` under `packages/`, `examples/` and `apps/`
58+
found none that faults on a write it judges. How many stored predicates in a
59+
deployment fault is unknown, and ADR-0137 names that as the point: the loud
60+
state is what finds them.
61+
62+
**Fix.** Read the refusal. It names the field, the rule, and the key or
63+
overload that faulted. Then correct the predicate: fix the key's spelling,
64+
guard the null operand with `!= null`, or move a check that reads through a
65+
lookup into a `validations[]` `script` rule, whose condition does read one hop
66+
through a reference.
67+
68+
Unchanged: a predicate that evaluates is judged exactly as before, in both
69+
directions. So is the ADR-0113 legacy-row rule for an evaluated `requiredWhen`.
70+
Option-level `visibleWhen` is not a field-rule predicate, so D2 does not reach
71+
it, and it stays fail-open. The render side is not touched here (ADR-0137 D3
72+
keeps its directions for display).
73+
74+
`@objectstack/lint`: the build-time messages for a field `requiredWhen` no
75+
longer say the server "skips" a faulting predicate. The unbound-root message,
76+
the `parent`-without-a-master message and the null-guard message now say the
77+
server refuses the write.
78+
79+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authored moves: `packages/spec` is untouched, `requiredWhen` and `readonlyWhen` keep their schema, and no stored shape is refused, so `objectstack migrate meta` has nothing to rewrite and the ledger has no row to gain. What changes is the runtime's answer to a predicate that cannot run, and the repair is specific to each broken predicate. There is no mechanical FROM to TO rewrite. The other categories are closed on facts: both packages publish (not `unpublished`); no ADR-0087 id covers a runtime fault direction (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->

‎packages/lint/src/validate-expressions.test.ts‎

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -985,10 +985,11 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
985985
});
986986

987987
// #4977 — the same gate, extended to the slot the same issue gave a server
988-
// `parent` binding. `requiredWhen` stays FAIL-OPEN at runtime, so this build
989-
// gate is the only thing that stops an unbindable declaration from shipping
990-
// and enforcing nothing forever — which is why the message must name that
991-
// consequence and not `readonlyWhen`'s opposite one.
988+
// `parent` binding. `requiredWhen` REFUSES the write at runtime when the
989+
// predicate cannot be evaluated (ADR-0137 D2; it was fail-OPEN until then),
990+
// so this build gate is what stops an unbindable declaration from shipping
991+
// and refusing writes in production — which is why the message must name
992+
// that consequence and not `readonlyWhen`'s LOCKED one.
992993
describe('parent-scoped `requiredWhen` needs a resolvable master (#4977)', () => {
993994
const parentScopeIssues = (obj: Record<string, unknown>) =>
994995
validateStackExpressions({ objects: [obj] }).filter((i) => /reads `parent`/.test(i.message));
@@ -1006,8 +1007,9 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
10061007
expect(issues[0]!.where).toMatch(/field 'description' requiredWhen/);
10071008
expect(issues[0]!.message).toMatch(/declares no `master_detail` relationships/);
10081009
// The CONSEQUENCE clause is what separates this from its `readonlyWhen`
1009-
// twin: fail-open there, fail-closed here, opposite fixes.
1010-
expect(issues[0]!.message).toMatch(/the requirement would never be enforced/);
1010+
// twin: that one LOCKS the field, this one REFUSES the write.
1011+
expect(issues[0]!.message).toMatch(/writes would be refused/);
1012+
expect(issues[0]!.message).not.toMatch(/never be enforced/);
10111013
expect(issues[0]!.message).not.toMatch(/locked on every write/);
10121014
});
10131015

@@ -1883,10 +1885,11 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
18831885
expect(m).toMatch(/editable/);
18841886
});
18851887

1886-
it('`requiredWhen` — says the requirement is never enforced, not anything about visibility', () => {
1888+
it('`requiredWhen` — says the server refuses the write, not anything about visibility', () => {
18871889
const m = messageFor('requiredWhen');
1888-
expect(m).toMatch(/never enforced/);
1889-
expect(m).toMatch(/saves with the field empty/);
1890+
expect(m).toMatch(/server REFUSES a write/);
1891+
expect(m).not.toMatch(/never enforced/);
1892+
expect(m).not.toMatch(/saves with the field empty/);
18901893
expect(m).not.toMatch(/VISIBLE/);
18911894
expect(m).not.toMatch(/showing for everyone/);
18921895
});
@@ -2517,9 +2520,9 @@ describe('null-guard gate (#4763)', () => {
25172520
// #4811 — the one surface the coverage review found to MEET the gate's
25182521
// totality criterion: `evaluateValidationRules` evaluates a field's
25192522
// `requiredWhen` against the same `materializeDeclaredFields`-merged record
2520-
// the object's validation rules see. It is also the quietest failure of the
2521-
// three covered surfaces: a faulting `requiredWhen` is fail-OPEN (logged and
2522-
// skipped), so the field is simply never required and the write sails through.
2523+
// the object's validation rules see. It WAS the quietest failure of the
2524+
// three covered surfaces — a faulting `requiredWhen` was fail-OPEN (logged
2525+
// and skipped) — until ADR-0137 D2 made it refuse the write like the rest.
25232526
describe('field `requiredWhen` — covered since #4811', () => {
25242527
const withField = (requiredWhen: string) =>
25252528
validateStackExpressions({
@@ -2552,14 +2555,15 @@ describe('null-guard gate (#4763)', () => {
25522555
});
25532556

25542557
// The consequence clause is per-surface, and getting it wrong sends the
2555-
// author to the wrong place. `requiredWhen` is fail-OPEN — `rule-validator`
2556-
// logs and skips — so it must NOT borrow the validation rules' "the write
2557-
// is rejected fail-closed" wording.
2558-
it('reports the fail-OPEN consequence, not the validation rules’ fail-closed one', () => {
2558+
// author to the wrong place. Since ADR-0137 D2 a faulting `requiredWhen`
2559+
// REFUSES the write — `rule-validator` no longer logs and skips — so it
2560+
// takes the validation rules' "the write is rejected fail-closed" wording
2561+
// and must NOT keep promising that the write goes through.
2562+
it('reports the fail-CLOSED consequence — a faulting requiredWhen refuses the write (ADR-0137 D2)', () => {
25592563
const [issue] = withField('has(record.budget) && record.budget > 100');
2560-
expect(issue.message).toContain('SKIPPED fail-open');
2561-
expect(issue.message).toContain('the field is never actually required');
2562-
expect(issue.message).not.toContain('rejected fail-closed');
2564+
expect(issue.message).toContain('rejected fail-closed');
2565+
expect(issue.message).not.toContain('SKIPPED fail-open');
2566+
expect(issue.message).not.toContain('the field is never actually required');
25632567
});
25642568

25652569
it('leaves the fail-closed wording on the surfaces that really fail closed', () => {

‎packages/lint/src/validate-expressions.ts‎

Lines changed: 39 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -527,13 +527,14 @@ function rulePredicates(rule: AnyRec, path: string): Array<{ label: string; raw:
527527
*
528528
* ## Why it is an error and not a warning
529529
*
530-
* Every fault direction available here is silent, and two of the three are
531-
* the opposite of what the author declared (see the per-slot table below):
532-
* a `visibleWhen` written to HIDE leaves the field visible to everyone, a
533-
* `readonlyWhen` written to unlock-under-a-condition locks the field on every
534-
* write, and a `requiredWhen` simply never fires. None of the three produces
535-
* a runtime error an author can find; the only signal that exists is this one
536-
* (#6146).
530+
* Every fault direction available here is either silent or the opposite of
531+
* what the author declared (see the per-slot table below): a `visibleWhen`
532+
* written to HIDE leaves the field visible to everyone, a `readonlyWhen`
533+
* written to unlock-under-a-condition locks the field on every write, and a
534+
* `requiredWhen` — silent until ADR-0137 D2 — now refuses every write that
535+
* reaches the root, because that root is never bound there. Only the last
536+
* produces a runtime error at all, and it produces it on the first write in
537+
* production; build time is where the author should meet it (#6146).
537538
*
538539
* Verdict scope is the field level only. Per-option `visibleWhen` is checked
539540
* by the loop in the field walk and deliberately NOT passed through here:
@@ -631,14 +632,15 @@ function rulePredicates(rule: AnyRec, path: string): Array<{ label: string; raw:
631632
* success, and the value silently never lands. The old sentence told this
632633
* author the field would be VISIBLE TO EVERYONE — the opposite failure, and
633634
* the opposite troubleshooting direction.
634-
* - **`requiredWhen` — fail-OPEN at both ends, and never about visibility.**
635-
* Server: the `requiredWhen` block logs `unknownVariableOf`'s name and
636-
* `continue`s — #4977 deliberately did not copy #4889's carve-out, so the
637-
* required-check is skipped for that write. Client: `fallback: false`, so
638-
* the form does not mark the field required either. Both ends agree and
639-
* both do nothing: the requirement is never enforced anywhere, and a record
640-
* saves with the field empty. "Falls back to VISIBLE" was not merely
641-
* imprecise here, it named the wrong property of the field.
635+
* - **`requiredWhen` — the server REFUSES, and never about visibility.**
636+
* Server: a `requiredWhen` that cannot be evaluated refuses the write,
637+
* naming the field and the rule (ADR-0137 D2). It was fail-OPEN until then
638+
* — the block logged `unknownVariableOf`'s name and `continue`d, and a
639+
* record saved with the field empty. Client: `fallback: false`, so the form
640+
* does not mark the field required, and the save it submits is refused. An
641+
* unbound root faults on every write that reaches it. "Falls back to
642+
* VISIBLE" was not merely imprecise here, it named the wrong property of
643+
* the field.
642644
*
643645
* `conditionalRequired` also reaches this helper (the field walk still passes
644646
* it). It is a `retiredKey` in `FieldSchema` — the strict schema rejects it by
@@ -803,9 +805,10 @@ const FIELD_RULE_SLOT_CONSEQUENCE: Record<string, string> = {
803805
'field editable (`fallback: false`). The server is the one that decides: ' +
804806
'the field looks writable, the save reports success, and the value silently never lands',
805807
requiredWhen:
806-
'the predicate faults and the requirement is never enforced anywhere — the server logs it ' +
807-
'and SKIPS the check (fail-open, #4977 deliberately did not take #4889\'s carve-out) and ' +
808-
'the form does not mark the field required either, so a record saves with the field empty',
808+
'the predicate faults on every write that reaches that root, and the server REFUSES a write ' +
809+
'whose requirement it cannot evaluate (ADR-0137 D2: the refusal names the field and the ' +
810+
'rule) — while the form does not mark the field required, so nothing on screen says why the ' +
811+
'save failed',
809812
// Listed rather than left to the `??` below, so the map covers every slot
810813
// the field walk passes and the default stays unreachable. `FieldSchema`
811814
// declares this key only as a `retiredKey`, which rejects it by name, so
@@ -1640,13 +1643,13 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
16401643
// What they do NOT share is the CONSEQUENCE, and the message has to name
16411644
// the right one or it prescribes the wrong fix (the same reason #4811's
16421645
// null-guard gate passes its outcome in explicitly instead of inferring
1643-
// it). The two runtimes fail in OPPOSITE directions on an unbindable
1644-
// `parent`: `readonlyWhen` fails CLOSED (#4889 — an unbound scope root
1645-
// resolves to LOCKED, so the field becomes unwritable forever), while
1646-
// `requiredWhen` stays fail-OPEN (#4977's ruling deliberately did not copy
1647-
// the carve-out), so the requirement silently enforces NOTHING. This gate
1648-
// is why fail-open is affordable there: the declaration that would rot
1649-
// unnoticed at runtime cannot ship in the first place.
1646+
// it). The two runtimes answer an unbindable `parent` differently:
1647+
// `readonlyWhen` fails CLOSED (#4889 — an unbound scope root resolves to
1648+
// LOCKED, so the field becomes unwritable forever), while `requiredWhen`
1649+
// REFUSES the write (ADR-0137 D2 — it was fail-OPEN until then, #4977)
1650+
// wherever the predicate reads `parent`. Either way the declaration is
1651+
// unusable, and this gate is where the author meets it instead of the
1652+
// first write in production.
16501653
//
16511654
// `conditionalRequired` (retired alias) and `visibleWhen` (no
16521655
// server-enforced `parent` binding of its own) keep their verdicts
@@ -1658,7 +1661,7 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
16581661
// indexed read here would have disarmed that scan silently.
16591662
for (const [slot, raw, consequence] of [
16601663
['readonlyWhen', f.readonlyWhen, `the field would be locked on every write`],
1661-
['requiredWhen', f.requiredWhen, `the requirement would never be enforced — the predicate faults, the server logs and skips it, and the field stays optional in the database`],
1664+
['requiredWhen', f.requiredWhen, `writes would be refused — the predicate faults wherever it reads \`parent\`, and the server refuses a write whose requirement it cannot evaluate`],
16621665
] as const) {
16631666
const source = celSourceOf(raw);
16641667
if (masters === 1 || !source || !readsParentRoot(source)) continue;
@@ -1679,29 +1682,30 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
16791682
// null-guard gate's totality criterion: `evaluateValidationRules`
16801683
// evaluates it against the SAME `materializeDeclaredFields`-merged
16811684
// record the object's validation rules see. It is also the surface
1682-
// where an unguarded predicate hurts most quietly — a faulting
1683-
// `requiredWhen` is fail-OPEN (`rule-validator.ts` logs
1684-
// "failed to evaluate — skipped"), so the field is simply never
1685-
// required and the write sails through. Validation rules at least
1686-
// reject fail-closed since #4761.
1685+
// where an unguarded predicate used to hurt most quietly — a faulting
1686+
// `requiredWhen` was fail-OPEN, so the field was simply never required
1687+
// and the write sailed through. Since ADR-0137 D2 it refuses the write
1688+
// (`rule-validator.ts`: "failed to evaluate … — write rejected"), the
1689+
// same outcome as a validation rule's since #4761, so it takes the
1690+
// `'fail-closed'` clause below.
16871691
//
16881692
// `readonlyWhen` is still NOT included even though it sits on the same
16891693
// field — but no longer because its binding is sparse. Since #6454
16901694
// `readonlyWhenBindings` runs both roots through
16911695
// `materializeDeclaredFields`, so `!= null` IS the right prescription
16921696
// there now; what holds the wiring back is clause 3 of the #4953 ruling
16931697
// (both server-side seams first — the flow trigger-record half is
1694-
// outstanding). When it is wired it needs `'fail-open'`, like
1695-
// `requiredWhen` above and unlike the validation-rule surface. Same for
1696-
// `conditionalRequired` / `visibleWhen`, which have no record-scoped
1698+
// outstanding). When it is wired it needs `'fail-closed'`, like
1699+
// `requiredWhen` above: a faulting `readonlyWhen` refuses the write since
1700+
// ADR-0137 D2 too. `conditionalRequired` / `visibleWhen` have no record-scoped
16971701
// total binding of their own. See the surface ledger in
16981702
// `validate-null-guards.ts`.
16991703
checkNullGuards(
17001704
`object '${objectName}' · field '${fname}' requiredWhen`,
17011705
`field '${fname}' requiredWhen`,
17021706
f.requiredWhen,
17031707
objectName,
1704-
'fail-open',
1708+
'fail-closed',
17051709
);
17061710
if (f.expression) {
17071711
// `expression` is the key `FieldSchema` declares for a computed field —

0 commit comments

Comments
 (0)