|
65 | 65 | * breaks it — strictly worse than not covering the surface at all. Hence: |
66 | 66 | * |
67 | 67 | * **This module may only be wired to a surface whose record binding is total.** |
| 68 | + * Necessary, not sufficient — see the `readonlyWhen` row below, where a total |
| 69 | + * binding is still excluded for a reason totality has nothing to say about. |
68 | 70 | * |
69 | 71 | * Surface ledger (each verdict traced to the code that decides it, so the next |
70 | | - * author does not have to re-derive it — #4811): |
| 72 | + * author does not have to re-derive it — #4811). `binding` is the measured |
| 73 | + * shape TODAY; `verdict` is whether this gate runs there. Those are two |
| 74 | + * questions, and since #6454 they have come apart on one row: |
71 | 75 | * |
72 | 76 | * | surface | binding | evidence | verdict | |
73 | 77 | * |:-------------------------------|:--------|:------------------------------------------------------------|:--------| |
74 | 78 | * | object validation rules | TOTAL | `rule-validator.ts` `materializeDeclaredFields(merged, …)` | covered | |
75 | 79 | * | lifecycle hook `condition` | TOTAL | `hook-wrappers.ts` `materializeDeclaredFields(…)` | covered | |
76 | 80 | * | field `requiredWhen` | TOTAL | same `merged` in `evaluateValidationRules` — fail-OPEN, so an unguarded predicate enforces NOTHING in silence | covered (#4811) | |
77 | | - * | field `readonlyWhen` | sparse | `stripReadonlyWhenFields` merges `{...previous, ...data}` and never materializes | excluded | |
78 | | - * | action `visible` / `disabled` | sparse | evaluated client-side; no materialization exists in `objectui` | excluded | |
79 | | - * | flow / edge `condition` | sparse | `record-change-trigger.ts` seeds `{...inputDoc, ...after}` | excluded | |
| 81 | + * | field `readonlyWhen` | TOTAL | `rule-validator.ts` `readonlyWhenBindings` materialises BOTH roots (#4953 clause 1, landed in #6454) | excluded — NOT on totality; see below | |
| 82 | + * | action `visible` / `disabled` | sparse | evaluated client-side; no materialization exists in `objectui` | excluded (decided — #4953 clause 2) | |
| 83 | + * | flow / edge `condition` | sparse | `record-change-trigger.ts` seeds `{...(inputData ?? {}), ...after}` — #4953 clause 1's other half, not yet wired | excluded (not yet) | |
80 | 84 | * | sharing-rule `condition` | n/a | compiled to a SQL filter; `NULL > x` is three-valued, never faults | excluded | |
81 | 85 | * | field `expression` (`Field.formula`) | n/a | product judgement, not a wiring gap — see below | excluded | |
82 | 86 | * |
83 | | - * The three exclusions that are *not* self-evident, spelled out because a |
| 87 | + * The four exclusions that are *not* self-evident, spelled out because a |
84 | 88 | * surface excluded without a reason is indistinguishable from one nobody |
85 | 89 | * looked at — the failure mode this whole family of issues is about: |
86 | 90 | * |
| 91 | + * - **Field `readonlyWhen` — the row where `binding` and `verdict` came |
| 92 | + * apart.** Its binding is no longer sparse. Since #6454 (the engine-core |
| 93 | + * share of the maintainer's #4953 ruling, clause 1) `rule-validator.ts` |
| 94 | + * builds the two roots in `readonlyWhenBindings` and runs BOTH — `record` |
| 95 | + * (the prior row overlaid with the PATCH) and `previous` — through the same |
| 96 | + * `materializeDeclaredFields`. The totality criterion above is therefore |
| 97 | + * SATISFIED here, and the evidence this row used to carry |
| 98 | + * (`stripReadonlyWhenFields` merging `{...previous, ...data}` raw) |
| 99 | + * describes code that no longer exists. What keeps the row excluded is |
| 100 | + * clause 3 of the same ruling: the gate widens once BOTH server-side seams |
| 101 | + * are total, and the other one — flow trigger-record seeding, services |
| 102 | + * lane — is not wired yet. Widening this face alone would also mean the |
| 103 | + * `binding` column had stopped being the thing that decides coverage, |
| 104 | + * which is the property #4811 bought. |
| 105 | + * |
| 106 | + * Two facts to carry into that widening; neither is bookkeeping: |
| 107 | + * |
| 108 | + * 1. **The fail policy is the OPPOSITE of the two surfaces #4763 wired.** |
| 109 | + * Validation rules and hook `condition`s are fail-CLOSED. A faulting |
| 110 | + * `readonlyWhen` is fail-OPEN: `isReadonlyWhenLocked` logs |
| 111 | + * `failed to evaluate — change allowed through`, and the field the |
| 112 | + * author declared frozen is WRITTEN. (One exception, #4889 — a fault |
| 113 | + * naming an UNBOUND ROOT resolves to LOCKED.) So this face wants |
| 114 | + * {@link nullGuardMessage}'s `'fail-open'` outcome for the same reason |
| 115 | + * the `requiredWhen` row already carries it: the damage is a declared |
| 116 | + * lock that silently enforces nothing, not a rejected write. |
| 117 | + * 2. **Making the binding total moved one verdict the OTHER way.** On a |
| 118 | + * total record `has(record.<declared>)` is uniformly TRUE and |
| 119 | + * `!has(record.<declared>)` uniformly FALSE, so a lock spelled |
| 120 | + * `readonlyWhen: !has(record.b)` STOPPED locking when #6454 landed. |
| 121 | + * That is the `declared-fields.ts` contract since #4649 — `has()` |
| 122 | + * guards an UNDECLARED key, never an empty value; test emptiness with |
| 123 | + * `!= null` — and #6454 measured the cell and pinned both spellings in |
| 124 | + * `rule-validator.test.ts`. It is why "this face is materialized now" |
| 125 | + * is not, on its own, an accurate summary of what changed here. |
| 126 | + * |
87 | 127 | * - **Action `visible` / `disabled`.** #4811 asked whether the ActionEngine |
88 | 128 | * materializes declared fields before evaluating. It does not: the record |
89 | 129 | * is whatever the client already fetched (a record-detail read, or a LIST |
|
93 | 133 | * `ExpressionInputSchema`, which the renderers preserve) and a fault IS |
94 | 134 | * fail-closed — the action silently vanishes — so the *trap* is real here. |
95 | 135 | * But with a sparse binding the prescription inverts (see the table), so the |
96 | | - * gate cannot be the thing that catches it. Covering this surface requires |
97 | | - * first deciding whether the action-predicate binding should be made total, |
98 | | - * which is a platform contract change, not a lint change. |
| 136 | + * gate cannot be the thing that catches it. That question is now DECIDED |
| 137 | + * rather than open: #4953 clause 2 defers making this binding total — |
| 138 | + * it would mean every REST read padding out all declared columns — so the |
| 139 | + * face stays sparse, is documented as sparse, and an author there guards |
| 140 | + * with `has()` (`declared-fields.ts` says the same from the engine's side). |
| 141 | + * The exclusion is permanent under the current decision, not pending one. |
| 142 | + * The ruling's replacement action for this face is the MIRROR of this gate |
| 143 | + * — flag `!= null` on a sparse binding — and it is an evaluation owed by |
| 144 | + * the devx / objectui lanes, never a widening of `checkNullGuards`. |
99 | 145 | * - **Flow / edge `condition`.** #4811 excluded these for flattened-scope |
100 | 146 | * ambiguity ("a bare identifier may be a flow variable"). That reason does |
101 | 147 | * not actually apply to this module — {@link findUnguardedNullableOperands} |
102 | 148 | * only ever resolves `record.<f>` / `previous.<f>` and never a bare |
103 | 149 | * identifier, and the engine binds `record` / `previous` unconditionally. |
104 | 150 | * The real blocker is totality: the trigger seeds the record as |
105 | | - * `{...inputDoc, ...after}`, so a declared column the write never mentioned |
| 151 | + * `{...(inputData ?? {}), ...after}` — spelled `inputDoc` here until #5671 |
| 152 | + * dropped that alias read — so a declared column the write never mentioned |
106 | 153 | * is an ABSENT key, and the `!= null` this gate prescribes would fault. |
| 154 | + * Since #4953 that sparseness is a NOT-YET rather than a decision: clause 1 |
| 155 | + * puts this seam under the same server-side totality guarantee as |
| 156 | + * `readonlyWhen`, and only the services-lane wiring is outstanding. When it |
| 157 | + * lands, this row and the `readonlyWhen` row flip together — which is |
| 158 | + * exactly what clause 3 asks for. |
107 | 159 | * (The flattened-scope ambiguity is real for a *bare-identifier* checker — |
108 | 160 | * flow inputs shadow record fields, and a node's `outputVariable` can |
109 | 161 | * overwrite either — but that is a different, unbuilt pass.) |
|
0 commit comments