From 6a7cc596ae95a77796dce623a4aa7ad4071554ce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:00:16 +0000 Subject: [PATCH] fix(lint): report a config.timeRelative that is not a descriptor object (#5647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flow start node whose `config.timeRelative` held a scalar — `timeRelative: 'daily'`, from fusing the sweep's cadence with its descriptor — was accepted in complete silence at every layer. Verified against origin/main before writing the rule: `FlowSchema.safeParse` and `defineFlow` ACCEPT the scalar (a node `config` is an open slot, ADR-0018, so no outer gate looks inside), `validateFlowTriggerReadiness` and `lintFlowPatterns` both returned `[]`, and `os validate` on a real example app printed byte-identical output with and without it. The engine routes a flow to the time-relative sweep only when `typeof config.timeRelative === 'object'`, so the scalar falls through that branch; with no other trigger key on the node `resolveTriggerBinding` returns undefined and `activateFlowTrigger` returns silently. Not one diagnostic anywhere — not even the single bind-time warn an object-but-unparseable descriptor gets, because the trigger is never handed the flow at all. That is the same "folds to no trigger, invisible everywhere" shape #3481 found for a non-string `triggerType`, one key over. `flow-time-relative-descriptor-unroutable` (warning) reports it at authoring time with the value, its type and the consequence, plus a hint that separates the two fused concepts: the descriptor says WHICH records to sweep, while HOW OFTEN is the sibling key `config.schedule`. A separate criterion, not a widening of #5496's `flow-time-relative-descriptor- invalid`. Widening was rejected because `isTimeRelative` also feeds `isAutoTriggered`, so it would have moved two already-published rules' coverage as a side effect of adding a third. The two ids instead partition the key's non-null values along the engine's own routing predicate — a value the engine routes gets the schema's verdict, a value it routes nowhere gets this one, and never both. Arrays and `Date` are `typeof 'object'`, so they stay with the shape rule, which already reports them off the schema's own words. The consequence clause is computed per flow rather than asserted uniformly: a start node that also declares a trigger the engine recognizes DOES bind and fire — on that trigger's terms, with the descriptor silently dropped — and claiming "never fires" about such a flow would be a false diagnostic. Nothing is made tolerant: a scalar is still not a descriptor, and runtime behaviour is unchanged. --- .../lint-time-relative-scalar-unroutable.md | 28 +++ packages/lint/src/index.ts | 1 + .../validate-flow-trigger-readiness.test.ts | 236 +++++++++++++++++- .../src/validate-flow-trigger-readiness.ts | 126 +++++++++- 4 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 .changeset/lint-time-relative-scalar-unroutable.md diff --git a/.changeset/lint-time-relative-scalar-unroutable.md b/.changeset/lint-time-relative-scalar-unroutable.md new file mode 100644 index 0000000000..3228b4e4a2 --- /dev/null +++ b/.changeset/lint-time-relative-scalar-unroutable.md @@ -0,0 +1,28 @@ +--- +"@objectstack/lint": patch +--- + +`os validate` now reports a `config.timeRelative` that is not a descriptor object + +A flow start node whose `config.timeRelative` held a scalar — `timeRelative: 'daily'` +is the natural mistake, from fusing the sweep's **cadence** with its **descriptor** — +was accepted in complete silence at every layer. The node `config` slot is open by +design (ADR-0018) so the schema parsed it; the engine routes a flow to the +time-relative sweep only when `config.timeRelative` is an object, so the flow fell +through that branch and, with no other trigger key on the node, bound to nothing and +never fired. Not one diagnostic was produced anywhere — not even the single bind-time +warn that an object-but-unparseable descriptor gets, because the trigger was never +handed the flow at all. + +A new authoring rule, `flow-time-relative-descriptor-unroutable` (warning), reports it +at authoring time with the value, its type, and the consequence — and a hint that +separates the two fused concepts: the descriptor says WHICH records to sweep +(`{ object, dateField, and exactly one of withinDays | offsetDays }`), while HOW OFTEN +is the sibling key `config.schedule`. + +Nothing is made tolerant: a scalar is still not a descriptor and the runtime's +behaviour is unchanged. The rule is a separate criterion from +`flow-time-relative-descriptor-invalid` (#5496) rather than a widening of it, and the +two partition the key along the engine's own routing predicate — a value the engine +routes gets the schema's verdict, a value it routes nowhere gets this one, and never +both. Arrays and `Date` are `typeof 'object'`, so they stay with the shape rule. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index add2995cff..11cdc29003 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -57,6 +57,7 @@ export { FLOW_DRAFT_STATUS_AMBIGUOUS, FLOW_TRIGGER_UNKNOWN_EVENT, FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, + FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, } from './validate-flow-trigger-readiness.js'; export type { FlowTriggerReadinessFinding, diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index 2e67b69236..2732bcf071 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -8,6 +8,7 @@ import { FLOW_DRAFT_STATUS_AMBIGUOUS, FLOW_TRIGGER_UNKNOWN_EVENT, FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, + FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, } from './validate-flow-trigger-readiness.js'; function recordFlow(overrides: Record = {}) { @@ -353,11 +354,17 @@ describe('validateFlowTriggerReadiness', () => { it('says nothing about a non-object timeRelative — the engine does not route it here', () => { // `AutomationEngine`'s trigger resolution requires `typeof … === 'object'`, // so `timeRelative: 'daily'` never reaches the time-relative trigger and no - // descriptor verdict applies to it. Whatever that flow's defect is, it is - // not this rule's, and guessing here would make the rule speak for flows - // the engine hands somewhere else. + // descriptor SHAPE verdict applies to it. Guessing here would make this rule + // speak for flows the engine hands somewhere else. + // + // #5647 answered the question this test used to leave open ("whatever that + // flow's defect is, it is not this rule's") — it is 1e's, a different id. + // So the assertion is now two-sided: the shape rule stays out, AND the + // unroutable rule steps in. One-sided, it would have kept passing even if + // the scalar had gone back to being reported by nobody at all. const findings = validateFlowTriggerReadiness(timeRelativeStack('daily')); expect(findings.some((f) => f.rule === FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID)).toBe(false); + expect(findings.map((f) => f.rule)).toEqual([FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE]); }); it('is inert on flows that declare no timeRelative at all', () => { @@ -377,6 +384,229 @@ describe('validateFlowTriggerReadiness', () => { FLOW_DRAFT_STATUS_AMBIGUOUS, ]); }); + + // ── #5647 — the descriptor is not an OBJECT ───────────────────────────── + // + // The complement of everything above, and the case with no runtime channel + // at all: `timeRelative: 'daily'` is not routed by the engine, so the + // trigger never safeParses it, so there is not even the one bind-time warn + // that the object-but-invalid case gets. Verified against `origin/main` + // before this rule existed: `validateFlowTriggerReadiness` and + // `lintFlowPatterns` both returned `[]`, `FlowSchema.safeParse` ACCEPTED the + // scalar (a node `config` is an open slot, ADR-0018), and `os validate` on a + // real example app printed byte-identical output with and without it. + describe('config.timeRelative is not an object (#5647)', () => { + /** A start node with nothing but the offending descriptor — the issue's flow. */ + function scalarOnlyStack(timeRelative: unknown) { + const stack = timeRelativeStack(timeRelative); + // `autolaunched`, so `type: 'schedule'` is not silently supplying a + // trigger the engine would fall back to. This is the never-fires case. + (stack.flows[0] as Record).type = 'autolaunched'; + return stack; + } + + it('flags the scalar from the issue, and names the value, the type and the consequence', () => { + const findings = validateFlowTriggerReadiness(scalarOnlyStack('daily')); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.rule).toBe(FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE); + expect(f.severity).toBe('warning'); + expect(f.path).toBe('flows[0].nodes[0].config.timeRelative'); + expect(f.where).toBe('flow "task_due_reminder" › start node'); + // The value the author wrote AND why it is not a descriptor. + expect(f.message).toContain('"daily"'); + expect(f.message).toContain('(a string)'); + expect(f.message).toContain('config.timeRelative'); + // The consequence chain, which is the whole reason this is not a nit: + // never routed → sweep never installed → this flow never fires at all, + // and no layer says so. + expect(f.message).toMatch(/never routed/); + expect(f.message).toMatch(/sweep is never\s+installed/); + expect(f.message).toMatch(/binds to NOTHING and\s+never fires/); + expect(f.message).toMatch(/zero diagnostics at any layer/); + // Single-line, like every other finding this rule emits (the CLI prints + // `• where: message` on one line). + expect(f.message).not.toContain('\n'); + expect(f.hint).not.toContain('\n'); + }); + + it("names the cadence-vs-descriptor confusion the scalar actually is", () => { + // The reason `'daily'` is the specimen: the author has fused "how often + // the sweep runs" with "which records it sweeps". The hint has to + // separate them or it does not help — the cadence key is a SIBLING. + const [f] = validateFlowTriggerReadiness(scalarOnlyStack('daily')); + expect(f.hint).toContain('config.schedule'); + expect(f.hint).toMatch(/sibling/); + expect(f.hint).toContain('dateField'); + expect(f.hint).toMatch(/withinDays \| offsetDays/); + expect(f.hint).toContain('TimeRelativeTriggerSchema'); + }); + + it('flags every non-object shape, each rendered as itself', () => { + const cases: Array<[unknown, string]> = [ + ['daily', '"daily" (a string)'], + ['', '"" (a string)'], + [7, '7 (a number)'], + [0, '0 (a number)'], + [true, 'true (a boolean)'], + [false, 'false (a boolean)'], + ]; + for (const [value, rendered] of cases) { + const findings = validateFlowTriggerReadiness(scalarOnlyStack(value)); + expect(findings.map((f) => f.rule), rendered).toEqual([ + FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, + ]); + expect(findings[0].message, rendered).toContain(rendered); + } + }); + + it('reports a function or symbol without claiming it is undefined', () => { + // `JSON.stringify` returns the VALUE `undefined` for both, so a naive + // renderer would print a message about a value being "undefined" when it + // is very much present. Only reachable from a TS-authored stack, but a + // diagnostic that misreports its own subject is worse than a vague one. + for (const [value, rendered] of [ + [() => 'daily', 'a function'], + [Symbol('daily'), 'a symbol'], + ] as Array<[unknown, string]>) { + const findings = validateFlowTriggerReadiness(scalarOnlyStack(value)); + expect(findings.map((f) => f.rule)).toEqual([FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE]); + expect(findings[0].message).toContain(rendered); + expect(findings[0].message).not.toContain('undefined'); + } + }); + + it('says the flow still binds — not that it never fires — when a sibling trigger exists', () => { + // Truthfulness of the consequence clause. `resolveTriggerBinding` falls + // THROUGH the time-relative branch for a scalar and keeps going, so a + // start node that also declares a trigger the engine recognizes does bind + // and does fire — on that trigger's terms, with the descriptor silently + // dropped (once per firing, no record on the context). Claiming "never + // fires" about such a flow would be a false diagnostic. + const withSchedule = scalarOnlyStack('daily'); + ( + (withSchedule.flows[0] as Record).nodes as Array> + )[0].config = { timeRelative: 'daily', schedule: { type: 'cron', expression: '0 8 * * *' } }; + const [f] = validateFlowTriggerReadiness(withSchedule); + expect(f.rule).toBe(FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE); + expect(f.message).toContain('config.schedule'); + expect(f.message).toMatch(/silently DROPPED/); + expect(f.message).not.toMatch(/never fires/); + // …and the certain half is still stated. + expect(f.message).toMatch(/never routed/); + }); + + it('names each fallback the engine would actually reach', () => { + const fallbackOf = (config: Record, flowPatch: Record = {}) => { + const stack = scalarOnlyStack('daily'); + const flow = stack.flows[0] as Record; + Object.assign(flow, flowPatch); + (flow.nodes as Array>)[0].config = { + timeRelative: 'daily', + ...config, + }; + return validateFlowTriggerReadiness(stack).find( + (f) => f.rule === FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, + )!.message; + }; + // Same order as `resolveTriggerBinding` checks them. + expect(fallbackOf({ triggerType: 'record-after-update', objectName: 'task' })) + .toContain('record-change trigger'); + expect(fallbackOf({ triggerType: ['record-after-create'], objectName: 'task' })) + .toContain('record-change trigger'); + expect(fallbackOf({ schedule: { type: 'cron', expression: '0 8 * * *' } })) + .toContain('config.schedule'); + expect(fallbackOf({}, { type: 'schedule' })).toContain('config.schedule'); + expect(fallbackOf({ triggerType: 'api' })).toContain('api trigger'); + expect(fallbackOf({}, { type: 'api' })).toContain('api trigger'); + }); + + it('partitions the key with the shape rule — never both, never neither', () => { + // The two ids split the non-null values of `config.timeRelative` along the + // ENGINE's routing predicate, so exactly one can speak about any given + // descriptor. This is the assertion that keeps 1e from becoming a second + // opinion on flows 1b-ii already covers (and vice versa). + const routed: unknown[] = [ + { object: 'task', dateField: 'due_at', withinDays: 3 }, // valid → neither + { object: 'task', field: 'due_at' }, // invalid object → INVALID + [{ object: 'task' }], // array is typeof 'object' → INVALID + new Date('2026-01-01T00:00:00Z'), // Date is typeof 'object' → INVALID + ]; + const unrouted: unknown[] = ['daily', 7, true, '']; + + for (const v of routed) { + const rules = validateFlowTriggerReadiness(scalarOnlyStack(v)).map((f) => f.rule); + expect(rules, `routed: ${String(v)}`).not.toContain( + FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, + ); + } + for (const v of unrouted) { + const rules = validateFlowTriggerReadiness(scalarOnlyStack(v)).map((f) => f.rule); + expect(rules, `unrouted: ${String(v)}`).toContain( + FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, + ); + expect(rules, `unrouted: ${String(v)}`).not.toContain( + FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, + ); + } + }); + + it('is silent when the key is absent or null — those declare no descriptor at all', () => { + // `null` is on the far side of the engine's `!= null` too, so it is not + // "a descriptor of the wrong type" — it is no descriptor. A flow with no + // trigger at all is a different (pre-existing) gap, not this rule's. + for (const v of [null, undefined]) { + const stack = scalarOnlyStack(v); + expect( + validateFlowTriggerReadiness(stack).map((f) => f.rule), + `${String(v)} must not be treated as a descriptor`, + ).not.toContain(FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE); + } + // The contrast, in the same test and on the same fixture builder. Without + // it this case is a bare negative: it would keep passing if the criterion + // were deleted outright and NOTHING were reported — green because nothing + // is produced rather than because the boundary is where it should be. + expect(validateFlowTriggerReadiness(scalarOnlyStack('')).map((f) => f.rule)).toContain( + FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, + ); + }); + + it('leaves `isTimeRelative` — and the two rules it feeds — exactly as they were', () => { + // The boundary this PR promised not to cross. `isTimeRelative` also feeds + // `isAutoTriggered`, so widening it to cover scalars would have changed + // `flow-draft-status-ambiguous`'s coverage as a side effect. It is not + // widened, so a DRAFT flow whose only trigger key is a scalar still does + // not get the draft warning — deliberately unchanged behaviour, pinned + // here so a later edit to `isTimeRelative` has to come past this test. + const stack = scalarOnlyStack('daily'); + delete (stack.flows[0] as Record).status; + const rules = validateFlowTriggerReadiness(stack).map((f) => f.rule); + expect(rules).toEqual([FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE]); + expect(rules).not.toContain(FLOW_DRAFT_STATUS_AMBIGUOUS); + + // The object-shaped control, to show the omission above is about the + // scalar and not about the draft rule having stopped working. + const objectStack = timeRelativeStack({ object: 'task', dateField: 'due_at', withinDays: 3 }); + delete (objectStack.flows[0] as Record).status; + expect(validateFlowTriggerReadiness(objectStack).map((f) => f.rule)).toEqual([ + FLOW_DRAFT_STATUS_AMBIGUOUS, + ]); + }); + + it('is reachable from the published barrel under its own value', () => { + // The id lands in every finding's `f.rule` and therefore in + // `os lint --json` and `suppressWarnings: ['']`, so a consumer needs + // the constant rather than the literal (#5648). The package-wide gate in + // `rule-id-barrel-exports.test.ts` proves this for every id; this line is + // the local statement of the same fact. + expect(FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE).toBe( + 'flow-time-relative-descriptor-unroutable', + ); + expect(FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE).not.toBe( + FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, + ); + }); + }); }); it('passes the record-after-write (create-OR-update) token (#3427)', () => { diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index ef371ee90e..16dc8bfeda 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -31,6 +31,17 @@ // all in `os validate`. The judgement is not re-implemented here: the rule // runs that schema and forwards its issue list verbatim. // +// 4. A `config.timeRelative` that is not an OBJECT at all — `timeRelative: +// 'daily'` (#5647). One step worse than 3, and it needs its own criterion +// because it falls on the far side of the engine's routing predicate: the +// engine hands a flow to the time-relative trigger only when +// `typeof config.timeRelative === 'object'`, so a scalar is never routed, +// the trigger never safeParses it, and rule 3 — which deliberately speaks +// only for routed flows — cannot see it either. Nothing anywhere says a +// word: not the schema (the node `config` slot is open by design, +// ADR-0018, so the scalar parses fine), not `os validate`, and not even +// the one bind-time warn rule 3's case gets. +// // The spec import is deliberate and is what makes rule 3 possible without a // second copy of the descriptor's shape living in this file. It stays inside the // package's stated dependency direction — lint → `@objectstack/spec`, never onto @@ -65,6 +76,23 @@ export const FLOW_TRIGGER_UNKNOWN_EVENT = 'flow-trigger-unknown-event'; * authoring time. `flow--` is the shape the next one takes. */ export const FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID = 'flow-time-relative-descriptor-invalid'; +/** + * #5647 — `config.timeRelative` is present but is not an object, so the engine + * never routes the flow to the time-relative trigger at all. + * + * The second id in the `flow--` family, and a DIFFERENT + * verdict from `…-INVALID` rather than a widening of it. The two partition the + * non-null values of one key along the engine's own routing predicate, so + * exactly one of them can ever fire on a given descriptor: + * + * - `typeof === 'object'` (including arrays and `Date`) — the engine ROUTES + * it, `TimeRelativeTriggerSchema` gets a verdict, and a bad shape is + * `…-INVALID`. That path has a bind-time warn; the rule moves it earlier. + * - anything else (`'daily'`, `7`, `true`, a function) — the engine routes it + * NOWHERE, so no schema and no trigger ever sees it. That is this id, and + * there is no runtime channel at all for it to be moved earlier from. + */ +export const FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE = 'flow-time-relative-descriptor-unroutable'; type AnyRec = Record; @@ -91,6 +119,24 @@ function asArray(v: unknown): AnyRec[] { return []; } +/** + * Render a non-object `config.timeRelative` for the 1e message: the value AND + * its type, because both halves of the mistake are informative — `'daily'` shows + * what the author meant, `string` shows why it is not a descriptor. + * + * `JSON.stringify` is not enough on its own: it returns `undefined` (the value, + * not the string) for a function or a symbol, which would interpolate the word + * "undefined" into a message about a value that is very much present. Those + * arrive only from a TS-authored stack, never from stored JSON, but a diagnostic + * that misreports its own subject is worse than a vague one. + */ +function renderNonObject(v: unknown): string { + const t = typeof v; + if (t === 'string' || t === 'number' || t === 'boolean') return `${JSON.stringify(v)} (a ${t})`; + if (t === 'bigint') return `${String(v)}n (a bigint)`; + return `a ${t}`; +} + /** The start node of a flow definition, if any. */ function startNodeOf(flow: AnyRec): { node: AnyRec; index: number } | undefined { const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; @@ -175,7 +221,12 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines // predicate, character for character (`AutomationEngine`'s trigger // resolution: `config.timeRelative != null && typeof … === 'object'`). // This rule therefore speaks for exactly the flows the engine hands to - // the time-relative trigger, and stays silent about the ones it does not. + // the time-relative trigger, and stays silent about the ones it does not + // — which is why the flows on the OTHER side of that predicate need + // their own criterion, in 1e below (#5647). Widening this guard was the + // alternative and was rejected: `isTimeRelative` also feeds + // `isAutoTriggered`, so it would have moved two already-published rules' + // coverage as a side effect of adding a third. if (isTimeRelative && start) { const tr = config.timeRelative as AnyRec; @@ -279,6 +330,79 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines }); } + // 1e. `config.timeRelative` present but NOT an object (#5647) — the exact + // complement of 1b's guard, and the reason it has to be a separate + // criterion instead of a wider 1b. + // + // `timeRelative: 'daily'` is the specimen: an author who has the cadence + // concept and the descriptor concept fused writes the cadence into the + // descriptor slot. Every layer then says nothing at all: + // + // - the schema parses it. A node's `config` is an OPEN slot (ADR-0018), + // so `FlowSchema.safeParse` / `defineFlow` accept the scalar happily; + // no outer gate looks inside. + // - the engine does not route it. Its predicate is + // `timeRelative != null && typeof … === 'object'`, so the flow falls + // THROUGH the time-relative branch — the sweep is never installed and + // `TimeRelativeTriggerSchema` is never asked, which is also why 1b-ii + // cannot reach this case. + // - so there is no bind-time warn either. An object-but-unparseable + // descriptor at least produces one line in a server log (that is what + // 1b-ii moves earlier). A scalar produces zero output at every layer + // — the same "folds to no trigger, invisible everywhere" shape #3481 + // found for a non-string `triggerType`, one key over, and 1d exists + // for that one on the same reasoning. + // + // Arrays and `Date` are deliberately NOT here: `typeof` says 'object' for + // both, so the engine DOES route them and 1b-ii already reports them off + // the schema's own verdict. The two criteria partition the key's non-null + // values, so they can never both speak about one descriptor. + if (start && config.timeRelative != null && typeof config.timeRelative !== 'object') { + // What the engine would fall through TO. A scalar `timeRelative` alone + // resolves to no binding at all (`resolveTriggerBinding` returns undefined + // and `activateFlowTrigger` returns silently) — the issue's case, and the + // worse one. But if the same start node also declares a trigger the engine + // recognizes, the flow does bind: it fires on THAT trigger's terms while + // the descriptor is silently dropped. Both are defects and both are this + // rule's, so the consequence clause is reported per flow rather than + // asserted uniformly — a message that claimed "never fires" about a flow + // whose sibling `schedule` fires it daily would be false, and a false + // diagnostic is worth less than none. + const fallback = + isRecordTriggered || isArrayRecordTriggered + ? 'its record-change trigger' + : config.schedule != null || flow.type === 'schedule' + ? 'its plain `config.schedule` cadence' + : triggerType === 'api' || flow.type === 'api' + ? 'its api trigger' + : undefined; + const consequence = fallback + ? `The flow still binds through ${fallback}, so the descriptor is silently DROPPED — it fires on ` + + `that trigger's terms (once per firing, with no record on the context) instead of once per ` + + `matching record, and nothing anywhere reports the difference.` + : `Nothing else on this start node declares a trigger either, so the flow binds to NOTHING and ` + + `never fires — with zero diagnostics at any layer, not even the one bind-time warn a ` + + `descriptor that IS an object gets when the trigger refuses it.`; + findings.push({ + severity: 'warning', + rule: FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, + where: `flow "${flowName}" › start node`, + path: `flows[${flowIndex}].nodes[${start.index}].config.timeRelative`, + message: + `has config.timeRelative = ${renderNonObject(config.timeRelative)}, which is not the descriptor ` + + `OBJECT this slot takes — the engine routes a flow to the time-relative sweep only when ` + + `config.timeRelative is an object, so this one is never routed there and the sweep is never ` + + `installed. ${consequence}`, + hint: + `config.timeRelative describes WHICH records to sweep — an object: ` + + `{ object, dateField, and exactly one of withinDays | offsetDays } (plus optional filter / ` + + `maxRecords). A cadence like 'daily' is not a descriptor: HOW OFTEN the sweep runs is the ` + + `sibling key config.schedule on the same start node (it defaults to daily, so it is usually ` + + `omitted). See TimeRelativeTriggerSchema and ` + + `content/docs/references/automation/time-relative-trigger.mdx.`, + }); + } + // 2. Auto-triggered flow whose status is 'draft' — authored or defaulted // (defineFlow parses at definition time, so the two are the same here). if (isAutoTriggered && (flow.status == null || flow.status === 'draft')) {