Architecture

Jaiph is a workflow system with a TypeScript CLI and a JavaScript kernel (src/runtime/kernel/) that interprets the workflow AST in process. There is no separate “workflow shell” emitted for execution.

This page describes how Jaiph is built: the repository layout of the major subsystems, the core components, the compile and run pipelines, and the runtime contracts (events, artifacts on disk, and distribution). It stays on the implementation boundaries. For workflow syntax and semantics, see the Language guide. For the CI-enforced import-graph and deep-module rules that keep src/ analyzable by AI agents (and humans) under a bounded context budget, see Agent analyzability.

Jaiph separates script-file emission from workflow execution on purpose. The transpiler turns each script block, and each inline script body, into real files under scripts/ with a stable layout and the JAIPH_SCRIPTS variable. NodeWorkflowRuntime still always executes from the AST through buildRuntimeGraph. Writing the script files this way keeps the bash entrypoints predictable for subprocesses, and it avoids duplicating workflow logic in a second language.

For how to contribute, see Contributing, which covers branches, test layers, the E2E assertion policy, and the bash harness. For the *.test.jh language and test blocks, see Testing.

System overview

Workflow authors write .jh / .test.jh modules. The toolchain turns those files into validated modules plus extracted script files, and then the same AST interpreter runs the workflows whether you use local jaiph run, Docker, or jaiph test.

  1. Parse source into AST. Every CLI path walks the entry plus its transitive .jh import closure once through loadModuleGraph (src/transpile/module-graph.ts) and reuses that ModuleGraph for the banner (metadataToConfig), validation (validateModule inside emitScriptsForModuleFromGraph, invoked by buildScriptsFromGraph), script-body extraction, and, across the parent to child process boundary on the default local jaiph run, for buildRuntimeGraph(graph) in the spawned runner (see Local module graph and the sequence diagram below). parsejaiph(source, filePath) is I/O-pure, and validation and script emit operate entirely on the in-memory graph and never re-read .jh files. Inside this compile-and-run graph pipeline, loadModuleGraph is the only routine that reads .jh sources from disk. A few paths outside the graph pipeline still read .jh directly, such as runWorkflowRaw on the jaiph run --raw path (src/cli/commands/run.ts) and the exported loadImportedModules helper (src/cli/shared/paths.ts).
  2. Compile-time validation runs before script extraction. The validator consumes the in-memory graph; imported ASTs are looked up by absolute path and never re-read from disk. Three validation entry points share the same per-module walk via validateModuleInto: validateModule(ast, graph) is the per-module throwing form (used by emitScriptsForModuleFromGraph / buildScriptsFromGraph() so the existing single-error path stays intact), validateReferences(graph) validates every reachable module then throws the first sorted error, and collectDiagnostics(graph) returns a populated Diagnostics collector (src/diagnostics.ts) with every recoverable error from every reachable module. The jaiph compile command walks the same import closure but routes through collectDiagnostics: it builds a graph per entry, collects diagnostics, prints them all (sorted by file/line/col, in path:line:col CODE message form on stderr — or as a single JSON array on stdout with --json), and exits non-zero if any diagnostic was collected. It does not emit scripts/, does not invoke buildRuntimeGraph(), and never spawns the workflow runner (src/cli/commands/compile.ts). For a directory argument it discovers *.jh via walkjhFiles, which skips *.test.jh; to validate a test module, pass that file explicitly. Imported modules in the closure are still validated recursively either way.
  3. CLI (dist/src/cli.js via npm, or a Bun-compiled dist/jaiph binary) prepares script executables (scripts-only), then spawns a detached child through the internal __workflow-runner argv marker (spawnJaiphWorkflowProcess in src/runtime/kernel/workflow-launch.ts). The child entrypoint is runWorkflowRunner (src/runtime/kernel/node-workflow-runner.ts), which loads or deserializes the module graph, calls buildRuntimeGraph(), then runs NodeWorkflowRuntime. Under Node the spawn is process.execPath + dist/src/cli.js + __workflow-runner; under the Bun standalone binary, process.execPath is the jaiph binary itself with the same marker. Script steps execute as managed subprocesses; prompt, inbox I/O, and event/summary emission are handled by the kernel under src/runtime/kernel/.
  4. Stream live events to the CLI and persist durable run artifacts.

Interactive jaiph run parses __JAIPH_EVENT__ lines from the runner’s stderr, renders the progress tree, and runs hooks. jaiph run --raw skips that shell. The child uses inherited stdio, so events still land on stderr unchanged. Use --raw when you embed Jaiph or when the host wraps a container (see CLI, jaiph run and Sandboxing).

All orchestration uses the Node workflow runtime, which is the AST interpreter, whether you run local jaiph run, jaiph test, or Docker jaiph run. Docker containers run the same jaiph run --raw / __workflow-runner dispatch with the compiled JS source tree and scripts mounted read-only.

Import-graph layering

The src/ import graph is an acyclic layered DAG: parse/format → transpile → runtime → CLI, over a shared leaf. The layer table in Agent analyzability is authoritative for which paths belong to each layer and what each may import. npm run arch:check (dependency-cruiser, .dependency-cruiser.cjs) enforces it in CI — no-circular, the per-layer “no upward import” rules (including the exception that lets runtime reuse compile only through the single public entry src/transpiler.ts), no-deep-imports-into-parse, which fails when code outside the parse package imports a src/parse/** file instead of the public entry src/parser.ts, no-deep-imports-into-transpile, which fails when code outside the transpile package imports a src/transpile/** internal instead of the single public entry src/transpiler.ts (which re-exports the module-graph API that runtime reuses), no-deep-imports-into-runtime, which fails when code outside the runtime package imports a src/runtime/** internal instead of one of the two public entries src/runtime/index.ts (production) or src/runtime/testing.ts (named test seams for cross-package tests), and no-deep-imports-into-format, which fails when code outside the format package imports a src/format/** internal instead of the public entry src/format/index.ts, and no-cross-cli-slice-imports, which fails when a file in one CLI slice (commands, run, serve, mcp, exec, or telemetry) imports another slice’s private tree instead of going through src/cli/shared/** or a lower-layer public entry. The runtimecli direction is enforced too: no production file under src/runtime/ imports src/cli/**. Pre-existing violations are grandfathered in .dependency-cruiser-known-violations.json; new cycles, upward imports, parse, transpile, runtime, or format deep imports, or cross-CLI-slice imports fail the build.

Core components

Local module graph

The toolchain has one canonical representation, ModuleGraph, for all .jh modules reachable from an entry point, parsed once. The same graph is used by the validator, the script emitter, and the runtime. On the default local jaiph run path the graph also crosses the parent CLI to child runner boundary, so each reachable .jh is parsed exactly once per run.

User-visible contracts (banner, hooks, run artifacts, run_summary.jsonl, return_value.txt, exit codes, __JAIPH_EVENT__ streaming) are unchanged.

Runtime vs CLI responsibilities

Runtime responsibilities (Node workflow runtime)

CLI responsibilities

Contracts

Channel transport remains file/queue based in runtime inbox logic.

Durable artifact layout

For an onboarding-style description of the same paths (what to expect in a repo, what to ignore in git), see Runtime artifacts.

The runtime persists step captures and the event timeline under a UTC-dated hierarchy:

.jaiph/runs/
  <YYYY-MM-DD>/                       # UTC date (see NodeWorkflowRuntime)
    <HH-MM-SS>-<source-basename>/       # UTC time + JAIPH_SOURCE_FILE or entry basename
      000001-module__step.out          # stdout capture per step (6-digit seq prefix)
      000001-module__step.err          # stderr capture (may be empty)
      artifacts/                       # user-published files (JAIPH_ARTIFACTS_DIR); created at run start
      inbox/                           # audit copies of routed channel payloads (optional)
      heartbeat                        # liveness: epoch ms, refreshed about every 10s
      return_value.txt                 # when `jaiph run` default workflow returns a value (success only)
      run_summary.jsonl                # durable event timeline

Two runs of the same source landing in the same UTC second (e.g. concurrent jaiph mcp / jaiph serve calls) would otherwise collide on this directory name and clobber each other’s artifacts; when that happens the second run’s directory gets an -<run id prefix> suffix appended instead of reusing the first run’s directory.

Step sequence numbers are monotonic and unique per run: RuntimeEventEmitter allocates them in memory (allocStepSeq) when opening each step’s capture files (%06d-<safe_name>.out|.err). There is no .seq file in the run directory.

Keyed hash chain (tamper-evident audit journal)

Every line written to run_summary.jsonl by RuntimeEventEmitter carries a prev_hash field. The field holds a keyed HMAC-SHA256 (in hex) of the previous raw JSON line — chainHmac(key, previousLine) — with chainHmac(key, CHAIN_GENESIS) for the first line. The key is a per-run 256-bit secret (generateChainKey), so the chain is not reproducible from the public algorithm alone: rewriting a line, or dropping a line and re-linking the survivors, invalidates the chain and cannot be re-forged without the key.

Key isolation (finding H-3). The journal is written by the trusted kernel process, but the audited workflow — its script steps and prompt/agent subprocesses — must not be able to forge the chain. The key travels in the kernel process env under JAIPH_CHAIN_KEY (CHAIN_KEY_ENV) and is scrubbed from every subprocess env: scrubTrustedKeys (node-workflow-runtime.ts) removes it — along with the journal path JAIPH_RUN_SUMMARY_FILE — from every script scope, and scrubPromptEnv (env-allowlist.ts) drops it at the agent boundary even though the JAIPH_ prefix otherwise forwards run-control keys into the Docker container (the in-container kernel legitimately needs it). The host (src/cli/commands/run.ts, src/cli/shared/workflow-call.ts) generates the key, forwards it to the runner, and — once the run is terminal — persists it in an operator-side store (writeChainKey) so read/export boundaries can verify it.

Key storage outside the run directory (finding M-3). The key is not written into the run directory, which is agent-writable ($JAIPH_RUN_DIR for script steps, bind-mounted rw at /jaiph/run under Docker). Storing it there let a workflow’s first script step mkdir "$JAIPH_RUN_DIR/.chain-key" to squat the path so the host’s write threw and was swallowed, then rewrite the journal freely with no integrity failure surfaced. Instead the key lives in an operator-side store — resolveAuditKeyStore (emit.ts), default ~/.jaiph/audit-keys, override JAIPH_AUDIT_KEY_DIR — that is never mounted into a container. Each run gets one entry directory <store>/<sha256(canonical run dir)> holding the secret key file; the directory’s existence is the durable “this run was keyed” marker. Persistence is a hard error: writeChainKey no longer swallows failures, and it creates the marker directory before the key, so even a partial write leaves the run marked keyed-but-keyless (which fails closed below) rather than silently unverifiable.

Verification at read/export boundaries. verifyRunSummaryChain(filePath, key, opts?) walks each line, checks prev_hash against the recomputed keyed digest, and returns { ok: false, error } at the first broken link (a missing/unreadable journal is a failure, not a silent pass). With opts.requireTerminal it additionally requires the journal to end with the WORKFLOW_END terminal marker (TERMINAL_EVENT_TYPE) — the chain commits to prefix integrity but not to length, so deleting the last K lines of a completed journal leaves a shorter-but-valid chain that would otherwise verify; requiring the terminal marker rejects any post-terminal tail truncation (finding L-3). verifyRunJournal(runDir) wraps it (always with requireTerminal, since a key is persisted only once the run is terminal): it looks up the run’s store entry and returns { verified: false, ok: true } when the run has no entry (an unkeyed/legacy run that cannot be verified — never blocked), { verified: true, ok: false } when the run was keyed but the key is missing at verification time (fail closed — a keyed run whose key vanished must not downgrade to “not verified” and let a tampered journal through), or { verified: true, ok } with the chain result otherwise. Every read/export boundary hard-fails when verified && !ok: run listing (loadPersistedRuns marks the run failed with TAMPERED_RESULT_TEXT), GET /v1/runs/{id}/events (409 E_TAMPERED), and OTLP/Sentry export (skip + warn, never POST a tampered journal).

Scope of the guarantee. A workflow script step cannot read the key or the journal path from its env, and cannot alter the journal in any way that verifies — any rewrite or omitted line is rejected, and any truncation during the run is caught because the kernel keeps appending under the pre-truncation head. A post-run clean truncation of a completed journal’s tail is also rejected: the terminal-marker check (finding L-3) fails a keyed journal that no longer ends with WORKFLOW_END. Because a .jh host run and its script steps execute under the same OS user, a hash chain still cannot defend against a post-run same-user process that deletes the run’s store entry, which makes the run unverifiable (verified:false) rather than a detectable tamper. Under Docker sandboxing the key never enters the container, and the key store lives outside every bind mount, so an in-sandbox workflow can neither read the key nor reach the store to squat or delete it.

Secret redaction

Before RuntimeEventEmitter writes an event line to run_summary.jsonl, it redacts the values of credential environment variables. A credential environment variable is a key whose name (case-insensitive) either contains one of the substrings SECRET, PASSWORD, PASSPHRASE, TOKEN, PRIVATE_KEY, ACCESS_KEY, API_KEY, CREDENTIAL or ends in _PAT / _DSN, and whose value is at least 4 characters. Substring (not just suffix) matching is deliberate: it catches AWS_SECRET_ACCESS_KEY, AWS_ACCESS_KEY_ID, STRIPE_SECRET_KEY, DB_PASSWORD, PASSPHRASE, SSH_PRIVATE_KEY, and SERVICE_CREDENTIALS, which the earlier four-suffix rule (_API_KEY / _TOKEN / _SECRET / _API_TOKEN) silently missed. For each matching value, both the raw value and its base64 / base64url / hex / URL-encoded re-encodings are replaced with [REDACTED] wherever they appear in:

The rule covers backend API keys such as ANTHROPIC_API_KEY, OPENAI_API_KEY, and CURSOR_API_KEY (the same names on the Docker env allowlist).

The same credential rule lives in one shared helper, redactCredentials (src/runtime/kernel/redact.ts). The helper is also the redaction boundary for returned call results. composeResult (src/cli/shared/workflow-call.ts) redacts a failed call’s diagnostic capture (the failed-step detail, the raw stderr and stdout, and the collected log messages) before it becomes jaiph serve’s result_text or a jaiph mcp tool result. A successful workflow’s return value is intentional API output rather than diagnostic capture, so it is returned verbatim. The journal that redactCredentials produces is what the OTLP export (otlp.ts), the Sentry export (sentry.ts), and GET /v1/runs/{id}/events (handler.ts) read back verbatim, so broadening the rule tightens all four surfaces at once.

Explicit non-guarantee. Redaction is literal-substring replacement of the value and the base64 / base64url / hex / URL-encoded encodings listed above. A secret transformed some other way — split across two output chunks, JSON-string-escaped, gzipped, re-chunked, or embedded as the password inside an opaque connection string (e.g. a DATABASE_URL, whose key name does not itself look like a credential) — is not guaranteed to be redacted. Beyond the two redaction boundaries (journal copies and returned call results), redaction is not applied at all: the per-step raw capture files (%06d-<name>.out / .err) are streamed to disk verbatim. Treat them, and the run directory as a whole, as sensitive. Redaction also covers only the durable journal copy of each event, not the live __JAIPH_EVENT__ progress stream on the runner’s stderr that the progress UI and hooks read, so a hook that reads a LOG or STEP_END line off that stream sees the field as the workflow authored it.

Channels and hooks in context

Channels are validated at compile time (validateReferences and the send RHS rules) and executed through an in-memory queue and dispatch in the Node runtime. Durable inbox/ files under the run directory appear only for routed sends, as an audit copy (see Inbox & Dispatch). Hooks are CLI-only. They load from hooks.json and run as shell commands with JSON on stdin, driven by the same __JAIPH_EVENT__ stream as the progress UI. One dispatch contract covers all three invocation modes: interactive jaiph run through the run emitter, and jaiph serve and jaiph mcp calls through callWorkflow’s shared event collector. jaiph run --raw and jaiph test are documented as no-hook lanes (see Hooks).

Test runner integration (*.test.jh in the kernel)

jaiph test wires into the same stack as jaiph run. runSingleTestFile (src/cli/commands/test.ts) calls loadModuleGraph(testFileAbs, workspaceRoot) once, then threads the resulting ModuleGraph through buildScriptsFromGraph(graph, tmpDir) and runTestFile(graph, …). runTestFile calls buildRuntimeGraph(graph) once per file and the runtime view is reused across all blocks and test_run_workflow steps (the import closure is constant for a given test file within a single process run). Each test_run_workflow step resolves mocks against that runtime view, then constructs NodeWorkflowRuntime with mockBodies / mock prompt env, passing suppressLiveEvents: true so RuntimeEventEmitter skips writing __JAIPH_EVENT__ lines to stderr while still appending run_summary.jsonl for that run. Without suppressLiveEvents, every workflow event would print to the test process’s stderr and swamp the node --test reporter output. Mock prompts, workflows, rules, and scripts are supported through the runtime’s mock infrastructure.

The buildScriptsFromGraph call writes scripts/ so imported workflows have paths under JAIPH_SCRIPTS. Unrelated *.jh files elsewhere in the repo are not compiled unless imported.

Authoring rules, fixtures, and mock syntax for *.test.jh are documented in Testing, not here.

CLI progress reporting pipeline

The progress UI combines a static step tree derived from the workflow AST (src/cli/run/progress.ts) with live updates from the runtime event stream. Event wiring: src/cli/run/events.ts and src/cli/run/stderr-handler.ts parse __JAIPH_EVENT__ lines; src/cli/run/emitter.ts bridges into the renderer. Line-oriented formatting (formatStartLine, formatHeartbeatLine, formatCompletedLine) lives primarily in src/cli/run/display.ts, which shares some display helpers with progress.ts. Async branch numbering (subscript ₁₂₃… prefixes) is driven by async_indices on step and log events — the runtime propagates a chain of 1-based branch indices through AsyncLocalStorage, and the stderr handler renders them at the appropriate indent level. Whether ANSI SGR colors are emitted is a single policy — canUseAnsi() (src/runtime/kernel/portability.ts) returns isTTY && NO_COLOR unset — and every color emission site (src/cli/commands/run.ts, src/cli/run/progress.ts, src/cli/shared/errors.ts) routes its gate through it rather than re-deriving isTTY && NO_COLOR locally. On Windows 10+ Node enables console VT processing automatically, so isTTY is a sufficient ANSI proxy with no extra win32 branch. const steps whose Expr value is kind: "match" are walked for nested run / ensure arms; matched targets appear as child items in the step tree (for example ▸ workflow my_flow or ▸ rule my_rule under the const row). This pipeline does not apply to jaiph run --raw.

Distribution: Node vs Bun standalone

Mermaid architecture diagram

flowchart TD
    U[User / CI] --> CLI[CLI: Node or Bun jaiph]

    subgraph Transpile["Per-module: emitScriptsForModuleFromGraph()"]
        VAL[validateModule]
        EMIT[Emit atomic script files under scripts/]
        VAL -->|compile errors| ERR[Deterministic compile errors]
        VAL --> EMIT
    end

    CLI -->|jaiph run| LMG1[loadModuleGraph entry + closure]
    LMG1 --> BS1[buildScriptsFromGraph]
    BS1 --> Transpile

    CLI -->|jaiph test| LMG2[loadModuleGraph(entry .test.jh)]
    LMG2 --> BS2[buildScriptsFromGraph]
    BS2 --> Transpile
    LMG2 --> TR[Node Test Runner in-process]

    Transpile -->|jaiph run local| RW[__workflow-runner child]
    Transpile -->|jaiph run Docker| DC[Container: jaiph run --raw]
    LMG1 -. JAIPH_MODULE_GRAPH_FILE (local non-Docker only) .-> RW

    RW --> G[buildRuntimeGraph from graph]
    G --> GRAPH[RuntimeGraph]
    RW --> RT[NodeWorkflowRuntime]
    RT --> GRAPH

    DC --> G
    DC --> RT

    TR -->|test_run_workflow| G
    TR --> RT

    RT -->|script steps| SCRIPT[Managed script subprocesses]
    RT -->|prompt steps| KERNEL[Kernel libs: prompt, events, inbox, stream, schema, mock]

    RT -->|live events| EV["__JAIPH_EVENT__ stderr only"]
    EV --> CLI
    CLI --> PR[Progress rendering]

    RT -->|channels files / queues| INBOX[Inbox under .jaiph/runs]
    RT -->|durable artifacts| SUM[.jaiph/runs + run_summary.jsonl]
    CLI --> HK[Hook dispatcher via event stream]
    HK --> HPROC[Hook shell commands]

Emit artifacts: buildScripts() persists only extracted script bodies under scripts/. No workflow-level shell modules or jaiph_stdlib.sh are produced.

Sequence diagram: regular flow (*.jh)

Interactive jaiph run (no --raw): banner, progress tree, hooks, and PASS/FAIL footer.

sequenceDiagram
    participant User
    participant CLI as CLI jaiph run
    participant Load as loadModuleGraph
    participant Prep as buildScriptsFromGraph
    participant TF as emitScriptsForModuleFromGraph per module
    participant Runner as __workflow-runner child
    participant Graph as buildRuntimeGraph(graph)
    participant Runtime as NodeWorkflowRuntime
    participant Kernel as JS kernel
    participant Report as Artifacts (.jaiph/runs)

    User->>CLI: jaiph run main.jh args...
    CLI->>Load: loadModuleGraph(entry, workspace)
    Load-->>CLI: ModuleGraph (modules map)
    Note over CLI: reuse entry AST for metadataToConfig / banner
    CLI->>Prep: buildScriptsFromGraph(graph, outDir)
    Prep->>TF: loop: validateModule + emit (in-memory AST)
    TF-->>Prep: scripts/ atomic only
    Prep-->>CLI: scriptsDir + env JAIPH_SCRIPTS
    alt local (non-Docker)
        CLI->>CLI: writeModuleGraph(<outDir>/.jaiph-module-graph.json)
        Note over CLI: set JAIPH_MODULE_GRAPH_FILE on child env
        CLI->>Runner: spawn detached __workflow-runner child
    else Docker
        CLI->>CLI: prepareImage (pull --quiet + verify jaiph)
        Note over CLI: runs before banner so pull doesn't interleave
        CLI->>Runner: spawn container running jaiph run --raw
        Note over CLI: CLI parses events on stderr only
    end
    alt JAIPH_MODULE_GRAPH_FILE set (local non-Docker)
        Runner->>Runner: readModuleGraph(file)
        Runner->>Graph: buildRuntimeGraph(graph)
        Note over Graph: no .jh re-reads
    else absent (Docker / --raw / test runner)
        Runner->>Runner: loadModuleGraph(sourceAbs, workspace)
        Runner->>Graph: buildRuntimeGraph(graph)
    end
    Graph-->>Runner: RuntimeGraph
    Runner->>Runtime: runDefault(run args)
    Runtime->>Kernel: prompt / managed scripts / emit / inbox
    Runtime-->>CLI: __JAIPH_EVENT__ on stderr
    Runtime->>Report: run_summary.jsonl + step artifacts
    Runner-->>CLI: exit + meta file with run_dir paths
    CLI-->>User: live progress
    CLI-->>User: PASS/FAIL

Docker: the inner container command is jaiph run --raw … (see Sandboxing): no banner or progress UI inside the container; __JAIPH_EVENT__ lines still appear on stderr for the host CLI to parse.

Sequence diagram: jaiph test flow

sequenceDiagram
    participant User
    participant CLI as CLI jaiph test
    participant Load as loadModuleGraph
    participant Prep as buildScriptsFromGraph
    participant TestRunner as runTestFile / runTestBlock
    participant Graph as buildRuntimeGraph(graph)
    participant Runtime as NodeWorkflowRuntime
    participant Report as Artifacts

    User->>CLI: jaiph test flow.test.jh
    CLI->>Load: loadModuleGraph(test file, workspace)
    Load-->>CLI: ModuleGraph (entry + import closure)
    CLI->>Prep: buildScriptsFromGraph(graph, tmp)
    Prep-->>CLI: scriptsDir
    CLI->>TestRunner: runTestFile(graph, workspace, scriptsDir, blocks)
    TestRunner->>Graph: buildRuntimeGraph(graph) once per file
    Graph-->>TestRunner: RuntimeGraph cached
    loop each test block
        TestRunner->>TestRunner: mocks / shell steps / expectations
        opt test_run_workflow step
            TestRunner->>Runtime: new runtime mockBodies from block (reuses cached graph)
            Runtime->>Runtime: runNamedWorkflow(ref args)
            Runtime-->>TestRunner: status output returnValue error
        end
    end
    Runtime->>Report: artifacts when workflows ran
    TestRunner-->>CLI: aggregate PASS/FAIL
    CLI-->>User: exit code

Summary