LWIR Internals
The structural validator behind validateLwir() — what it accepts, what it rejects, and what the runtime depends on.
Little Workflow: published on npm as little-workflow@0.2.0-alpha.1 (2026-10-06). Sections labeled UNRELEASED describe source-only changes. Release status
LWIR is the canonical JSON document the runtime executes. lwir.ts is the only place LWIR is parsed and structurally validated. Every other module — the compiler, the runtime, replay — assumes a value that passed validateLwir().
This page maps the validator phases against the source.
Top-Level Shape
type LwirWorkflow = {
apiVersion: "littleworkflow.dev/v0.1";
kind: "Workflow";
metadata: { name: string; version?: string; description?: string };
input: { schema: NormalizedSchemaDescriptor };
output: { schema: NormalizedSchemaDescriptor };
permissions?: {
models?: readonly string[];
tools?: readonly string[];
secrets?: readonly string[];
network?: readonly string[];
};
steps: readonly LwirStep[];
};apiVersion and kind are constants. Anything else there fails schema.invalid immediately.
Step Shape
type LwirStep = {
id: string;
uses: "ai.generate" | "tool.call" | "code.run" | "parallel" | "decision";
needs?: readonly string[];
with?: Record<string, unknown>;
input?: unknown;
output?: LwirStepOutput;
maxVisits?: number; // integer in [1, 100]; default 1 (DAG)
sensitive?: boolean; // reserved; validated but not yet enforced
steps?: readonly LwirStep[]; // only on parallel
};Step IDs are paths, so they have rules:
- non-empty string
- no
.,[, or](those are path delimiters:review-candidates[cand_123].score-candidate) - no
@attempt_substring (reserved for retry path markers) - unique within their parent list
A parallel step contains nested child steps, but those run under each branch — the validator forbids nested parallel steps.
Validator Phases
validateLwir() runs four phases. Each phase appends findings; nothing throws until the caller calls assertValidLwir().
Phase 1: Canonical Hashability
The validator first tries to canonicalize the value. If canonical.ts throws — typical causes are functions, bigint, or cyclic references — the validator records lwir.non_hashable and bails. Replay safety depends on canonical hashability, so this is non-negotiable.
Phase 2: Top-Level Shape
validateTopLevelShape() enforces the constants and required objects. Common findings:
schema.invalidat$.apiVersion,$.kind,$.metadata.name,$.input,$.output,$.steps.schema.invalidat$.permissions.<key>when a permission entry is not a string array.
Phase 3: JSON Schema Subset
validateSchemaValue() enforces a deliberately small JSON Schema subset:
typeis one ofobject,array,string,number,integer,boolean,null.- Allowed constraint keys:
additionalProperties,anyOf,const,enum,items,maxItems,maxLength,maximum,minItems,minLength,minimum,oneOf,properties,propertyNames,required,type. - Allowed annotation keys:
description,examples,title. These constrain nothing and are forwarded verbatim to the model, soz.string().describe("…")works everywhere a schema does. - Schemas can be
true(allow everything) orfalse(allow nothing). - Nested schemas are validated recursively.
propertyNames is the map-shape keyword zod emits for z.record(K, V) — { type: "object", propertyNames: <K>, additionalProperties: <V> } — and it is accepted because Ajv really evaluates it: a non-conforming key fails workflow-input validation in compiler.ts and step/final output validation in runtime.ts. Its key schema recurses through this same subset, so the boundary is the usual one: z.record(z.string(), V), z.record(z.string().min(1), V), z.record(z.enum([…]), V) and z.partialRecord(z.enum([…]), V) all pass, while z.record(z.string().regex(…), V) is still rejected because pattern is outside the subset wherever it appears. A key schema whose type is not string (z.record(z.number(), V) emits propertyNames: { type: "number" }) is rejected with schema.invalid: JSON object keys are always strings, so such a map could never accept a key.
One consequence: required normally has to have a properties object beside it, but a schema with propertyNames is exempt — a map declares its keys with propertyNames, and z.record(z.enum([…]), V) emits a bare required listing every key for its exhaustive-record semantics. Ajv enforces required independently of properties, so nothing is accepted-and-ignored. Outside map shapes the rule stands: a bare required on an object is an authoring slip.
Unsupported keys (patternProperties, if/then/else, $ref, $dynamicRef, format, pattern, etc.) are rejected with schema.invalid. The runtime never needs to evaluate them, and shrinking the surface keeps Ajv predictable across alpha hosts.
default, deprecated, readOnly, and writeOnly are rejected on purpose even though they look like annotations: each asserts behaviour (value substitution, access control) that the alpha runtime does not implement. Every Ajv instance here runs without useDefaults, so an accepted default would never be substituted — the same "declared but silently dropped" failure the permissions.secrets rejection exists to prevent.
Phase 4: Steps
validateStepList() is the densest phase. It walks every step list (including nested parallel.steps) and records:
| Code | Triggered when |
|---|---|
step.invalid | A step is not an object. |
step.invalid_id | Missing, empty, contains . / [ / ], or contains @attempt_. |
step.duplicate_id | Two steps in the same list share an id. |
step.invalid_needs | needs is present but not a string array. |
step.missing_dependency | A needs[] entry or a {{ steps.X.output }} reference points to an id that doesn't exist. |
step.undeclared_dependency | An expression references a step id that is real, but the step did not list it in needs. A planner's draft rarely reports it: the compiler adds the missing needs entry first (see LWIR). A fixed plan still reports it. |
output.missing_schema | A step references another step's json-mode output without that step declaring a schema. |
lwir.invalid_cycle | The dependency graph has a cycle that violates the decision-step rules (see below). |
lwir.output_ambiguous_for_multi_visit | {{ steps.X.output }} is used on a step with maxVisits > 1. Use lastOutput or allVisits. |
lwir.decision_target_referenced_by_needs | A non-decision-target, non-decision step declares a decision target in its needs. The decision controls routing to that target; declaring it in needs deadlocks the dependent step. Catches author/planner errors at compile time. |
Cycle validation
Cycle detection uses Tarjan's strongly-connected-components (SCC) algorithm over the union of needs edges and decision-step transition edges (decision.with.cases[].to and decision.with.default). A cycle is allowed only if:
- Every elementary cycle in the SCC contains a decision step. Implemented by removing decision nodes from the SCC's induced subgraph and re-running Tarjan; any non-trivial residual SCC indicates a decision-free cycle and is rejected.
- Every step on the cycle has
maxVisits >= 2.
Cycles that fail either condition are rejected as lwir.invalid_cycle. Cycles that pass both are back-edges; the runtime re-executes the targeted step as a new visit. maxVisits must be an integer in [1, 100] (enforced with step.max_visits_out_of_range).
Per-Step Type Rules
After the structural pass, each step is dispatched on uses:
A decision step's with.cases[] is an ordered list of { when, to } pairs; the first truthy when expression wins. with.default is the fallback target. Every to must name a step in the same scope or the sentinel "end" (terminates the current scope). The validator checks that all targets exist in scope (decision.target_not_in_scope). A decision step does not have an output contract; the runtime records { chosen: "<targetId>" } in the event log for auditability.
The expression validator (expressions.ts) runs against step.with, step.input, and step.cache. Inside a parallel.steps[] body, {{ item.* }} references become legal; outside, they fail with expression.invalid_root.
Output Modes And Compatibility
Every executable step must declare an output with a mode from text | object | array | choice | json. object and array require a schema; choice requires values; json may omit the schema unless another step consumes it as a typed reference.
The validator runs an output-compatibility pass: if step B references {{ steps.A.output.foo }} and step A is json without a schema, step A gets an output.missing_schema finding. This is what guarantees that downstream readers see structured data, not raw assistant text.
Parallel Coordination Rules
For uses: "parallel", validateParallelStep() enforces:
with.itemsis a{{ ... }}expression yielding an array.with.itemKeyis an{{ item.* }}expression.with.maxBranchesis a positive integer<= 100(alpha cap).with.maxConcurrencyis a positive integer<= maxBranches.with.failureModeisfail_fastorall_settled.with.fanIn.orderisinputoritemKey.with.fanIn.outputisarray.outputisarrayand the items schema enforces the runtime envelope shape (itemKey,status,output | outputRef,error,artifacts).
parallel steps cannot nest inside another parallel. Detection is via context.allowItemExpressions — once set to true (inside a parallel body), encountering uses: "parallel" raises parallel.nested_unsupported.
onFailure.fixer Validator (code.run steps)
For uses: "code.run" steps that declare onFailure.fixer, the validator checks the fixer config shape:
fixer.modelmust be a non-empty model slot string (fixer.model.missing) in the workflow's model allowlist (fixer.model.disallowed). This prevents the fixer from attempting to invoke a model that won't be available at runtime.fixer.maxAttemptsmust be a positive integer; there is no upper cap (fixer.max_attempts_invalid).fixer.systemis required and must be a non-empty string (fixer.system_missing).
Step type naming is now unified in alpha: ai.generate, tool.call, code.run, parallel, decision.
WorkflowVersion and WorkflowDefinitionHash
WorkflowVersion = sha256(canonicalJson(LWIR)). This is unchanged.
This milestone introduces a second identity primitive alongside it:
WorkflowDefinitionHash— hash of the workflow's identity-bearing authoring-time fields (planner config, models, tool names, worker config, memory config, schemas). Stable across runs as long as the definition does not change; computed once whendefineWorkflow({...})is called.WorkflowVersion— per-run hash of LWIR, unchanged.
A single WorkflowDefinition can produce many WorkflowVersions (different inputs → different LWIR). The WorkflowDefinitionHash is what the orchestrator's manifest tracks for each available workflow; WorkflowVersion is what worker sessions reference when executing steps.
See Identity and Manifests for how the two hashes compose with the three manifest types.
Registration
registerWorkflowVersion() is the only path that produces a WorkflowVersion. It calls assertValidLwir(), canonicalizes the document, deep-freezes the result, and stamps an id derived from sha256(canonicalLwir):
return {
id: `wfver_${hash.slice(7, 7 + 16)}`, // e.g. "wfver_4f0a..."
hash: "sha256:<64 hex>",
canonicalizer: "little-workflow-canonical-json@alpha",
canonicalJson, // string, exact bytes
lwir, // frozen
};compiler.ts calls this with the planner's draft and wraps the result with the compiled-lock seed described on the Compile Pipeline page.
What This Validator Is Not
- It does not call models, tools, or run code. It is pure.
- It does not enforce business rules — for example, an
ai.generatestep with a hopelessly broad schema validates fine here; the planner repair loop is what nudges that. - It does not cross-check against the orchestration request. That is
validateRequestBinding()incompiler.ts. The split exists so that hand-written LWIR (e.g. CLIlittle validate) is meaningful even without a request.