Found while measuring the corpus for #6637 (PR for that card adds flow-trigger-unroutable). Filed standalone rather than fixed there, because the repair is a judgement about this example app's intended semantics, not a lint decision — and #6637's dispatch forbids silencing a new rule's hit with a baseline entry.
The defect
examples/app-todo/src/flows/task.flow.ts:118-131 — TaskCompletionFlow:
export const TaskCompletionFlow: Flow = {
name: 'task_completion',
type: 'record_change',
nodes: [
{ id: 'start', type: 'start', label: 'Start', config: { objectName: 'todo_task', triggerCondition: 'record.status != previous.status && record.status == "completed"' } },
...
Two independent problems on one start node:
- No
triggerType at all. AutomationEngine.resolveTriggerBinding (packages/services/service-automation/src/engine.ts:1625-1719) claims a record-change flow only when the authored token starts with record-; with the key absent, every later branch also misses (timeRelative, config.schedule, flow.type === 'schedule', flow.type === 'api', triggerType === 'api') and the method returns undefined at :1718. activateFlowTrigger then returns at :1729 without binding. The flow declares itself record-triggered and is, at runtime, a manual flow that never fires.
triggerCondition is not a key anything reads. The start node's trigger gate is config.condition (see every showcase flow, and resolveTriggerBinding's condition: config.condition). A node config is an open slot by design (ADR-0018), so the schema accepts the misspelling silently. Even once (1) is fixed, this predicate would not gate anything — the flow would fire on every write.
The expression itself also uses a record. / previous. prefix, while the showcase flows write bare field names (priority == 'urgent' && (previous == null || previous.priority != 'urgent')), so the repair needs someone to decide the intended dialect too. That is why this is a card and not a drive-by fix.
Why nothing catches it today
Measured, not assumed — every named channel keys off the same resolver that already gave up:
getTriggerBindingAudit (engine.ts:2387-2400) skips it: if (!resolved) continue; // manual / screen flow — nothing to bind.
- Its two consumers therefore say nothing — the automation plugin's
kernel:bootstrapped warn loop (packages/services/service-automation/src/plugin.ts:907-910) and the CLI startup summary's unbound list (packages/cli/src/commands/serve.ts:3428).
- The summary's dead-object check (
serve.ts:3435-3441) requires s.bound && s.triggerType === 'record_change', so it skips it too.
- Only trace anywhere: the banner's count line reads one more flow registered than bound, with no name and no reason.
#6637's new flow-trigger-unroutable deliberately does NOT cover this shape — its criterion requires config.triggerType to be PRESENT, so the rule speaks about a contradiction rather than an omission. That boundary is pinned by a test in packages/lint/src/validate-flow-trigger-readiness.test.ts naming this issue, so widening it is a deliberate edit rather than a side effect.
Corpus measurement (origin/main, 34 authored flows across app-todo / app-showcase / app-crm)
flow.type census: autolaunched 24 · schedule 4 · screen 3 · record_change 2 · api 1.
Of the two record_change flows, showcase_urgent_task_alert is correctly wired (record-after-write). task_completion is the only dead one — 1 of 34.
Suggested shape of the fix
Decide the intent, then either:
- arm it —
triggerType: 'record-after-update' plus the predicate moved to config.condition in the dialect the showcase uses; or
- if the flow is meant to be invoked by hand, declare
type: 'autolaunched' and drop the trigger keys.
Whichever way, a companion decision is whether the wider lint criterion (a record_change flow that resolves to no binding at all, triggerType absent) should then ship — with this instance repaired, it would fire on nothing in the tree.
Generated by Claude Code
Found while measuring the corpus for #6637 (PR for that card adds
flow-trigger-unroutable). Filed standalone rather than fixed there, because the repair is a judgement about this example app's intended semantics, not a lint decision — and #6637's dispatch forbids silencing a new rule's hit with a baseline entry.The defect
examples/app-todo/src/flows/task.flow.ts:118-131—TaskCompletionFlow:Two independent problems on one start node:
triggerTypeat all.AutomationEngine.resolveTriggerBinding(packages/services/service-automation/src/engine.ts:1625-1719) claims a record-change flow only when the authored token starts withrecord-; with the key absent, every later branch also misses (timeRelative,config.schedule,flow.type === 'schedule',flow.type === 'api',triggerType === 'api') and the method returnsundefinedat :1718.activateFlowTriggerthen returns at :1729 without binding. The flow declares itself record-triggered and is, at runtime, a manual flow that never fires.triggerConditionis not a key anything reads. The start node's trigger gate isconfig.condition(see every showcase flow, andresolveTriggerBinding'scondition: config.condition). A nodeconfigis an open slot by design (ADR-0018), so the schema accepts the misspelling silently. Even once (1) is fixed, this predicate would not gate anything — the flow would fire on every write.The expression itself also uses a
record./previous.prefix, while the showcase flows write bare field names (priority == 'urgent' && (previous == null || previous.priority != 'urgent')), so the repair needs someone to decide the intended dialect too. That is why this is a card and not a drive-by fix.Why nothing catches it today
Measured, not assumed — every named channel keys off the same resolver that already gave up:
getTriggerBindingAudit(engine.ts:2387-2400) skips it:if (!resolved) continue; // manual / screen flow — nothing to bind.kernel:bootstrappedwarn loop (packages/services/service-automation/src/plugin.ts:907-910) and the CLI startup summary'sunboundlist (packages/cli/src/commands/serve.ts:3428).serve.ts:3435-3441) requiress.bound && s.triggerType === 'record_change', so it skips it too.#6637's new
flow-trigger-unroutabledeliberately does NOT cover this shape — its criterion requiresconfig.triggerTypeto be PRESENT, so the rule speaks about a contradiction rather than an omission. That boundary is pinned by a test inpackages/lint/src/validate-flow-trigger-readiness.test.tsnaming this issue, so widening it is a deliberate edit rather than a side effect.Corpus measurement (
origin/main, 34 authored flows across app-todo / app-showcase / app-crm)flow.typecensus:autolaunched24 ·schedule4 ·screen3 ·record_change2 ·api1.Of the two
record_changeflows,showcase_urgent_task_alertis correctly wired (record-after-write).task_completionis the only dead one — 1 of 34.Suggested shape of the fix
Decide the intent, then either:
triggerType: 'record-after-update'plus the predicate moved toconfig.conditionin the dialect the showcase uses; ortype: 'autolaunched'and drop the trigger keys.Whichever way, a companion decision is whether the wider lint criterion (a
record_changeflow that resolves to no binding at all,triggerTypeabsent) should then ship — with this instance repaired, it would fire on nothing in the tree.Generated by Claude Code