The reference implementation SHALL:
- use Lark with
parser="lalr"andpropagate_positions=True - generate clear syntax errors with line and column
- preserve statement separation by NEWLINE and/or semicolons
- produce an AST using dataclasses (not raw parse trees)
The parser produces an AST with the following dataclass nodes.
All AST nodes MUST have a unique UUID (v4) stored in the node_id field. This ID:
- Is automatically generated when the node is created
- Is unique across all nodes in the AST
- Is included in JSON output as the
idfield - Enables stable references to specific nodes across tools and systems
| Node | Description |
|---|---|
Program |
Root containing namespaces, facets, event_facets, workflows, implicits |
Note: The Python AST dataclass
Programhas separate fields (namespaces,facets,event_facets,workflows,implicits,schemas). The JSON serialization flattens these into a unifieddeclarationslist. Both representations carry the same information.
| Node | Description |
|---|---|
Namespace |
namespace qname { body } |
UsesDecl |
uses qname |
FacetDecl |
facet Name(params) => (returns) body? |
EventFacetDecl |
event facet Name(params) => (returns) body? |
WorkflowDecl |
workflow Name(params) => (returns) body? |
ImplicitDecl |
implicit name = CallExpr |
SchemaDecl |
schema Name { fields } |
SchemaField |
name: Type field within a schema |
| Node | Description |
|---|---|
FacetSig |
Name, params, returns, mixins |
Parameter |
name: Type = default? |
TypeRef |
Type name (builtin or qualified) |
ReturnClause |
=> (params) |
MixinSig |
with Name(args) in signature |
MixinCall |
with Name(args) as alias in call |
| Node | Description |
|---|---|
AndThenBlock |
andThen [foreach] { block }, andThen script "code", or andThen when { cases } |
Block |
{ steps* yield? } |
ForeachClause |
foreach var in reference |
StepStmt |
name = CallExpr |
YieldStmt |
yield CallExpr |
PromptBlock |
prompt { system/template/model directives } for LLM-based facets |
ScriptBlock |
script [python] "code..." or script { code } for inline sandboxed Python execution |
WhenBlock |
when { cases } — conditional branching within andThen |
WhenCase |
case expr => { block } or case _ => { block } |
CatchClause |
catch { block } or catch when { cases } — error recovery |
| Node | Description |
|---|---|
CallExpr |
Name(args) mixins* |
NamedArg |
name = expr |
Reference |
$.path (input) or step.path (step output) |
Literal |
String, Integer, Double, Boolean, or Null |
BinaryExpr |
left op right — operators: +, -, *, /, %, ++, ==, !=, >, <, >=, <=, &&, || |
UnaryExpr |
op operand — operators: - (negation), ! (logical NOT) |
ConcatExpr |
String concatenation via ++ (legacy; new code uses BinaryExpr) |
ArrayLiteral |
[elem, ...] |
MapLiteral |
#{"key": value, ...} |
IndexExpr |
target[index] |
| Node | Description |
|---|---|
SourceLocation |
line, column, end_line, end_column, source_id |
ASTNode |
Base class with node_id (UUID) and optional location |
Program
├── namespaces: list[Namespace]
│ ├── name: str
│ ├── uses: list[UsesDecl]
│ ├── facets: list[FacetDecl]
│ ├── event_facets: list[EventFacetDecl]
│ ├── workflows: list[WorkflowDecl]
│ └── implicits: list[ImplicitDecl]
├── facets: list[FacetDecl]
│ ├── sig: FacetSig
│ │ ├── name: str
│ │ ├── params: list[Parameter]
│ │ ├── returns: ReturnClause?
│ │ └── mixins: list[MixinSig]
│ ├── pre_script: ScriptBlock? # pre-processing script (runs before event/begins)
│ ├── body: AndThenBlock? | PromptBlock?
│ │ # AndThenBlock (regular):
│ │ ├── foreach: ForeachClause?
│ │ ├── block: Block?
│ │ │ ├── steps: list[StepStmt]
│ │ │ │ └── catch: CatchClause? # step-level catch
│ │ │ └── yield_stmt: YieldStmt?
│ │ ├── script: ScriptBlock? # andThen script variant (mutually exclusive with block/when)
│ │ └── when: WhenBlock? # andThen when variant (mutually exclusive with block/script)
│ │ └── cases: list[WhenCase]
│ │ ├── condition: expr? # None for default case
│ │ ├── block: Block
│ │ └── is_default: bool
│ │ # PromptBlock:
│ │ ├── system: str?
│ │ ├── template: str?
│ │ └── model: str?
│ │ # ScriptBlock:
│ │ ├── language: str (default "python")
│ │ └── code: str
│ └── catch: CatchClause? # declaration-level catch (error recovery)
│ ├── block: Block? # simple catch { steps }
│ └── when: WhenBlock? # conditional catch when { cases }
├── event_facets: list[EventFacetDecl]
├── workflows: list[WorkflowDecl]
├── implicits: list[ImplicitDecl]
└── schemas: list[SchemaDecl]
├── name: str
└── fields: list[SchemaField]
├── name: str
└── type: TypeRef | ArrayType
- Built-in types:
String,Long,Int,Double,Boolean,Json - Qualified types:
namespace.TypeName
- Input references (
$.field) refer to workflow/facet parameters - Step references (
step.field) refer to outputs of previous steps - Nested paths (
$.data.nested.field) supported - Pass-by-step (FacetRef): when a parameter type is itself a facet
name (e.g.
ds: Value), the argument is a bare step reference (ds = s1). The runtime bindsdsto aStepReferencecarrying the referenced step's id, workflow id, and facet name. Inside the consumingandThenbody,$.ds.<field>dereferences theStepReferenceagainst the upstream step's persisted attributes, consultingreturnsthenparams(returns shadow params on name collision). The reference is read-only. Validator ruleSTEP_REF_FACET_MISMATCHenforces exact facet-name match between the source step's call target and the declared parameter type. - Mixin aliases on FacetRef access: a mixin declared on a facet
signature with
with M() as <alias>exposes its sub-step to a FacetRef consumer as$.fref.<alias>.<field>. The alias shares the consumer-side namespace with the facet's own params and returns; collisions are rejected byMIXIN_ALIAS_NAME_CONFLICT. Mixins without anasalias are unreachable from FacetRef consumers. Validator ruleREF_INVALID_FACET_REF_ATTRIBUTEchecks the first one or two segments past the FacetRef param against the unionparams ∪ returns ∪ mixin_aliasesof the referenced facet (and the aliased mixin, when applicable).
Every aliased mixin on a facet sig executes as a real sub-step, in parallel with sibling aliased mixins, fully before the parent body runs. Init order is strict:
- Parent params are bound from call args at parent
FACET_INIT_BEGIN. - Mixin sig-args (
with M(x = $.input) as mon the facet decl) are evaluated in the parent's scope and become the mixin sub-step's bound params. - Mixin bodies run, each with
$.isolated to the mixin's own attributes — no workflow-root inheritance, no parent reach-out. The mixin's own facet defaults populate any attributes the parent didn't bind. - Parent body runs after every aliased mixin sub-step has
terminated;
$.alias.fieldreads a snapshot of the mixin sub-step's{params, returns}written toparent.params[alias]atMIXIN_CAPTURE_BEGIN.
A mixin sub-step that errors propagates the error to the parent
step. Un-aliased mixins remain purely configurational: their
sig-args flat-merge into parent.params per the v0.21.0 contract
and the mixin facet's body, if any, does NOT execute.
Yields collected at the parent's STATEMENT_CAPTURE_BEGIN are
routed to one of two destinations:
- Parent step if the yield target matches the parent's own
facet name (
yield F(output = ...)insidefacet F). - Mixin sub-step if the yield target is a declared alias
(
yield aliasName(out = ...)), or a mixin facet name with exactly one alias on the parent (yield F(...) with M(out = ...)when M is uniquely aliased on F).
Routing to a mixin sub-step writes the yield's values onto the
sub-step's attributes.returns, applying the same yield-merge
rules (lists concat, sets/frozensets union, scalars overwrite).
The override is visible to FacetRef consumers via the live
persisted sub-step but not to the parent's own snapshot reads —
the snapshot was taken at MIXIN_CAPTURE_BEGIN before any parent
yield runs.
When two or more sig-level mixins target the same facet, the bare
target form is ambiguous (it can't pick an alias). Validator rule
YIELD_TARGET_AMBIGUOUS rejects this; the author must use the
alias name in the yield.
Two side-effect-only statements live alongside step assignments inside any andThen block (and inside catch handlers, foreach bodies, when cases, mixin bodies — anywhere a step statement is valid):
andThen {
s1 = Download(url = $.url)
sys.log(name = s1.body, size = s1.length)
sys.assert(s1.body startsWith "<html")
s2 = Parse(data = s1.body)
}
sys.log(name = expr, ...)evaluates each named arg, then emits a Splunk-format JSON record on thefacetwork.sys.loglogger. The record carries the evaluated name/value pairs in aneventsub-object plus the runtime context (workflow_id,runner_id,server_id,step_id,facet_name,hostname). It also writes an INFO step-log entry so the dashboard surfaces it.sys.assert(boolean_expr)evaluates the condition; onfalsethe containing step is marked errored with anAssertionError. Standard catch handling applies. RuleSYS_ASSERT_NOT_BOOLEANrejects non-Boolean conditions at validate time.
These statements produce no value and never appear in expression
position. At runtime they walk a tiny three-state transition table
(CREATED → FACET_INIT_BEGIN → STATEMENT_END → STATEMENT_COMPLETE)
that completes in a single tick — no facet body, no event dispatch.
The dependency graph treats them like any other statement: a sys
statement that references a step waits for that step to complete
before firing.
The comparison level of the expression grammar accepts these
non-associative keyword operators alongside == / != / < /
<= / > / >=:
| Operator | Operand types | Result |
|---|---|---|
a in B |
a: any, B: collection | string |
Boolean |
a not in B |
same as in |
Boolean |
A contains b |
A: collection | string, b: any |
Boolean |
a startsWith b |
both String |
Boolean |
a endsWith b |
both String |
Boolean |
They're usable anywhere a Boolean expression is valid — sys.assert
conditions, when cases, andThen when guards. Validator rules
TYPE_CONTAINMENT_OPERAND and TYPE_STRING_MATCH_OPERAND reject
malformed combinations at compile time.
- Parameters can have optional default values:
name: Type = expr - Supported default expressions: literals (
"hello",42,3.14,true,null), references, and concat expressions - The
ParameterAST node has an optionaldefaultfield - The emitter produces a
"default"key in the JSON AST when a default is present - The runtime evaluator uses defaults for any parameters not supplied in the
inputsdict
- Steps within a block can reference earlier steps
- Yield statements merge outputs back to containing facet
- Implicit declarations provide default values
- Schemas can be instantiated in step statements:
cfg = Config(timeout = 30) - Schema fields become the step's returns (accessible via
step.field) - Schema instantiation uses the same
CallExprAST node as facet calls - Schemas cannot have mixins;
Config() with Mixin()is a validation error - Schema fields are validated at compile time (unknown fields produce errors)
Script blocks embed sandboxed Python code. There are two distinct uses with different timing and data flow.
- Placement:
facet/event facet/workflow Name(...) script { code } - Timing: Runs during
state.facet.scripts.Begin, afterFacetInitializationand before event transmission or block execution - Input:
paramsdict contains the declaration's input parameters - Output: Values written to
resultdict are stored as params (not returns) on the step, making them available via$.fieldin downstreamandThenblocks - Cardinality: At most one pre-script per declaration
- Placement:
andThen script { code }— appears where a regularandThen { steps }block would - Timing: Runs during
state.block.execution.Begin, concurrently with otherandThenblocks (both regular and script) - Input:
paramsdict contains the container step's params (including any values added by a pre-script) - Output: Values written to
resultdict are stored as returns on the block step, merged into the containing declaration's outputs during the capture phase (state.statement.capture.Begin) alongside yield results from regular blocks - Cardinality: Zero or more andThen script blocks per declaration, interleaved freely with regular andThen blocks
- Scripts receive two pre-defined variables:
params(dict, input) andresult(dict, output) - Python standard library imports are available
- Execution errors are captured and reported as step failures (the step transitions to an error state)
- Scripts are executed via
ScriptExecutorwhich usesexec()in a restricted namespace
Declaration params
│
▼
┌─────────────────┐
│ Pre-script │ writes to result → stored as params
└────────┬────────┘
│ params (original + pre-script additions)
▼
┌─────────────────────────────────────────────┐
│ All andThen blocks (concurrent) │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Regular block │ │ andThen script block │ │
│ │ steps + yield │ │ params → result dict │ │
│ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │
│ yield results result dict values │
└─────────┼─────────────────────┼──────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────┐
│ Capture phase: merge all into declaration │
│ outputs (yield params + block returns) │
└──────────────────────────────────────────────┘
- All nodes are
@dataclassdecorated - Base
ASTNodeclass with:node_id: str- Auto-generated UUID (v4) for unique identificationlocation: Optional[SourceLocation]- Source position for error reporting
- Both fields use
kw_only=Truefor inheritance compatibility - UUIDs are generated via
uuid.uuid4()at node creation time
- Extends
lark.Transformer - Uses
@v_args(meta=True)for location tracking - Converts Lark parse tree to AST nodes
preprocess_script_braces()converts brace-delimitedscript { code }toscript "escaped_code"before LALR parsing- Tracks brace depth to handle nested Python dicts/sets
- Respects Python string literals (single, double, triple-quoted) and FFL comments
- Strips common indentation (dedent) and preserves line numbers via blank-line padding
PreprocessErrorexception for unbalanced braces
AFLParserclass wraps Lark parser- Calls
preprocess_script_braces()before Lark parsing ParseErrorexception with line/columnparse()convenience function