|
90 | 90 | * | field `requiredWhen` | TOTAL | same `merged` in `evaluateValidationRules` — fail-OPEN, so an unguarded predicate enforces NOTHING in silence | covered (#4811) | |
91 | 91 | * | field `readonlyWhen` | TOTAL | `rule-validator.ts` `readonlyWhenBindings` materialises BOTH roots (#4953 clause 1, landed in #6454) | excluded — NOT on totality; see below | |
92 | 92 | * | action `visible` / `disabled` | sparse | evaluated client-side; no materialization exists in `objectui` | excluded (decided — #4953 clause 2) | |
93 | | - * | flow start / edge / `decision` condition — record-triggered flow | TOTAL | `record-change-trigger.ts` `buildContext` layers `previous` under the payload/after-row then runs BOTH `record` and `previous` through a structural-mirror `materializeDeclaredFields` (#4953 clause 1's other half, services lane); `seedRunVariables` flattens that same total record to bare names | covered (#29026), outcome `run-fails`; see below | |
| 93 | + * | flow start / edge / `decision` condition — record-triggered flow | TOTAL | `record-change-trigger.ts` `buildContext` layers `previous` under the payload/after-row then runs BOTH `record` and `previous` through a structural-mirror `materializeDeclaredFields` (#4953 clause 1's other half, services lane); `seedRunVariables` flattens that same total record to bare names | covered (#29026), outcome `run-fails`; the runtime publish gate is stricter on a bare start guard; see below | |
94 | 94 | * | the same slots, any other flow | sparse or unknown | the inbound hook hands the request body, an action an empty record carrying at most `id`, a `map` item its own keys; nothing materializes them | excluded | |
95 | 95 | * | sharing-rule `condition` | n/a | compiled to a SQL filter; `NULL > x` is three-valued, never faults | excluded | |
96 | 96 | * | field `expression` (`Field.formula`) | n/a | product judgement, not a wiring gap — see below | excluded | |
|
266 | 266 | * before any node, whatever the entrance, so what it proves non-null |
267 | 267 | * is presumed at every edge and `decision` branch of the flow |
268 | 268 | * ({@link provenNonNullKeys}); an author does not re-guard downstream. |
| 269 | + * **The two doors disagree on a BARE start guard, and the runtime |
| 270 | + * publish gate is the stricter one.** `os validate` sees the flow's |
| 271 | + * entrances, so where the bound set is provable a bare |
| 272 | + * `budget != null` start condition proves `record.budget` (point 2) |
| 273 | + * and an edge comparing `record.budget > 200000` passes. The runtime |
| 274 | + * publish gate judges the written flow alone |
| 275 | + * (`perWriteSnapshotEntrance`): the bound set is not provable there, |
| 276 | + * no bare name is judged, the bare guard proves nothing, and the same |
| 277 | + * edge is refused. The stricter side stands: with the bound set not |
| 278 | + * provable, a bare name is not provably the field, and deriving the |
| 279 | + * presumption from it would be unsound. Nobody has measured a sound |
| 280 | + * way for that door to stand down on such a finding instead, so it |
| 281 | + * does not. Spell the start guard `record.budget != null` and |
| 282 | + * both doors accept the flow. Pinned in |
| 283 | + * `validate-expressions.flow-null-guards.test.ts`. |
269 | 284 | * 4. **Outcome `run-fails`, not `fail-closed`.** `evaluateCondition` |
270 | 285 | * throws on the fault (ADR-0032 §1c) and the record-change trigger |
271 | 286 | * isolates the write, so the run fails and the write lands. |
|
275 | 290 | * `previous.x != null` itself faults there; the full guard is |
276 | 291 | * `previous != null && previous.x != null`. The gate still reports the |
277 | 292 | * unguarded `previous.x` (it faults on the update leg's null), and |
278 | | - * `previous != null` alone is not accepted as guarding `previous.x`. |
| 293 | + * `previous != null` alone is not accepted as guarding `previous.x`. The |
| 294 | + * message names that full guard for a `previous.` operand |
| 295 | + * ({@link PREVIOUS_GUARD_CLAUSE}), on every surface: a validation rule |
| 296 | + * and a hook condition bind `previous` to `null` on an insert too. |
279 | 297 | * Other declared predicate slots (a `screen` field's `visibleWhen`) are |
280 | 298 | * not judged: their fault does not take this outcome, and nobody has |
281 | 299 | * measured what it does take. |
@@ -304,6 +322,18 @@ export const NULL_GUARD_HINT = |
304 | 322 | `Guard it with '!= null'` + |
305 | 323 | ` — 'has(x)' does NOT do that: a declared field holding null is still PRESENT, so has(x) is true.`; |
306 | 324 |
|
| 325 | +/** |
| 326 | + * The clause {@link nullGuardMessage} adds after {@link NULL_GUARD_HINT} for a |
| 327 | + * `previous.<f>` operand, and only for one. An insert binds `previous` itself to |
| 328 | + * `null` (`previousBinding` in the rule validator, `seedRunVariables` on a |
| 329 | + * flow's create leg), so `previous.<f> != null` faults there too and the guard |
| 330 | + * that holds on both legs names `previous` first. Kept out of the shared hint, |
| 331 | + * which stays verbatim with the runtime's sentence. |
| 332 | + */ |
| 333 | +const PREVIOUS_GUARD_CLAUSE = (operand: string): string => |
| 334 | + ` On an insert \`previous\` is itself null, so \`${operand} != null\` alone faults there too:` + |
| 335 | + ` guard it with \`previous != null && ${operand} != null\`.`; |
| 336 | + |
307 | 337 | /** Ordering + arithmetic operators — the ones with no `null` overload. */ |
308 | 338 | const FAULTING_BINARY_OPS = new Set(['<', '<=', '>', '>=', '+', '-', '*', '/', '%']); |
309 | 339 |
|
@@ -770,6 +800,7 @@ export function nullGuardMessage( |
770 | 800 | `as nullable (no \`required: true\`, no \`defaultValue\`).${hasNote} At runtime the operand is ` + |
771 | 801 | `null, CEL has no \`${finding.operator}\` overload for null, and the whole predicate aborts — ` + |
772 | 802 | `${OUTCOME_CLAUSE[outcome]}. ` + |
773 | | - `The predicate compares a value that is null. ${NULL_GUARD_HINT}` |
| 803 | + `The predicate compares a value that is null. ${NULL_GUARD_HINT}` + |
| 804 | + (finding.operand.startsWith('previous.') ? PREVIOUS_GUARD_CLAUSE(finding.operand) : '') |
774 | 805 | ); |
775 | 806 | } |
0 commit comments