Everything above assumes an execution exists. Two failure classes we’ve measured don’t produce one — or produce a clean one with quietly wrong values.
1. The execution that never happened. On self-hosted 2.31.5 we found that a Schedule Trigger of type weeks whose JSON is missing weeksInterval never fires in production. Not late — never. There’s no execution row, so read-back, reconciliation diffs, audit logs and row-count assertions all have nothing to inspect, and “quiet” renders identically to “healthy.” Two properties make it nasty:
- Manual execution skips the recurrence check, so hand-testing cannot find it by construction.
- Saving the trigger once in the UI normalizes the node and fills the missing field. A workflow you tested through the UI is not the workflow in your JSON file. If you ship or version JSON (templates, git, IaC), the artifact you verified isn’t the artifact you shipped.
- A missing
triggerAtMinute is the same class, milder: it becomes a hash-derived pseudo-random minute instead of the one you set.
What we do now: publish a copy exactly as the file defines it, without an intervening UI save, and watch for a real production fire. In the Executions list, schedule-triggered runs carry no flask icon while manual ones do — a cheap mechanical discriminator for “the schedule did this, not me.”
2. Plausible-but-wrong from timezone. Workflow Settings → Timezone affects trigger firing but not new Date() inside a Code node, which resolves in the process’s local time. The schedule fires correctly and only the date math is off by a day: row counts are right, read-back matches what you wrote, the value is simply wrong. Related: new Date('YYYY-MM-DD') parses as UTC midnight, so in UTC-minus zones a date string from a sheet lands on the previous local day and every day-difference shifts by one. This one shipped for us — it doesn’t reproduce in JST, so it was invisible during development and only surfaced when we ran the boundary cases (due−2 must not fire, due−3 must fire). What fixed it: build “today” from $now (Luxon, workflow timezone), do date-part arithmetic instead of millisecond addition, and round rather than floor for day differences so DST transitions don’t bite.
3. Disabled nodes pass input straight through. On production runs a disabled node returns its input unchanged. If anything downstream has a fallback expression, you get silent degradation — no error, no warning, and output that looks like the unprocessed input. It survives read-back, so it belongs on the “succeeds while doing nothing” list.
Caveat: all of the above is measured on self-hosted 2.31.5; we have no Cloud measurements of our own.
On the known-answer test inputs — right instinct, but it still only catches things once a run exists. The trigger-side equivalent is asserting that a run happened at all: have the workflow write its own heartbeat row and alert on the absence of one. Absence of signal is the one thing none of the read-back layers can see.