@@ -3077,38 +3077,19 @@ export class AutomationEngine implements IAutomationService {
30773077 // runaway it exists to stop.
30783078 let reentryHeld = false ;
30793079
3080- // Initialize variable context
3081- const variables = this . seedDeclaredVariables ( flow , context ) ;
3082- // Inject trigger record. `$record` is the canonical handle; `record` is a
3083- // friendlier alias so templates/conditions can write `{record.title}` and
3084- // `record.status`. We also flatten the record's own fields to top-level
3085- // variables (so bare references like `status`/`budget` resolve in start
3086- // conditions and edge predicates) WITHOUT clobbering flow inputs already
3087- // seeded above. `previous` exposes the pre-update row for transition gates.
3088- if ( context ?. record ) {
3089- variables . set ( '$record' , context . record ) ;
3090- variables . set ( 'record' , context . record ) ;
3091- for ( const [ k , v ] of Object . entries ( context . record ) ) {
3092- if ( ! variables . has ( k ) ) variables . set ( k , v ) ;
3093- }
3094- }
3095- // Always bind `previous` — to `null` on the create/insert leg (there is no
3096- // prior row) — so a start condition can DISCRIMINATE create vs update on a
3097- // `record-after-write` flow: `previous == null` is the create leg (#3427).
3098- // Binding only-when-truthy left `previous` an unknown CEL variable on
3099- // insert, so ANY reference to it (even `previous == null`) threw
3100- // "Unknown variable: previous" and failed the whole condition.
3101- variables . set ( 'previous' , context ?. previous ?? null ) ;
3102-
3080+ // Initialize the run's variable environment. Every binding a run is
3081+ // entitled to — the flow's declared variables, the trigger record and
3082+ // its flattened fields, `previous`, and the engine-owned `$runId` /
3083+ // `$flowName` / `$flowLabel` — is seeded by `seedRunVariables`, the one
3084+ // chokepoint `executeWithoutRetry` shares (#9704). See that helper for
3085+ // why the seeding is not written out here any more.
3086+ //
3087+ // The run id is minted BEFORE the seeding because `$runId` is part of
3088+ // that environment. Order-safe: `nextRunId()` is a stateless random id
3089+ // (no counter to advance), and nothing between the old call site and
3090+ // this one reads or mints one.
31033091 const runId = this . nextRunId ( ) ;
3104- // Expose the run id to executors (ADR-0019): a pausing node (e.g. Approval)
3105- // reads `$runId` to map its external state back to this run for resume.
3106- variables . set ( '$runId' , runId ) ;
3107- // Expose flow identity to executors so externalized state (e.g. an
3108- // approval request row) can carry a human-readable origin. Captured in
3109- // the variable snapshot, so still present after a suspend/resume.
3110- variables . set ( '$flowName' , flowName ) ;
3111- variables . set ( '$flowLabel' , flow . label ?? flowName ) ;
3092+ const variables = this . seedRunVariables ( flow , flowName , context , runId ) ;
31123093 const startedAt = new Date ( ) . toISOString ( ) ;
31133094 const steps : StepLogEntry [ ] = [ ] ;
31143095
@@ -6315,6 +6296,78 @@ export class AutomationEngine implements IAutomationService {
63156296 return variables ;
63166297 }
63176298
6299+ /**
6300+ * Seed a run's COMPLETE variable environment — the one chokepoint every
6301+ * attempt of every run goes through (#9704).
6302+ *
6303+ * `seedDeclaredVariables` above turns the flow's own DECLARATIONS into
6304+ * bindings; this adds everything the ENGINE owns on top of them, in the
6305+ * order the precedence rules require:
6306+ *
6307+ * 1. declared variables (params, then `defaultValue`) — seeded first, so
6308+ * the record flattening below cannot shadow a flow input;
6309+ * 2. the trigger record: `$record` is the canonical handle, `record` a
6310+ * friendlier alias so templates and conditions can write
6311+ * `{record.title}` / `record.status`, plus the record's own fields
6312+ * flattened to top-level names so bare references (`status`, `budget`)
6313+ * resolve in start conditions and edge predicates — WITHOUT clobbering
6314+ * anything already bound;
6315+ * 3. `previous`, bound ALWAYS — to `null` on the create/insert leg, since
6316+ * there is no prior row — so a start condition can DISCRIMINATE create
6317+ * from update on a `record-after-write` flow: `previous == null` is the
6318+ * create leg (#3427). Binding it only when truthy left `previous` an
6319+ * unknown CEL variable on insert, so ANY reference to it (even
6320+ * `previous == null`) threw "Unknown variable: previous" and failed the
6321+ * whole condition;
6322+ * 4. `$runId`, so a pausing node (e.g. Approval) can map its external
6323+ * state back to this run for resume (ADR-0019), and `$flowName` /
6324+ * `$flowLabel`, so externalized state carries a human-readable origin.
6325+ * All three are captured in the variable snapshot, so they survive a
6326+ * suspend/resume.
6327+ *
6328+ * ⚠️ It is ONE method because the two callers drifting apart is the defect
6329+ * it repairs, not a tidiness preference. `execute()` seeded all of the
6330+ * above and `executeWithoutRetry()` — the method `retryExecution` re-runs
6331+ * the flow through on EVERY retry attempt — seeded only (1) and `$record`,
6332+ * so a retry attempt ran in a strictly smaller environment than the first
6333+ * one: conditions are strict CEL, where reading an unbound name ABORTS the
6334+ * predicate instead of yielding `false` (#4697), so the retry failed for a
6335+ * reason attempt 1 never hit, which reads as a flaky flow rather than a
6336+ * defect. The two methods had already drifted once per card on four
6337+ * separate exits (#9378, #9415, #9414, #9510) before this one, always in
6338+ * the same direction — the copy that is not `execute()` is the one a repair
6339+ * forgets. `buildRunTrigger` is the same chokepoint pattern, for the same
6340+ * reason. ⛔ So a change here belongs here: re-inlining either caller's copy
6341+ * re-opens the drift, and `retry-attempt-pause.test.ts` pins the two
6342+ * snapshots against EACH OTHER precisely so it cannot happen silently.
6343+ *
6344+ * The caller mints `runId` and passes it in rather than this helper minting
6345+ * one, because the run id is the caller's own bookkeeping: it keys the log
6346+ * row, the continuation and the returned envelope, and a helper that
6347+ * produced a second one would put a `$runId` in the snapshot that names no
6348+ * run anybody can resume.
6349+ */
6350+ private seedRunVariables (
6351+ flow : FlowParsed ,
6352+ flowName : string ,
6353+ context : AutomationContext | undefined ,
6354+ runId : string ,
6355+ ) : Map < string , unknown > {
6356+ const variables = this . seedDeclaredVariables ( flow , context ) ;
6357+ if ( context ?. record ) {
6358+ variables . set ( '$record' , context . record ) ;
6359+ variables . set ( 'record' , context . record ) ;
6360+ for ( const [ k , v ] of Object . entries ( context . record ) ) {
6361+ if ( ! variables . has ( k ) ) variables . set ( k , v ) ;
6362+ }
6363+ }
6364+ variables . set ( 'previous' , context ?. previous ?? null ) ;
6365+ variables . set ( '$runId' , runId ) ;
6366+ variables . set ( '$flowName' , flowName ) ;
6367+ variables . set ( '$flowLabel' , flow . label ?? flowName ) ;
6368+ return variables ;
6369+ }
6370+
63186371 /**
63196372 * Execute a flow without triggering retry logic (used by retryExecution to prevent recursion).
63206373 *
@@ -6351,12 +6404,19 @@ export class AutomationEngine implements IAutomationService {
63516404 return { success : false , code : 'FLOW_DISABLED' , error : `Flow '${ flowName } ' is disabled` } ;
63526405 }
63536406
6354- const variables = this . seedDeclaredVariables ( flow , context ) ;
6355- if ( context ?. record ) {
6356- variables . set ( '$record' , context . record ) ;
6357- }
6358-
6407+ // [#9704] The SAME environment attempt 1 runs in — seeded through the
6408+ // same chokepoint `execute()` uses. This method used to seed only the
6409+ // declared variables and `$record`, so every retry attempt ran in a
6410+ // strictly smaller environment than the first: `record` and its
6411+ // flattened fields, `previous`, `$runId`, `$flowName` and `$flowLabel`
6412+ // were all absent. Under strict CEL an unbound name ABORTS the
6413+ // predicate rather than yielding false (#4697), so a start condition or
6414+ // edge predicate reading `previous` (#3427) or a bare record field
6415+ // failed on the retry for a reason attempt 1 never hit — a flaky flow,
6416+ // to its author — and a pausing node on a retry attempt had no `$runId`
6417+ // to map its external state back to this run with (ADR-0019).
63596418 const runId = this . nextRunId ( ) ;
6419+ const variables = this . seedRunVariables ( flow , flowName , context , runId ) ;
63606420 const startedAt = new Date ( ) . toISOString ( ) ;
63616421 const steps : StepLogEntry [ ] = [ ] ;
63626422
0 commit comments