Runtime Graph
Audience: Tier 2/3 (team adopter, framework contributor).
This chapter documents the per-workflow runtime-graph.json artefact
introduced in v0.5.0 milestone 8 — the data-plane mirror of stage-graph.json,
materialised from the audit log on every approval gate. Cross-link to
Plane Architecture (the control/data plane
separation that motivates this artefact) and
State Machine (the lifecycle whose transitions
trigger compile).
1. What it is
stage-graph.json is structural truth — every stage definition, every
requires_stage / produces / consumes edge. It's stable across
workflow runs.
runtime-graph.json is execution truth — for the current workflow,
which stages have started, which have approved, what each stage's
memory.md looks like, what sensors fired. One file per workflow, lives
at <record>/runtime-graph.json — <record>/ = the intent's record dir,
aidlc/spaces/<space>/intents/<YYMMDD>-<label>/. Same node shape as
stage-graph.json, populated with telemetry instead of structure.
It exists so consumers (milestone 11's Bolt fork/merge, milestone 12's gate ritual, milestone 14's doctor, v0.10.0's cross-workflow observer) read one materialised view rather than re-walking the audit log on every query.
2. Schema
The TS interface below is the locked contract. Changing it requires bumping every consumer in the same PR.
interface RuntimeGraph {
workflow_id: string; // ISO timestamp from LATEST WORKFLOW_STARTED audit row (so a re-birthed intent identifies the live workflow, not a dead one)
scope: string; // from state.md "Scope" field
started_at: string; // ISO 8601, same row as workflow_id
stages: RuntimeStage[]; // chronological order by started_at
bolt_dag?: BoltDag; // present only when units-generation's unit-of-work-dependency.md carries a valid (well-formed, acyclic) fenced edge block; absent/malformed/cyclic blocks omit the node
}
interface BoltDag {
units: { name: string; depends_on: string[]; kind?: string }[]; // verbatim from the authored edge block; kind (service|spec|ui|packaging|library) present only when the edge block tags the unit
batches: string[][]; // topological levels; each level = units whose deps are all satisfied by prior levels; level entries sorted lexicographically (deterministic)
}
interface RuntimeStage {
stage_slug: string;
started_at: string | null; // ISO from STAGE_STARTED; null when `instances` is present
completed_at: string | null; // ISO from STAGE_COMPLETED; null when pending OR when `instances` is present
agent: string | null; // lead_agent; null when `instances` is present
memory_path: string; // <record>/<phase>/<stage>/memory.md (parent stage path even on instance-bearing rows)
memory_entries: number | null; // null = no memory.md file OR `instances` is present; else parseMemoryHeadings.total
memory_breakdown: { // null when memory_entries is null
interpretations: number;
deviations: number;
tradeoffs: number;
open_questions: number;
} | null;
sensor_firings: SensorFiring[]; // empty array in milestone 8 (sensors fire in milestone 9 + milestone 10)
outcome: "approved" | "failed" | "pending";
learnings_captured: { // null on pending rows; populated on transition to approved
from_orchestrator: number; // zero in milestone 8 (gate ritual is milestone 12)
from_user_addition: number;
} | null;
instances?: BoltInstance[]; // present only when stage runs per-Bolt; milestone 11 populates
}
interface BoltInstance {
bolt: string;
worktree: string;
started_at: string;
completed_at: string | null;
memory_path: string;
memory_entries: number | null;
memory_breakdown: { interpretations: number; deviations: number; tradeoffs: number; open_questions: number; } | null;
sensor_firings: SensorFiring[];
outcome: "approved" | "failed" | "pending";
}
interface SensorFiring {
id: string;
fire_id: string; // 8-hex correlator emitted by the milestone 9 dispatcher on every row
result: "passed" | "failed" | "budget-override" | "incomplete"; // 4-state (milestone 12 Q10)
ts: string; // FIRED row's timestamp
detail_path?: string;
}
When instances is present, the stage-row's single-instance fields
(started_at, completed_at, memory_entries, memory_breakdown)
are NULL — those values sit on each instance instead. Stage-row
fields and instance-array fields never coexist.
The Bolt/unit dependency DAG (bolt_dag)
The optional bolt_dag node is the machine-readable unit dependency
graph the engine reads to compute a parallel build batch — "the DAG is
the permission" for a swarm fan-out. Its source is the fenced
yaml units: edge block that units-generation (2.7) authors on
unit-of-work-dependency.md, beside the human-readable prose:
units:
- name: auth
kind: service
depends_on: []
- name: api
depends_on: [auth]
Each unit may carry an optional kind (service | spec | ui | packaging |
library), what the unit IS. It rides verbatim into bolt_dag.units[].kind
and drives the per-unit construction design pruning (see
Stage definition produces_kinds): a stage's produces
artifacts are filtered to the ones that apply to each unit's kind. An untagged
unit carries no kind key and keeps the full design-artifact matrix. An invalid
kind value makes the whole block malformed (see below), so a typo fails loud at
the 2.7 gate rather than pruning wrong.
compile parses that structured block — a pure-data parse, no model
call — into units (verbatim edges) and batches (topological
levels). Each batch is the set of units whose dependencies are all
satisfied by earlier batches, so a batch's units have no mutual
dependency and can run in parallel. Level entries are sorted
lexicographically before emission, so the node is deterministic
regardless of authored order.
The node is omitted entirely when the artifact is absent, or when
its edge block is absent, malformed (duplicate name, dangling or
self-dependency, unparseable), or cyclic — compile writes a stderr
diagnostic naming the reason and leaves bolt_dag off the envelope
rather than emit a wrong-but-valid DAG. Those failures are surfaced
upstream at the 2.7 gate by the required-sections sensor, which
validates the same block and reports edge_block: ok | absent |
malformed | cyclic. Authoring the edges as structured data (knowledge
work, once, behind the 2.7 approval gate) is what keeps the hook-fired
compile byte-identical on re-run: no model sits in the compile path. The orchestrate engine validates a cached bolt_dag against unit-of-work-dependency.md and self-heals per-unit iteration on the read side when the node is absent or disagrees with that authored artifact; the graph file itself is only repaired by the next compile.
3. Compile lifecycle
The compile is invoked by the PostToolUse Bash hook
(.claude/hooks/aidlc-runtime-compile.ts) on every transition-class
audit emit. The hook fires on every Bash tool call from the
conductor and filters cheaply:
- Command filter — only
bun .claude/tools/aidlc-(state|jump|bolt|utility).tsinvocations get past the early exit.aidlc-runtime.tsis excluded (recursion guard);aidlc-log.tsemits only chatty in-stage events;aidlc-worktree.tsemits only WORKTREE_* events. - Audit-existence guard — exit if the intent's
audit/shard doesn't exist yet. - Heartbeat — write
<record>/.aidlc-hooks-health/runtime-compile.lastfor doctor's silent-hook detection. - Last-3-block tail-read — split
audit.mdon\n---\n, take the last 3 entries. - Event-class filter — match
**Event**: (GATE_APPROVED|STAGE_STARTED|STAGE_AWAITING_APPROVAL|AUDIT_MERGED|WORKFLOW_COMPLETED)against any of the 3 blocks. Exit on no match. - Dispatch —
spawnSync("bun", [".claude/tools/aidlc-runtime.ts", "compile", ...]).
WORKFLOW_COMPLETED is in the transition set so the final-stage
approve fires the compile. handleCompleteWorkflow at
aidlc-state.ts:575-593 emits 4 audit rows — STAGE_COMPLETED +
PHASE_COMPLETED + PHASE_VERIFIED + WORKFLOW_COMPLETED — and the last 3
of those are PHASE_COMPLETED + PHASE_VERIFIED + WORKFLOW_COMPLETED.
(On the approve path the STAGE_COMPLETED is suppressed because approve
already emitted it, and a GATE_APPROVED precedes the run — so a
final-stage approve appends 5 rows in one Bash call either way.)
Without WORKFLOW_COMPLETED in the regex, the runtime-graph would
never record the final stage as approved.
The compile itself walks the full audit log (so the result is
event-sourced, not transition-incremental), pairs STAGE_STARTED with
the next STAGE_COMPLETED for the same slug, reads each stage's
memory.md via parseMemoryHeadings() from aidlc-lib.ts, and writes
the artefact atomically via writeFileAtomic inside withAuditLock.
4. Outcome enum and chronological pairing
Three outcome values: "approved" | "failed" | "pending".
- approved —
STAGE_STARTED@T1paired with a laterSTAGE_COMPLETED@T2. The row'scompleted_atisT2. - pending —
STAGE_STARTED@T1with no laterSTAGE_COMPLETEDfor that slug. The row'scompleted_atisnull. - failed — emitted by the parent-stage rollup of
instances[]only (single-instance stages stay"approved" | "pending"). When a Construction stage'sinstances[]is non-empty, the parent'soutcomeis the rollup of its instances: all approved →approved; any failed →failed; otherwise (any pending, no failures) →pending. Single-instance stages do not emitfailedbecause the underlyingBOLT_FAILEDevent has no Construction-stage scope outside the instances-bearing path.
Re-jump handling: /aidlc --stage <slug> re-emits STAGE_STARTED for
an already-completed slug. The audit log carries
STAGE_STARTED@T1, STAGE_COMPLETED@T2, STAGE_STARTED@T3. Pairing rule
matches STARTED@T1 with COMPLETED@T2 → would yield approved, but
the LATEST STAGE_STARTED for the slug supersedes any earlier row —
one row per slug, latest STARTED wins. So the result is a pending row
with started_at: T3, completed_at: null.
Single-stage exclusion: a --single stage-runner run commits its
STAGE_STARTED/STAGE_COMPLETED pair under a synthetic
**Workflow**: single-stage:<slug> id (audit-only; see
aidlc-orchestrate.ts handleSingleReport). The pairing skips any
STAGE_* row whose Workflow field starts with single-stage: —
those rows belong to no main workflow, so they never create or
complete a row in the main runtime-graph.json (and therefore never
inflate summary counts). Main-workflow STAGE_* rows carry no
Workflow field; absence means the row is kept. The same exclusion
applies to aidlc-state.ts's hasStageAuditEvent dedup check, so a
single run's STAGE_COMPLETED cannot suppress the main workflow's own
completion emission for the same slug.
5. MEMORY_EMPTY semantics
MEMORY_EMPTY audit rows are emitted by the compile (the only
emitter — audit-format.md:171 registers
tools/aidlc-runtime.ts compile) when a stage row meets ALL of:
outcome === "approved"(pending rows do not emit — see §6 below)memory_entries === 0(file exists, zero entries under the four canonical §13 headings)
Pending rows with zero entries do NOT emit. A stage still in flight may legitimately have zero entries because the conductor hasn't written to memory.md yet — emitting MEMORY_EMPTY mid-flight would generate noise that doesn't represent a real diary skip. The signal milestone 14's doctor wants is "stage approved with zero entries" — that requires the stage to have approved.
Idempotency — exactly once per (slug, gate-completion)
runtime-graph.json itself is byte-equivalent across re-compiles
against the same audit log. MEMORY_EMPTY emits are stronger:
at most one MEMORY_EMPTY row per (stage_slug, completed_at) tuple.
Inside the locked section the compile re-reads audit.md, scans for
existing MEMORY_EMPTY rows for each zero-entry-approved slug, and
suppresses the emit if any prior row's Timestamp is at or after this
slug's completed_at. This means:
- The first compile after a stage approves with zero entries emits one MEMORY_EMPTY row.
- Every subsequent compile during the same workflow does NOT re-emit for that slug.
--stage <slug>re-jump + re-approve produces a newSTAGE_COMPLETED(latercompleted_at) — if the stage is still empty on re-approval, a fresh MEMORY_EMPTY row emits because the prior row's Timestamp is now < the new completed_at.
Doctor's MEMORY_EMPTY-rate metric reads these rows directly without de-duplication; one row per gate-completion-with-empty-diary.
If the artefact write fails after MEMORY_EMPTY emits inside the locked section, the audit log carries N MEMORY_EMPTY rows for stages whose runtime-graph.json never landed. The next compile sees those rows in its suppression scan and skips re-emit; the artefact then lands. No duplicate emits, no phantom artefacts.
6. v0.4.0 backfill rule
Stages that completed before milestone 13's memory.md lifecycle ships have no memory.md history. The backfill rule:
memory_entries: null↔memory_breakdown: null↔ no MEMORY_EMPTY emit.- Both fields move together. The discriminator is "did
parseMemoryHeadingsexecute?" — if memory.md exists (even zero-byte), it executed and the keys are numbers; if memory.md is absent, both arenull.
Without this rule, every v0.4.x user upgrading to v0.5.0 would see a storm of MEMORY_EMPTY rows on the first post-upgrade workflow.
7. Recovery model — snapshot + suffix replay
runtime-graph.json + audit.md form an event-sourced pair.
audit.md is the append-only event log; runtime-graph.json is a
materialised snapshot taken at the last gate transition. A reader with
both reconstructs the current state by reading the snapshot, then
replaying audit rows after the snapshot's last completed_at.
Five recovery sources, in human read order:
- Artefact tree (
<record>/<phase>/<stage>/) — what was produced. - memory.md (
<record>/<phase>/<stage>/memory.md) — what the conductor chose to capture. - audit/ shards — the canonical event log; what actually happened.
- state.md — the active-stage cursor.
- runtime-graph.json — the materialised view; faster to query than re-walking audit, but always re-derivable from it.
Freshness caveat for pending rows
Pending rows' memory_entries and memory_breakdown were snapshotted
at last compile time. If a stage is mid-flight and the conductor
has written more entries since the last compile fired, the snapshot
lags. Recovery consumers must re-parse memory.md at recovery time;
they must NOT trust snapshotted counts for pending rows.
v0.5.0 has no consumer that reads pending counts live. Documented for
v0.6.0 --resume, which will need this carve-out.
Parallel-Bolt mid-flight recovery (closed in v0.5.0)
A workflow with parallel Bolts crashing mid-batch had no per-Bolt
recovery seam in milestone 8 — the schema reserved instances? but compile
only wrote single-instance rows on main, and worktrees never received
a runtime-graph fragment. Closed in v0.5.0 by aidlc-runtime.ts
fragment-fork (Bolt start) and fragment-merge (Bolt complete
--merge), and by the compile populator extension that emits
BoltInstance[] when audit shows ≥ 2 distinct slugs in a
Construction-phase stage's window.
The per-Bolt fragment is dead-on-arrival in v0.5.0 (no v0.5.0 reader
of the worktree's record-dir runtime-graph.json). v0.6.0 --resume
should treat the fragment as a hint, with main's post-merge
runtime-graph as canonical, and additionally check for orphaned
worktrees per aidlc-bolt.ts to surface a recovery prompt for those.
8. CLI surface
# Walk audit + memory.md, write runtime-graph.json (invoked by hook).
bun .claude/tools/aidlc-runtime.ts compile
# Print one stage row from runtime-graph.json (debug/test surface).
bun .claude/tools/aidlc-runtime.ts read <stage-slug>
# Print deterministic aggregates over runtime-graph.json: stage/phase
# outcome tallies, memory-entry counts by category, sensor 4-state
# tallies, learnings captured, and workflow duration. Read-only; the
# session skills (session-cost, replay, outcomes-pack) consume the
# --json shape so every number they render comes from here, not from
# LLM-side counting.
bun .claude/tools/aidlc-runtime.ts summary [--json]
# Byte-copy main runtime-graph.json into a Bolt's worktree fragment
# (one-shot; called by `aidlc-bolt start --worktree`). No audit emit —
# the fragment lifecycle rides on STATE_FORKED + AUDIT_FORKED.
bun .claude/tools/aidlc-runtime.ts fragment-fork --slug <kebab-slug>
# Remove the worktree fragment (idempotent; called by
# `aidlc-bolt complete --merge`). No audit emit — the fragment
# lifecycle rides on STATE_MERGED + AUDIT_MERGED. Main's runtime-graph
# is rebuilt event-source by the post-Bash compile hook on AUDIT_MERGED.
bun .claude/tools/aidlc-runtime.ts fragment-merge --slug <kebab-slug>
All subcommands accept --project-dir <path> to override the standard
cwd-based resolution.
The compile is hook-driven in normal operation; manual invocation exists for tests and debugging.
9. Why hook-driven, not LLM-tool-coupled
Earlier plan revisions proposed inserting spawnSibling(...,
"aidlc-runtime.ts compile", ...) calls inside handleApprove /
handleAdvance / handleComplete --merge. That approach violates the
load-bearing tenet documented in
Plane Architecture:
Where you require determinism, use a tool. Where you require knowledge, use an LLM/agent. Where you require judgement, use a human.
Runtime-graph compile is data-plane substrate that must be observable
from outside any specific session. Coupling it to LLM-invoked tools
means LLM omission breaks the determinism guarantee — if the
conductor forgets to call aidlc-orchestrate.ts report --stage <slug> --result approved --user-input "<exact choice>" after a human
clicks Approve, the audit row never appends AND the compile never
fires; runtime-graph silently lags, recovery substrate is corrupt.
The PostToolUse Bash hook fires on the conductor's actual
subprocess invocation regardless of what the LLM does next. The
audit-emit-side seam (bun aidlc-(state|jump|bolt|utility).ts) is
the deterministic anchor.
10. Known gaps closed by future PRs
- MEMORY_EMPTY-rate metric — milestone 14 doctor surfaces the rate using
the
(Stage, ISO-second)de-dup tuple frozen in §5. learnings_capturedprovenance counts — milestone 12 gate ritual populatesfrom_orchestratorandfrom_user_addition.sensor_firingsarray — milestone 9 + milestone 10 dispatch sensors and populate this slot.- Bolt fork/merge of runtime-graph.json — closed in v0.5.0 by
fragment-fork(no new audit event; rides on STATE_FORKED + AUDIT_FORKED) andfragment-merge(no new audit event; rides on STATE_MERGED + AUDIT_MERGED). Compile populatesinstances[]from audit's BOLT_*-tagged events when ≥ 2 distinct slugs sit inside a Construction stage's window. - CLI-mode dispatch for headless workflows — v0.6.0+ may ship a non-Claude-Code execution path; the hook only fires inside a Claude Code session.
11. Fragment lifecycle
The per-Bolt runtime-graph fragment file lives at
<worktree>/<record>/runtime-graph.json, gitignored, mirroring
main's location. Its lifecycle is:
- Fork on Bolt start.
aidlc-bolt start --worktree --slug <slug>delegates toaidlc-runtime fragment-fork --slug <slug>after state-fork + audit-fork. Single-read protocol:readFileSynconce into a buffer,writeFileSyncfrom the buffer to the fragment path, hash the same buffer for the stdout envelope. Closes the byte-copy / hash race against a concurrent compile rewriting main mid-fork. If main has no runtime-graph.json yet, the fragment is an empty graph anchored to the worktree's state cursor. - Evolve during the Bolt's life. The post-Bash compile hook fires
on every transition-class audit emit — including transitions
inside the worktree. Each fire recompiles the worktree's
runtime-graph.json (the fragment) from the worktree's audit view.
The fragment may end up with
instances[]populated for siblings that were active at this Bolt's audit-fork instant; later-starting siblings won't appear in the fragment because the worktree's audit is a snapshot at fork time. - Merge on Bolt complete.
aidlc-bolt complete --merge --slug <slug>delegates toaidlc-runtime fragment-merge --slug <slug>after state-merge + audit-merge. fragment-merge hashes the fragment for stdout observability,unlinkSync's it, and emits a JSON envelope. After the parent Bash invocation returns, the compile hook re-fires on main and rebuilds main's runtime-graph withinstances[]populated for the just-merged slug. - Defense-in-depth removal.
aidlc-worktree mergeandaidlc-worktree discardboth callgit worktree remove, which transitively removes the fragment. fragment-merge's explicit removal pairs with the implicit cleanup as a defense-in-depth pattern, mirroring how state-merge andgit worktree removealready pair on the state side. - Failure modes.
fragment-forkfailures (worktree-missing, fragment-already-exists, byte-copy IO error, spawn timeout) causeaidlc-boltto emitBOLT_FAILEDwith aReason: fragment-fork-*field for doctor attribution (fragment-fork-failedfor IO / guard errors;fragment-fork-timeoutfor spawn SIGTERM); state-fork + audit-fork are NOT rolled back (each emitted its own audit row already).fragment-mergefailures after audit-merge has already landed produce an unusual partial-success audit signatureBOLT_COMPLETED → STATE_MERGED → AUDIT_MERGED → BOLT_FAILED (Reason: fragment-merge-*)(fragment-merge-failedfor IO / guard errors;fragment-merge-timeoutfor spawn SIGTERM); the fragment file persists until the implicitgit worktree removecleanup. Subsequent compile against main produces a coherent runtime-graph (the BOLT_FAILED at this position scores the instance"approved"because the STATE_MERGED-wins precedence in the rollup reflects that the Bolt's content has already propagated to main. The BOLT_FAILED here is recovery telemetry; it records the seam, and the content itself remained intact).
Next Steps
- Why the data plane is structured this way — the control/data
plane separation that makes
runtime-graph.jsona mirror ofstage-graph.jsonrather than a second source of truth. See Plane Architecture. - The lifecycle that triggers compile — the workflow / phase / stage transitions whose audit emits drive the compile hook. See State Machine.
- The audit log this graph is derived from - the 74-event taxonomy and the emitter registry. See State Machine and the User Guide's State and Audit Trail.