Skip to content

Commit 059b422

Browse files
committed
feat(spec)!: hold a predicate to what the engine can run; declare its fault semantics
`PredicateSchema` / `PredicateInputSchema` composed the PERSISTENCE contract (`source` OR `ast`) while a predicate exists to be evaluated and the CEL engine reads `source` alone. An `ast`-only envelope and a `source` blank after trimming parsed, registered, passed `objectstack validate`, then faulted or short-circuited at evaluation time. They now compose the evaluated rule, and the field-rule triad on `FieldSchema` binds to them. The predicate contract's docblock states the fault semantics the consumers are held to: a faulting field-rule predicate refuses the SUBMIT naming the field and the rule; visibility stays fail-open at RENDER; a blank or faulting gate predicate is diagnosed, never a silent true. The evaluation helper's fallback stays freely specifiable. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
1 parent 03b7b81 commit 059b422

2 files changed

Lines changed: 84 additions & 8 deletions

File tree

‎packages/spec/src/data/field.zod.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { KeySetGuidance } from '../shared/suggestions.zod';
1111
import { SELECT_OPTION_EDITABILITY_GUIDANCE } from '../shared/editability-boundary';
1212
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
1313
import { SystemIdentifierSchema } from '../shared/identifiers.zod';
14-
import { ExpressionInputSchema } from '../shared/expression.zod';
14+
import { ExpressionInputSchema, PredicateInputSchema } from '../shared/expression.zod';
1515
import { FilterConditionSchema } from './filter.zod';
1616
import { FIELD_KEY_GUIDANCE } from './authoring-key-lint';
1717
import { DEFAULT_AUTONUMBER_FORMAT } from './autonumber-format';
@@ -1639,10 +1639,25 @@ export const FieldSchema = lazySchema(() => {
16391639
* state live as the record changes (UX), and the server enforces
16401640
* `requiredWhen` and ignores writes to a field whose `readonlyWhen` is TRUE
16411641
* (so the rule can't be bypassed). e.g. `P\`record.status == 'paid'\``.
1642+
*
1643+
* These three are THE field-rule triad, and they are EVALUATED slots: they
1644+
* compose `PredicateInputSchema` (ADR-0136 D1), not the persistence
1645+
* `ExpressionInputSchema`, so an envelope carrying only an `ast` and a
1646+
* `source` that is blank after trimming are refused HERE, at authoring,
1647+
* instead of faulting at evaluation. A blank predicate is the triad's third
1648+
* state — not "no rule" and not "engine fault" — and refusing it at
1649+
* authoring is what keeps those two apart at runtime.
1650+
*
1651+
* What happens when one of these DOES fault (a stored rule, a column renamed
1652+
* out from under it) is contract, not renderer choice: the submit is refused
1653+
* loudly and names the field and the rule (ADR-0136 D2), while visibility
1654+
* stays fail-OPEN at render so a rule that could not run never hides a
1655+
* control and writes `null` over a column the user never saw (D3). The two
1656+
* directions are a pair; neither is safe alone.
16421657
*/
1643-
visibleWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'`"),
1644-
readonlyWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`"),
1645-
requiredWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. The only slot; the `conditionalRequired` alias was removed in protocol 17."),
1658+
visibleWhen: PredicateInputSchema.optional().describe("Predicate (CEL) — field is shown only when TRUE (else hidden). Needs a non-blank `source`: an evaluated slot is held to what the engine can run (ADR-0136 D1). A fault at RENDER is fail-open (the field shows) and refuses the SUBMIT, naming the field and this rule (D2/D3). e.g. P`record.type == 'invoice'`"),
1659+
readonlyWhen: PredicateInputSchema.optional().describe("Predicate (CEL) — field is read-only when TRUE. Needs a non-blank `source` (ADR-0136 D1); a fault refuses the submit and names the field and this rule (D2). e.g. P`record.status == 'paid'`"),
1660+
requiredWhen: PredicateInputSchema.optional().describe("Predicate (CEL) — field is required when TRUE. Needs a non-blank `source` (ADR-0136 D1); a fault refuses the submit and names the field and this rule (D2). A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. The only slot; the `conditionalRequired` alias was removed in protocol 17."),
16461661

16471662
/**
16481663
* [REMOVED in protocol 17 — #3855] The deprecated alias of `requiredWhen`.

‎packages/spec/src/shared/expression.zod.ts‎

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -395,13 +395,74 @@ export type TemplateExpressionInput = z.input<typeof TemplateExpressionInputSche
395395

396396
/**
397397
* Predicate — an Expression whose evaluation is expected to be boolean.
398-
* Spec layer cannot enforce return type at parse time; this alias exists for
399-
* intent documentation and future runtime type-check wiring.
398+
* Spec layer cannot enforce return type at parse time; this alias carries the
399+
* intent, and since ADR-0136 it also carries the one thing the spec layer CAN
400+
* enforce about a predicate: that the engine can run it at all.
401+
*
402+
* ## A predicate is an EVALUATED slot, by definition (ADR-0136 D1)
403+
*
404+
* These two composed {@link ExpressionSchema} / {@link ExpressionInputSchema}
405+
* — the PERSISTENCE contract, whose rule is "`source` OR `ast`" — for as long
406+
* as the alias was only intent documentation. That was the defect, not the
407+
* documentation: a predicate exists to be EVALUATED, so the envelope it accepts
408+
* has to be one the engine can evaluate, and the engine reads `source` alone
409+
* (`cel-engine.ts` `evaluate`: "AST-only evaluation not yet supported; persist
410+
* `source`"). An `ast`-only envelope and a `source` that is blank after
411+
* trimming both parsed here, registered, passed `objectstack validate`, and
412+
* then faulted or short-circuited at evaluation time — declared, never
413+
* enforced. So these compose {@link EvaluatedExpressionSchema} /
414+
* {@link EvaluatedExpressionInputSchema} and refuse both spellings at
415+
* authoring, with the published sentence
416+
* {@link EVALUATED_EXPRESSION_SOURCE_REQUIRED}.
417+
*
418+
* `ExpressionSchema` / `ExpressionInputSchema` are NOT narrowed: they remain
419+
* the persistence contract. This is the same move `FlowEdgeSchema.condition`
420+
* made, one rule reaching a second family of slots rather than a second rule.
421+
*
422+
* ## The fault semantics this contract declares (ADR-0136 D2–D4)
423+
*
424+
* A predicate has THREE states, not two, and each has a declared consequence.
425+
* The states are "absent", "authored", and "authored but not evaluable" —
426+
* and a BLANK predicate is the third, never the first: it is refused at
427+
* authoring by the rule above, and where one is already stored it takes the
428+
* fault path, never a silent pass.
429+
*
430+
* - **At SUBMIT, a faulting field-rule predicate refuses the write, loudly,
431+
* naming the field and the rule** (D2). Nothing is persisted. A rule that
432+
* could not run has produced no verdict, and treating "no verdict" as the
433+
* author's verdict is what let one misspelled column show more, lock less
434+
* and demand less at once, with no state that said "this rule did not run".
435+
* - **At RENDER, visibility stays fail-OPEN** (D3). A faulting `visibleWhen`
436+
* SHOWS the field. This direction is load-bearing and is not a softening of
437+
* D2: hiding a control whose rule faulted is how a form comes to write
438+
* `null` over a column the user never saw, and the submit-time refusal is
439+
* what makes the pair safe. `readonlyWhen` / `requiredWhen` keep their
440+
* existing render directions for display.
441+
* - **A blank or faulting GATE predicate is diagnosed, never a silent `true`**
442+
* (D4) — the action / visibility gate path, which answered a bare `true`
443+
* for a blank source before it ever reached the evaluator.
444+
*
445+
* D2–D4 are consequences a CONSUMER delivers — the renderer, the submit path,
446+
* the gate evaluator — and `packages/spec` carries no business logic
447+
* (Prime Directive #2). They are declared here because the declaration is the
448+
* contract those consumers are held to, and because a consumer cannot be held
449+
* to a direction no protocol states. What this file ENFORCES is the authoring
450+
* refusal above.
451+
*
452+
* ## What this contract deliberately does NOT do
453+
*
454+
* It does not bake a fault direction into the evaluation HELPER. The helper's
455+
* fallback stays freely specifiable, because two shipped strategies depend on
456+
* that freedom — fault-to-flag (asking `true` and `false` and diffing the
457+
* answers) and fault-to-throw (a caller that wants the fault to propagate).
458+
* A contract that fixed the helper's fallback would delete both. The direction
459+
* is declared at the field-rule and gate layer, which is where the author's
460+
* intent lives; the helper stays a mechanism.
400461
*/
401-
export const PredicateSchema = ExpressionSchema;
462+
export const PredicateSchema = EvaluatedExpressionSchema;
402463
export type Predicate = z.input<typeof PredicateSchema>;
403464

404-
export const PredicateInputSchema = ExpressionInputSchema;
465+
export const PredicateInputSchema = EvaluatedExpressionInputSchema;
405466
export type PredicateInput = z.input<typeof PredicateInputSchema>;
406467

407468
/**

0 commit comments

Comments
 (0)