This document defines the formal execution semantics of the FFL (Agent Workflow Language) runtime. It is the authoritative contract governing correctness, determinism, persistence, and agent interaction.
This specification applies regardless of implementation language, database technology, or deployment topology.
The FFL runtime is responsible for executing workflows compiled from FFL source. Execution is dependency-driven, iterative, distributed, and restart-safe.
The runtime consists of:
- an Evaluator that orchestrates execution,
- an external persistence API that encapsulates database access,
- an event system that dispatches work to agents.
The runtime MUST support:
- parallel execution,
- forward references,
- resumable execution,
- and idempotent recovery from failure.
The compiler produces a compiled AST describing:
- workflows,
- steps,
- facets,
- mixins,
- blocks (
andThen), - dependency relationships.
The runtime maps this AST into:
- runtime execution structures, and
- persistent representations stored via the persistence API.
The compiled AST is the source of truth for workflow structure. Persistence is the source of truth for execution state.
The Evaluator MUST NOT directly access the database.
All persistence operations are performed through an external persistence API, which:
- hides database implementation details,
- enforces concurrency and locking semantics,
- provides atomicity guarantees.
The evaluator interacts with persistence exclusively through this API.
The persistence API MUST support:
- Fetch a step by persistent step ID
- Fetch an event by persistent event ID
- Persist newly created steps
- Persist step state transitions
- Persist events and event state transitions
- Fetch all persisted steps belonging to a block ID
- Fetch all persisted blocks belonging to a containing step ID
Each step progresses through three conceptual phases. These are derived, not persisted.
-
Creation Eligible
- Step exists in the compiled AST
- All dependencies are
state.statement.Complete
-
Execution Eligible
- Step exists in persistence
- Step is not complete or errored
- Step is not waiting on an external event
-
Execution Scheduled
- Step selected at an iteration boundary for evaluation
Each step progresses through the following states:
state.statement.Created
state.facet.initialization.Begin state.facet.initialization.End
state.facet.scripts.Begin state.facet.scripts.End
state.statement.scripts.Begin state.statement.scripts.End
state.mixin.blocks.Begin state.mixin.blocks.Continue state.mixin.blocks.End
state.mixin.capture.Begin state.mixin.capture.End
state.statement.capture.Begin state.statement.capture.End
state.statement.blocks.Begin state.statement.blocks.Continue state.statement.blocks.End
state.block.execution.Begin state.block.execution.Continue state.block.execution.End
state.EventTransmit
state.statement.End state.statement.Complete state.statement.Error
- Only one agent may transition a step at a time
- Once a step reaches
state.statement.Complete, it is immutable - Any unrecoverable failure transitions the step to
state.statement.Error
See runtime-impl.md §4.3 for Python transition tables and StateChanger class hierarchy.
During state.facet.initialization.Begin:
- All attribute expressions MUST be evaluated
- Results MUST be stored in the step’s persistent facet structure
- Expressions MAY include arithmetic, grouping, and references
Implemented —
FacetInitializationBeginHandlerinfacetwork/runtime/handlers/initialization.py.
A step's mixin args come from two sources, both evaluated in the
parent's scope at FACET_INIT_BEGIN:
- Sig-level mixins declared on the facet signature
(
facet F(input: String) with M(x = $.input) as m). - Call-site mixins declared on the call expression
(
s = F(input = ...) with M(x = 1) as m).
The compiled AST stores call-site mixins in the call's mixins list:
{
"call": {
"target": "FacetName",
"args": [...],
"mixins": [
{"type": "MixinCall", "target": "RetryPolicy", "args": [{"name": "max_retries", "value": {"type": "Int", "value": 5}}], "alias": "retry"},
{"type": "MixinCall", "target": "AlertConfig", "args": [{"name": "channel", "value": {"type": "String", "value": "alerts"}}]}
]
}
}Sig-level mixins live on the facet declaration's mixins list with
the same shape ({type: MixinSig, target, alias?, args?}).
During FACET_INIT_BEGIN, after evaluating the step's own call args,
the runtime MUST evaluate mixin args with these rules:
-
Evaluation order: Call args first; then sig-level mixin args (in declaration order); then call-site mixin args (in declaration order). Call-site args override sig-level args on the same alias.
-
Aliased mixins (
with Foo(x=1) as alias): The evaluated mixin args are stored as a nested dict under the alias key. The handler receivesparams["alias"] = {"x": 1, ...}. AfterMIXIN_CAPTURE_BEGINruns (see §8.x below), this dict is refreshed to a{params, returns}snapshot of the executed mixin sub-step. -
Non-aliased mixins (
with Foo(x=1)): The evaluated mixin args are flat-merged into the step params. A mixin arg MUST NOT override an explicit call arg with the same name. Un-aliased mixins do NOT execute as sub-steps — they remain pure configuration. -
Dependencies: Mixin args MAY contain step references. The dependency graph MUST scan mixin args in addition to call args when computing step dependencies.
-
Implicit fallback: Implicit defaults still apply for params not provided by either the call args or the mixin args.
Example:
ingest = IngestReading(sensor_id = $.id) with RetryPolicy(max_retries = 5) as retry
The handler receives, immediately after FACET_INIT_BEGIN:
params = {
"sensor_id": "sensor_001",
"retry": {"max_retries": 5}, # nested dict of evaluated mixin sig-args
}After MIXIN_CAPTURE_BEGIN (post mixin-execution), the retry dict is
refreshed to include the mixin sub-step's computed returns alongside
its bound params.
A step MAY NOT evaluate expressions that reference another step unless the referenced step is in state.statement.Complete.
Steps with no blocking references MAY evaluate concurrently.
Blocks (andThen) are first-class execution units.
Rules:
- A block MAY execute only after its containing step reaches:
state.statement.blocks.Begin
- Blocks within the same phase MAY execute concurrently
- Block completion contributes to step completion
- A step MUST NOT transition to
state.EventTransmituntil all required blocks complete
Implemented — see
examples.mdExample 4 for the authoritative execution trace.
The EventTransmit state has two distinct behaviors depending on whether the step's facet is an event facet:
-
Non-event facets (
FacetDecl): EventTransmit is a pass-through. The handler callsrequest_state_change(True)and the step continues immediately tostate.statement.blocks.Begin. -
Event facets (
EventFacetDecl): EventTransmit creates anEventDefinitionwith payload built from step attributes, adds it toIterationChanges, and then callsrequest_state_change(False). The step blocks atEventTransmitand does NOT advance. This causes the evaluator to eventually reach a fixed point (see §10.1).
The handler MUST resolve the facet type by looking up the facet declaration in the full Program AST (see §11.1).
Implemented —
StatementBlocksBeginHandlercreates blocks for any step with anandThenbody.
The StatementBlocksBeginHandler MUST create blocks for any step that has an andThen body — not just the workflow root. The handler checks two sources for block definitions:
- Statement-level block: The step's statement in the compiled AST has an inline
andThenblock (e.g.,s1 = SomeFacet(input = $.a) andThen { ... }). - Facet-level block: The facet declaration referenced by the step has an
andThenbody (e.g.,facet Adder(...) andThen { ... }).
Precedence rule: Statement-level blocks take precedence over facet-level blocks. If a step has both, the statement-level block is used.
For each block found, the handler creates a StepDefinition with ObjectType.AND_THEN, with container_id set to the current step.
Implemented —
get_block_ast()resolves workflow root, statement-level, and facet-level block ASTs.
When a block step enters BlockExecutionBegin, the handler MUST resolve the correct AST for the block's contents:
- Workflow root block: The AST is the workflow declaration's
andThenbody (fromWorkflowDecl.body). - Facet-level block: The AST is the facet declaration's
andThenbody (fromFacetDecl.body), resolved by looking up the facet by name in the Program AST. - Statement-level block: The AST is the inline
andThenbody attached to the step statement in the compiled AST (fromStepStmt.body).
The evaluator MUST have access to the full Program AST to resolve these references (see §11.1).
Implemented —
MixinBlocksBeginHandler,MixinBlocksContinueHandler,MixinCaptureBeginHandlerinfacetwork/runtime/handlers/.
An aliased mixin (with M(args) as alias on a facet sig) executes
as a real sub-step under the parent. The lifecycle, relative to the
parent's state walk:
parent FACET_INIT_BEGIN evaluate parent.params, then sig-mixin
args in parent scope → seed
parent.params[alias] as nested dict
parent MIXIN_BLOCKS_BEGIN create one CREATED sub-step per alias;
sub-step.params is seeded from
parent.params[alias]
[aliased mixin sub-steps run in parallel through STEP_TRANSITIONS]
• mixin FACET_INIT_BEGIN skip call-arg eval (already bound);
apply facet defaults for any params
or returns the parent didn't bind
• mixin STATEMENT_BLOCKS_* execute the mixin facet's andThen
body, with `$.` isolated to the
mixin's own attributes (no workflow
root, no parent inheritance)
• mixin STATEMENT_CAPTURE_* yields → mixin sub-step's returns
• → STATEMENT_COMPLETE (or STATEMENT_ERROR)
parent MIXIN_BLOCKS_CONTINUE wait on every aliased sub-step. If
any errored, parent step also errors
with the first error message.
parent MIXIN_CAPTURE_BEGIN for each alias, snapshot
{params + returns} of the sub-step
into parent.params[alias],
overwriting the FACET_INIT nested
dict. Returns shadow params on key
collision (mirrors
FacetAttributes.merge).
parent STATEMENT_BLOCKS_* parent body runs; `$.alias.field`
reads the snapshot
parent STATEMENT_CAPTURE_BEGIN parent body's yields targeting an
alias (`yield aliasName(...)` or
`yield F(...) with M(...)`) merge
into the persisted sub-step's
returns per standard yield-merge
rules. This is visible to FacetRef
consumers but NOT to the parent's
own snapshot read (the snapshot was
taken at MIXIN_CAPTURE_BEGIN, before
these yields are routed).
FacetRef consumer reads. When a downstream step receives the
parent by reference and reads $.<fref>.<alias>.<field>, the
resolver returns the live persisted sub-step — so parent-yield
overrides applied at the parent's STATEMENT_CAPTURE_BEGIN are
visible. This precedence is encoded in
expression._resolve_path's StepReference branch: mixin alias
lookup first, then attrs.returns, then attrs.params.
Un-aliased mixins do NOT execute as sub-steps. Their sig-args
flat-merge into parent.params per the v0.21.0 contract (§7); they
are unreachable from FacetRef consumers (REF_INVALID_FACET_REF_ATTRIBUTE).
When a step encounters an error and the step's facet or statement has a catch clause, the runtime MUST intercept the error before transitioning to state.statement.Error. Instead, the step enters the catch phase:
state.catch.Begin → state.catch.Continue → state.catch.End → state.statement.capture.Begin
Catch interception occurs at two points:
- When child blocks error (during
state.statement.blocks.Continue) - When event handler processing errors
Catch execution rules:
- The runtime MUST store error information as pseudo-returns on the step:
error(the error message) anderror_type(the error class name). These are accessible viastep.errorandstep.error_typein catch block expressions. - Simple catch (
catch { ... }): Creates a single catch sub-block. - Conditional catch (
catch when { case condition => { ... } case _ => { ... } }): Evaluates conditions and creates sub-blocks for each matching case. A default case (case _ =>) is required. - Catch sub-blocks use
ObjectType.AND_CATCHand follow the same block execution pattern asandThenblocks. - If all catch sub-blocks complete successfully, the step resumes normal flow at
state.statement.capture.Begin. - If any catch sub-block itself errors, the step transitions to
state.statement.Error(catch failure propagates).
See runtime-impl.md §10 for catch handler implementations.
Schema instantiation steps (SchemaInstantiation) use a simplified state machine:
state.statement.Created → state.facet.initialization.Begin → state.facet.initialization.End → state.statement.End → state.statement.Complete
Schema instantiation:
- Evaluates arguments during
state.facet.initialization.Begin - Stores evaluated values as returns (accessible via
step.fieldName), not as params - Skips all script, mixin, event, and block phases
- Completes immediately after initialization
See runtime-impl.md §4.3 for the
SCHEMA_TRANSITIONStable.
Execution proceeds in iterations.
Within an iteration:
- Step evaluation occurs entirely in memory
- State transitions and data mutations are accumulated in memory
- No persistence writes occur mid-iteration
An iteration ends when:
- no additional steps can advance due to dependencies
At iteration completion:
- All in-memory state changes are atomically committed
- All generated events are published
- Control returns to the system
See runtime-impl.md §2 for the
Evaluatorclass and iteration loop implementation.
After an iteration completes:
- The evaluator re-evaluates all steps
- Steps previously blocked by dependencies MAY become eligible
- Newly eligible steps are scheduled in the next iteration
Execution continues until a fixed point is reached.
Implemented — see
examples.mdExample 4, Iteration 1.
A fixed point occurs when no step can advance in an iteration. When a fixed point is reached and at least one step is blocked at state.EventTransmit (waiting for an external event), the evaluator MUST:
- Atomically commit all accumulated changes (steps and events) to persistence.
- Pause execution. Events created during prior iterations become visible to external agents in the persistence layer.
- Yield control to the external system.
The evaluator does NOT terminate — it pauses and waits for a resumption signal (see §10.2).
Implemented — see
examples.mdExample 4 for the full two-run trace.
The evaluator supports distributed multi-run execution:
- Run 1 — The evaluator processes all internal work until reaching a fixed point. Steps blocked at
EventTransmithave their events committed to persistence. - Pause — External agents (microservices) poll the persistence layer, discover events, process them, and send
StepContinuesignals (see §12.1). - Run N — The evaluator resumes when a
StepContinueevent is received. The blocked step advances pastEventTransmitand execution continues.
State is fully persisted at each pause boundary. The evaluator MUST be restartable — it reconstructs execution state entirely from persistence.
A workflow MAY require multiple pause/resume cycles if multiple event facets are encountered at different points in the execution graph.
Implemented — see
runtime-impl.md§17.5.1 for the full implementation details.
The original resume() approach (§10.2) scans all steps in a workflow each iteration, which is O(N²) for N steps. For large workflows (hundreds of steps), this becomes prohibitively slow. The runtime SHOULD use notification-driven step resume instead.
Mechanism — parent notification cascade:
- When a handler completes,
continue_step()advances the step pastEventTransmittostate.statement.blocks.Beginand saves to persistence. resume_step()processes the continued step. When a step progresses, it notifies its parent block (block_id) and containing step (container_id) that a child has changed.- The next round processes only the notified parents. Each parent checks whether its children are complete and, if so, progresses — which in turn notifies its parent.
- The cascade stops when no more parents are notified (i.e., the workflow is paused waiting for other steps, or the root has completed).
This is O(depth): only steps that receive a child-completion notification are loaded and processed. Unlike the chain-walking approach, steps that don't need re-evaluation are never loaded.
Correctness requirements:
continue_step()MUST advance the step state pastEventTransmitbefore saving to persistence. The step MUST NOT haverequest_transitionset — theStatementBlocksBeginHandlermust execute first to create any andThen block children before the step transitions toStatementBlocksContinue.- Concurrent
resume_step()calls for sibling steps in the same workflow MUST be serialized (e.g., via per-workflow locking). If a lock cannot be acquired, the call MAY be skipped — the active resume will observe all completed siblings when it checks the block. - Task completion (marking the task as
COMPLETED) MUST NOT depend onresume_step()succeeding. If the resume fails, capacity MUST still be freed. A background sweep (§10.4) will retry.
Implemented — see
runtime-impl.md§17.5.3 for the full implementation details.
Who decides what runs next? There is no dedicated scheduler/orchestrator process — no leader, no master, no per-workflow lock. "What runs next" is computed in-process by whichever runner claims the next continuation event (_fw_continue). Every runner is homogeneous and does the same two things each poll cycle: run handler work it can load, and advance the workflow state machine. Coordination is only the atomic claim_task() + optimistic step versioning in MongoDB.
MongoDB — single source of truth; atomic claim_task()
┌──────────────────────────────────────────────────────────────────────┐
│ tasks │
│ • fw:execute:<Workflow> bootstrap — start the first step │
│ • <facet handler tasks> the real work (osm.cache.Download, …) │
│ • fw:resume:<Facet> external agent finished a step → resume │
│ • _fw_continue "re-evaluate parent block & schedule next" │
└──────────────────────────────────────────────────────────────────────┘
▲ claim / commit atomically (optimistic version.sequence)
│ │ │
│ any runner can claim any of these — disjoint by task-list/namespace
│ │ │
┌─────┴───────┐ ┌─────┴───────┐ ┌─────┴───────┐
│ Runner 1 │ │ Runner 2 │ … │ Runner N │ (RegistryRunner /
│ │ │ │ │ │ RunnerService —
│ each poll cycle (identical on every runner): │ leaderless)
│ 1. claim a HANDLER task → run the handler (only facets │
│ whose module it can import) │
│ 2. claim a _fw_continue → ADVANCE THE WORKFLOW: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ continuation processor (continuation.py) │ │
│ │ → step state machine (StateHandlers + │ │
│ │ StepStateChanger over StepState) │ │
│ │ → evaluator (refs / when / foreach) │ │
│ │ ⇒ which child steps are now runnable? │ │
│ │ ⇒ create their handler tasks, and emit more │ │
│ │ _fw_continue for parent blocks not local │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────┘ └─────────────┘ └─────────────┘
So the "thing that determines the next steps" is the continuation processor + step state machine + evaluator, embedded in every runner — not a separate service. fw:execute kicks off the first step; _fw_continue events drive all subsequent block re-evaluation and step scheduling; the periodic stuck-step sweep (§10.4) is the safety net for lost events.
The notification-driven resume_step() (§10.3) eliminates O(N²) scans but still requires per-workflow locking: only one server can resume a given workflow at a time. For large distributed deployments (100+ servers processing the same workflow), this becomes a bottleneck.
process_single_step() replaces per-workflow locking with per-step atomic operations:
- When a handler completes,
continue_step()advances the step pastEventTransmit(same as §10.3). process_single_step(step_id)processes the continued step and cascades up through parent blocks in the same call. Each round commits atomically and follows dirty-block notifications up the hierarchy.- If any dirty blocks remain unprocessed (e.g., the step is on a different server), continuation tasks are generated on the
_fw_continuetask list. Any server can claim and process these. - Step updates use optimistic concurrency via a
version.sequencecounter. If two servers process the same step concurrently, the version check prevents conflicting writes.
Continuation events are the distributed equivalent of the Scala ContextCache.addContinuationEvents() pattern. They are lightweight tasks (_fw_continue) that carry only a step_id and reason. They are committed atomically alongside step changes and handler tasks in a single persistence operation.
Server A: handler completes for step X
→ continue_step(X, result)
→ process_single_step(X)
Round 1: process X → creates children → children dispatched inline → complete
Round 2: parent block re-evaluates → all children done → block completes
Round 3: workflow root re-evaluates → completes
Commit: step updates + continuation events (if any remain)
Server B: claims continuation task for block Y
→ process_single_step(Y)
Block checks children → not all done yet → no progress
(step stays at Continue, no harm done — idempotent)
Benefits for distributed processing:
- No per-workflow locks: Each step is processed independently. Multiple servers can process different steps in the same workflow concurrently.
- Atomic commits: Step changes, handler tasks, and continuation events are committed in a single persistence operation. No partial state visible to other servers.
- Idempotent processing: Steps can be safely re-processed. Optimistic concurrency prevents conflicting writes. Duplicate continuation tasks are deduplicated.
- Linear scaling: Adding more servers increases throughput proportionally. Each server claims tasks independently from the shared queue.
Correctness requirements:
- All changes (step updates, created steps, handler tasks, continuation tasks) MUST be committed atomically.
- Step updates SHOULD use optimistic concurrency (version check) to detect concurrent modifications.
- Continuation task deduplication MUST prevent unbounded task growth — at most one pending continuation per target step. Continuations are coalesced per block, at both generation and claim time: a block is given a continuation only if it has no PENDING one (generation-time dedup), and a runner claiming a continuation for a step first deletes the step's other PENDING continuations (claim-time coalescing) — one re-evaluation already reflects all the step's children. Both check PENDING state only, so an event arriving after a continuation is claimed still gets a fresh one (nothing is lost). Without this, a large
foreachfan-out (each of N children re-dirtying the parent block) produces O(N²) duplicate continuations that storm the runner pool and livelock it (seeruntime-impl.md§17.5.1). process_single_step()MUST follow the parent chain within the same call when possible, generating continuation events only for blocks that cannot be processed locally.
Implemented — see
runtime-impl.md§17.5.2 for the full implementation details.
The event-driven step processing (§10.3, §10.3.1) handles the common case. A periodic stuck-step sweep MUST run as a safety net for edge cases (crash between continue_step() and process_single_step(), MongoDB failure during commit, orphaned EventTransmit steps with no task, lost continuation events).
The sweep:
- Finds workflows with steps at intermediate states (
EventTransmit,blocks.Begin,block.execution.Begin) - For each stuck step: calls
process_single_step()directly to cascade completion - For
EventTransmitsteps with event facets but no pending/running task: creates a new task
The sweep MUST NOT call the full resume() — it processes each stuck step individually to avoid O(N²) scans.
The sweep runs synchronously on the poll thread, so it MUST yield to event-task claiming: skip the sweep entirely when all worker slots are busy, and bound the work per invocation (step count and wall-clock), deferring the remainder to the next cycle. An unbounded sweep over a large foreach fan-out otherwise out-runs the poll interval and starves claiming — the steps it tries to unstick never get dispatched and the next sweep re-finds them (livelock). See runtime-impl.md §17.5.2.
Using the compiled AST, the evaluator MUST:
- Identify steps not yet persisted
- Verify all dependencies are complete
- Create persistent step records
- Initialize them in
state.statement.Created
Steps MUST NOT be created prematurely.
Implemented — yield steps are created by
BlockExecutionContinueHandler._create_ready_steps()only after all non-yield statements in the block are terminal.
Yield steps (YieldAssignment) are created lazily — they are deferred until all non-yield statements in the same block are terminal (complete or error), regardless of the yield's explicit dependencies.
This means:
- In a block with steps
s1,s2, andyield F(output = s1.x), the yield is not created whens1completes — it waits untils2also completes, even thoughs2is not an explicit dependency of the yield. - The yield step is created in the first iteration where all non-yield statements (
s1ands2) are committed asstatement.Complete. - Because the yield's dependencies are already satisfied at creation time, the yield step runs to
statement.Completein the same iteration it is created.
Rationale: Yield statements are not regular dependency-graph participants. They represent the block's output and should only execute after the block's regular work is fully done. The DependencyGraph.get_ready_statements() method enforces this by checking that all non-yield statement IDs are in the completed set before including any yield in the ready list.
Effect on step counts: The total number of steps in a workflow grows over iterations as yield steps are created. For example, a workflow with 8 total steps may have only 6 steps after iteration 0, with the remaining 2 yield steps created in later iterations.
Effect on iteration counts: Lazy yield creation does not change the total number of iterations. Yield steps complete in the same iteration they are created, so no additional iterations are needed.
Implemented —
StatementBlocksBeginHandler._create_block_steps()assignsstatement_id="block-N"for multi-block workflows.
When a workflow or facet has multiple andThen blocks, each block step is assigned a statement_id of "block-N" (where N is the zero-based index into the body list). This allows get_block_ast() to resolve the correct body element for each block.
Implemented —
BlockExecutionBeginHandler._process_foreach()creates sub-blocks per array element.
When a block has a foreach clause (andThen foreach var in expr { ... }), the BlockExecutionBegin handler:
- Evaluates the iterable expression using the current evaluation context.
- Creates one sub-block step per array element, each with:
object_type=AND_THENblock_idset to the parent foreach blockforeach_varandforeach_valueset for the iteration variable binding
- Caches the body AST (block without foreach clause) for each sub-block.
- Skips normal
DependencyGraphcreation — sub-blocks handle their own dependencies.
The BlockExecutionContinue handler detects foreach blocks and tracks sub-block completion directly (all sub-blocks must reach statement.Complete).
The FacetInitializationBegin handler propagates foreach_var/foreach_value from the containing block step to the EvaluationContext, making the iteration variable available in child step expressions.
Empty iterables produce no sub-blocks and the foreach block completes immediately.
Implemented —
get_facet_definition()performs qualified and short-name lookups across the Program AST.
The evaluator MUST have access to the full Program AST (not just the WorkflowDecl) to look up FacetDecl and EventFacetDecl declarations by name. This is required for:
- EventTransmit (§8.1): The handler must determine whether a step's facet is an
EventFacetDeclto decide between pass-through and blocking behavior. - Block AST resolution (§8.3): The handler must look up facet-level
andThenbodies when a step calls a facet that has its own block. - Statement-level block creation (§8.2): The handler must distinguish between statement-level and facet-level blocks.
The evaluator's ExecutionContext MUST provide a get_facet_definition(facet_name) method that returns the full facet declaration node from the Program AST.
Implemented —
ExecutionContext._block_ast_cacheprovides direct AST lookup for foreach sub-blocks and multi-block bodies.
The ExecutionContext maintains a _block_ast_cache that maps block step IDs to their AST bodies. This cache is checked first in get_block_ast(), before traversing the containment hierarchy. It is used by:
- Foreach sub-blocks (§11.3): Each sub-block's body AST is cached at creation time.
- Multi-block bodies (§11.2):
_select_block_body()uses the block'sstatement_idto index into the body list.
Events are persistent entities with at least the following lifecycle:
event.Created event.Dispatched event.Processing event.Completed event.Error
- An event is processed by exactly one agent at a time
- Failed events MAY be retried
- Event completion MAY unblock dependent steps
Implemented — see
examples.mdExample 4 for the full interaction model.
StepContinue is a system event type that resumes steps blocked at state.EventTransmit. It is the mechanism by which external agents signal that event processing is complete.
Event structure:
event_type:"StepContinue"payload:{ "step_id": <StepId of the blocked step> }
Processing flow:
- An external agent completes processing an event and writes the result to persistence.
- The agent sends a
StepContinueevent targeting the step that is blocked atEventTransmit. continue_step()advances the step pastEventTransmittostate.statement.blocks.Beginand persists the new state.process_single_step()processes the continued step, cascades up through parent blocks, and generates continuation events for any remaining dirty blocks (see §10.3.1). In single-server mode,resume_step()may be used instead (see §10.3).
Continuation events: When process_single_step() cannot process all dirty parent blocks within the same call (e.g., the parent is on another server), it generates _fw_continue continuation tasks. These are lightweight tasks that carry only the target step_id. Any server can claim and process them.
Idempotency: Processing a StepContinue for a step that has already advanced past EventTransmit MUST be a no-op. Duplicate StepContinue events MUST NOT cause errors. Continuation tasks for already-completed steps are harmless — process_single_step() detects terminal states and returns immediately.
state.statement.Erroris terminal by default- Retry behavior is event-level
- Evaluator MUST treat retries as idempotent
- No implicit evaluator-level retries are permitted
- A runner only claims tasks for facets it has a handler for (plus the
fw:execute/fw:resumeprotocol tasks). A task that does end up on a runner with no handler for it is released back topending(with backoff), not failed — it is failed only once retries are exhausted, i.e. no runner in the fleet can service it. See runtime-impl.md §17.1.1.
All runtime operations MUST be idempotent, including:
- step creation
- state transitions
- event publication
The system MUST tolerate:
- evaluator restarts
- agent restarts
- duplicate execution attempts
without producing duplicate side effects.
- Dependency resolution is deterministic
- Final outputs are deterministic for identical inputs
- Ordering of concurrent execution is explicitly undefined
- Execution MUST converge to the same final persisted state
Agents MAY:
- read the step associated with their event
- update that step
- signal completion or error
Agents MUST NOT:
- modify other steps
- create steps
- alter workflow structure
- bypass evaluator control
All persisted artifacts MUST include:
workflow_versionstep_schema_versionruntime_version
The evaluator MUST refuse or safely handle incompatible versions.
The runtime MUST emit structured telemetry for:
- step state transitions
- dependency resolution
- iteration boundaries
- event publication
Telemetry MUST NOT affect execution semantics.
The FFL runtime executes workflows as deterministic, dependency-driven, iterative evaluations using in-memory execution, abstracted persistence, explicit state machines, and strict agent boundaries.
This contract is non-negotiable.
The following are explicitly out of scope:
- speculative execution
- dynamic workflow mutation
- agent-created steps
- implicit retries
These MAY be added in future versions.
namespace test.one {
facet Value(input: Long, output: Long)
workflow TestOne(input: Long = 1) => (output: Long) andThen {
s1 = Value(input = $.input + 1)
s2 = Value(input = s1.input + 1)
yield TestOne(output = s2.input + 1)
}
}
Execution Walkthrough
-
Workflow initialization
- Before s1 can be evaluated, the workflow step TestOne MUST be initialized with its attributes.
- In this example, TestOne.input takes its default value:
TestOne.input = 1
-
Step s1 evaluation
- s1 has no blocking step references (it references
$.input, which is local workflow input). - The evaluator evaluates:
s1.input = $.input + 1 = 1 + 1 = 2- The evaluated value is stored in the persistent representation of step s1.
- s1 has no blocking step references (it references
-
Step s2 evaluation (dependency enforcement)
- s2 references
s1.input, so s1 MUST be instate.statement.Completebefore s2 begins evaluation. - Once s1 is complete:
s2.input = s1.input + 1 = 2 + 1 = 3- The evaluated value is stored in the persistent representation of step s2.
- s2 references
-
Yield capture and merge into the containing step
- The
yield TestOne(...)does not mutate TestOne immediately. - Yield capture is performed during the containing step's capture phase:
state.statement.capture.Begin- This deferred capture is REQUIRED because:
- a containing step may have multiple blocks (andThen) and multiple yields,
- the containing step must remain immutable while blocks are executing,
- yields must only be merged once all relevant blocks have completed.
- The
-
Yield attribute merge
- After all steps in the andThen block are complete, the evaluator collects the yield result and merges yielded attributes into the containing step (TestOne):
TestOne.output = s2.input + 1 = 3 + 1 = 4- The merge produces the final persisted attribute set for TestOne, after which TestOne may transition to
state.statement.Complete.
Key Guarantees Demonstrated
- Workflow inputs (
$.input) must be initialized before dependent steps can evaluate. - Dependency-driven scheduling: s2 cannot evaluate until s1 is complete.
- Yield capture is deferred to
state.statement.capture.Beginto preserve immutability during block execution. - Yield results are merged into the containing step only after block completion.
namespace test.two {
facet Value(input: Long, output: Long)
workflow TestTwo(input: Long = 1) => (output: Long) andThen {
a = Value(input = $.input + 1)
b = Value(input = $.input + 10)
c = Value(input = a.input + b.input)
yield TestTwo(output = c.input)
}
}
Execution Walkthrough
-
Workflow initialization
- The workflow step TestTwo MUST be initialized before any block step may evaluate.
- With the default:
TestTwo.input = 1
-
Parallel evaluation (a and b)
- a references only
$.inputand has no blocking step references. a.input = $.input + 1 = 1 + 1 = 2- b references only
$.inputand has no blocking step references. b.input = $.input + 10 = 1 + 10 = 11- Because a and b have no inter-dependencies, they MAY be evaluated concurrently in the same iteration.
- a references only
-
Fan-in dependency (c)
- c references both
a.inputandb.input. - Therefore, c MUST NOT begin evaluation until:
- a is in
state.statement.Complete, and - b is in
state.statement.Complete.
- a is in
- Once both are complete:
c.input = a.input + b.input = 2 + 11 = 13
- c references both
-
Yield capture and merge
- Yield capture occurs in the containing step (TestTwo) during:
state.statement.capture.Begin- The yield assigns:
TestTwo.output = c.input = 13- Yield results are merged only after all steps in the block have completed.
Mapping to the Iteration Model
The evaluator progresses through iterations based on dependency eligibility.
-
Iteration 1 (eligible at start)
- Eligible steps: a, b
- Actions:
- Evaluate a and b (in memory)
- They reach completion (or an event boundary), and their results become available
- c is NOT eligible yet because its dependencies were not complete at the beginning of the iteration.
-
Iteration boundary
- The evaluator commits in-memory updates and/or publishes events (per the runtime contract).
- The evaluator re-evaluates step eligibility.
-
Iteration 2 (newly eligible)
- Newly eligible step: c
- Because both a and b are now complete, c becomes eligible and may execute.
This illustrates the core rule:
A step that becomes unblocked by dependencies in one iteration is scheduled in a subsequent iteration, never mid-iteration.
Key Guarantees Demonstrated
- Independent steps may run concurrently.
- Fan-in steps wait for all dependencies.
- Eligibility expands only between iterations.
- Yield merge remains deferred until block completion to preserve step immutability.
Note: Steps can have the same name if in different blocks (
andThen), but must be unique within a block. All blocks will be executed concurrently. The yields will be performed after the block is executed by the TestThree step.
namespace test.three {
facet Value(input: Long, output: Long)
workflow TestThree(input: Long = 1) => (output1: Long, output2: Long, output3: Long) andThen {
a = Value(input = $.input + 1)
b = Value(input = $.input + 10)
c = Value(input = a.input + b.input)
yield TestThree(output1 = c.input)
} andThen {
a = Value(input = $.input + 1)
b = Value(input = $.input + 10)
c = Value(input = a.input + b.input)
yield TestThree(output2 = c.input)
} andThen {
a = Value(input = $.input + 1)
b = Value(input = $.input + 10)
c = Value(input = a.input + b.input)
yield TestThree(output3 = c.input)
}
}
For the Python reference implementation (state changers, handlers, transition tables, source file map), see runtime-impl.md.