| @objectstack/spec | minor |
|---|
feat(spec)!: FlowEdgeSchema.condition is an evaluated slot — it composes the new EvaluatedExpressionInputSchema, and structuralConditionRefusal no longer admits an ast-only envelope (#15807)
BREAKING in the accept-set sense, landing in the launch window as minor
(the lockstep convention: major is refused by check-changeset-no-major, and
breaking-ness is carried by this banner plus the ADR-0087 disposition): the
edge condition of a flow — FlowEdgeSchema.condition, the branch predicate
AutomationEngine.evaluateCondition runs at every traversal — now refuses at
authoring an envelope the engine cannot evaluate, where it used to parse,
register, pass objectstack validate, and then answer a silent false: a
branch that quietly never fired.
Two spellings of one seam, refused by ONE rule with one sentence
(EVALUATED_EXPRESSION_SOURCE_REQUIRED, the rule #15430 introduced for the
assignment value envelope):
edges:
- { id: e1, source: check, target: approve, condition: { dialect: cel, ast: { kind: const, value: true } } } # `ast` only — the engine never reads it
- { id: e2, source: check, target: reject, condition: { dialect: cel, source: ' ' } } # blank after trimming
- { id: e3, source: check, target: escalate, condition: ' ' } # the shorthand for the same blank sourceAn expression in an evaluated slot needs a non-blank
source: the expression engine evaluatessource(the canonical persisted form of phase M9.1) and cannot evaluateastalone, so an envelope carrying onlyast, or asourcethat is blank after trimming, would validate and register and then fault at run time. Write{ dialect: 'cel', source: '…' }.
- New export
EvaluatedExpressionInputSchema(typeEvaluatedExpressionInput), the sibling ofExpressionInputSchemafor an evaluated slot: the bare-string shorthand still normalizes to{ dialect: 'cel', source }, but the string must be non-blank after trimming, and the envelope arm composesEvaluatedExpressionSchema(sourcerequired and non-blank) instead ofExpressionSchema.FlowEdgeSchema.conditionis the first slot to compose it. Anast-only envelope and a blank bare string surface as oneinvalid_unionissue at the slot carrying the sentence above; a blanksourceinside an envelope surfaces as onecustomissue atsource. ExpressionSchema/ExpressionInputSchemaare NOT narrowed. They remain the persistence contract (sourceORast), whose docblock declares thatastbecomes required in build output at phase M9.2. When AST-only evaluation lands,EvaluatedExpressionSchemais the one place to relax, and every evaluated slot follows.structuralConditionRefusalno longer admits anast-only envelope on either structural condition slot (config.conditionon a node,edge.condition). #15662's refusal admitted it on purpose through arec.ast !== undefinedclause, because the spec still admitted the shape atedge.conditionand refusing it from the consumer side would have decided #15430's question there; with the edge schema closed, that admission kept the refusal deliberately holed for a shape the engine cannot run on either slot.STRUCTURAL_CONDITION_SHAPE_REFUSALnow reads "an expression envelope carrying a stringsource" and says why. Consequence onconfig.condition(a start node's trigger gate, a decision node's predicate — an open record with no schema in front of it): anast-only envelope there is refused atregisterFlow, reported as a locatederrorbyobjectstack validate, and refused byevaluateConditionwith the same sentence, instead of answering a silentfalse. AnastBESIDE a stringsourceis still admitted everywhere. The whitespace-only STRING ruling onconfig.condition(#15662: consistentfalseon both sides) is untouched.- Three doors agree, through the spec.
registerFlowrefuses the flow atFlowSchema.parse(edge) or at its structural pass (config.condition);objectstack validaterefuses it at itsObjectStackDefinitionSchemaparse (edge) or reports the structural refusal (config.condition);evaluateConditionrefuses the shape a stored flow or a direct caller hands it. None of them grew a rule of its own.
What an author does with a refused edge condition. An edge condition that
carried only ast has no evaluable form under M9.1: author its source. A
whitespace-only condition — envelope or bare string — was never a predicate
(the engine answered false, so that edge never fired): remove the
condition key if the edge was meant to be unconditional, or write the
expression if it was meant to branch. Every edge condition with a
non-blank source is unchanged, and nothing is renamed, retired or rewritten —
the refusal itself carries the prescription.
A flow ALREADY STORED in sys_metadata stops running entirely — the whole
flow, not just the edge. The paragraph above is the author's remedy, at
objectstack validate / POST /flows; a stored row has no author in front of
it. Stored flows are deliberately NOT canonicalized by
applyConversionsToStoredItem (spec/src/conversions/stored.ts, and the same
skip in metadata/src/loaders/database-loader.ts's rowToData) — flow-node
conversions need the automation engine's live executor registry, so flows
canonicalize at registerFlow instead, which parses through
canonicalizeStoredFlow → FlowSchema.parse. Each of the three boot paths in
service-automation/src/plugin.ts wraps that call in try/catch, logs one
warn naming the flow, and continues. So an edge that used to answer a silent
false while the rest of the flow ran now takes the flow down with it: it is
never registered, its trigger is never armed, and the only announcement is that
one warn line — [Automation] failed to register flow at boot,
[Automation] cold-boot flow bind: failed to register flow at the kernel:ready
bind, [Automation] flow re-sync: failed to register flow on a re-sync. That
warn line is also the locator: its issues[].path names the offending edge —
edges[N].condition — beside the sentence above, so nothing has to be exported
to find it. Author the source — or remove the key, if the edge was meant to
be unconditional — and republish. A stack authored in config files has a second
door, objectstack validate, which locates the same edge at
flows.N.edges.N.condition. Registered as the ADR-0087 D3 semantic entry
flow-edge-condition-evaluated-slot-source-required, which carries the same
judgment for a consumer replaying the chain.
Not touched here: start.config.condition has no Zod schema to narrow (the
start node's config is an open record); its producer-side gate is the
structural refusal above, which this change tightens but does not type.