Skip to content

[17.0-rc][疑似平台] 记录变更流会因自己的写入重入自身 —— 阻止死循环的是引擎的 loop-breaker,而不是流作者写的 start condition #701

Description

@yinlianghui

Found during the 17.0 GA acceptance sweep on @objectstack/* 17.0.0-rc.2 (current main), flow phase.

Symptom

Two separate flows, same engine WARN, during ordinary user-driven runs:

08:06:38.474  [automation] flow 'case_escalation' re-entered for the same record 'RSXDsNkjQMIdFRdd'
              while still running — breaking self-trigger loop. Its start condition did not
              suppress the re-fire; …
08:10:01.262  [automation] flow 'opportunity_approval' re-entered for the same record 'eo3MZDZmsYqP2CHJ'
              while still running — breaking self-trigger loop. …

Why this is a defect and not just noise

The conditions as authored should have evaluated false on the flow's own write:

  • case_escalation sets status='escalated' / is_escalated=true — its start condition excludes already-escalated cases.
  • opportunity_approval sets approval_status='pending' — its gate excludes deals already pending.

So on the re-entrant dispatch, either the start condition is skipped / failing open, or its evaluation aborts and is counted as a fire. Either way, the guard flow authors write to prevent self-triggering is demonstrably not the mechanism preventing the loop — the engine's last-resort loop-breaker is. That safety net holds today (one WARN per occurrence, no runaway, no data corruption observed), but every flow author who relies on a status-based guard is being silently saved by a mechanism they don't know they depend on, and any future change to the loop-breaker's scope (e.g. cross-record chains, delayed re-fires past the "while still running" window) has no second line of defence.

An earlier boot of the same build showed the same WARN on case_escalation during seed-driven writes, so it is not specific to user context.

Reproduction

  1. Create a crm_case with priority='medium', then PATCH priority='critical' (user session).
  2. The flow fires, escalates the case (its own UPDATE), and the WARN appears for that same record.
  3. Same with opportunity_approval: PATCH an opportunity's amount across the large-deal threshold; the flow's own approval_status write triggers the re-entry WARN.

Attribution

Platform (automation engine, record-change trigger provider — trigger-context/condition evaluation on re-entrant dispatch). Related in family to #684 (both are trigger-context evaluation gaps on the record-change path); a fix for one should be tested against the other.

Refs #684 #507

Activity

  1. added
    bugSomething isn't working
    upstream:objectstackBlocked on / caused by the ObjectStack platform — tracked upstream
    on Aug 5, 2026
  2. yinlianghui commented on Aug 5, 2026

    @yinlianghui
    CollaboratorAuthor

    补充一条来自 #684 实现过程的交互事实(不另开 issue,本条归属这里)。

    #684 给全部 7 个 record-change flow 加上了 runAs: 'system',其中就包括本 issue 点名的 case_escalation 和 opportunity_approval。这改变了重入分派时的第二道防线:

    结论:修完 #684 之后,loop-breaker 对这两个 flow 就是唯一的防线了,本 issue 描述的"作者写的 start condition 不是真正起作用的机制"这一风险不降反升。这不是 #684 引入的缺陷(那条拒绝本来就是 bug 的副产品,不是设计),但它把本 issue 的优先级论据坐实了:请在平台侧修 start condition 在重入分派上的求值时,把 runAs: 'system' 的 record-change flow 一并纳入回归。

    实测环境同本 issue:@objectstack/* 17.0.0-rc.2。引擎的重入护栏 activeRecordFlows 以 flowName::recordId 为键,与 runAs 无关,所以护栏本身的行为没有变化。

    Refs #684


    Generated by Claude Code

  3. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    GA reading (17.0.0) — REPRODUCES on both named flows. The loop-breaker is still the only thing stopping the loop.

    From #1153 (GA close-out B2), session session_01XAK3brMLjd4ykF4QAhFnuo. Live, user-driven, objectstack dev -p 4002 --seed-admin --fresh, @objectstack/* 17.0.0 GA, hotcrm d4ddee0.

    Why the probe was built this way

    "Does the flow terminate" was never the question — it always terminated. The question is which mechanism halts it, and a flow that merely finishes cannot tell the two apart. So the probe asserts three things, and prints all of them:

    1. the flow actually fired (otherwise a missing WARN means nothing — that is the vacuous-pass shape),
    2. whether an [automation] loop-breaker WARN carrying this record's id appears (the log is filtered by the probe's own record id, so seed-time WARNs cannot be mistaken for the repro),
    3. whether the author's guard terms were in fact false at the moment of the re-fire.

    case_escalation — the card's step 1/2 repro

    created crm_case v5_h-3H_CbMj6jag (HTTP 201) priority=medium
    PATCH priority=critical -> HTTP 200
    
    record after the flow settled:
      status=escalated  is_escalated=true  escalated_date=2026-08-14T13:14:38.628Z
      => case_escalation actually FIRED: true
    
    loop-breaker WARNs naming v5_h-3H_CbMj6jag: 1
      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.
    
    start-condition terms on the flow's OWN write:
      record.status != "escalated"   -> false  (status=escalated)
      record.escalated_date == null  -> false  (escalated_date=2026-08-14T13:14:38.628Z)
    

    The flow ran, its own write set both guard terms false, and the engine's loop-breaker still had to catch the re-entry. The engine's own message says it: "Its start condition did not suppress the re-fire."

    opportunity_approval — the card's step 3 repro

    created crm_opportunity RnrCSh-5GvgPkzSM (HTTP 201) amount=1000
    PATCH amount=250000 (>= LARGE_DEAL_AMOUNT 100000) -> HTTP 200
      approval_status=pending
    loop-breaker WARNs naming RnrCSh-5GvgPkzSM: 1
      2026-08-14T13:14:45.822Z WARN [automation] flow 'opportunity_approval' re-entered for
      the same record while still running — breaking self-trigger loop; ...
    

    Same shape. Its gate excludes deals already pending, the flow's own write sets approval_status = pending, and the breaker still fires.

    Not only user-driven

    Three more of the same WARN appeared unprompted during the fresh-boot demo_bootstrap seed sweep (13:10:06–07, record ids foDLsXSrb6CKOKbe, hIICKFaqJ1q_DuZD, hN6omYKfEla8E9EV), which matches this card's note that an earlier boot showed it during seed-driven writes. Not user-context specific on GA either.

    What changed since rc.2, and what did not

    Only the WARN's wording — GA adds the pointer to the record meta and the SQLite boolean hint. The behaviour is identical. The 2026-08-05 follow-up on this card (runAs: 'system' from #684 removing the accidental second line of defence) therefore still stands as written: for these two flows the loop-breaker is the sole guard, and the risk this card raised is unmitigated on GA.

    Disposition

    Stays open, labels untouched. Mirror and nomination posted (linked below). No target:v17 applied.


    Generated by Claude Code


    Generated by Claude Code

  4. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Mirror and nomination, per #1153:


    Generated by Claude Code


    Generated by Claude Code

  5. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Closed — moved upstream to objectstack-ai/objectstack#8689

    Dispositioned under the transfer sweep #1156 (part 2). Maintainer ruling, 2026-08-14, quoted verbatim and untranslated:

    hotcrm 席位的原则是用平台的能力做元数据应用的开发,平台的需求应该转给平台

    and for the hotcrm side of a transferred card, the same ruling: 「关掉,只留指向镜像的指针」. Authority: #1156.

    The mirror — verified before this close, not assumed

    objectstack-ai/objectstack#8689 — "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)". Confirmed open, labelled bug, and carrying this card's defect on both named flows, including this card's runAs: 'system' follow-up as the reason the risk is now higher rather than lower.

    The reading this close rests on

    The GA reading already on this card — comment of 2026-08-14, live and user-driven on @objectstack/* 17.0.0 GA.

    The probe was built so a green could not be vacuous: it asserts that the flow actually fired, that a loop-breaker WARN carrying that record's id appeared (the log filtered by the probe's own id, so seed-time WARNs cannot be miscounted), and what the author's guard terms evaluated to at the re-fire. On case_escalation, both guard terms were false on the flow's own write and the breaker still had to catch the re-entry; opportunity_approval reproduced the same shape; three more occurred unprompted during the fresh-boot seed sweep.

    The engine's own message is the report: "Its start condition did not suppress the re-fire." And the SQLite boolean hint that message offers does not apply here — this app's condition gates on status strings and a null check, not on the boolean.

    Why it closes here

    The observable contract that fails — "a record-change flow fires only when its start condition is true" — is the automation engine's. The two candidate readings (the re-entrant dispatch skips condition evaluation, or evaluation aborts and the abort counts as a fire) need different platform fixes and neither is reachable from app metadata. This card's own 2026-08-05 follow-up also asks that runAs: 'system' record-change flows be included in the upstream regression, which only the platform can honour.

    Nothing is lost by this close: #8689 is open, carries both flows, the probe design, the unprompted seed-time occurrences and the repro, and is readable without hotcrm context.


    Generated by Claude Code


    Generated by Claude Code

  6. added and removed on Sep 9, 2026
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

    bugSomething isn't workingpriority:p2Medium: important, M3upstream:objectstackBlocked on / caused by the ObjectStack platform — tracked upstream

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions