|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +--- |
| 4 | + |
| 5 | +feat(spec)!: `FlowEdgeSchema.condition` is an evaluated slot — it composes the new `EvaluatedExpressionInputSchema`, and `structuralConditionRefusal` no longer admits an `ast`-only envelope (#15807) |
| 6 | + |
| 7 | +<!-- adr-0087: registered flow-edge-condition-evaluated-slot-source-required --> |
| 8 | + |
| 9 | +**BREAKING** in the accept-set sense, landing in the launch window as `minor` |
| 10 | +(the lockstep convention: `major` is refused by `check-changeset-no-major`, and |
| 11 | +breaking-ness is carried by this banner plus the ADR-0087 disposition): the |
| 12 | +edge condition of a flow — `FlowEdgeSchema.condition`, the branch predicate |
| 13 | +`AutomationEngine.evaluateCondition` runs at every traversal — now refuses at |
| 14 | +authoring an envelope the engine cannot evaluate, where it used to parse, |
| 15 | +register, pass `objectstack validate`, and then answer a **silent `false`**: a |
| 16 | +branch that quietly never fired. |
| 17 | + |
| 18 | +Two spellings of one seam, refused by ONE rule with one sentence |
| 19 | +(`EVALUATED_EXPRESSION_SOURCE_REQUIRED`, the rule #15430 introduced for the |
| 20 | +`assignment` value envelope): |
| 21 | + |
| 22 | +```yaml |
| 23 | +edges: |
| 24 | + - { id: e1, source: check, target: approve, condition: { dialect: cel, ast: { kind: const, value: true } } } # `ast` only — the engine never reads it |
| 25 | + - { id: e2, source: check, target: reject, condition: { dialect: cel, source: ' ' } } # blank after trimming |
| 26 | + - { id: e3, source: check, target: escalate, condition: ' ' } # the shorthand for the same blank source |
| 27 | +``` |
| 28 | +
|
| 29 | +> An expression in an evaluated slot needs a non-blank `source`: the expression |
| 30 | +> engine evaluates `source` (the canonical persisted form of phase M9.1) and |
| 31 | +> cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` |
| 32 | +> that is blank after trimming, would validate and register and then fault at |
| 33 | +> run time. Write `{ dialect: 'cel', source: '…' }`. |
| 34 | + |
| 35 | +- **New export `EvaluatedExpressionInputSchema`** (type `EvaluatedExpressionInput`), |
| 36 | + the sibling of `ExpressionInputSchema` for an evaluated slot: the bare-string |
| 37 | + shorthand still normalizes to `{ dialect: 'cel', source }`, but the string |
| 38 | + must be non-blank after trimming, and the envelope arm composes |
| 39 | + `EvaluatedExpressionSchema` (`source` required and non-blank) instead of |
| 40 | + `ExpressionSchema`. `FlowEdgeSchema.condition` is the first slot to compose |
| 41 | + it. An `ast`-only envelope and a blank bare string surface as one |
| 42 | + `invalid_union` issue at the slot carrying the sentence above; a blank |
| 43 | + `source` inside an envelope surfaces as one `custom` issue at `source`. |
| 44 | +- **`ExpressionSchema` / `ExpressionInputSchema` are NOT narrowed.** They remain |
| 45 | + the persistence contract (`source` OR `ast`), whose docblock declares that |
| 46 | + `ast` becomes required in build output at phase M9.2. When AST-only |
| 47 | + evaluation lands, `EvaluatedExpressionSchema` is the one place to relax, and |
| 48 | + every evaluated slot follows. |
| 49 | +- **`structuralConditionRefusal` no longer admits an `ast`-only envelope** on |
| 50 | + either structural condition slot (`config.condition` on a node, |
| 51 | + `edge.condition`). #15662's refusal admitted it on purpose through a |
| 52 | + `rec.ast !== undefined` clause, because the spec still admitted the shape at |
| 53 | + `edge.condition` and refusing it from the consumer side would have decided |
| 54 | + #15430's question there; with the edge schema closed, that admission kept the |
| 55 | + refusal deliberately holed for a shape the engine cannot run on either slot. |
| 56 | + `STRUCTURAL_CONDITION_SHAPE_REFUSAL` now reads "an expression envelope |
| 57 | + carrying a string `source`" and says why. Consequence on `config.condition` |
| 58 | + (a start node's trigger gate, a decision node's predicate — an open record |
| 59 | + with no schema in front of it): an `ast`-only envelope there is refused at |
| 60 | + `registerFlow`, reported as a located `error` by `objectstack validate`, and |
| 61 | + refused by `evaluateCondition` with the same sentence, instead of answering a |
| 62 | + silent `false`. An `ast` BESIDE a string `source` is still admitted |
| 63 | + everywhere. The whitespace-only STRING ruling on `config.condition` (#15662: |
| 64 | + consistent `false` on both sides) is untouched. |
| 65 | +- **Three doors agree, through the spec.** `registerFlow` refuses the flow at |
| 66 | + `FlowSchema.parse` (edge) or at its structural pass (`config.condition`); |
| 67 | + `objectstack validate` refuses it at its `ObjectStackDefinitionSchema` parse |
| 68 | + (edge) or reports the structural refusal (`config.condition`); |
| 69 | + `evaluateCondition` refuses the shape a stored flow or a direct caller hands |
| 70 | + it. None of them grew a rule of its own. |
| 71 | + |
| 72 | +**What an author does with a refused edge condition.** An edge condition that |
| 73 | +carried only `ast` has no evaluable form under M9.1: author its `source`. A |
| 74 | +whitespace-only condition — envelope or bare string — was never a predicate |
| 75 | +(the engine answered `false`, so that edge never fired): remove the |
| 76 | +`condition` key if the edge was meant to be unconditional, or write the |
| 77 | +expression if it was meant to branch. Every edge condition with a |
| 78 | +non-blank `source` is unchanged, and nothing is renamed, retired or rewritten — |
| 79 | +the refusal itself carries the prescription. |
| 80 | + |
| 81 | +**A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole |
| 82 | +flow, not just the edge.** The paragraph above is the author's remedy, at |
| 83 | +`objectstack validate` / `POST /flows`; a stored row has no author in front of |
| 84 | +it. Stored flows are deliberately NOT canonicalized by |
| 85 | +`applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same |
| 86 | +skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`) — flow-node |
| 87 | +conversions need the automation engine's live executor registry, so flows |
| 88 | +canonicalize at `registerFlow` instead, which parses through |
| 89 | +`canonicalizeStoredFlow` → `FlowSchema.parse`. Each of the three boot paths in |
| 90 | +`service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one |
| 91 | +`warn` naming the flow, and continues. So an edge that used to answer a silent |
| 92 | +`false` while the rest of the flow ran now takes the flow down with it: it is |
| 93 | +never registered, its trigger is never armed, and the only announcement is that |
| 94 | +one warn line — `[Automation] failed to register flow` at boot, |
| 95 | +`[Automation] cold-boot flow bind: failed to register flow` at the kernel:ready |
| 96 | +bind, `[Automation] flow re-sync: failed to register flow` on a re-sync. That |
| 97 | +warn line is also the locator: its `issues[].path` names the offending edge — |
| 98 | +`edges[N].condition` — beside the sentence above, so nothing has to be exported |
| 99 | +to find it. Author the `source` — or remove the key, if the edge was meant to |
| 100 | +be unconditional — and republish. A stack authored in config files has a second |
| 101 | +door, `objectstack validate`, which locates the same edge at |
| 102 | +`flows.N.edges.N.condition`. Registered as the ADR-0087 D3 semantic entry |
| 103 | +`flow-edge-condition-evaluated-slot-source-required`, which carries the same |
| 104 | +judgment for a consumer replaying the chain. |
| 105 | + |
| 106 | +Not touched here: `start.config.condition` has no Zod schema to narrow (the |
| 107 | +start node's `config` is an open record); its producer-side gate is the |
| 108 | +structural refusal above, which this change tightens but does not type. |
0 commit comments