Repository navigation
A record-change flow's start condition is not evaluated on the re-entrant dispatch its own write causes — the loop-breaker is the only thing stopping the loop, and it says so itself (17.0.0 GA) #8689
Description
Part of objectstack-ai/hotcrm#701. Measured live on @objectstack/* 17.0.0 GA, user-driven REST writes against a booted dev server.
Searched first: #4862 (predicate bulk writes: previous unbound, record a bare payload) is closed and is a different path — this is the ordinary single-record record-after-update path.
Symptom
A record-change flow whose start condition, as authored, evaluates false on the flow's own write, is nevertheless re-dispatched for the same record; the engine's last-resort loop-breaker catches it and logs:
2026-08-14T13:14:39.075Z WARN [automation] flow 'case_escalation' re-entered for the
same record while still running — breaking self-trigger loop; the triggering record's
id is in this record's meta. Its start condition did not suppress the re-fire; if it
guards on a boolean field (e.g. `is_escalated != true`), note booleans persist as 0/1
on SQLite/libsql and CEL `1 != true` is true. {"recordId":"v5_h-3H_CbMj6jag"}
The message's own words are the report: "Its start condition did not suppress the re-fire."
Why the guard should have held — measured, not asserted
The reporting app's case_escalation gates on status strings and a null check, not on the boolean the WARN's hint is about, so the SQLite 1 != true explanation does not apply here:
condition: has(record.priority) && record.priority == "critical"
&& (!has(record.escalated_date) || record.escalated_date == null)
&& (!has(record.status)
&& record.status != "escalated" && record.status != "resolved" && record.status != "closed")
Record state at the moment of the re-fire, read back from the API:
status=escalated is_escalated=true escalated_date=2026-08-14T13:14:38.628Z
record.status != "escalated" -> false
record.escalated_date == null -> false
Both terms false. The condition as written cannot be true on that dispatch, yet the dispatch happened and only the breaker stopped it.
Probe design (so a green could not be vacuous)
Three things asserted together, because "the flow terminated" cannot distinguish the two mechanisms:
- the flow actually fired (record reached
status=escalated) — otherwise a missing WARN proves nothing; - a loop-breaker WARN carrying that record's id (the log is filtered by the probe's own id, so unrelated WARNs cannot be miscounted);
- the guard terms evaluated at the re-fire.
Second flow, same shape
created crm_opportunity RnrCSh-5GvgPkzSM amount=1000
PATCH amount=250000 (crosses the large-deal threshold) -> HTTP 200
approval_status=pending
loop-breaker WARNs naming RnrCSh-5GvgPkzSM: 1
WARN [automation] flow 'opportunity_approval' re-entered for the same record while
still running — breaking self-trigger loop; ...
Its gate excludes deals already pending; its own write sets approval_status = pending; the breaker still fires.
Also reproduced unprompted during the platform's own seed sweep on a fresh boot — three occurrences on case_escalation (record ids foDLsXSrb6CKOKbe, hIICKFaqJ1q_DuZD, hN6omYKfEla8E9EV). Not user-context specific.
Why it matters even though nothing runs away today
The breaker holds — one WARN per occurrence, no runaway, no corruption. The defect is that the guard flow authors write is not the mechanism preventing the loop, and nothing tells them so at authoring time. Every author using a status-based re-fire guard is relying on a safety net they do not know they depend on, and the net's scope is narrow by construction: it is keyed on flowName::recordId while still running. Anything outside that window — a cross-record chain, a delayed re-fire, a re-entry after the first run completes — has no second line of defence, because the first line was never actually running.
Two candidate readings, both worth naming since they need different fixes:
- the re-entrant dispatch skips condition evaluation, or
- evaluation aborts and the abort is counted as a fire.
Either way the observable contract — "a record-change flow fires only when its start condition is true" — does not hold on the dispatch a flow's own write causes.
Repro
Any record-after-update flow whose data node writes a field its own start condition excludes. Create a record outside the condition, PATCH it into the condition, and watch the server log for [automation] … re-entered … breaking self-trigger loop naming that record id while the record shows the flow's own write applied.
Generated by Claude Code
Activity
hotlong commentedon Aug 14, 2026
Triage: lands in
packages/services/service-automation/src/engine.ts(the loop-breaker WARN quoted in the card is onorigin/main@1eb28a1in that file, withengine-residual-log-cause.test.tsalongside) pluspackages/triggers/trigger-record-change/for the dispatch half ⇒domain:services. Type Bug, queued.Rationale: routed by landing package, verified rather than guessed — the card's evidence is a log line, so the file that emits it was located on main before labelling.
Not escalated: the observable contract the card names — "a record-change flow fires only when its start condition is true" — is declared behaviour that is not enforced on the re-entrant dispatch. Restoring it is the repair lane; nothing about the authoring surface changes.
The card names two candidate mechanisms and does not distinguish them (dispatch skips evaluation / evaluation aborts and the abort counts as a fire). Deciding which one is true is the first deliverable, before any fix: they need different repairs, and a fix written for the wrong one will look green. The card's own probe design (fire + breaker WARN carrying the record id + the guard terms evaluated at re-fire) is the shape the regression pin should take — a test that only asserts "the flow terminated" cannot tell the two mechanisms apart and is vacuous here.
⛔ Do not fix this by strengthening the loop-breaker. The breaker working is not the defect; the defect is that it is the only thing working. A change that makes the breaker catch more cases and leaves condition evaluation unfixed is the wrong direction and should be reported as such.
Size/model suggestion: M,
opus.Generated by Claude Code
fix(automation): evaluate the start condition on a record-change flow…