4343// the one bind-time warn rule 3's case gets.
4444//
4545// 5. A `type: 'record_change'` flow whose start-node `triggerType` the engine
46- // routes NOWHERE — `triggerType: 'onCreate'` (#6637). The quietest member
47- // of the family: rules 3 and 4 are about one key's shape, this one is
48- // about a flow that declares WHAT it is and then contradicts it. See 1f
49- // for the measured silence — every named runtime channel skips it because
50- // they all key off the same resolution that already gave up.
46+ // routes NOWHERE — present but off-grammar (`triggerType: 'onCreate'`,
47+ // #6637's original specimen) or absent entirely (the omission shape,
48+ // widened into this same id by #7215 once #7039 had repaired the corpus's
49+ // one live instance). Both are a flow that declares WHAT it is and then
50+ // never arms it — two spellings of one defect. See 1f for the measured
51+ // silence — every named runtime channel skips it because they all key off
52+ // the same resolution that already gave up.
5153//
5254// The spec import is deliberate and is what makes rule 3 possible without a
5355// second copy of the descriptor's shape living in this file. It stays inside the
@@ -150,7 +152,10 @@ export const FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE = 'flow-time-relative-desc
150152/**
151153 * #6637 — a `type: 'record_change'` flow whose start-node `triggerType` the
152154 * engine routes to NO trigger at all, so the flow is silently demoted to a
153- * manual one.
155+ * manual one. Widened by #7215 to also cover the token being ABSENT
156+ * entirely — the omission shape is exactly as dead at runtime as the
157+ * contradiction shape this id originally caught, so one id and one severity
158+ * cover both (see 1f for the history of why the widening waited).
154159 *
155160 * A separate id from `flow-trigger-unknown-event`, on the same distinction that
156161 * separates the two `timeRelative` ids: whether the engine ROUTES the value.
@@ -161,9 +166,10 @@ export const FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE = 'flow-time-relative-desc
161166 * ROUTES it to the record-change trigger, which maps it to zero hook events
162167 * and says so in a bind-time warn. That is `…-UNKNOWN-EVENT`, and this rule
163168 * file moves that warn earlier.
164- * - anything else (`onCreate`, `on_update`, `''`, `['onCreate']`) — the engine
165- * routes it NOWHERE. That is this id, and there is no runtime channel to
166- * move earlier from: see 1f for the three call sites that each skip it.
169+ * - anything else (`onCreate`, `on_update`, `''`, `['onCreate']`, or the key
170+ * absent entirely) — the engine routes it NOWHERE. That is this id, and
171+ * there is no runtime channel to move earlier from: see 1f for the three
172+ * call sites that each skip it.
167173 */
168174export const FLOW_TRIGGER_UNROUTABLE = 'flow-trigger-unroutable' ;
169175
@@ -560,25 +566,33 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines
560566 // are the types a genuinely manual flow declares, and neither reaches
561567 // here. That is what makes this decidable at authoring time.
562568 //
563- // Two shapes are deliberately NOT this rule's, each pinned by a test:
569+ // One shape is deliberately NOT this rule's, pinned by a test:
564570 //
565- // - `triggerType` ABSENT on a `record_change` flow. Dead the same way
566- // and arguably worse, but it is an omission rather than a
567- // contradiction, and the corpus measurement (#6637) found a live
568- // instance of it in `examples/app-todo` whose repair is a judgement
569- // about that app's semantics, not a lint decision (#6882 — the flow
570- // also writes its predicate to a `triggerCondition` key nothing
571- // reads, so arming it is not a one-token edit). Widening this
572- // criterion to cover it would gate a shipped example app on a guess.
573- // The criterion here requires the key to be PRESENT so that widening
574- // is a deliberate act, not a side effect.
575571 // - a `record_change` flow that ALSO declares something the engine
576572 // does route (`config.schedule`, `triggerType: 'api'`). That flow
577573 // binds and fires — on the wrong trigger's terms. A real defect, a
578574 // different one ("mis-bound", not "never bound"), with its own
579575 // severity argument to make. `routesToSomeTrigger` below is the
580576 // engine's chain character for character precisely so this rule
581577 // stays silent there instead of guessing at a second verdict.
578+ //
579+ // The criterion covers BOTH ways a `record_change` flow ends up
580+ // unrouted: `triggerType` PRESENT but off-grammar (the contradiction —
581+ // `triggerType: 'onCreate'`, #6637's original specimen) and
582+ // `triggerType` ABSENT entirely (the omission — dead the same way,
583+ // arguably worse, and previously excluded on purpose). They were not
584+ // always one rule's concern: at #6637 time the omission shape had a
585+ // live instance in `examples/app-todo` (`TaskCompletionFlow`, #6882)
586+ // whose repair was a judgement about that app's semantics rather than a
587+ // lint decision, so covering the omission then would have gated a
588+ // shipped example app on a guess — the criterion required the key to be
589+ // PRESENT and the omission was tracked separately (#7041 item 2).
590+ // #7039 repaired that instance (`TaskCompletionFlow` now declares
591+ // `triggerType: 'record-after-update'`), which put a green corpus
592+ // under the open question; #7215 (maintainer-ruled) decided to widen
593+ // now rather than wait for the next omission instance to make landing
594+ // costly again. Both shapes are equally dead at runtime — two
595+ // spellings of one defect — so they share this id and severity.
582596 const routesToSomeTrigger =
583597 isRecordTriggered ||
584598 isArrayRecordTriggered ||
@@ -587,7 +601,8 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines
587601 flow . type === 'schedule' ||
588602 flow . type === 'api' ||
589603 triggerType === 'api' ;
590- if ( start && flow . type === 'record_change' && config . triggerType != null && ! routesToSomeTrigger ) {
604+ if ( start && flow . type === 'record_change' && ! routesToSomeTrigger ) {
605+ const hasTriggerType = config . triggerType != null ;
591606 findings . push ( {
592607 // `error` (#5762's criterion, applied to a fourth id). The verdict is
593608 // the engine's own routing chain — literal `startsWith`/`typeof` tests
@@ -601,12 +616,15 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines
601616 where : `flow "${ flowName } " › start node` ,
602617 path : `flows[${ flowIndex } ].nodes[${ start . index } ].config.triggerType` ,
603618 message :
604- `declares type: 'record_change' but its start node's triggerType is ` +
605- `${ renderTriggerToken ( config . triggerType ) } , which the engine routes to NO trigger — it binds a ` +
606- `record-change flow only for a token starting with 'record-', so this flow is demoted to a manual ` +
607- `one and never fires. Nothing NAMES it: the unbound-flow audit resolves the same binding and skips ` +
608- `the flow as "manual — nothing to bind", so neither the boot warning nor the startup summary lists ` +
609- `it; the only trace is the banner's flow count being one higher than its bound count.` ,
619+ `declares type: 'record_change' but ` +
620+ ( hasTriggerType
621+ ? `its start node's triggerType is ${ renderTriggerToken ( config . triggerType ) } , which the engine ` +
622+ `routes to NO trigger`
623+ : `its start node has no triggerType at all, so there is nothing for the engine to route` ) +
624+ ` — it binds a record-change flow only for a token starting with 'record-', so this flow is demoted ` +
625+ `to a manual one and never fires. Nothing NAMES it: the unbound-flow audit resolves the same binding ` +
626+ `and skips the flow as "manual — nothing to bind", so neither the boot warning nor the startup ` +
627+ `summary lists it; the only trace is the banner's flow count being one higher than its bound count.` ,
610628 hint :
611629 `Use record-{before,after}-{create,update,delete,write} ('write' is create OR update in one flow, ` +
612630 `#3427; create/insert are synonyms). If the flow really is launched by hand or from a screen, ` +
0 commit comments