Skip to content

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

Closed

Description

@hotlong

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:

  1. the flow actually fired (record reached status=escalated) — otherwise a missing WARN proves nothing;
  2. 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);
  3. 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

  1. added theissue type on Aug 14, 2026
  2. hotlong commented on Aug 14, 2026

    @hotlong
    ContributorAuthor

    Triage: lands in packages/services/service-automation/src/engine.ts (the loop-breaker WARN quoted in the card is on origin/main @ 1eb28a1 in that file, with engine-residual-log-cause.test.ts alongside) plus packages/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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions