Runtime Graph
Audience: Tier 2/3 (team adopter, framework contributor).
This chapter documents the per-workflow runtime-graph.json artefact:
the data-plane mirror of stage-graph.json, materialised from the audit
log on every approval gate by aidlc-runtime.ts compile. 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>/. Its rows are
RuntimeStage records (aidlc-runtime.ts), a separate shape from
stage-graph.json's GraphStage (aidlc-graph.ts); the two are joined
by stage slug.
It exists so consumers (the engine's Unit batches, read through
resolveBoltDag in aidlc-lib.ts; the status line and /aidlc --status
when the Unit plan file is not there; the learnings surface memory_path
lookup in aidlc-learnings.ts; doctor's graph diagnostics in
aidlc-doctor-bundle.ts; and runtime summary) 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-created intent identifies the live workflow, not a dead one)
scope: string; // from aidlc-state.md "Scope" field (or the latest WORKFLOW_STARTED row's Scope when the state has none)
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; new names use lowercase kebab-case, while safe legacy single-segment names beginning with a digit or containing uppercase letters, underscores, or dots remain accepted (the swarm derives a separate internal Bolt slug); 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[]; // SENSOR_FIRED rows in the stage window, paired to their terminal row by Fire id
outcome: "approved" | "failed" | "pending";
learnings_captured: { // null on pending rows; on approved single-instance rows, RULE_LEARNED + SENSOR_PROPOSED rows in the window counted by Source
from_orchestrator: number;
from_user_addition: number;
} | null;
instances?: BoltInstance[]; // present only when a Construction stage's window holds STATE_FORKED rows for at least two distinct Bolt slugs
}
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 (four random bytes) the sensor dispatcher stamps on every row of one fire
result: "passed" | "failed" | "budget-override" | "incomplete"; // the dispatcher's three terminal rows, plus incomplete for a FIRED row with no terminal (in a still-open window, only once the newest stage-start or sensor row is 60 s past it)
ts: string; // FIRED row's timestamp
detail_path?: string;
}
When instances is present, the stage-row's single-instance fields
(started_at, completed_at, agent, memory_entries,
memory_breakdown) are NULL. started_at and completed_at sit on each
instance instead; memory_entries and memory_breakdown stay null on
the instances too (compile does not read a per-Bolt memory.md).
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. It is also an engine input for the
optional directive.wave on a recorded stage-major path. Before emitting a
wave, the engine validates this cache against the authored dependency artifact
and uses the healed in-memory batches and kinds to resolve every per-Unit entry,
including build, completion-receipt, paired-review, and Unit-memory paths. The
conductor consumes only that directive; it never reads this cached node or
reconstructs sibling paths. 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 (silently),
or when the artifact exists but its edge block is absent, malformed
(duplicate name, dangling or self-dependency, unparseable), or cyclic:
then 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. The engine reads the same block before the 2.7 review request and before the 2.7 gate opens, and while it cannot, it hands the agent the defect and the block's shape instead; a block that breaks after the approval stops Construction's next on the same repair step for the agent, never on an error for the person. 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-rebuild-stage-graph.ts) on every transition-class
audit emit. The hook fires on every Bash tool call from the
conductor. Before these compile filters it relays an engine error
directive's message to the human and binds a new intent to its session (see
Hooks and Tools);
neither affects the compile. It then filters cheaply:
- Command filter (
classifyRuntimeCompileCommandinaidlc-lib.ts): only thestate,jump,bolt,unit, andutilitytools,orchestrate report, and the semantic one-shot routes (aidlc status,doctor,version,help,scope change,config set,recompose,report) get past the early exit. The runtime route is rejected (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-engine/hooks-health/rebuild-stage-graph.lastfor doctor's silent-hook detection. - Last-3-block tail-read: merge every
audit/shard of the active intent into one buffer, split it on\n---\n, take the last 3 entries. - Event-class filter: match
**Event**: (GATE_APPROVED|STAGE_STARTED|STAGE_AWAITING_APPROVAL|AUDIT_MERGED|UNIT_MERGED|WORKFLOW_COMPLETED)against any of the 3 blocks. Exit on no match. - Dispatch —
aidlc engine runtime compile ....
WORKFLOW_COMPLETED is in the transition set so the final-stage
approve fires the compile. handleCompleteWorkflow in aidlc-state.ts
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.
The window before the first compile
init, intent create and orchestrate next are not transition-class
commands, so a fresh workflow that has run only those commands still has
no runtime-graph.json when its first gate reports. In the common path,
the file first appears at the orchestrate report --result
awaiting-approval on the first gated stage. An earlier aidlc status,
config set, or copy-channel utility intent-create route compiles it
sooner when one happens to run, but the file MAY still be absent at the
first gate: consumers cannot assume it exists. A fresh clone,
a git clean, or a single dropped hook compile leaves the same hole
mid-workflow, because the file is gitignored and machine-local.
Consumers must therefore treat an absent file as ordinary state and
recompute or degrade, never fail: learnings surface silently recomputes
the one field it reads (memory_path, derived exactly as the compile
derives it), so the stage-protocol section 13 ritual
still runs on the first gate. A MALFORMED file, or a row that exists
without a memory_path, is different — that is corruption, and it fails.
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: the Learning Loop section of audit-format.md's event registry
lists tools/aidlc-runtime.ts compile) when a stage row meets ALL of:
outcome === "approved"(pending rows do not emit; see 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 is "stage approved with zero entries", which 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 the intent's audit/
shards, 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.
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. Missing memory.md rule
A stage whose memory.md does not exist (a diary that was never created) gets null counts and no MEMORY_EMPTY row:
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.
7. Recovery sources
runtime-graph.json and the intent's audit/ shards form an
event-sourced pair. The shards are the append-only event log;
runtime-graph.json is a materialised snapshot taken at the last
transition-class emit, and every compile re-derives it from the whole log.
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.
- aidlc-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.
aidlc-state.ts resume (handleResume) does not read
runtime-graph.json: it reports the cursor fields of aidlc-state.md
(current stage, phase, status, scope, agent, next stage, gate state, the
unit checkpoint when one is set) and scans the audit tail for a
SESSION_COMPACTED row with no stage activity after it.
Parallel-Bolt mid-flight recovery
aidlc-runtime.ts fragment-fork (called by aidlc-bolt start
--worktree) byte-copies main's runtime-graph.json into the Bolt's
worktree (or writes an empty graph there when main has none), and fragment-merge (called by aidlc-bolt complete --merge)
removes it; neither emits an audit row. The compile populates
BoltInstance[] when a Construction-phase stage's window holds
STATE_FORKED rows for at least two distinct Bolt slugs. The full
lifecycle is in section 11.
No consumer reads the worktree fragment's content: fragment-merge
hashes and removes it, a swarm recovery moves it into its resume archive,
resume reports from aidlc-state.md and the audit tail (above), and
main's post-merge runtime-graph.json is rebuilt from main's audit shards.
8. CLI surface
# Walk audit + memory.md, write runtime-graph.json (invoked by hook).
aidlc engine runtime compile
# Print one stage row from runtime-graph.json (debug/test surface).
aidlc engine runtime 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.
aidlc engine runtime 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.
aidlc engine runtime 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.
aidlc engine runtime 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 {{INVOKE}} engine orchestrate report --stage <slug> --result approved --user-input "Approve" 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 command invocation
regardless of what the LLM does next. The audit-emitting hidden dispatcher
routes (aidlc engine state ..., jump ..., bolt ...,
utility ..., and orchestrate report ...) are the deterministic
anchor.
10. Harness dispatch
The compile hook is a core hook, not a Claude Code one. Claude Code
registers it as a PostToolUse Bash hook in settings.json; the Codex,
Copilot, Cursor, Kiro CLI, Kiro IDE and opencode adapters forward their
shell post-tool event to the same aidlc-rebuild-stage-graph.ts. Kiro
IDE does not surface the shell command, so its adapter sends
source: "ide-audit-sync" and the hook skips the command filter, gates
on the audit tail, and skips the compile when runtime-graph.json is
already at least as new as every audit shard.
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 (solo/swarm path).
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 120-event taxonomy and the emitter registry. See State Machine and the User Guide's State and Audit Trail.