P3b — child workflow EXECUTION, workflow outputs:, the status tree, and akm workflow plan
Status: ready for implementation
Phase: P3b of the akm task/workflow refactor
Owner artifacts: src/workflows/exec/child-workflow.ts (new — the ONE child
drive), src/workflows/exec/native-executor.ts (the one dispatch-seam branch),
src/workflows/exec/step-work.ts (childParams, the live-only childRun
outcome field, the child-blocked step arm), src/workflows/exec/run-workflow.ts
(the child-blocked outcome kind), src/workflows/exec/unit-dispatch.ts +
src/core/errors.ts (the P3a WORKFLOW_CHILD_EXECUTION_UNSUPPORTED guard is
retired), src/workflows/runtime/run-outputs.ts (new — declared-output
resolution and the exported result), src/workflows/runtime/runs.ts
(resolve-at-completion + the status tree), src/workflows/ir/schema-v4.ts +
src/workflows/ir/compile.ts + src/workflows/parser.ts (the outputs:
declaration), src/workflows/freeze/child-output-references.ts (new — the
freeze-time reference check), src/core/state/migrations.ts (migration
024-workflow-run-outputs),
src/storage/repositories/workflow-runs-repository.ts (the outputs_json
column and the four child-excluding scope queries — see B-N10, amended by
Review log R2 from three to four),
src/commands/workflow/plan.ts + src/commands/workflow-cli.ts (akm workflow plan), the §6 authorized behavior flips, and the §8 docs.
This document is the single source of truth for P3b. Lanes do not
re-derive these facts from the codebase and do not read the parent plan. Every
file:line below was verified at the head of
claude/breaking-changes-0-9-2-3cfyvp — originally at 6ec07482 (P3a's
code-review round 2), and re-verified through c5432167 (P3a's round 3
f35d433d and its close-out) during adoption. Neither changed executable
behavior, so the dispatch-seam and contract citations stand; the handful that
did not survive re-verification, and the P3a Review log entries (R9–R12)
recorded after this spec was drafted, are settled in §10 Head reconciliation
(adoption). Where §10 and an earlier section disagree, §10 wins.
0. What P3b is (and is not)
P3a made a child workflow a frozen target inside the parent's plan — the
complete child plan embedded, hash-verified on every decode, bounded at freeze —
and landed the durable storage a child run needs. It deliberately shipped with
no production caller: dispatching a child-workflow-targeted unit failed
closed with WORKFLOW_CHILD_EXECUTION_UNSUPPORTED (P3a Review log R8).
P3b makes the child run.
P3b is:
- the child executor —
src/workflows/exec/child-workflow.ts, reached from the ONE dispatch seam innative-executor.ts, which derives theinvocation_key, publishes the child idempotently, and drives it with the existing engine (runWorkflowSteps, pointed at the child run row). No second executor, no second scheduler, no second journal (§3); - workflow
outputs:— a source-level declaration of named, optionally schema-validated projections of step artifacts, compiled into source IR, frozen into the plan additively withinirVersion5, resolved and persisted at run completion, and promoted by a composing parent step (§4.2–§4.4); - the parent-child status tree on
akm workflow status, additive-only, with blocked children surfaced with their exact resume commands (§4.5); akm workflow plan <ref>— compile + freeze with zero durable writes, printing the canonical step graph, per-step frozen target kinds, task/child expansion boundaries, child plan hashes, input bindings, source read set, effective concurrency/budgets, and lowering notices (§4.6);- crash-window, replay-determinism, contention, and fixture-family coverage for all of the above (§5).
P3b is not:
- a plan
irVersionbump.WORKFLOW_IR_V5_VERSIONstays5.outputsis an ADDITIVE plan field withinirVersion5 — see §0.1 and B-N1. - a
hashVersionbump. Both prefixes stayakm.workflow.unit\0v6\0andakm.workflow.gate\0v6\0, andhashVersionstays6. §3.6 states why the preimage needs no change and B-N3 states why a plan-leveloutputsdeclaration is correctly absent from it. - a child-scheduling phase. A child is driven inline, inside its parent unit's journaled attempt, by the parent's own process. There is no background child driver, no daemon, no queue.
- an auto-resume phase. A
blockedchild is never resumed by the engine — a gate is a gate, for a child exactly as for a parent (§3.4, row A-14). - a deduplication phase. P3a's row B-23 stands: a diamond embeds two
independent copies and therefore publishes two independent child runs, under
two
invocation_keys. - a vocabulary-rename phase.
src/workflows/ir/schema-v4.tskeeps its FILE NAME and every exported TYPE name. P4 owns the rename. - a limit-relaxation phase.
WORKFLOW_MAX_PLAN_BYTES,WORKFLOW_MAX_COMPOSITION_DEPTH(8), andWORKFLOW_MAX_EMBEDDED_CHILD_PLAN_BYTES(1 MiB) are unchanged. - the phase that settles P3a Review log R9 or R11. R9 (a v3 task's
authored
with:gains reference semantics only when composed) is a FREEZE-side decision whose two options both land insrc/workflows/freeze/targets/task.ts/freeze/task-bindings.ts— neither file is on Lane A's (§3.1) or Lane B's (§4.1) list, and both lists are binding. R11 (publishChildWorkflowRunhand-rollsinsertRun's column list) stays RECORDED even though P3b wires that method's first production caller. Both are deferred to P4; see §10.
Rules of engagement (unchanged since P1b):
- A defect discovered that is not in §6 is recorded in the Review log and left unfixed. Do not "improve" anything on the way past.
- If preserving a behavior and implementing an authorized change appear to conflict, stop and record it — preserving wins until the Review log says otherwise.
- Editing a pre-existing test that §6 does not name is a review-blocking violation.
0.1 Naming discipline (binding, D1)
P3a's D1 rule carries forward verbatim and gains one clause:
| Counter | Value in P3b | Where it lives |
|---|---|---|
| Workflow plan schema | plan irVersion 5 — UNCHANGED |
WORKFLOW_IR_V5_VERSION, plan.irVersion, workflow_runs.plan_ir_version |
| Unit / gate input hash | hashVersion 6 — UNCHANGED |
akm.workflow.unit\0v6\0, akm.workflow.gate\0v6\0, the hashVersion preimage field |
| Child invocation key | \0v1\0 — UNCHANGED |
akm.workflow.child-invocation\0v1\0 (src/workflows/exec/child-invocation.ts:45) |
Never write a bare v5 or v6 in prose, a comment, a test name, or a commit
message. The \0v6\0 and \0v1\0 inside a hash PREFIX STRING are byte
literals — write them verbatim. Everywhere else the words "plan irVersion",
"hashVersion", or "child-invocation vocabulary" must accompany the number.
A P3b commit that changes any of the three numbers above is a review-blocking violation.
0.2 Commit ladder (binding)
| # | Commit | Contents |
|---|---|---|
| 1 | docs(p3): behavior spec for child execution, outputs, and status tree |
this file only |
| 2 | test(p3b): failing tests for workflow outputs, the status tree, and akm workflow plan (lane b) |
§7's Lane B suites, red |
| 3 | feat(p3b): workflow outputs, the run status tree, and akm workflow plan |
Lane B + the §6 flips Lane B owns |
| 4 | test(p3b): failing tests for the child executor (lane a) |
§7's Lane A suites, red |
| 5 | feat(p3b): child workflow execution |
Lane A + the §6 flips Lane A owns |
| 6 | test(p3b): crash windows, replay determinism, and the child-workflow fixture family (lane c) |
Lane C (tests + fixtures + the one manifest edit) |
| 7 | docs(p3b): child execution, workflow outputs, and the status tree |
§8 docs |
Lane B lands before Lane A. Lane A's child executor promotes
workflowRunExportedResult (§4.4), which Lane B introduces; the reverse
dependency does not exist (the status tree renders whatever child rows exist,
and renders nothing when there are none). Commit 3 must be green on its own;
commit 5 depends on 3; commit 6 depends on 5.
Lane C is a test-and-fixture commit by construction: it adds no src/** file
and edits exactly one pre-existing non-test file,
tests/fixtures/execution-contracts/workflows/manifest.json (F-C1).
1. Binding design decisions (verbatim)
§1.1–§1.5 are copied verbatim from the phase decisions and are binding. §1.6 records the disambiguations this spec adds, each with head-verified evidence. Where a verbatim block and a disambiguation appear to conflict, the disambiguation states which reading wins and why.
1.1 Lane A — child executor (binding)
- New src/workflows/exec/child-workflow.ts. When the native executor reaches a unit whose frozenTarget.kind === "child-workflow", it does NOT go through UnitDispatcher (that envelope is {prompt,frozenTarget}->{ok,text} — a process-call shape, not a run handle). Instead: derive invocation_key (P3a helper) from (parentRunId, parentUnitId, v6 unitInputHash); publishChildWorkflowRun idempotently (crash between publish and parent recording must find the SAME child on retry — pin it); then DRIVE the child run with the existing engine (the same run-workflow loop the top-level path uses, pointed at the child run row — spec must name the exact reuse seam after reading src/workflows/exec/run-workflow.ts and src/workflows/runtime/runs.ts; no second executor).
- Status mapping: child completed -> parent unit completes with the child's exported output as the unit artifact; child failed -> parent unit fails with journal failure_reason "child_workflow_failed" surfacing the child run id; child blocked -> parent run blocks, the blocked-state notes name the child run id and the exact resume command; publication/integrity failure -> its own specific failure reason.
- Cancellation: the parent's AbortSignal propagates into the child drive loop; an aborted child leaves BOTH runs resumable (leases released per existing lease semantics).
- Retry/resume: a parent retry or resume reuses the SAME child while (parent unit identity + effective input hash) are unchanged — the invocation_key guarantees it; gateFeedback changing the unit hash yields a NEW child (already true via the v6 preimage — pin it).
- Nesting: a child containing its own child-workflow units drives recursively; depth was bounded at freeze (P3a), so the executor needs no second bound — but pin one 3-level integration test.
- Provenance: thread eventSource into the child drive exactly as run-workflow.ts's options.eventSource does today.
1.2 Lane B — workflow outputs + status tree + akm workflow plan (binding)
- Workflow
outputs:declaration (source level): named outputs, each{from: steps.<id>.output(.<seg>)*, schema?: <bounded JSON Schema via the existing validateJsonSchemaSubset>}. Compiled into source IR, frozen into the plan (plan v5 already carries the field or gains it here — read what P3a landed and specify exactly; if it requires a plan-shape addition, it is ADDITIVE within irVersion 5 because v5 shipped this same release — say so explicitly). At run completion the engine resolves declared outputs from step artifacts, validates against schemas, and persists them as the run's exported result. A child's exported result is what the parent unit promotes (Lane A consumes this). A child with NO outputs declaration exports {runId, status} metadata only, and a parent step referencing steps..output. beyond that fails at freeze if statically knowable, else at pre-attempt. akm workflow statusgains the parent-child tree: children listed under their parent with status glyphs, blocked children surfaced with resume commands. ADDITIVE ONLY — akm workflow verbs are Stable tier; existing output lines/JSON fields keep their exact shapes, new fields/sections only. Pin the existing status output shape before extending (read the current renderer + its envelope tests).- New CLI:
akm workflow plan <ref> --json— compile+freeze WITHOUT publishing (zero durable writes, zero usage/event rows — pin that), print: source format, canonical step graph, per-step frozen target kinds, task/child expansion boundaries, child plan hashes, input bindings (names + kinds, literal values shown but NEVER resolved env/secret values), source read set (relative paths), effective concurrency/budgets, lowering notices. Text mode = human summary; --json = the full structure. Register beside the other workflow verbs; tests/contracts/command-cli-contract.test.ts must be updated in the same commit (additive).
1.3 Lane C — crash-window + replay + fixtures (binding)
- Crash windows (SIGKILL a driver subprocess, mirror tests/integration/workflow-crash-windows.test.ts's technique): (1) kill after child publication, before any child unit ran -> resume finds the child by invocation_key, drives it, no duplicate child; (2) kill mid-child-execution -> both runs resumable, child resumes its own journal; (3) kill after child completed, before parent unit finalized -> resume completes the parent unit WITHOUT re-running the child (journal replay). Plus: two-parent-process contention on one child (leases).
- Replay determinism: a resumed parent replays completed child units from the journal byte-identically (no re-dispatch), mirroring the existing chaos.test.ts patterns.
- Fixture family tests/fixtures/execution-contracts/workflows/child-workflow/: direct child, task-wrapped child, child-with-outputs, 3-level nesting; registered per the family manifest convention; a structural test proving each freezes to the expected target kinds (mirror characterization-fixture-contracts.test.ts).
1.4 Docs (ride with code) (binding)
docs/reference/workflow-schema.md (outputs:, child execution semantics, status tree), docs/reference/workflows.md, docs/guides/run-workflows.md (child resume flows), docs/reference/cli.md if it enumerates verbs, CHANGELOG [Unreleased] (child execution + outputs + workflow plan verb), docs/migration/v0.9.1-to-v0.9.2.md. Every akm example must pass scripts/lint-doc-examples.ts. STABILITY.md: workflow plan starts Evolving (new verb) — one-line tier note.
1.5 Preservation gates (binding)
All P3a suites green; frozen-plan/chaos/run-lease/crash-window suites green; existing top-level run/resume/abandon/status behavior byte-identical for non-child workflows (Stable tier); fail-before-mutation unchanged; every akm workflow verb's existing JSON envelope shape unchanged (additive fields only).
1.6 Binding disambiguations added by this spec
Each row states a decision the verbatim blocks leave open, or corrects a detail contradicted by head. Every claim carries its evidence.
B-N1 — plan irVersion 5 does NOT carry outputs today; P3b adds it,
additively, without bumping the version.
§1.2 says "plan v5 already carries the field or gains it here — read what P3a
landed and specify exactly". Verified: it does not.
WorkflowPlanGraphV4 (src/workflows/ir/schema-v4.ts:184-193) is exactly
{irVersion, title, params?, paramSchemas?, budget?, execution, sourceReadSet, steps}; decodeWorkflowPlanV4's closed key set (:220-224) and the shared
validateWorkflowPlanStructure key set (src/workflows/ir/schema.ts:163-168,
extended by planExtraKeys: ["sourceReadSet"] at schema-v4.ts:231) both omit
it, and rg outputs src/workflows/ir/ finds no field of that name anywhere —
its single hit is prose inside checkInputReference's diagnostic
(compile.ts:312, "names step outputs (steps.outputSchema (schema-v4.ts:180), a different
concept — see B-N2. P3b therefore adds outputs as a new optional top-level
plan field.
That addition is ADDITIVE within irVersion 5, and the version does not
bump, for two independent reasons, both of which must be stated in the code
comment on the new field:
irVersion5 has not shipped. It was introduced by P3a inCHANGELOG.md's[Unreleased]section for the SAME 0.9.2 release P3b ships in. Noplan_ir_version = 5row exists anywhere outside a development tree, so there is no stored plan for the addition to be incompatible with. A bump to 6 would retire a version that never reached a user.- A plan that declares no
outputs:is byte-identical. The field follows P2b's A-N7 rule — absent, never{}, when nothing is declared — socanonicalPlanJsonemits the identical bytes andcomputePlanHashthe identical digest for every workflow that does not use the feature. Every pre-existing plan-hash pin, frozen-plan fixture, andtask-binding-identity.test.tsdeterminism assertion is therefore untouched, which is why §6 lists no plan-hash flip.
B-N2 — plan outputs and step outputSchema are different concepts and
neither replaces the other.
IrStepPlanV4.outputSchema (schema-v4.ts:180, authored as a step's output:)
is a typed-artifact contract on ONE step, enforced by validateStepArtifact
(step-work.ts:891-905) and retryable through the bounded gate loop. Plan
outputs is a run-level export projection over already-promoted step
artifacts, enforced once at run completion. They compose: a step may declare
output: and a plan may project that same step's artifact under an outputs:
name. P3b changes nothing about outputSchema.
B-N3 — the hashVersion 6 preimage is unchanged, and plan outputs is
correctly absent from it.
§3.3 of the P3a spec fixes the unit preimage's field list. P3b adds no field and
removes none. A plan-level outputs: declaration is not a unit input: it
governs what the run exports after every unit has run, so a unit's completed
journal row stays valid across a change to it — the same reasoning retry and
onError are excluded under P3a §3.3 exclusion 1. The CHILD's own outputs
declaration IS covered, transitively and wholesale: it lives inside
frozenTarget.frozenPlan, which the parent unit's preimage hashes through
frozenTarget (step-work.ts:702), and any byte of it changes
frozenTarget.planHash and therefore frozenTarget.contentHash (P3a row A-15).
A reviewer must not "fix" this by adding outputs to the preimage.
B-N4 — outputs: is a Markdown-frontmatter key only; a GitHub-shaped
workflow cannot declare one.
parseGithubWorkflowSource (src/workflows/source-ir/github-yaml.ts:78-129)
accepts exactly ROOT_KEYS = ["name", "on", "jobs"] (:36) and returns
{sourceIrVersion, name, triggers, jobs, source} — it produces no params and
has no extension surface for one. params: is therefore already
Markdown-frontmatter-only (src/workflows/parser.ts:116's WORKFLOW_KEYS,
parseParams at :548-579), and outputs: is defined symmetrically: the
same authoring surface, the same name grammar, the same schema-subset validator,
the same per-schema byte bound.
Consequences, all of which must be stated in docs/reference/workflow-schema.md:
- A GitHub-shaped child workflow exports
{runId, status}metadata only. That is not a defect; it is the same reason such a workflow declares noparams:. - Composition itself is unaffected: the PARENT must be GitHub-shaped (only
jobs.<id>.steps[].usescomposes), the CHILD may be either format, and a child that wants to export a real artifact is authored in Markdown. The §5.5 fixture family exercises exactly this pairing.
B-N5 — the reuse seam is runWorkflowSteps, called with the child RUN ID as
target. Nothing else.
§1.1 requires the spec to "name the exact reuse seam". It is
runWorkflowSteps(options) (src/workflows/exec/run-workflow.ts:231), the
exported entry point akm workflow run itself calls
(src/commands/workflow-cli.ts:191). Evidence that pointing it at a child run
row is exactly the top-level path, with no special casing:
runWorkflowAttempt(:278) callsgetNextWorkflowStep(options.target, …)(:282), which callsresolveRunSpecifier(runs.ts:879). Its FIRST branch isrepo.getRunById(specifier)(:886) — a run id resolves to that row and returns immediately, withautoStarted: false. No ref canonicalization, no scope lookup, nostartWorkflowRun.requireExecutableWorkflowPlan(row)(:291) then decodes the child's ownplan_jsonagainst its ownplan_hash— the identical integrity gate a top-level run gets.acquireRunLease(:314) takes the CHILD's own run lease on the CHILD's row, with its own holder id and its ownLeaseHeartbeat. That is what makes two-parent contention on one child arbitrate correctly (§5.3) rather than needing a new mechanism.driveRun(:937) walks the child's spine throughcompleteWorkflowStep, journals the child's units under the CHILD's run id, and re-reads the child's final state.
driveRun itself is module-private and must not be exported: it takes no
lease, starts no heartbeat, seeds no accounting, and runs no retry loop.
Reaching for it would be the "second executor" §1.1 forbids.
B-N6 — the child drive MUST pass a no-op disposeDispatchResources.
runWorkflowAttempt's finally (run-workflow.ts:358-368) drains the cached
opencode serve child-process registry on EVERY exit path. That drain exists so
a one-shot CLI invocation can exit (module doc, "owner finding 4"). Letting the
CHILD drive run it would kill servers the PARENT's sibling units — a map step
fanning out beside the composing step — are still using, mid-dispatch. The child
drive therefore passes disposeDispatchResources: () => {}; the parent's own
finally remains the single owner of the process-lifecycle drain, for the whole
process, exactly as today. Pinned by row A-24.
B-N7 — the child drive passes NO maxSteps and NO maxRetries.
The parent's --max-steps budget counts DISTINCT PARENT spine steps
(run-workflow.ts:971-976); a composing step consumes exactly one of them, no
matter how many steps its child has. Forwarding the parent's remaining budget
into the child would make one parent step cost N, silently, and would make the
same composition behave differently depending on where in the parent it sits.
The child is driven to a terminal-for-this-invocation state (completed, failed,
blocked, or aborted) or not at all. Likewise maxRetries: the parent's retry
loop (runWorkflowSteps, :245-275) re-opens the failed parent step, which
re-derives the same invocation_key and therefore re-drives the SAME child —
that is the retry semantics §1.1 asks for, and a second retry budget inside the
child would double-count it.
B-N8 — parentUnitId in the invocation key is the parent unit's
journalBaseId.
StepWorkUnit carries two ids (step-work.ts:137-145): unitId, the
content-derived base (<node_id>:<sha256> / <node_id>:solo), and
journalBaseId, which is unitId or <unitId>~l<loop> inside a gate loop
(:551). journalBaseId is what dispatchJournaledAttempt passes as
request.unitId (native-executor.ts runUnit's
request: { ...request, unitId: attemptId }, with attemptIdFor returning
journalBaseId) and therefore what reserveUnitAttempt writes to
workflow_run_units.unit_id. Using it as parentUnitId:
- makes
workflow_runs.parent_unit_idjoin exactly to a realworkflow_run_unitsrow, which is how §4.5's tree resolves a child's parent STEP (repo.getUnit(parentRunId, parent_unit_id)?.step_id—workflow-runs-repository.ts:1055); - is stable across parent retries (
attemptIdForreturns the samejournalBaseIdfor every attempt of the same unit), so a retry reuses the same child (row A-15); - changes under a gate loop (
~l<n>), which is a second, independent reason a gate loop yields a new child. The first — the one §1.1 asks to pin — is thatgateFeedbackis ahashVersion6 preimage field (P3a §3.3), so the unit input hash changes on its own. Row A-16 asserts the hash-driven reason directly, againstunitInputHash, so the pin does not depend on the id suffix.
B-N9 — --json means the global --format json; no new flag is added.
§1.2 writes akm workflow plan <ref> --json. There is no --json boolean
anywhere in this CLI: the one canonical way to select JSON is the global
--format (GLOBAL_OUTPUT_ARGS, src/cli/shared.ts:163-164), which
defineJsonCommand splices onto every leaf (:221). P2b's own new verb set the
precedent and the spec wording (akm task explain <ref> --format json, P2b row
B-53). akm workflow plan <ref> --json would additionally FAIL
scripts/lint-doc-examples.ts, which checks every --flag in a fenced doc
example against the real command tree. So: the verb is
akm workflow plan <ref> [--format json], and every doc example uses that
spelling.
B-N10 — a child run must be invisible to the FOUR scope queries.
P3a §5.2 pins run.scopeKey = the PARENT's scope_key so a status tree can
find children in one scope. Four pre-existing queries select by scope and would
therefore start returning child rows:
| Site | Consequence if unfiltered |
|---|---|
listRuns (workflow-runs-repository.ts:372) |
akm workflow list shows each child as a peer of its parent; --active double-counts one logical run |
getActiveRunRowForScope (:352) |
akm workflow run workflows/<childRef> ATTACHES to a child a parent is currently driving (resolveRunSpecifier, runs.ts:900,917) — two drivers, one run |
findActiveOrBlockedRunForScope (:415) |
akm show's active-run guard (getActiveWorkflowRun, runs.ts:1113-1121) reports a child as "the" active run |
findActiveRunForScope (:355) |
Found by code review, round 4, finding 2 (Review log R2); missed by the original draft of this row. publishWorkflowRunV4's (:593) own "already has an active run in this scope" start guard, reached from startWorkflowRun when resolveRunSpecifier's OWN (already-filtered) getActiveRunRowForScope check above found no top-level run to attach to. Left unfiltered, this site does not merely fail to exclude a child the way the other three would — it actively MISFIRES: akm workflow run workflows/<childRef> on a scope occupied only by a parent-driven child finds nothing to attach to (the site above correctly excludes the child), proceeds to start fresh, and then THIS site finds the child and refuses with RESOURCE_ALREADY_EXISTS naming the CHILD's own run id — inviting the operator to akm workflow abandon a run a parent is actively driving. |
All four gain AND parent_run_id IS NULL. For any database with no child rows
— every pre-P3b database and every non-composing workflow — the result set is
byte-identical, which is what keeps these Stable-tier surfaces preserved.
listRuns additionally gains an opt-in includeChildren filter, surfaced as
akm workflow list --children (additive boolean, default false), so an
operator can still enumerate them. akm workflow status <childRunId> needs no
flag and is unchanged — a child run id is a run id.
B-N11 — P3a's WORKFLOW_CHILD_EXECUTION_UNSUPPORTED is RETIRED, and its
guard becomes an internal-invariant guard.
P3a Review log R8 added a UsageError guard at the top of
dispatchWorkflowExecution (unit-dispatch.ts:180-187) plus the
UsageErrorCode member (errors.ts:139) and its USAGE_HINTS entry (:216),
because a child-workflow target legitimately reached dispatch with nothing to
do about it. After P3b that premise is gone: dispatchJournaledAttempt routes a
child-workflow unit to the child executor before dispatch is reached (§3.2), so
arriving at dispatchWorkflowExecution with one means the executor seam was
bypassed — an engine routing bug, not a user-facing "not implemented yet".
Chosen: the guard stays but becomes a plain Error naming the seam, and the
error CODE is deleted (no producer would remain). Rejected alternatives:
- Delete the guard entirely. A bypassed seam would then fall into the generic
kind !== "command"ConfigErrorreading "is not a command target." — the exact false, unhelpful message R8 was opened to remove. Rejected for the same reason R8 rejected it. - Keep the
UsageErrorand its code. AUsageErrortells a user to change their input; nothing a user can author causes this. Keeping a user-facing code with no user-reachable producer is dead vocabulary.
tests/workflows/child-workflow-dispatch-guard.test.ts flips accordingly
(F-A1); its negative control is byte-unchanged.
B-N12 — run-output resolution reads STEP ROWS and fails loudly on a
truncation envelope.
clipStepEvidenceForPersistence (runs.ts:642-691) may replace an over-cap
step artifact with a TruncatedEvidenceValue in the row. completeWorkflowStep
sees only the CURRENT step's live evidence — earlier steps' complete in-memory
values live in driveRun's liveEvidence map (run-workflow.ts:942-959),
which the runtime cannot reach. So resolution reads the persisted rows, and a
declared output whose source artifact is a truncation envelope fails through the
existing isTruncatedEvidence predicate (runs.ts:611-617) with a loud,
by-name error — never silently exporting the envelope. This is the identical
contract a resumed run's steps.<id>.output reference already gets
(step-work.ts's truncation guard); P3b adds no second reading of it.
B-N13 — an output-resolution failure rolls back inside the existing
completion transaction; fail-before-mutation is preserved.
Whether a run COMPLETES is only knowable after deriveRunState(refreshedSteps)
(runs.ts:838), which needs the step row written. Rather than projecting the
spine in memory, resolution runs immediately after deriveRunState yields
"completed", INSIDE the same repo.transaction (:782-858); a failure throws
there, and SQLite rolls the whole transaction back — the step completion
included. The observable result is exactly fail-before-mutation: the step stays
pending, the run stays active, no event is appended (appendEvent runs after
the transaction, :867), and the run is resumable once the workflow is fixed or
abandoned. The tests/integration/tasks-runtime-v3-runner.test.ts canary is
untouched.
B-N14 — a child run's workflow_entry_id is NULL.
InsertRunInput.workflowEntryId is number | null
(workflow-runs-repository.ts:230) and the column is a nullable INTEGER
(migrations.ts:886). startWorkflowRun fills it via
resolveWorkflowEntryId(asset.sourcePath, asset.ref, asset.adapterId)
(runs.ts:303) — an INDEX lookup keyed on the asset's source path, which the
child publication contract has no access to: P3a row C-11 forbids
publishChildWorkflowRun from any source access, and the child plan was frozen
into the parent long before this dispatch. Resolving it live would additionally
make a resumed run's child row depend on the current index state, which is not
frozen. NULL is the honest value; row A-08 asserts it.
B-N15 — the child-blocked instruction needs NO renderer change.
The blocked-state notes (§3.4) are written through completeWorkflowStep's
notes field, which formatWorkflowStatusPlain already renders under the step
(src/output/text/workflow-format.ts:59-61), and are returned as the executed
step's summary, which formatWorkflowRunPlain already renders
(:159-161). Lane A therefore touches no output module, which is what keeps
Lane A's and Lane B's file lists disjoint (Lane B owns workflow-format.ts
exclusively, for §4.5's tree and §4.6's plan summary).
B-N16 — publishChildWorkflowRun MUST be the outermost transaction on its
connection; the §3.2 seam already guarantees that, and no lane may move it.
Added during adoption, from P3a Review log R10, which landed after this
spec was drafted. WorkflowRunsRepository.publishChildWorkflowRun reaches its
BEGIN IMMEDIATE through withImmediateTransaction
(src/core/state-db.ts:690-726), whose re-entrancy guard (:698) silently
joins an already-open transaction instead of issuing its own BEGIN. Nested
inside a DEFERRED repo.transaction(), the SELECT-else-INSERT can read a stale
snapshot, both publishers can miss the existing row, and the loser's INSERT
hits idx_workflow_runs_invocation_key with a raw SQLiteError — losing
exactly the idempotency rows A-13, C-01, and C-04 depend on.
P3b is correct by construction, and that is the property to pin rather than
re-derive: driveChildWorkflowUnit is reached from dispatchJournaledAttempt
(§3.2), which holds no open transaction — reserveJournaledDispatch and
finishJournaledDispatch each open and close their own. The two DEFERRED
repo.transaction() sites R10 names, resumeWorkflowRun
(src/workflows/runtime/runs.ts:506) and completeWorkflowStep (:782), are
both OUTSIDE the child drive, and R10 explicitly flags completeWorkflowStep
as "the most plausible place a P3b child-spawn gets wired" — P3b deliberately
does not wire it there. Consequences, all binding:
- Wiring the publication inside any
repo.transaction()callback is a review-blocking violation, including as an "optimization" that batches the child row with the parent step's completion. - No catch-and-reselect fallback is added. R10 declined to add one on the grounds that P3a had no production caller; P3b's caller is un-nested, so the fallback would be untested surface guarding a case the seam forbids.
- Row C-04's contention test must exercise the real repository and the real index (§5.3 already says so) — it is the test that would catch a future nesting regression.
2. Behavior table (input → expected after P3b)
Rows are tagged PRESERVE (must not change; a failure is a regression) or NEW (this phase's authorized change). Every NEW row needs at least one test asserting both its code/reason and its message text.
2.1 The dispatch seam (Lane A)
| # | Input | Expected after P3b | Tag |
|---|---|---|---|
| A-01 | A parent unit whose frozenTarget.kind === "child-workflow" reaches dispatchJournaledAttempt |
Routed to driveChildWorkflowUnit; UnitDispatcher is never called for it (assert with an injected dispatcher that records every call) |
NEW |
| A-02 | The same unit | Still reserves and finishes its OWN attempt row in workflow_run_units (phase: "unit", runner: "exec"), so retry/reuse/journal accounting are unchanged |
NEW |
| A-03 | dispatchWorkflowExecution called directly with a child-workflow target |
Plain Error (NOT a UsageError, no code), message names src/workflows/exec/child-workflow.ts and says the unit reached the command dispatch path |
NEW (was UsageError / WORKFLOW_CHILD_EXECUTION_UNSUPPORTED) |
| A-04 | rg WORKFLOW_CHILD_EXECUTION_UNSUPPORTED src/ |
Zero hits | NEW |
| A-05 | A command / shell / script unit |
Dispatch path byte-unchanged; no child code is reached | PRESERVE |
| A-06 | computeStepWorkList on a child-workflow step |
timeoutMs: null (P3a Review log R1's arm), runner: "exec", prompt built as today |
PRESERVE |
2.2 Publication and identity (Lane A)
| # | Input | Expected after P3b | Tag |
|---|---|---|---|
| A-07 | A child-workflow unit dispatching for the first time | computeChildInvocationKey({parentRunId, parentUnitId: <journalBaseId>, unitInputHash}), then publishChildWorkflowRun — exactly one child row, one workflow_started event, one step set |
NEW |
| A-08 | That child row | parent_run_id = parent, parent_unit_id = the parent unit's journalBaseId, invocation_key = A-07's key, scope_key = the PARENT's, plan_ir_version = 5, workflow_entry_id NULL (B-N14), agent_harness/agent_session_id copied from the parent |
NEW |
| A-09 | That child row's params_json |
Exactly the resolved inputBindings of the composing unit (resolveTaskInputBindings, step-work.ts:397); {} when the step binds nothing |
NEW |
| A-10 | A child plan whose recomputed computePlanHash(frozenPlan) ≠ frozenTarget.planHash |
Parent unit fails, failure_reason: "child_workflow_publish_failed", message names the child ref and both hashes. No child row, no event |
NEW |
| A-11 | Resolved params that violate the child plan's paramSchemas |
Same reason child_workflow_publish_failed, message carries validateWorkflowParams' errors. No child row |
NEW |
| A-12 | publishChildWorkflowRun throwing for any other reason |
Same reason, message wraps the cause | NEW |
| A-13 | Calling the seam twice with the same three key inputs | The SAME child run id both times; childRunsOf(parent).length === 1 |
NEW |
| A-14 | A parent RESUME of the composing step | Same invocation_key → same child; the child is driven, never re-published |
NEW |
| A-15 | A parent RETRY (--max-retries) of the composing step |
Same child (journalBaseId is retry-stable, B-N8) |
NEW |
| A-16 | A gate loop ≥ 2 on the composing step | computeUnitInputHash differs (the gateFeedback preimage field, P3a §3.3) → a DIFFERENT invocation_key → a NEW child run. Asserted directly on the two hashes, not on the id suffix |
NEW |
| A-17 | Two different composing steps in one parent, same child ref, same bindings | Two child runs (different parentUnitId → different key). Deduplication is out of scope (P3a row B-23) |
NEW |
2.3 Driving and status mapping (Lane A)
| # | Input | Expected after P3b | Tag |
|---|---|---|---|
| A-18 | Child status active after publication |
runWorkflowSteps({target: <childRunId>, …}) drives it (B-N5) |
NEW |
| A-19 | Child reaches completed |
Parent unit COMPLETES; its result is workflowRunExportedResult(childRow) (§4.4); evidence.output for a solo step is that object |
NEW |
| A-20 | Child reaches failed |
Parent unit fails, failure_reason: "child_workflow_failed", message names the child run id, the child ref, and the child's own failed step |
NEW |
| A-21 | Child reaches blocked |
Parent unit fails with failure_reason: "child_workflow_blocked"; the composing STEP completes blocked (not failed); the parent RUN derives blocked; the notes are §3.4's exact string |
NEW |
| A-22 | A child already blocked from a previous parent attempt |
Identical to A-21 without driving it — runWorkflowSteps is not called, no lease is taken |
NEW |
| A-23 | A child already failed from a previous parent attempt |
Identical to A-20 without driving it | NEW |
| A-24 | Any child drive | Passes disposeDispatchResources: () => {} (B-N6); the parent's registry drain fires exactly once, in the parent's own finally |
NEW |
| A-25 | Any child drive | Passes no maxSteps and no maxRetries (B-N7); the composing step consumes exactly ONE of the parent's maxSteps regardless of the child's step count |
NEW |
| A-26 | Parent invoked with --max-steps 1 on a workflow whose first step composes a 4-step child |
The child runs all 4 of its steps; stepsProcessed === 1 |
NEW |
| A-27 | A child whose lease is already held live by another driver | Parent unit fails, failure_reason: "child_workflow_busy", message carries the holder and expiry from acquireRunLease's existing text; the parent run fails and stays resumable |
NEW |
2.4 Cancellation, provenance, nesting (Lane A)
| # | Input | Expected after P3b | Tag |
|---|---|---|---|
| A-28 | Parent AbortSignal aborts mid-child-drive |
The child drive observes it (it is the parent unit's dispatch signal — the heartbeat controller, run-workflow.ts:966); the child returns aborted: true; the parent unit fails with failure_reason: "aborted" |
NEW |
| A-29 | After A-28 | BOTH leases are released (each drive's own finally, run-workflow.ts:350-368); both runs are resumable; akm workflow status on each shows no live lease |
NEW |
| A-30 | The parent loses its OWN run lease mid-child-drive | The heartbeat's controller aborts the child drive; the parent stops loudly through the existing assertAlive path; the child is left resumable |
NEW |
| A-31 | RunWorkflowOptions.eventSource set on the parent |
Threaded into the child drive's eventSource verbatim, so child units and child gate judges observe it exactly as parent ones do |
NEW |
| A-32 | eventSource absent (every akm workflow run invocation) |
Absent in the child drive; byte-identical behavior | PRESERVE |
| A-33 | StepExecutionContext.dispatcher (the test seam) |
Threaded into the child drive's dispatcher, so an injected fake serves child units and child judges (workflowSummaryJudge, run-workflow.ts:568-574) |
NEW |
| A-34 | StepExecutionContext.maxConcurrency |
Threaded into the child drive's maxConcurrency; the child's effective width is min(parent engine cap, child plan's execution.maxConcurrency) |
NEW |
| A-35 | A child that itself composes a grandchild | Drives recursively through the identical seam; three levels complete; three run rows exist, linked root → child → grandchild |
NEW |
| A-36 | The executor's own depth handling | None. Depth is bounded at freeze and re-bounded at decode (P3a §4.5, row A-23). A second executor-side bound is a review-blocking addition | NEW |
2.5 Workflow outputs: — authoring and freeze (Lane B)
| # | Input | Expected after P3b | Tag |
|---|---|---|---|
| B-01 | Markdown frontmatter outputs: {report: {from: steps.summarize.output}} |
Parses; reaches WorkflowSourceIrV1.outputs; compiles to WorkflowPlanDraft.outputs; freezes to plan.outputs |
NEW |
| B-02 | outputs: with a schema: |
Same, with schema frozen alongside from |
NEW |
| B-03 | An output name outside ^[A-Za-z_][A-Za-z0-9_]*$ |
Parse error naming the key and the grammar (the params: message, adapted) |
NEW |
| B-04 | More than WORKFLOW_MAX_OUTPUTS (64) entries |
Parse error naming the cap | NEW |
| B-05 | from: that is not a valid steps.<id>.output(.<seg>)* reference |
Parse error from checkReferenceSyntax |
NEW |
| B-06 | from: naming a step id the document does not declare |
Compile error naming the step and the output | NEW |
| B-07 | from: params.<name> |
Rejected — an output projects a STEP artifact, never a param (a param is already on the run row) | NEW |
| B-08 | A schema: outside the enforced JSON Schema subset |
Parse error from the existing checkSchemaDefinition (parser.ts:1387), same message shape as a params: schema |
NEW |
| B-09 | A schema: over WORKFLOW_MAX_SCHEMA_BYTES (256 KiB) |
Parse error naming the cap, same as params: |
NEW |
| B-10 | outputs: in a GitHub-shaped .yml |
Rejected by the existing closed ROOT_KEYS check, message unchanged (B-N4) |
PRESERVE |
| B-11 | A workflow declaring NO outputs: |
plan.outputs is absent, never {}; canonicalPlanJson and computePlanHash are byte-identical to P3a's |
NEW |
| B-12 | decodeWorkflowPlanV4 on a plan with a valid outputs |
Accepts; irVersion stays 5 |
NEW |
| B-13 | decodeWorkflowPlanV4 on outputs: {} |
Fails — absent-never-empty (P2b A-N7) | NEW |
| B-14 | decodeWorkflowPlanV4 on outputs whose keys are not in sorted-unique order |
Fails — canonical wire order, same rule as inputBindings (schema-v4.ts:670-672) |
NEW |
| B-15 | decodeWorkflowPlanV4 on outputs.<n>.from naming a step not in plan.steps |
Fails, naming the output and the step | NEW |
| B-16 | An unknown key inside an outputs entry |
Fails through the module's existing assertKeys |
NEW |
| B-17 | Two freezes of the same source declaring outputs: |
Byte-identical plan hash | NEW |
2.6 Workflow outputs: — resolution and export (Lane B)
| # | Input | Expected after P3b | Tag |
|---|---|---|---|
| B-18 | A run whose final step completes, plan declares outputs: |
workflow_runs.outputs_json holds the canonical JSON of the resolved map; written in the SAME transaction as the completion |
NEW |
| B-19 | A run whose plan declares no outputs: |
outputs_json stays NULL; nothing is written |
NEW |
| B-20 | An output whose from resolves to a missing property |
The completion transaction rolls back; UsageError code WORKFLOW_OUTPUT_INVALID, exit 2, naming the output and the reference. Step stays pending, run stays active, no event appended (B-N13) |
NEW |
| B-21 | An output whose source step artifact is a truncation envelope | Same failure, message names the output and says the artifact was truncated at persistence (B-N12) | NEW |
| B-22 | An output whose resolved value violates its declared schema |
Same failure, message carries validateJsonSchemaSubset's errors |
NEW |
| B-23 | A run that fails or blocks | No output resolution runs; outputs_json stays NULL |
NEW |
| B-24 | workflowRunExportedResult(row) on a completed run with outputs_json |
The parsed map | NEW |
| B-25 | workflowRunExportedResult(row) on any run with outputs_json NULL |
{runId: <id>, status: <status>} — synthesized, never stored |
NEW |
| B-26 | akm workflow status <runId> on a run with outputs_json |
The envelope's run.outputs carries the map |
NEW |
| B-27 | akm workflow status <runId> on a run without it |
run.outputs is absent (not null) — every pre-existing envelope byte-identical |
PRESERVE |
| B-28 | A parent step inputs: [steps.<child>.output.<name>] where <name> is a declared child output |
Freezes | NEW |
| B-29 | The same where <name> is NOT declared by the child (and the child declares outputs:) |
COMPOSITION_INVALID at FREEZE, naming the step, the child ref, the bad name, and the child's declared names |
NEW |
| B-30 | The same where the child declares NO outputs: and <name> is not runId/status |
COMPOSITION_INVALID at freeze, message says the child exports {runId, status} only and points at outputs: |
NEW |
| B-31 | A reference DEEPER than the first segment (steps.<child>.output.<name>.<path>) |
Freeze checks only the first segment; the rest resolves at pre-attempt through the existing resolver and fails there with its existing message | NEW |
| B-32 | A reference into a NON-child step's output | Freeze check does not apply; behavior byte-unchanged | PRESERVE |
2.7 The status tree (Lane B)
| # | Input | Expected after P3b | Tag |
|---|---|---|---|
| B-33 | akm workflow status <runId> on a run with NO children |
Envelope and text bytes identical to P3a's; no children key, no children: block |
PRESERVE |
| B-34 | akm workflow status <parentRunId> with children |
Envelope gains children: [...], in created_at, id order (P3a row C-13) |
NEW |
| B-35 | Each children[] entry |
{runId, workflowRef, workflowTitle, status, spawnedByUnitId, stepId, currentStepId, createdAt, updatedAt}, plus resume when blocked and children when it has its own |
NEW |
| B-36 | stepId on a child entry |
The parent STEP that spawned it — repo.getUnit(parentRunId, parent_unit_id)?.step_id; null when the unit row is gone |
NEW |
| B-37 | A 3-level tree | Nested recursively; the depth of the rendered tree equals the composition depth | NEW |
| B-38 | Text mode with children | A children: block immediately after steps:, one glyph-prefixed line per child (§4.5's glyph table), indented by depth |
NEW |
| B-39 | A blocked child in text mode | Two extra indented lines: resume: and then:, carrying §4.5's exact commands |
NEW |
| B-40 | akm workflow list in a scope containing child runs |
Child rows are excluded; output for a childless scope is byte-identical (B-N10) | NEW |
| B-41 | akm workflow list --children |
Child rows included, each carrying parentRunId |
NEW |
| B-42 | akm workflow run workflows/<childRef> while a parent drives a child of that ref |
Starts a NEW top-level run; never attaches to the child (B-N10) | NEW |
| B-43 | akm show's active-run guard while a child is active |
Reports the PARENT run, never the child | NEW |
| B-44 | akm workflow status <childRunId> |
Works; the envelope gains run.parentRunId and run.spawnedByUnitId |
NEW |
| B-45 | akm workflow status/list/resume/abandon/run on any non-child run |
Every existing JSON field and every existing text line byte-identical (Stable tier) | PRESERVE |
2.8 akm workflow plan (Lane B)
| # | Input | Expected after P3b | Tag |
|---|---|---|---|
| B-46 | akm workflow plan workflows/<ref> |
Exit 0; human summary on stdout (§4.6) | NEW |
| B-47 | akm workflow plan workflows/<ref> --format json |
One JSON object with §4.6's exact key set | NEW |
| B-48 | Either mode, before/after row counts | Zero new rows in workflow_runs, workflow_run_steps, workflow_run_units, workflow_run_unit_attempts, and the events table; zero usage rows; no warn-log file written |
NEW |
| B-49 | A workflow composing a child | Per-step expansion names via: "child", the child ref, the child planHash, the child's declared output names, and the child's own steps nested |
NEW |
| B-50 | A task-wrapped step | expansion names via: "task" and the taskRef |
NEW |
| B-51 | inputBindings on a step |
Names + kinds; a literal's VALUE is shown; a reference's from is shown and never resolved |
NEW |
| B-52 | A step with env: bindings / an env-ref |
environment reports kind + name (and, for env-ref, ref/keys/secretNames — all NAMES). No literal env VALUE, ever |
NEW |
| B-53 | A fixture whose command body, persona, script bytes, and env: hold sentinel secrets |
No sentinel appears in stdout or in the JSON envelope bytes, in either mode | NEW |
| B-54 | sourceReadSet in the output |
Relative paths only; expect(path.isAbsolute(p)).toBe(false) for every entry |
NEW |
| B-55 | A workflow that fails to freeze (any COMPOSITION_INVALID / INPUT_BINDING_INVALID case) |
The verb reports the same error, same code, same exit code, and still writes nothing | NEW |
| B-56 | Compile warnings (collectWorkflowWarnings) |
Returned in the envelope's warnings[]. The verb never calls warn() (that writes a log file, breaking B-48) |
NEW |
| B-57 | Lowering notices from freeze | Returned in notices[], the same projection akm workflow run renders |
NEW |
| B-58 | akm workflow plan with no ref / an unknown ref |
Usage error exit 2 / WORKFLOW_NOT_FOUND exit 1, standard {ok:false,error,code} envelope |
NEW |
| B-59 | The command registration | main.subCommands.workflow.subCommands.plan exists with a required ref positional and the global format flag |
NEW |
2.9 Crash windows, contention, replay (Lane C)
| # | Input | Expected after P3b | Tag |
|---|---|---|---|
| C-01 | SIGKILL after the child row is published, before any child unit ran | Resume finds the child by invocation_key, drives it to completion. childRunsOf(parent).length === 1; exactly one workflow_started event for the child |
NEW |
| C-02 | SIGKILL mid-child-execution (a child unit journaled running) |
Both runs resumable; after lease expiry a fresh process resumes, the child re-dispatches only the interrupted unit, and both runs complete | NEW |
| C-03 | SIGKILL after the child completed, before the parent unit row finished | Resume completes the parent unit with the child's exported result and dispatches zero child units (marker count for every child unit stays at its pre-crash value) | NEW |
| C-04 | Two parent processes driving composing steps that resolve to the same (parentRunId, invocationKey) |
Exactly one child row; one drive holds the child lease and the other's unit fails child_workflow_busy naming the holder. Never two drivers |
NEW |
| C-05 | A resumed parent whose composing step already completed | The parent unit is REUSED from the journal (classifyUnitReuse → reuse); driveChildWorkflowUnit is never entered; the promoted artifact is byte-identical to the live run's |
NEW |
| C-06 | The same, compared byte-for-byte | The resumed parent's step evidence JSON equals the interrupted run's, exactly (the chaos-suite comparison technique) | NEW |
| C-07 | A tampered child input_hash on the parent's composing unit row |
The existing replay_divergence hard failure, message unchanged |
PRESERVE |
2.10 Fixtures (Lane C)
| # | Input | Expected after P3b | Tag |
|---|---|---|---|
| C-08 | tests/fixtures/execution-contracts/workflows/child-workflow/ |
Four registered workflows: direct-child, task-wrapped-child, child-with-outputs, three-level |
NEW |
| C-09 | Every *.yml and *.md under child-workflow/workflows/ |
Registered in the manifest's childWorkflow.workflows — no orphan fixtures. (Unlike planV4, this family registers BOTH extensions: the parent must be .yml, a child declaring outputs: must be .md — B-N4) |
NEW |
| C-10 | Freezing each registered fixture | The manifest's expectedStepTargetKinds match, INCLUDING child-workflow entries |
NEW |
| C-11 | Freezing three-level |
The embedded chain is 3 plans deep; each level's frozenTarget.planHash matches computePlanHash of its own embedded plan |
NEW |
| C-12 | Freezing child-with-outputs |
The embedded child plan carries outputs; the parent step referencing a declared name freezes |
NEW |
| C-13 | The planV4 family |
Byte-unchanged: same files, same expectedTargetKindSet ["command","script","shell"], same assertions |
PRESERVE |
3. Lane A — the child executor
3.1 Files
| File | Change |
|---|---|
src/workflows/exec/child-workflow.ts |
New. driveChildWorkflowUnit(input): Promise<UnitOutcome> — the ONE child drive (§3.3). Imports computeChildInvocationKey from ./child-invocation, runWorkflowSteps from ./run-workflow, workflowRunExportedResult from ../runtime/run-outputs (Lane B), withWorkflowRunsRepo from the repository, and canonicalPlanJson/computePlanHash + validateWorkflowParams/frozenStepRows. No new limit, no new scheduler, no new journal writer. |
src/workflows/exec/native-executor.ts |
dispatchJournaledAttempt's single dispatch call (:1168) becomes a two-arm branch (§3.2). The prepareAttemptWorktree child-workflow comment (:1000-1008) is corrected: dispatch no longer fails closed; a child-workflow unit that also declares isolation: worktree still carries no gitCommitOid, and the prepared worktree is simply unused by the drive. No other edit. |
src/workflows/exec/step-work.ts |
StepWorkUnit gains childParams?: Readonly<Record<string, unknown>> (§3.3 step 2); UnitOutcome gains the LIVE-ONLY childRun? field (§3.4); ExecutedStepOutcome/StepExecutionResult gain childBlocked?; reduceStepOutcomes (:1043) sets it; FinalizeStepResult (:1646) gains {kind: "child-blocked"; summary: string}; finalizeExecutedStep's !result.ok arm checks childBlocked FIRST; a new blockStepForChildWorkflow sits beside blockStepForJudgeFailure (:1700). The :454 comment (P3a R8's "dispatch itself is what fails closed") is corrected to name the child executor. |
src/workflows/exec/run-workflow.ts |
StepGateLoopOutcome["kind"] (:733) gains "child-blocked"; it is not added to STEP_FINISHED_KINDS (:747); RunWorkflowResult (:197) gains childBlocked?: {stepId, childRunId, childRef, resume, then}; runStepGateLoop maps finalize.kind === "child-blocked" to it and returns; driveRun surfaces it and breaks. |
src/workflows/exec/unit-dispatch.ts |
The P3a guard (:173-187) becomes the internal-invariant guard of B-N11: a plain Error, no code. |
src/core/errors.ts |
WORKFLOW_CHILD_EXECUTION_UNSUPPORTED is deleted from the UsageErrorCode union (:139) and from USAGE_HINTS (:216). |
Lane A touches no file under src/output/, src/commands/, src/core/state/, or src/storage/ (B-N15). Its only src/core/ edit is the two-line code deletion above.
3.2 The dispatch seam (exact)
native-executor.ts:1168, inside dispatchJournaledAttempt, becomes:
const dispatched =
request.frozenTarget.kind === "child-workflow"
? await driveChildWorkflowUnit({
request,
target: request.frozenTarget,
ctx: input.ctx,
childParams: input.workUnit.childParams ?? {},
inputHash: input.inputHash,
dispatcher,
})
: await dispatchUnit(request, dispatcher);
Why here and nowhere else:
- Before it,
reserveJournaledDispatchhas already claimed the parent unit's attempt row (running, claim holder, expiry). A crash between the reservation and the child's publication therefore leaves arunningparent row and NO child — recovered by C-01's resume, which re-dispatches the parent unit and publishes the child idempotently. - After it,
redactUnitOutcome,finishJournaledDispatch, the worktree epilogue, and thejournal_write_failedclassification all run unchanged, so a child-workflow unit is journaled exactly like any other and the durable-row reuse that C-03 and C-05 depend on works with no new code. - It is inside the retry loop (
runUnit), so aretry:policy on the composing step still applies — and, because none of the three new failure reasons is a member ofPROGRAM_RETRY_REASONS(src/workflows/program/schema.ts:77), no authoredretry.on:can name one. A failed child is re-driven by an explicit parent retry or resume, never by an automatic in-step re-dispatch. Row A-15 pins that this reuses the same child.
dispatchUnit (:1267) is not modified: it owns structured-output parsing,
usage capture, and UnitTransportError classification, none of which applies to
a run handle.
3.3 driveChildWorkflowUnit — the drive contract
export interface DriveChildWorkflowInput {
readonly request: UnitDispatchRequest; // unitId === the parent unit's journalBaseId (B-N8)
readonly target: FrozenChildWorkflowTarget;
readonly ctx: StepExecutionContext;
readonly childParams: Readonly<Record<string, unknown>>;
readonly inputHash: string; // the hashVersion 6 unit input hash
readonly dispatcher: UnitDispatcher;
}
export function driveChildWorkflowUnit(input: DriveChildWorkflowInput): Promise<UnitOutcome>;
Ordered steps. Every failure before step 6 produces child_workflow_publish_failed.
-
Integrity re-check.
computePlanHash(target.frozenPlan) === target.planHash(row A-10). The plan was already verified at decode; re-checking here is cheap and makes the publication's own inputs self-consistent. -
Params.
childParamscomes fromStepWorkUnit.childParams, whichbuildStepWorkUnitsets fromtaskInputsResolution.values— the SAME resolution P2b already runs for every frozen target'sinputBindings(step-work.ts:397), so no second binding resolver exists. RunvalidateWorkflowParams(target.frozenPlan, childParams); a non-empty error list fails with row A-11's message. -
Key.
computeChildInvocationKey({parentRunId: ctx.runId, parentUnitId: request.unitId, unitInputHash: inputHash})(B-N8). -
Publish.
publishChildWorkflowRun(P3a §5.2), called with no transaction open on the connection (B-N16 — the seam guarantees it; do not wrap this call), with:Field Value parentRunIdctx.runIdspawnedByUnitIdrequest.unitIdinvocationKeystep 3's key run.idrandomUUID()— used only on the INSERT path; the SELECT-first contract returns the existing row otherwiserun.workflowReftarget.refrun.scopeKeythe parent row's scope_keyrun.workflowEntryIdnull(B-N14)run.workflowTitletarget.frozenPlan.titlerun.paramsJsonJSON.stringify(childParams)run.currentStepIdtarget.frozenPlan.steps[0]?.stepId ?? nullrun.agentHarness/run.agentSessionIdcopied from the parent row run.createdAt/updatedAt/checkinArmedAtnowstepsfrozenStepRows(target.frozenPlan)— the same helperstartWorkflowRunuses (runs.ts:340)planJsoncanonicalPlanJson(target.frozenPlan)planHashtarget.planHash -
Pre-drive status read. Re-read the returned child row's
status. -
Drive, or not.
Child status at step 5 Action activerunWorkflowSteps(§3.3.1's options)completedrunWorkflowStepstoo — it is a documented pure no-op for a terminal run (completedRunResult,run-workflow.ts:545-553, which takes no lease). ONE code pathblockedSkip the drive. No lease is taken (row A-22) failedSkip the drive (row A-23) -
Map. Re-read the child row (or reuse step 5's, when the drive was skipped) and map its FINAL status through §3.4's table.
3.3.1 The exact runWorkflowSteps options
await runWorkflowSteps({
target: childRunId,
...(ctx.signal ? { signal: ctx.signal } : {}), // the parent unit's dispatch signal (A-28)
...(ctx.dispatcher ? { dispatcher: ctx.dispatcher } : {}), // A-33
...(ctx.maxConcurrency !== undefined ? { maxConcurrency: ctx.maxConcurrency } : {}), // A-34
...(ctx.eventSource !== undefined ? { eventSource: ctx.eventSource } : {}), // A-31
disposeDispatchResources: () => {}, // B-N6, A-24
});
Deliberately not passed, each with its row: params / parameterFlags
(a run id with parameters is a UsageError by design, runs.ts:888-892);
maxSteps / maxRetries (B-N7, rows A-25/A-26); loadPlan (the child's frozen
plan is already the authority); summaryJudge (the child builds its own from
its own frozen plan — threading the dispatcher at A-33 is what makes a gated
child testable); heartbeatScheduler (the child owns its own lease heartbeat).
ctx.signal is the parent unit's dispatch signal, which executeStepPlan
derived from driveRun's dispatchSignal — the heartbeat controller
(run-workflow.ts:966). So a parent Ctrl-C, a parent deadline, a budget abort,
AND a lost parent lease all abort the child drive (rows A-28, A-30).
3.4 Status mapping (exact)
UnitOutcome gains one LIVE-ONLY field, declared with the same contract
notices already carries ("the current contract intentionally excludes them
from durable result_json/evidence", step-work.ts:92):
/** Live-only child-run identity for a child-workflow unit. Excluded from durable evidence. */
childRun?: {
runId: string;
ref: string;
status: WorkflowRunStatus;
currentStepId: string | null;
};
It is excluded from the deterministic artifact graph automatically:
buildEvidence (step-work.ts:924) projects a closed whitelist
({unitId, ok, result|text, failureReason}), so a new field cannot leak into a
hashed artifact. Nothing needs to change there — assert it.
| Child final status | Parent unit | failure_reason |
Parent STEP | Parent RUN |
|---|---|---|---|---|
completed |
ok: true, result = workflowRunExportedResult(childRow) |
— | completes normally through the gate | continues |
failed |
ok: false |
child_workflow_failed |
failed (existing reduction) |
failed |
blocked |
ok: false |
child_workflow_blocked |
blocked via blockStepForChildWorkflow |
blocked (via deriveRunState) |
| aborted mid-drive | ok: false |
aborted |
not finalized — driveRun breaks on options.signal.aborted |
left active/resumable |
| never reached (publication/integrity) | ok: false |
child_workflow_publish_failed |
failed |
failed |
| lease held by another driver | ok: false |
child_workflow_busy |
failed |
failed |
| any other throw out of the drive or its post-drive re-read (Review log R1) | ok: false |
child_workflow_drive_failed |
failed |
failed |
Failure-reason vocabulary (exact strings, and none is a member of
PROGRAM_RETRY_REASONS):
child_workflow_failed
child_workflow_blocked
child_workflow_publish_failed
child_workflow_busy
child_workflow_drive_failed
child_workflow_drive_failed is the catch-all sibling of
child_workflow_publish_failed: added by Review log R1 (code-review round
4) once §3.5's original "classified by the existing dispatch_error
handling" premise was found false — no handling exists at the dispatch seam
for a throw out of this step, so every non-lease-busy throw between the drive
call and the post-drive re-read must be mapped to an honest UnitOutcome
here, never rethrown. See R1 for the full failure-scenario evidence and why
driveChildWorkflowUnit (not the dispatch seam) is the correct place to catch
it.
child_workflow_failed error text (exact shape):
Child workflow run <childRunId> (<childRef>) failed at step "<childStepId>".
Inspect it with `akm workflow status <childRunId>`; the parent run's step
"<parentStepId>" cannot advance until it succeeds.
blockStepForChildWorkflow's notes (exact shape) — the ONE place the resume
sequence is worded, mirroring judgeFailureNotes (step-work.ts:1666):
Step "<parentStepId>" composes child workflow run <childRunId> (<childRef>),
which is blocked at its own step "<childStepId>". Nothing in this run advances
until the child does — a gate is a gate for a child workflow too, so `akm` will
not resume it for you. Clear it with `akm workflow resume <childRunId>`, then
`akm workflow resume <parentRunId>` and `akm workflow run <parentRunId>` to
continue: re-driving the parent drives the resumed child.
Two properties this wording pins, each its own test:
- the CHILD is resumed first, and the PARENT's re-drive is what advances it —
the child drive never calls
resumeWorkflowRunitself (row A-22); - the notes name the child run id and both commands verbatim, so the text renderer needs no change (B-N15).
finalizeExecutedStep's !result.ok arm checks result.childBlocked before
the artifactSchemaFailure retry branch and before the failed completion, and
returns {kind: "child-blocked", summary}. runStepGateLoop maps that kind to
StepGateLoopOutcome.kind === "child-blocked", records
RunWorkflowResult.childBlocked, and returns; because the kind is NOT in
STEP_FINISHED_KINDS, the blocked step consumes no maxSteps allowance — the
same accounting a judge outage already gets.
akm workflow run's exit code is unchanged: run.status === "blocked" already
maps to EXIT_CODES.GENERAL (workflow-cli.ts:208-210).
3.5 Cancellation and leases
- The child holds its OWN lease on its OWN row, acquired and released by
runWorkflowAttempt(run-workflow.ts:314, 350-357). Nothing new. - An abort propagates through
ctx.signal(§3.3.1); the child'sdriveRunbreaks between steps,runWorkflowAttempt'sfinallyreleases the child lease, and the parent's ownfinallyreleases the parent lease. Both runs are resumable (row A-29). - A LOST parent lease aborts the child drive through the same signal, then the
parent's
heartbeat.assertAlive()throws loudly at the next dispatch boundary — the existing path, unchanged (row A-30). - A live foreign lease on the CHILD makes
acquireRunLeasethrow its existingUsageError.driveChildWorkflowUnitcatches exactly that shape and converts it tochild_workflow_busy, preserving the holder/expiry text (row A-27). Corrected by Review log R1 (this premise was false as originally written): it does not catch "nothing else" and let the remainder propagate to "the existingdispatch_errorhandling" — no such handling exists at the dispatch seam (dispatchJournaledAttemptawaitsdriveChildWorkflowUnitwith notryof its own, §3.2). Every OTHER throw — out ofrunWorkflowStepsitself, or out of the post-driverepo.getRunByIdre-read (spec step 7) — is also caught, inside the SAMEtryblock, and mapped tochild_workflow_drive_failed(§3.4). Nothing is ever rethrown out ofdriveChildWorkflowUnit.
3.6 Why hashVersion stays 6
The parent unit's preimage already covers everything the child drive depends on:
frozenTarget (P3a §3.3) carries the child ref, planHash, contentHash,
via, taskRef, inputBindings, and the entire embedded child plan; params
and inputs cover the resolution scope the bindings read from; gateFeedback
covers the loop. Nothing the executor adds is a unit INPUT: the child run id is
minted at dispatch (an output), and the exported result is an output. A P3b
commit that touches either hash prefix or the hashVersion field is a
review-blocking violation (§0.1, B-N3).
4. Lane B — outputs, the status tree, akm workflow plan
4.1 Files
| File | Change |
|---|---|
src/workflows/resource-limits.ts |
WORKFLOW_MAX_OUTPUTS = 64 (new). WORKFLOW_MAX_PLAN_BYTES, WORKFLOW_MAX_COMPOSITION_DEPTH, WORKFLOW_MAX_EMBEDDED_CHILD_PLAN_BYTES, WORKFLOW_MAX_SCHEMA_BYTES unchanged. |
src/workflows/parser.ts |
WORKFLOW_KEYS (:116) gains "outputs"; parseOutputs added beside parseParams (:548); WorkflowDocument carries it. |
src/workflows/schema.ts |
WorkflowDocument.outputs?: Record<string, WorkflowOutputDeclaration>. |
src/workflows/source-ir/schema.ts |
WorkflowSourceIrV1.outputs? (:180-194); the root keys(...) list (:210-227) gains "outputs"; validateOutputs beside validateParams (:233). |
src/workflows/source-ir/compile.ts |
The Markdown→IR mapping (:88-115) carries outputs through jsonClone, exactly as params is carried at :95. parseGithubWorkflowSource is untouched (B-N4). |
src/workflows/ir/compile.ts |
WorkflowPlanDraft.outputs? (:91-98); the draft assembly (:160-180) emits it, absent when empty; reference validation (checkReferenceField :282-297 / checkInputReference :300-324 — NOT collectWorkflowWarnings at :349) gains the from-names-a-declared-step check (row B-06) and the params.* rejection (row B-07), reusing checkInputReference's existing param-rejection message shape. |
src/workflows/ir/schema-v4.ts |
FrozenWorkflowOutput + WorkflowPlanGraphV4.outputs? (:184-193); decodeWorkflowPlanV4's assertKeys (:220-224) and the planExtraKeys array (:231) each gain "outputs"; decodeWorkflowOutputs added. File name and every exported TYPE name unchanged. |
src/workflows/freeze/child-output-references.ts |
New. assertChildOutputReferences(steps) — the freeze-time first-segment check (§4.4, rows B-28…B-31). Pure over the frozen step list. |
src/workflows/ir/freeze-v4.ts |
Carries compiled.plan.outputs onto the frozen plan; calls assertChildOutputReferences(steps) after steps is built and before the plan object is assembled. |
src/workflows/runtime/run-outputs.ts |
New. resolveWorkflowRunOutputs(plan, stepRows) and workflowRunExportedResult(row) (§4.3, §4.4). Pure; no IO. |
src/workflows/runtime/runs.ts |
completeWorkflowStep resolves + persists at completion (§4.3); WorkflowRunDetail.children? and childRunTree (§4.5); toWorkflowRunSummary gains the three conditional fields; listWorkflowRuns threads includeChildren. |
src/core/state/migrations.ts |
Migration 024-workflow-run-outputs (§4.3). |
src/storage/repositories/workflow-runs-repository.ts |
WorkflowRunRow.outputs_json; setRunOutputs; ListRunsFilter.includeChildren; the parent_run_id IS NULL predicate on listRuns (:372), getActiveRunRowForScope (:352), findActiveOrBlockedRunForScope (:415). |
src/sources/types.ts |
WorkflowRunSummary gains outputs?, parentRunId?, spawnedByUnitId? — all optional, all spread conditionally. |
src/commands/workflow/plan.ts |
New. akmWorkflowPlan(ref) → §4.6's envelope. Read-only by construction. |
src/commands/workflow-cli.ts |
workflowPlanCommand registered in workflowCommand.subCommands (:358-365); --children added to workflowListCommand (:73-76). |
src/output/shapes/passthrough.ts |
"workflow-plan" added to PASSTHROUGH_COMMANDS (:66-72). |
src/output/text/workflow.ts |
{command: "workflow-plan", handler: …} registered (:16-23). |
src/output/text/workflow-format.ts |
The children: block in formatWorkflowStatusPlain (§4.5) and formatWorkflowPlanPlain (§4.6). |
schemas/akm-workflow.json |
An outputs property (maxProperties: 64, propertyNames.pattern = the param-name pattern, per-entry {from, schema?}). additionalProperties: false at the root makes this edit mandatory. |
Lane B touches no file under src/workflows/exec/. Lane A and Lane B file
lists are disjoint.
4.2 outputs: — the declaration
Authored in Markdown frontmatter only (B-N4):
outputs:
report:
from: steps.summarize.output
changed_count:
from: steps.collect.output.total
schema: { type: integer, minimum: 0 }
Grammar, each rule with its enforcement site:
| Rule | Enforced by | Row |
|---|---|---|
Name matches PROGRAM_PARAM_NAME_PATTERN (^[A-Za-z_][A-Za-z0-9_]*$) |
parseOutputs |
B-03 |
At most WORKFLOW_MAX_OUTPUTS (64) entries |
parseOutputs |
B-04 |
Entry is a mapping with exactly from and optional schema |
parseOutputs |
B-16 |
from parses through checkReferenceSyntax and is a stepOutput expression |
parseOutputs |
B-05 |
from names a declared step |
ir/compile.ts reference validation |
B-06 |
from is not params.<name> |
parseOutputs |
B-07 |
schema passes checkSchemaDefinition (the enforced subset) |
parseOutputs |
B-08 |
schema is at most WORKFLOW_MAX_SCHEMA_BYTES |
parseOutputs |
B-09 |
The name pattern is chosen so steps.<child>.output.<name> addresses a declared
output under the EXISTING reference grammar — no new expression syntax is added
anywhere.
Frozen shape:
export interface FrozenWorkflowOutput {
/** A validated `steps.<id>.output(.<seg>)*` reference into a step artifact. */
readonly from: string;
/** Bounded JSON Schema (the `validateJsonSchemaSubset` subset). Absent when undeclared. */
readonly schema?: Record<string, unknown>;
}
// WorkflowPlanGraphV4 gains:
readonly outputs?: Record<string, FrozenWorkflowOutput>;
Absent, never {} (P2b A-N7, B-N1 reason 2). decodeWorkflowOutputs
enforces: non-empty record; sorted-unique keys (canonical wire order, the
inputBindings rule at schema-v4.ts:670-672); each name matching the pattern;
each entry's closed key set; from re-parsing through parseReference as a
stepOutput whose stepId exists in plan.steps; schema, when present, a
record. Failures go through the module's existing fail().
4.3 Resolution at run completion
Migration, appended to STATE_MIGRATIONS and as the last key of
STATE_MIGRATION_SAFETY_BY_ID (both, or module load crashes —
assertStateMigrationSafetyRegistry), classified "additive":
024-workflow-run-outputs
ALTER TABLE workflow_runs ADD COLUMN outputs_json TEXT;
The pure resolver, src/workflows/runtime/run-outputs.ts:
export type ResolveRunOutputsResult =
| { ok: true; outputs: Record<string, unknown> }
| { ok: false; errors: string[] };
export function resolveWorkflowRunOutputs(
plan: WorkflowPlanGraphV4,
steps: readonly WorkflowRunStepRow[],
): ResolveRunOutputsResult;
Algorithm, per declared output, in declaration order:
- Build the step-output scope with the existing
stepOutputsFromEvidence/projectStepOutputpair (step-work.ts:836-849) over the persistedevidence_jsonof every step row. One scope, built once. - Resolve
fromwith the existingresolveReferenceString. A miss recordsoutput "<name>": <the resolver's own message>. - If the resolved value (or any whole-value ancestor on the path) satisfies
isTruncatedEvidence, recordoutput "<name>" reads step "<id>"'s artifact, which exceeded the evidence persistence cap and was not stored(B-N12, row B-21). - If
schemais declared, runvalidateJsonSchemaSubset; record every error prefixed with the output name (row B-22).
completeWorkflowStep's write transaction (runs.ts:782-858), immediately
after const state = deriveRunState(refreshedSteps) (:838):
if (state.status === "completed" && plan.outputs) {
const resolved = resolveWorkflowRunOutputs(plan, refreshedSteps);
if (!resolved.ok) {
throw new UsageError(
`Workflow run ${run.id} completed its final step but its declared outputs could not be resolved:\n` +
resolved.errors.map((e) => ` - ${e}`).join("\n"),
"WORKFLOW_OUTPUT_INVALID",
);
}
repo.setRunOutputs(run.id, JSON.stringify(resolved.outputs));
}
The throw rolls the transaction back — step completion included — so the
observable outcome is fail-before-mutation (B-N13, row B-20). appendEvent runs
outside the transaction (:867), so no event is emitted either.
WORKFLOW_OUTPUT_INVALID is a new UsageErrorCode member with a
USAGE_HINTS entry:
Check each `outputs:` entry's `from:` against the step artifact it names, and its
`schema:` against the value that step actually promotes.
4.4 The exported result
/**
* What a completed run EXPORTS: the resolved declared outputs, or `{runId,
* status}` metadata when the plan declared none. The `{runId, status}` form is
* synthesized on read and never stored (row B-25).
*/
export function workflowRunExportedResult(row: WorkflowRunRow): Record<string, unknown>;
outputs_jsonnon-null → the parsed map (row B-24).- otherwise →
{ runId: row.id, status: row.status }(row B-25).
This is exactly what Lane A's completed-child arm promotes as the parent unit's
result (row A-19), which the reducer then promotes as the step's output
artifact through the unchanged buildEvidence/reduceStepOutcomes path.
The freeze-time reference check (freeze/child-output-references.ts) runs
over the frozen step list, after every target is resolved and therefore after
every embedded child plan is available:
For each step, for each reference in the step's inputs[], map.over,
route.input, and each reference-kind inputBindings[].from: parse it; if it
is steps.<S>.output.<first>… and step S's frozen target has
kind === "child-workflow", then
| Child plan | Accepted first segments |
|---|---|
declares outputs |
exactly its declared names |
declares no outputs |
exactly runId and status |
Anything else fails at freeze with COMPOSITION_INVALID:
Workflow step <stepId> reads "<reference>", but child workflow <childRef>
exports <either "outputs: a, b, c" or "only {runId, status} — it declares no
`outputs:`">. Declare the output in the child's `outputs:` frontmatter, or
reference one of the names above.
Only the FIRST segment is checked. Deeper path segments are not statically
knowable (the value's shape is unconstrained unless the output declares a
schema:) and resolve at pre-attempt through the existing resolver, failing
with its existing message (row B-31). A reference AT steps.<S>.output with no
further segment is always accepted — it names the whole exported object.
4.5 akm workflow status — the parent-child tree
Pinned baseline (do this before extending). formatWorkflowStatusPlain
(src/output/text/workflow-format.ts:34-106) emits, in order: workflow:,
run:, title:, status:, optional currentStep:, the steps: block (each
- <title> [<id>] (<status>) plus an optional notes: line), then —
only under --units — a blank line and the units: block, then — only when
stalled — a blank line and the check-in directive. The JSON envelope is
WorkflowRunDetail = {run, workflow: {ref, title, steps}, checkin?, warnings?, units?}. Row B-33 asserts both are byte-identical for a childless run; it is a
PRESERVE row and must be written FIRST.
JSON, additive. WorkflowRunDetail gains:
children?: WorkflowChildRunNode[]; // absent, never [], when the run has none
export interface WorkflowChildRunNode {
runId: string;
workflowRef: string;
workflowTitle: string;
status: WorkflowRunStatus;
/** workflow_runs.parent_unit_id — the parent unit that spawned it. */
spawnedByUnitId: string;
/** The parent STEP that unit belongs to; null when its unit row is gone. */
stepId: string | null;
currentStepId: string | null;
createdAt: string;
updatedAt: string;
/** Present only when status === "blocked". */
resume?: { command: string; then: string };
/** This child's own children. Absent, never [], when it has none. */
children?: WorkflowChildRunNode[];
}
Built by childRunTree(repo, runId) from repo.childRunsOf(runId) (P3a's
created_at, id order), recursively, with stepId from
repo.getUnit(parentRunId, parent_unit_id)?.step_id ?? null (row B-36). The
recursion is bounded by construction: composition depth is bounded at freeze to
8 (P3a), so the child-run graph is at most 8 deep.
resume for a blocked child:
command: akm workflow resume <childRunId>
then: akm workflow resume <rootRunId> && akm workflow run <rootRunId>
WorkflowRunSummary gains three optional, conditionally-spread fields, so every
non-child envelope is byte-identical (row B-27/B-45):
| Field | Present when |
|---|---|
outputs?: Record<string, unknown> |
outputs_json is non-null |
parentRunId?: string |
parent_run_id is non-null |
spawnedByUnitId?: string |
parent_unit_id is non-null |
Text, additive. The children: block renders ONLY when result.children is
a non-empty array, immediately after the steps: block and before the units:
block. Glyph table (drawn from the repo's existing vocabulary —
src/output/text/status-list.ts:24's ✗/✓ and
src/output/text/proposal-format.ts:175's →):
| Child status | Glyph |
|---|---|
completed |
✓ |
active |
→ |
blocked |
⚠ |
failed |
✗ |
Order is spawn order, never severity order — the tree is structural, so
renderStatusEntries' worst-first sort is deliberately NOT used. Nesting adds
two spaces per level.
children:
- ✓ 4f2b… workflows/leaf [completed] (step "dispatch")
- ⚠ 91ac… workflows/review [blocked] (step "verify")
resume: akm workflow resume 91ac…
then: akm workflow resume <rootRunId> && akm workflow run <rootRunId>
- → c30e… workflows/deep [active] (step "inner")
akm workflow list excludes child rows by default and gains --children
(B-N10, rows B-40…B-43).
4.6 akm workflow plan <ref>
Registration: defineJsonCommand, subCommands.plan on workflowCommand, one
required positional ref, and the global --format (B-N9). Tier: Evolving
(STABILITY.md, §8).
Implementation — src/commands/workflow/plan.ts:
const asset = await loadWorkflowAsset(ref);
const frozen = await compileResolveFreezeWorkflowV4(asset, loadConfig());
— the SAME two calls startWorkflowRun makes (runs.ts:263-268), and then
nothing else. It does not call publishWorkflowRunV4, startWorkflowRun,
warn(), appendEvent, akmIndex, or any repository method.
Zero durable writes (row B-48), verified, not assumed. rg '\bwarn\(' src/workflows/freeze/ src/workflows/ir/ src/workflows/source-ir/ and
rg 'appendEvent|recordUsage' src/workflows/freeze/ src/workflows/ir/ are both
empty at head: freeze itself emits no event and writes no warn log.
src/workflows/runtime/runs.ts contains exactly two warn() call sites —
:280, inside startWorkflowRun (the collectWorkflowWarnings advisories),
and :818, inside completeWorkflowStep (the evidence-truncation warning).
This verb calls neither function — collectWorkflowWarnings
(ir/compile.ts:349) is called directly and its lines are returned in the
envelope's warnings[] (row B-56).
The env-audit appendEvent calls are in prepareStepDispatchPrerequisites
(native-executor.ts), which runs at DISPATCH, not at freeze. The test asserts
row counts across workflow_runs, workflow_run_steps, workflow_run_units,
workflow_run_unit_attempts, and the events table before and after.
Envelope (--format json), exact key set:
ok, ref, title, sourceFormat, sourcePath, irVersion, planHash, published,
execution{maxConcurrency}, budget?{maxTokens?,maxUnits?}, params?, outputs?,
steps[], sourceReadSet[], notices[], warnings[]
published is the literal false, present so a consumer can never mistake this
for a run envelope.
Each steps[] entry:
stepId, sequenceIndex, kind ("unit"|"map"|"route"), targetKind
("command"|"shell"|"script"|"child-workflow"|null), concurrency?, inputs[],
environment[], inputBindings?[], gate{criteria,maxLoops,judgeEngine},
outputSchema?, expansion
expansion is the task/child boundary:
expansion |
When |
|---|---|
{via: "direct"} |
an ordinary step |
{via: "task", taskRef} |
the step's target was reached through a tasks/<ref> |
{via: "child", childRef, childPlanHash, childVia, childTaskRef?, childOutputs, steps[]} |
a child-workflow target; steps[] is the child's own step list, recursively, in the identical shape |
The closed print list, and what is deliberately never printed. The secret-free discipline is P2b's B-N4, applied here:
| Printed | Never printed |
|---|---|
inputBindings[].name / .kind |
any resolved reference VALUE (references resolve at pre-attempt; there is nothing to resolve here) |
a literal binding's .value |
a literal environment binding's .value |
a reference binding's .from |
request.command.content, request.persona, request.conversation |
environment[].kind / .name |
request.runtime.environment |
an env-ref's .ref / .keys / .secretNames (all NAMES) |
a script target's bytesBase64 |
gate.judgeEngine (the engine NAME) |
any credential, token, or engine apiKey |
Row B-53's sentinel test plants a distinct sentinel in a command body, a
persona, script bytes, and an env: literal, and asserts none appears in either
mode's bytes.
Text mode is a human summary of the same data:
workflow: team//workflows/release (markdown)
source: workflows/release.md
plan: irVersion 5, hash 4f2ba91c3d0e… (not published)
limits: maxConcurrency 4; budget max_units 50, max_tokens 100000
params: channel, version
outputs: report <- steps.summarize.output
steps:
1. notify [command] direct
2. build [script] via tasks/plan-v4-task
3. dispatch [child-workflow] -> workflows/release-checklist (plan 91acbe20f5d1…)
with: channel="stable" (literal), files <- steps.build.output.files (reference)
exports: report, changed_count
3.1 verify [command] direct
4. summarize [command] direct
read set:
workflows/release.md
commands/notify.md
workflows/release-checklist.md
notices:
! lowering[warn] <code> (<adapter>): <message>
warnings:
! <compile warning>
tests/contracts/command-cli-contract.test.ts gains a workflow plan arm in
the SAME commit (F-B4), mirroring its existing task explain arm.
5. Lane C — crash windows, replay, contention, fixtures
5.1 Files
| File | Contents |
|---|---|
tests/integration/workflow-child-crash-windows.test.ts |
New. Rows C-01…C-04, using tests/integration/_helpers/workflow-crossproc.ts unchanged. |
tests/integration/workflows/child-replay-determinism.test.ts |
New. Rows C-05…C-07, mirroring chaos.test.ts's in-process patterns. |
tests/fixtures/execution-contracts/workflows/child-workflow/** |
New. The four-workflow family (§5.4). |
tests/fixtures/execution-contracts/workflows/manifest.json |
Edited (F-C1). One new top-level childWorkflow key. |
tests/workflows/characterization-fixture-contracts.test.ts |
Edited (F-C2). A new describe for the family; every pre-existing describe byte-unchanged. |
Lane C adds no src/** file and edits exactly one pre-existing non-test file.
5.2 Crash windows
The technique is tests/integration/workflow-crash-windows.test.ts's, verbatim:
a real bun child running tests/integration/_helpers/workflow-chaos-runner.ts
against the parent's isolated storage, synchronized on marker files and journal
polling (never a sleep), SIGKILLed at a precise durable window, then
expireLease and a fresh process. The helper needs no change: the CHILD's
units dispatch through the same seam and write the same markers, so
dispatchCount(markerDir, <childUnitId>) already counts them.
| Window | Kill point | Durable state at the kill | Resume must |
|---|---|---|---|
| CW-1 (C-01) | The child row is published; no child unit has dispatched | one workflow_runs row with parent_run_id; one workflow_started event; the parent unit row running |
find the child by invocation_key, drive it to completion, publish NO second child, emit NO second workflow_started |
| CW-2 (C-02) | A child unit row is running |
both runs active, both leases orphaned |
after lease expiry: re-dispatch exactly that one child unit (marker count goes 1→2 for it, stays put for every other), complete both runs |
| CW-3 (C-03) | The child run is completed; the parent unit row is still running |
child terminal, parent unit unfinished | complete the parent unit from the child's exported result and dispatch zero child units (every child marker count unchanged) |
CW-3 is the row that proves the child's own journal is what prevents
re-execution: the parent unit re-dispatches (a running row is not reusable),
re-enters driveChildWorkflowUnit, republishes idempotently, and
runWorkflowSteps on a completed child is the documented no-op
(completedRunResult) — no lease, no dispatch.
5.3 Two-parent contention on one child (C-04)
Two bun children drive the same parent run's composing step against the same
storage. The first to acquireRunLease on the CHILD drives it; the second's
driveChildWorkflowUnit catches the lease UsageError and returns
child_workflow_busy. Asserted: exactly one child row; exactly one
workflow_started event; the loser's parent unit row carries
failure_reason = "child_workflow_busy" and its message contains the winner's
holder id; the loser's parent run is failed and resumable; a subsequent resume
of the loser converges on the SAME child.
The parent's own run lease already prevents two engines driving one PARENT — the
pre-existing run-lease.test.ts guarantee. C-04 is the CHILD-row analogue and
must exercise the real index and the real lease, never a mocked repository.
5.4 Replay determinism (C-05, C-06)
Mirroring chaos.test.ts: run a composing parent to completion with an injected
dispatcher that records every call; snapshot the parent's
workflow_run_steps.evidence_json for the composing step; then resume the same
run in a fresh invocation and assert
driveChildWorkflowUnitis never entered (the parent unit reuses its completed row throughclassifyUnitReuse),- the dispatcher records zero new calls,
- the re-read step evidence is byte-identical to the snapshot.
C-07 keeps the existing replay_divergence guard honest: tampering with the
composing unit row's input_hash fails the resume loudly with the unchanged
message, exactly as it does for any other target kind.
5.5 The fixture family
tests/fixtures/execution-contracts/workflows/child-workflow/, registered under
a new childWorkflow manifest key with the same shape planV4 uses:
| id | Parent | Child(ren) | Proves |
|---|---|---|---|
direct-child |
workflows/direct-child.yml |
workflows/leaf.yml |
uses: workflows/<ref> freezes to kind: "child-workflow", via: "direct" |
task-wrapped-child |
workflows/task-wrapped-child.yml + tasks/wrap-leaf.yml |
workflows/leaf.yml |
via: "task" with taskRef |
child-with-outputs |
workflows/child-with-outputs.yml |
workflows/exporter.md |
a Markdown child declaring outputs:; the embedded child plan carries outputs; a parent reference to a declared name freezes |
three-level |
workflows/three-level.yml |
workflows/mid.yml → workflows/leaf.yml |
recursive embedding, three levels, each planHash verified |
Manifest fragment:
"childWorkflow": {
"description": "Source fixtures (not byte-snapshots) that freeze end to end into durable plans containing child-workflow targets. bundleRoot's subtree is copied verbatim into a sandboxed stash and indexed before each ref below is frozen.",
"bundleRoot": "child-workflow",
"workflows": [ { "id": "...", "file": "...", "ref": "...", "expectedStepTargetKinds": { }, "expectedChildRefs": { }, "expectedChildDepth": 1 } ],
"expectedTargetKindSet": ["child-workflow", "command", "shell"]
}
The registration test differs from planV4's in exactly one way and the
difference must be commented: it enumerates *.yml and *.md under
child-workflow/workflows/, because a parent must be GitHub-shaped (only
jobs.<id>.steps[].uses composes) while a child declaring outputs: must be
Markdown (B-N4). planV4's .yml-only enumeration is byte-unchanged.
The structural test mirrors
characterization-fixture-contracts.test.ts's plan-v4 freeze test: freeze
through startWorkflowRun, decode the stored plan_json, assert
stepTargetKinds(plan) equals the manifest's map, and additionally walk each
child-workflow target asserting frozenPlan.irVersion === 5,
computePlanHash(frozenPlan) === planHash, and — for three-level — a chain
depth of 3.
6. AUTHORIZED-FLIPS table
Every pre-existing test whose expectations change in P3b, with the exact site and the exact new expectation. An edit to any pre-existing test not listed here is a review-blocking violation. New test files are §7, not here.
The list below was built by grepping, at head, for every test that (a) pins a
workflow verb's registration or output shape, (b) references
WORKFLOW_CHILD_EXECUTION_UNSUPPORTED or the child-workflow dispatch guard, (c)
asserts a run-completion path, or (d) asserts an exhaustive plan/UsageErrorCode
key set. Each grep and its result is recorded in the "Explicitly NOT flipped"
table so a reviewer does not have to re-derive it.
F-A1 — child-workflow-dispatch-guard.test.ts (Lane A)
tests/workflows/child-workflow-dispatch-guard.test.ts — the whole file's
premise flips (B-N11). It stays a file; it does not become a new one.
- The header comment (
:5-36) is rewritten: after P3b, reachingdispatchWorkflowExecutionwith achild-workflowtarget means the child executor seam (src/workflows/exec/child-workflow.ts) was bypassed — an engine routing bug — not an unimplemented feature. :82-92and:94-102— both entry points now throw a plainError; theexpect(caught).toBeInstanceOf(UsageError)andexpect(err.code).toBe("WORKFLOW_CHILD_EXECUTION_UNSUPPORTED")assertions becomeexpect(caught).not.toBeInstanceOf(UsageError)plus a message assertion namingchild-workflow.ts. Theexpect(err.message).toContain(...)ref/unit-id assertions and theexpect(err.message).not.toContain("is not a command target")assertion are kept unchanged — they are exactly the R8 properties that must survive.:104-111— theUSAGE_HINTStest is removed: the code no longer exists, so there is no hint to assert.:113-134— the ordinary-target negative control is byte-unchanged.childWorkflowRequest(:52-70) is byte-unchanged.
F-A2 — tests/workflows/hash-v6.test.ts (Lane A) — verify, do not edit
Listed here because a reviewer will reach for it. P3b changes neither hash
prefix nor the preimage field list (§3.6, B-N3), so this file is
byte-unchanged and is a §7 preservation gate, not a flip. Its A-15 test (a
changed embedded child planHash changes the parent unit hash) is precisely the
property Lane A's invocation_key derivation depends on.
F-B1 — tests/integration/workflows/schema-drift.test.ts needs NO edit
Verified at head: the file pins the dispatch-significant bounds
(WORKFLOW_MAX_GATE_LOOPS, WORKFLOW_MAX_CONCURRENCY, WORKFLOW_MAX_RETRIES,
WORKFLOW_MAX_TIMEOUT_MS, engine-name pattern/length), the program enums, the
budget key set, and a set of not.toContain / in root assertions
(:85-108). It never enumerates the root property list exhaustively, and it
does not assert params.maxProperties. Adding an outputs property with
maxProperties: 64 is therefore invisible to it. WORKFLOW_MAX_OUTPUTS is an
authoring-document bound, not a dispatch-significant one, so it is deliberately
not added to this pin (A-N10's rule, applied). §7 gate.
F-B2 — tests/integration/commands/workflow-cli-envelope.test.ts (Lane B)
One additive arm only; every existing test in the file is byte-unchanged.
A new test asserts akm workflow plan workflows/release-flow --format json
returns exit 0 and an envelope carrying ok, planHash, published: false,
and a steps array — the same additive convention P2b used for task explain.
The pre-existing workflow create / list / run + status / create --print
/ not-found / retired-next tests are untouched; row B-33's byte-identity claim
is asserted against this file's existing status test.
F-B3 — tests/completions.test.ts needs NO edit
Verified: its top-level list (:73-90) contains "workflow" but never
enumerates the workflow SUBCOMMANDS; the only workflow-subcommand assertions
(:181, :201-204) are about workflow create's --from flag-value scoping.
Registering plan is invisible to it. §7 gate.
F-B4 — tests/contracts/command-cli-contract.test.ts (Lane B)
Additive, in the SAME commit that registers the verb (§1.2's binding
instruction, and the exact convention its own P2b comment at :27-31 records).
A third describe, "canonical workflow CLI surface", asserting
main.subCommands.workflow === workflowCommand, that
workflowCommand.subCommands.plan.args.ref matches
{type: "positional", required: true}, and that …plan.args.format matches
{type: "string"}. The existing command run and task explain describes are
byte-unchanged.
F-B5 — tests/integration/cli-errors.test.ts needs NO edit
Verified: its READ_ONLY_VERBS list (:414-423) is a curated set of verbs that
take no required args; workflow plan <ref> takes a required positional and
so does not belong in it, by that list's own stated rule (:411-413). The
shape-registry guard it implements is instead satisfied by F-B2's envelope arm,
which exercises the real workflow-plan shape end to end. §7 gate.
F-B6 — tests/integration/state-migration-023.test.ts (Lane B) — retroactive, code-review round 4 finding 4 / Review log R4
tests/integration/state-migration-023.test.ts is a P3a-owned file (Lane
C, added 497d3760), not on Lane B's §4.1 list — but P3b's own migration
024-workflow-run-outputs (§4.3) is authorized to (and, per §7/§9, MUST) land
directly after 023-child-workflow-runs in both registries, which falsifies
the file's original claim that 023 is the FINAL entry of each. This is exactly
the §0 case ("preserving a behavior and implementing an authorized change
conflict — stop and record it"): the edit is an unavoidable, mechanical
consequence of the authorized migration-024 addition, not an improvement made
in passing.
Two finality assertions, at (pre-existing) :98-103 and :105-111, are
replaced with position-RELATIVE assertions — 023 immediately follows 022, in
both registries, still classified additive — mirroring the exact pattern this
same file already used one migration earlier for 022 relative to 021:
test("appears in STATE_MIGRATIONS directly after 022-workflow-unit-attempts", () => {
const ids = STATE_MIGRATIONS.map((migration) => migration.id);
const index = ids.indexOf(MIGRATION_ID);
expect(index).toBeGreaterThan(-1);
expect(ids[index - 1]).toBe(PRECEDING_MIGRATION_ID);
});
test("is classified additive in STATE_MIGRATION_SAFETY_BY_ID, directly after 022-workflow-unit-attempts", () => {
const classifiedIds = Object.keys(STATE_MIGRATION_SAFETY_BY_ID);
const index = classifiedIds.indexOf(MIGRATION_ID);
expect(index).toBeGreaterThan(-1);
expect(classifiedIds[index - 1]).toBe(PRECEDING_MIGRATION_ID);
expect(getStateMigrationSafety(MIGRATION_ID)).toBe("additive");
});
A one-line comment directly above the first test names the reason ("Not asserted as the final entry: P3b's migration 024-workflow-run-outputs … is authorized to append after this one"). Every other test in the file — the additive-schema, additive-SQL, migration-comment, and partial-unique- index describes — is byte-unchanged.
Landed in 77c656ea (docs(p3b): close out child-executor phase) without a
§6 entry — the commit's own message already named the change and its reason
correctly, but the spec was never updated to match, which is the review-
blocking gap this entry closes retroactively. The FINALITY property the
original .at(-1) pins protected is not lost, only relocated: the new
tests/integration/state-migration-024.test.ts (F-B7 below; code-review
round 4 finding 5 / Review log R5) pins 024-workflow-run-outputs as the
migration that actually owns "final" now.
F-B7 — tests/integration/storage/workflow-runs-repository.characterization.test.ts:113 (Lane B) — retroactive, code-review round 4 finding 4 / Review log R4
Also a P3a-owned file (added 1adee4ef), not on Lane B's §4.1 list.
getRunById returns the full row verbatim exhaustively lists every
workflow_runs column; P3b's migration 024-workflow-run-outputs (§4.3)
adds one (outputs_json), so the row's own commit message calls this "the
direct, mechanical consequence of an authorized change" — the same §0 case
as F-B6, and structurally identical to migration 023's own P3a-era precedent
three lines above in the same object literal (parent_run_id /
parent_unit_id / invocation_key, added when 023 landed). One field added
directly after the existing invocation_key: null, line, with a comment
naming the migration and the spec section:
// Resolved declared outputs: (migration 024, P3b §4.3): NULL until a
// run whose plan declares outputs: completes.
outputs_json: null,
No other line in the file changed. Landed in c1dee6c4 (feat(p3b): workflow outputs, the run status tree, and akm workflow plan) without a §6 entry —
closed retroactively here, same as F-B6.
F-C1 — tests/fixtures/execution-contracts/workflows/manifest.json (Lane C)
One new top-level key, childWorkflow (§5.5). The schemaVersion,
equivalent, currentFreeze, currentFreezeWithSchema, rejected,
singleJob, and planV4 keys are byte-unchanged. The
WorkflowsManifestFragment interface in
characterization-fixture-contracts.test.ts reads only the keys it declares, so
this addition breaks nothing (it is extended by F-C2 in the same commit).
F-C2 — tests/workflows/characterization-fixture-contracts.test.ts (Lane C)
Additive only:
WorkflowsManifestFragment(:115-118) gainschildWorkflow, and aChildWorkflowManifestEntryinterface is added besidePlanV4ManifestEntry(:109-113).- Two new
describeblocks are appended, mirroring theplan-v4pair: a registration test (.ymland.md, §5.5) and a freeze/structural test. - Every pre-existing block is byte-unchanged, including
"workflows/plan-v4 fixture registration"(:225-236), theirVersion 5freeze test (:263-269), theexpectedTargetKindSetcharacterization (:279), and the task-composed read-set characterization (:290). Row C-13 asserts this.
Explicitly NOT flipped (verified at head, do not edit)
| File | Grep run | Result |
|---|---|---|
tests/workflows/hash-v6.test.ts |
prefix / hashVersion pins |
P3b changes neither (F-A2) |
tests/workflows/task-binding-identity.test.ts |
WORKFLOW_IR_V5_VERSION, \0v6\0, two-freeze determinism |
unchanged: no version bump, and outputs is absent on a plan that declares none (B-N1) |
tests/integration/workflows/frozen-plan.test.ts |
irVersion / executionSupport pins |
unchanged: irVersion stays 5 |
tests/integration/workflows/chaos.test.ts, tests/integration/_helpers/workflow-chaos-runner.ts |
run-completion + resume paths | unchanged: non-child runs take no new code path |
tests/integration/workflows/run-lease.test.ts |
lease arbitration | unchanged: the child uses the SAME lease code on its own row |
tests/integration/workflow-crash-windows.test.ts |
SIGKILL windows | unchanged: P3b adds a sibling file, never edits this one |
tests/integration/workflows/v4-atomic-publication-red.test.ts |
parent publication atomicity | unchanged: publishChildWorkflowRun has its own suite and does not join this one |
tests/integration/storage/child-run-publication.test.ts |
P3a Lane C storage contract | unchanged: P3b calls the API, it does not change it |
tests/integration/tasks-runtime-v3-runner.test.ts |
fail-before-mutation canary | unchanged (B-N13) |
tests/core/errors-usage-hints.test.ts |
NEW_CODE_HINTS covers P1a's five codes; the regression guard pins INVALID_FLAG_VALUE only |
removing WORKFLOW_CHILD_EXECUTION_UNSUPPORTED and adding WORKFLOW_OUTPUT_INVALID are both invisible to it |
tests/integration/workflows/status-units.test.ts, tests/integration/workflow-cli.test.ts, tests/integration/workflows/checkin-surfacing.test.ts, tests/integration/workflows/plan-v4-retirement.test.ts |
rg 'toEqual\(\{' <files> returns only parseWorkflowRefInput rows in workflow-cli.test.ts |
no whole-object status assertion exists, so the additive children / run.outputs fields are invisible |
tests/integration/workflows/schema-drift.test.ts |
root property enumeration | none exists (F-B1) |
tests/completions.test.ts |
workflow subcommand enumeration | none exists (F-B3) |
tests/integration/cli-errors.test.ts |
READ_ONLY_VERBS |
workflow plan is out of scope by that list's own rule (F-B5) |
tests/fixtures/execution-contracts/workflows/plan-v4/** |
family contents | sources, not plan bytes; the family keeps its name and contents (row C-13) |
7. Preservation gates (the reviewer runs these)
- [ ]
bun run checkgreen. - [ ] Every P3a suite green:
tests/workflows/child-workflow-freeze.test.ts,tests/workflows/child-workflow-limits.test.ts,tests/workflows/plan-v5-schema.test.ts,tests/workflows/child-invocation-key.test.ts,tests/integration/workflows/plan-version-policy.test.ts,tests/integration/storage/child-run-publication.test.ts,tests/integration/workflows/child-freeze-read-set.test.ts— all byte-unchanged. - [ ]
tests/workflows/hash-v6.test.tsgreen and byte-unchanged (F-A2). - [ ]
tests/integration/workflows/frozen-plan.test.tsgreen and byte-unchanged. - [ ]
tests/integration/workflows/chaos.test.tsandtests/integration/_helpers/workflow-chaos-runner.tsgreen and byte-unchanged. - [ ]
tests/integration/workflows/run-lease.test.tsgreen and byte-unchanged. - [ ]
tests/integration/workflow-crash-windows.test.tsgreen and byte-unchanged. - [ ]
tests/integration/workflows/v4-atomic-publication-red.test.tsgreen and byte-unchanged. - [ ]
tests/integration/tasks-runtime-v3-runner.test.tsgreen and byte-unchanged (fail-before-mutation, B-N13). - [ ]
tests/integration/workflows/schema-drift.test.tsgreen and byte-unchanged (F-B1). - [ ]
tests/completions.test.tsgreen and byte-unchanged (F-B3). - [ ]
tests/integration/cli-errors.test.tsgreen and byte-unchanged (F-B5). - [ ]
tests/workflows/task-binding-identity.test.tsgreen and byte-unchanged — two freezes of a source declaring nooutputs:are still byte-identical, at plan hash AND unit hash (B-N1 reason 2). - [ ] The migration position/safety registry test green
(
024-workflow-run-outputslast in bothSTATE_MIGRATIONSandSTATE_MIGRATION_SAFETY_BY_ID, classified"additive"). - [ ]
tests/architecture/import-cycle-ratchet.test.tsgreen:src/execution/**still imports nothing fromsrc/workflows/**, andsrc/workflows/exec/child-workflow.tscloses no cycle withrun-workflow.ts(it is imported BY the executor and importsrunWorkflowSteps; if the ratchet objects, the drive is reached through an injected function value, the patternfreeze-v4.ts'sChildFreezeFnalready establishes — record the choice in the Review log). - [ ]
rg WORKFLOW_CHILD_EXECUTION_UNSUPPORTED src/ tests/returns zero hits (row A-04). - [ ]
rg 'akm\.workflow\.(unit|gate)\\0v5\\0' src/ tests/returns zero hits, andrg 'hashVersion: 7|WORKFLOW_IR_V6' src/returns zero hits (§0.1). - [ ]
rg 'irVersion' src/workflows/ir/schema-v4.tsshows5, unchanged. - [ ]
bun run lintincludes the doc-examples check; everyakm …example added in §8 is lint-doc-examples-clean, and none of them spells--json(B-N9). - [ ] For a workflow with no
outputs:and no child steps:akm workflow status,list,run,resume, andabandonproduce byte-identical stdout and JSON envelopes to the same commands at6ec07482(Stable tier; row B-33/B-45 — capture the baseline in commit 2, before any Lane Bsrc/**change lands).
New suites this phase adds (these are NOT flips):
| File | Covers |
|---|---|
tests/workflows/child-executor-seam.test.ts |
Rows A-01…A-06 |
tests/integration/workflows/child-execution.test.ts |
Rows A-07…A-27 |
tests/integration/workflows/child-cancellation.test.ts |
Rows A-28…A-30 |
tests/integration/workflows/child-nesting.test.ts |
Rows A-31…A-36 |
tests/workflows/workflow-outputs-source.test.ts |
Rows B-01…B-17 |
tests/integration/workflows/workflow-outputs-runtime.test.ts |
Rows B-18…B-27 |
tests/workflows/child-output-references.test.ts |
Rows B-28…B-32 |
tests/integration/workflows/status-tree.test.ts |
Rows B-33…B-45 |
tests/commands/workflow-plan.test.ts |
Rows B-46…B-59 |
tests/integration/workflow-child-crash-windows.test.ts |
Rows C-01…C-04 |
tests/integration/workflows/child-replay-determinism.test.ts |
Rows C-05…C-07 |
8. Docs that ride with the code
| File | Contents |
|---|---|
docs/reference/workflow-schema.md |
A new outputs: section beside "What a step's output is" (§4.2's grammar, the bounds, the Markdown-only note and why — B-N4, the outputSchema distinction — B-N2, and the {runId, status} default). The "Child workflows" section's "What is not yet available" subsection (:198-212) is REPLACED by "Child execution": the status mapping table (§3.4), the blocked-child resume sequence, the retry/resume identity rule, and the note that a child is driven inline by its parent's process. The composition-limits table is byte-unchanged. |
docs/reference/workflows.md |
The child-workflow paragraph (:67) drops WORKFLOW_CHILD_EXECUTION_UNSUPPORTED and gains one paragraph on what running a composing workflow now does, with a pointer to the status tree. |
docs/guides/run-workflows.md |
A new "Child runs" section after "Check status": reading the tree, the blocked-child three-command recovery (§3.4's exact sequence), that akm workflow list hides children and --children shows them, and that akm workflow status <childRunId> works directly. |
docs/reference/cli.md |
The akm workflow synopsis block (:553-562) gains akm workflow plan workflows/<name> and akm workflow list --children; a akm workflow plan subsection beside the status one (:696-710) documents the two modes, the zero-writes guarantee, and the secret-free print list. |
docs/architecture/workflow-engine.md |
The child-workflow section (:79) is corrected: the dispatch seam (§3.2), the drive contract (§3.3), why runWorkflowSteps is reused rather than a second executor (B-N5), and why hashVersion stays 6 (§3.6). A short "Run outputs" subsection covers resolve-at-completion and the rollback rule (B-N13). |
CHANGELOG.md [Unreleased] |
Three entries. (1) Under a feature heading: child workflows now execute — the status mapping, the blocked-child resume flow, and that a parent retry/resume reuses the same child. (2) Workflow outputs: — the declaration, the exported result, and the {runId, status} default. (3) New verb akm workflow plan (Evolving). Plus a correction line: the P3a entry at :150 naming WORKFLOW_CHILD_EXECUTION_UNSUPPORTED is amended to say that code existed only in the P3a increment and is gone in the shipped release. |
docs/migration/v0.9.1-to-v0.9.2.md |
The "Workflow cutover" section (:110) gains a short subsection: nothing to migrate for child execution (no stored child run predates this release), plus the one operational note that akm workflow list hides child runs by default. |
STABILITY.md |
One row in the tier table (:60-65): `akm workflow plan` | Evolving | New in 0.9.2; envelope shape may change. The six existing workflow rows at :60-65 — status, list, create, resume, abandon, run — are byte-unchanged. |
Every akm example must pass the doc-examples lint that bun run lint runs,
and none may spell --json (B-N9).
9. Acceptance criteria
Structure
- [ ]
src/workflows/exec/child-workflow.tsis the only module that publishes or drives a child run;native-executor.tscalls into it and contains no child logic of its own. - [ ]
driveRunis not exported fromrun-workflow.ts; the child drive goes throughrunWorkflowSteps(B-N5). - [ ] The child drive passes a no-op
disposeDispatchResources, nomaxSteps, and nomaxRetries(B-N6, B-N7). - [ ]
publishChildWorkflowRunis called with no transaction open on the connection — it appears in norepo.transaction()callback anywhere insrc/**(B-N16, P3a Review log R10). - [ ]
WORKFLOW_IR_V5_VERSION === 5; both hash prefixes read\0v6\0; thehashVersionpreimage field reads6; the child-invocation prefix reads\0v1\0(§0.1). - [ ]
WorkflowPlanGraphV4.outputsis absent — never{}— when nothing is declared, andcomputePlanHashis byte-identical to P3a's for every workflow that declares none (B-N1). - [ ]
WORKFLOW_MAX_OUTPUTSlives insrc/workflows/resource-limits.ts;WORKFLOW_MAX_PLAN_BYTES,WORKFLOW_MAX_COMPOSITION_DEPTH, andWORKFLOW_MAX_EMBEDDED_CHILD_PLAN_BYTESare unchanged. - [ ]
024-workflow-run-outputsis the last entry ofSTATE_MIGRATIONSand the last key ofSTATE_MIGRATION_SAFETY_BY_ID, classified"additive". - [ ]
WORKFLOW_CHILD_EXECUTION_UNSUPPORTEDis gone from theUsageErrorCodeunion and fromUSAGE_HINTS;WORKFLOW_OUTPUT_INVALIDis present in both. - [ ] Lane A's and Lane B's file lists (§3.1, §4.1) are disjoint; Lane C adds no
src/**file. - [ ]
src/commands/workflow/plan.tsimports nothing fromsrc/workflows/runtime/runs.tsbeyond the asset loader, and never callsstartWorkflowRun,publishWorkflowRunV4,warn,appendEvent, orakmIndex.
Behavior
- [ ] Every PRESERVE row of §2 holds, verified by its cited test.
- [ ] Every NEW row of §2 has at least one test asserting its reason/code and its message text.
- [ ] A child-workflow unit never reaches
UnitDispatcher, and still journals its own attempt row (A-01, A-02). - [ ] The three key inputs produce one child; a second call with the same three
returns the same child; a changed
gateFeedbackproduces a different one (A-07, A-13, A-16). - [ ] Completed / failed / blocked / aborted / publication-failure / busy each map to §3.4's exact row, with the exact failure reason and message (A-19…A-23, A-27, A-28).
- [ ] A blocked child blocks the parent RUN, and the notes name the child run id and the three-command sequence verbatim (A-21).
- [ ] An abort leaves BOTH runs resumable with no live lease (A-29).
- [ ] A three-level composition drives to completion and produces three linked run rows (A-35).
- [ ]
outputs:parses, compiles, freezes, resolves at completion, and is exported; a run without a declaration exports{runId, status}(B-01…B-25). - [ ] An unresolvable / truncated / schema-violating output rolls the completion
back with
WORKFLOW_OUTPUT_INVALID, leaving the steppendingand the runactive(B-20…B-22). - [ ] A parent reference to an undeclared child output fails at FREEZE with
COMPOSITION_INVALID; a deeper path fails at pre-attempt (B-29…B-31). - [ ]
akm workflow statuson a childless run is byte-identical, envelope and text (B-33). - [ ] Child runs are hidden from
list, from the scope-attach path, and fromakm show's guard (B-40, B-42, B-43). - [ ]
akm workflow planwrites zero rows across all five tables and emits no warn log (B-48), and leaks no sentinel in either mode (B-53). - [ ] All three crash windows converge with no duplicate child and no re-run
child unit (C-01…C-03); two parents contending on one child produce one
child row and one
child_workflow_busy(C-04). - [ ] A resumed parent replays its completed composing step byte-identically, with zero dispatcher calls (C-05, C-06).
Gates
- [ ] Every §7 checkbox ticked.
- [ ] Every §8 doc updated in the same commit range, examples lint-clean.
- [ ] No pre-existing test outside §6 was edited.
10. Head reconciliation (adoption)
This spec was pre-drafted at fe74774a, against a head of 6ec07482 (P3a's
code-review round 2), while P3a was still closing out. It is adopted here,
re-verified through head c5432167.
The one fact that shapes everything below: the two commits P3a added after this spec was drafted changed no executable behavior.
f35d433d("fix(p3): address review findings (round 3)") changed exactly one file — this spec — alint-doc-examplesfix that backtick-quoted the bareakmin §3.4'sblockStepForChildWorkflownotes block.c5432167("docs(p3): close out plan v5 child-freeze phase") changedCHANGELOG.md,docs/reference/workflow-schema.md,docs/reference/workflows.md, the P3a spec's Review log (R9–R12, §10.2), and one doc comment onpublishChildWorkflowRuninsrc/storage/repositories/workflow-runs-repository.ts— R10's tightening. No statement, no signature, and no test changed.
So every executable byte this spec cites is the byte it was verified against,
and every dispatch-seam, contract, and file:line claim was re-checked at
c5432167. The §3.2 seam citation is exact at head: native-executor.ts:1168
reads const dispatched = await dispatchUnit(request, dispatcher); inside
dispatchJournaledAttempt (:1136), with dispatchUnit itself at :1267.
The workflow-runs-repository.ts line numbers this spec cites are stated
post-c5432167 (publishChildWorkflowRun :624, childRunsOf :673,
getUnit :1055), so R10's doc-comment growth is already absorbed.
10.1 The fifteen disambiguations, re-verified
| # | Verdict | Evidence at head |
|---|---|---|
| B-N1 | CONFIRMED (one citation AMENDED) | WorkflowPlanGraphV4 schema-v4.ts:184-193 carries no outputs; decodeWorkflowPlanV4's assertKeys (:220-224) and validateWorkflowPlanStructure (schema.ts:163-168) + planExtraKeys: ["sourceReadSet"] (:231) both omit it. AMENDED: the rg outputs src/workflows/ir/ claim — the grep's one hit is diagnostic prose at compile.ts:312, not a field; wording corrected in place. |
| B-N2 | CONFIRMED | IrStepPlanV4.outputSchema at schema-v4.ts:180; validateStepArtifact at step-work.ts:891. |
| B-N3 | CONFIRMED | computeUnitInputHash (step-work.ts:701) still emits akm.workflow.unit\0v6\0 (:703) with hashVersion: 6 (:706); frozenTarget remains a preimage field. |
| B-N4 | CONFIRMED, and independently reinforced by P3a round 2 | ROOT_KEYS = ["name","on","jobs"] at github-yaml.ts:36, enforced at :119; WORKFLOW_KEYS at parser.ts:116; parseParams at :548. P3a round 2's finding 4 corrected workflow-schema.md to say composition is authorable only through the GitHub-shaped jobs.<id>.steps[].uses surface — which is exactly B-N4's parent-is-.yml / child-may-be-.md pairing, and exactly what §5.5's fixture family and its dual-extension enumeration already encode. No change. |
| B-N5 | CONFIRMED | runWorkflowSteps exported at run-workflow.ts:231, called by workflow-cli.ts:191; runWorkflowAttempt :278 → getNextWorkflowStep :282 → resolveRunSpecifier runs.ts:879 whose first branch is repo.getRunById(specifier) :886; requireExecutableWorkflowPlan :291; acquireRunLease call :315 (fn :385); driveRun :937, still module-private. |
| B-N6 | CONFIRMED | The registry drain is run-workflow.ts:364, await (options.disposeDispatchResources ?? disposeDispatchResources)(), inside the attempt finally. |
| B-N7 | CONFIRMED | maxSteps accounting at run-workflow.ts:971-976, consumed at :1126 via STEP_FINISHED_KINDS (:747); the retry loop at :245-275. |
| B-N8 | CONFIRMED (one citation AMENDED) | StepWorkUnit.unitId step-work.ts:139, journalBaseId :146, derived :551; attemptIdFor returns journalBaseId at native-executor.ts:840, used at :882. AMENDED: the bare :1040 cite for repo.getUnit → workflow-runs-repository.ts:1055. |
| B-N9 | CONFIRMED | GLOBAL_OUTPUT_ARGS cli/shared.ts:163; defineJsonCommand splices it at :221. No --json boolean exists anywhere. |
| B-N10 | CONFIRMED as drafted, INCOMPLETE as drafted | getActiveRunRowForScope :352, listRuns :372, findActiveOrBlockedRunForScope :415 — all three still scope-only; getActiveWorkflowRun reads the third at runs.ts:1117; the attach path is runs.ts:900,917. Post-implementation code review (round 4, finding 2 / Review log R2) found a FOURTH site this row never named: findActiveRunForScope (:355), publishWorkflowRunV4's (:593) own start guard — reached from startWorkflowRun exactly when the three-site check above found nothing to attach to. B-N10 in §1.6 above is amended in place to four sites; this adoption-time verification of the original three-site draft stands unedited as the historical record of what was checked at c5432167. |
| B-N11 | CONFIRMED | The P3a guard is live at unit-dispatch.ts:173-187; the code is at errors.ts:139 and USAGE_HINTS:216; the two stale comments to correct are step-work.ts:452-460 and native-executor.ts:999-1008. F-A1's line ranges in child-workflow-dispatch-guard.test.ts (:5-36, :52-70, :82-92, :94-102, :104-111, :113-134) are all exact. |
| B-N12 | CONFIRMED | isTruncatedEvidence runs.ts:611, clipStepEvidenceForPersistence :642; driveRun's liveEvidence doc block run-workflow.ts:942-959 still says in terms a later invocation "reads the rows, where a reference into a truncated artifact fails loudly by name". |
| B-N13 | CONFIRMED | completeWorkflowStep :693; its write transaction :782; deriveRunState(refreshedSteps) :838; appendEvent outside it at :867. |
| B-N14 | CONFIRMED | InsertRunInput.workflowEntryId: number | null at workflow-runs-repository.ts:230; workflow_entry_id INTEGER at migrations.ts:886; resolveWorkflowEntryId at runs.ts:303. |
| B-N15 | CONFIRMED | formatWorkflowStatusPlain workflow-format.ts:34, its notes: render :59-60; formatWorkflowRunPlain :121, its summary render :159-161. |
Nothing in P3a's rounds 2–3 falsified a disambiguation's substance. Two line citations inside B-N1 and B-N8 were corrected in place; both were drafting errors, not head drift.
10.2 P3a Review log entries recorded after this spec was drafted
R1–R8 predate fe74774a and are already woven into §1.6, §2.1, and §6. R9–R12
landed with P3a's round 3 and close-out.
| Entry | Status for P3b |
|---|---|
R9 — a v3 task's authored with: gains reference semantics only when composed, RECORDED, "decision point handed to P3b/P4" |
DECLINED for P3b; deferred to P4. Both options R9 offers are freeze-side: option 1 adds a literal-only AuthoredChildInputs variant in freeze/targets/child-workflow.ts + freeze/task-bindings.ts; option 2 documents the divergence in workflow-schema.md's composition section. Neither file is on §3.1's or §4.1's binding file lists, and P3b changes no binding-classification behavior — §3.3 step 2 consumes taskInputsResolution.values through the unchanged P2b resolver. Picking either option inside P3b would be the "improve anything on the way past" §0 forbids. Recorded here so no lane adopts it mid-phase; added to §0's "P3b is not" list. |
R10 — publishChildWorkflowRun's idempotency guarantee holds only as the OUTERMOST transaction |
ADOPTED as new disambiguation B-N16, with a §3.3 step 4 inline note and a §9 acceptance bullet. This is the one round-3 entry with live consequences for P3b: P3b wires this method's first production caller, and R10 names completeWorkflowStep (runs.ts:782) as "the most plausible place a P3b child-spawn gets wired". P3b does not wire it there — §3.2 places the seam at dispatchJournaledAttempt, outside any transaction — so P3b is correct by construction, and B-N16 pins that rather than leaving it to be re-derived. |
R11 — publishChildWorkflowRun duplicates insertRun's 13-column list, RECORDED, "P3b will need to revisit anyway once it wires a real caller" |
DECLINED for P3b; stays RECORDED. P3b wires the caller but refactors nothing: insertRun's signature is unchanged, §3.3 step 4's field table is written against the existing PublishChildWorkflowRunInput (workflow-runs-repository.ts:273-284), and src/storage/repositories/workflow-runs-repository.ts appears on §4.1's Lane B list only for outputs_json, setRunOutputs, ListRunsFilter.includeChildren, and the three parent_run_id IS NULL predicates. Extending insertRun is a P4 deletion/consolidation task. Added to §0's "P3b is not" list. |
| R12 — R8's docs fix overstated a composing step's dispatch-failure blast radius; three doc sites reworded | CONFIRMED, no P3b change. All three sites (docs/reference/workflows.md, docs/reference/workflow-schema.md's "What is not yet available", CHANGELOG.md) are passages §8 already REPLACES or amends once child execution lands, so the rewording changes the text P3b deletes, not the instruction to delete it. §8's anchors survive: workflow-schema.md's ### What is not yet available is at :198, workflows.md's WORKFLOW_CHILD_EXECUTION_UNSUPPORTED sentence at :67, CHANGELOG.md's P3a entry mention at :150, docs/architecture/workflow-engine.md at :79, docs/migration/v0.9.1-to-v0.9.2.md's "## Workflow cutover" at :110. |
10.3 Citations amended (the complete list)
Every amendment is a corrected pointer or a corrected count; no behavior table row, no §6 flip, and no §7 gate changed meaning.
| Site | Was | Is at head |
|---|---|---|
| §0 preamble | "verified at … 6ec07482" |
verified at 6ec07482, re-verified at f35d433d |
| B-N1 | "rg outputs src/workflows/ir/ returns only the per-STEP outputSchema" |
the grep's one hit is prose at compile.ts:312; outputSchema is at schema-v4.ts:180 |
| B-N8 | repo.getUnit(...), :1040 |
workflow-runs-repository.ts:1055 |
§4.1, ir/compile.ts row |
reference validation :338-390 |
checkReferenceField :282-297, checkInputReference :300-324; :338-390 lands on collectWorkflowWarnings (:349), the wrong function |
| §4.6, zero-writes paragraph | "every warn() on the start path … (runs.ts:279-281, :360-364)" |
runs.ts has exactly two warn() sites — :280 (startWorkflowRun) and :818 (completeWorkflowStep); there is no warn() at :360-364. The verb calls neither function, so the row B-48 guarantee is unchanged (and better evidenced) |
§8, STABILITY.md row |
"The five existing workflow rows" | six at :60-65: status, list, create, resume, abandon, run |
10.4 Verified unchanged, no amendment needed
Spot-checked at head and exact: WORKFLOW_MAX_PLAN_BYTES /
WORKFLOW_MAX_SCHEMA_BYTES / WORKFLOW_MAX_COMPOSITION_DEPTH /
WORKFLOW_MAX_EMBEDDED_CHILD_PLAN_BYTES (resource-limits.ts:7,14,269,286);
PROGRAM_RETRY_REASONS (program/schema.ts:77);
computeChildInvocationKey and its akm.workflow.child-invocation\0v1\0
prefix (child-invocation.ts:43,45);
WorkflowSourceIrV1 (source-ir/schema.ts:180-194), its root keys(...)
(:210-227) and validateParams (:233); the Markdown→IR params clone
(source-ir/compile.ts:95); WorkflowPlanDraft (ir/compile.ts:91) and the
draft assembly (:160-180); frozenStepRows (runtime/plan-classifier.ts:102)
called at runs.ts:340; the two startWorkflowRun calls §4.6 mirrors
(runs.ts:263,268); completedRunResult (run-workflow.ts:545-553) and
workflowSummaryJudge (:565-574); workflowListCommand.args
(workflow-cli.ts:73-76), workflowCommand.subCommands (:358-365), and the
blocked-run exit code (:209); PASSTHROUGH_COMMANDS' workflow block
(passthrough.ts:66-72) and workflowFormatters (text/workflow.ts:16-23);
ChildFreezeFn (ir/freeze-v4.ts:70) and the plan assembly (:123-127).
The §6 "no edit needed" verifications also hold verbatim: schema-drift.test.ts
:85-108 (F-B1), completions.test.ts :73-90 (F-B3), cli-errors.test.ts
:411-423 (F-B5).
One head observation with no spec consequence, recorded so a lane does not
mistake it for drift: P3a round 2 gave decodeWorkflowPlanV4 two new trailing
parameters, depth = 0 and budget = { embeddedBytes: 0 }
(schema-v4.ts:213-218), threading the aggregate embedded-bytes bound through
the child-plan recursion. Every §4.1 edit to that function is to its
assertKeys list and planExtraKeys; the signature is untouched by P3b.
Review log
R1 — a throw out of the child drive escaped dispatchJournaledAttempt, leaving the parent's composing attempt row stuck running and reporting a false "unit was not dispatched" diagnostic (RESOLVED — code-review round 4, finding 1)
Status: RESOLVED, code-review round 4.
Found by code review: driveChildWorkflowUnit (child-workflow.ts) caught
only isLeaseBusyError around the drive call and rethrew everything else, on
the strength of §3.5's claim that other throws are "classified by the
existing dispatch_error handling". That handling does not exist at this
seam. dispatchJournaledAttempt (native-executor.ts, the §3.2 branch)
awaits driveChildWorkflowUnit with no try of its own, so a throw skips
finishJournaledDispatch entirely and the parent's attempt row — reserved
moments earlier by reserveJournaledDispatch — is never finished. The throw
then escapes runUnit into scheduleUnits → concurrentMap
(src/core/concurrent.ts), whose worker loop wraps fn in a bare
try {} catch {} and leaves the result slot undefined by design (its own
doc: "A thrown fn is SWALLOWED"). executeStepPlanInConnection then maps
that undefined to {failureReason: "aborted", error: "unit was not dispatched (aborted or scheduler failure)"} — a diagnostic that is not just
generic but actively FALSE (the unit WAS dispatched; the real cause was
silently discarded).
Failure scenario, reproduced deterministically: a GitHub-shaped parent
composing a Markdown child that declares outputs: {report: {from: steps.work.output.missing}}. The child completes its only step; Lane B's
completeWorkflowStep then tries to resolve the declared output, fails
(missing property), and throws UsageError("WORKFLOW_OUTPUT_INVALID", …)
INSIDE the child's own completion transaction — which propagates out of the
child's own runWorkflowAttempt, out of driveWithRealEngine, and (before
this fix) out of driveChildWorkflowUnit itself. Observed state: the parent
run is failed; the composing unit's attempt row is stuck status: "running", failure_reason: null, finished_at: null, with a live 90s
claim; the child run row is published and left active (its own completion
transaction rolled back, per B-N13 — fail-before-mutation held on the CHILD
side); the step summary says the unit was never dispatched. A subsequent
resume + re-drive reproduces the IDENTICAL false diagnostic — the composition
is unrecoverable by inspection, since nothing in the observable state points
at the real cause.
Other reachable throws on the same (unwrapped) path, none matching
isLeaseBusyError's "is already being driven by engine" text: the child's
own LeaseHeartbeat.assertAlive() firing mid-drive (a genuinely stolen CHILD
lease, distinct from A-30's chained-parent-abort case, which does not throw
— see the code comment at the fix site for why); requireExecutableWorkflowPlan
rejecting a tampered child plan_json; and runWorkflowAttempt's "is
blocked/failed and cannot be executed" guard firing when the child's status
changes between this function's own step 5 read and the drive's internal
getNextWorkflowStep re-read.
Resolution: driveChildWorkflowUnit now wraps BOTH the driveWithRealEngine
call AND step 7's getRunById re-read in the SAME try, so every throw
between them produces a UnitOutcome instead of escaping. The
isLeaseBusyError arm is unchanged (child_workflow_busy, preserving the
holder/expiry text); every other throw is mapped to a new sibling failure
reason, child_workflow_drive_failed — same shape as
child_workflow_publish_failed, carrying errorMessage(err), the child run
id, and the child ref (both already known at this point, unlike the publish
arm) — and returned, never rethrown. This restores finishJournaledDispatch
on every path: the parent's attempt row is always finished, with an honest
reason.
§3.4 (failure-reason vocabulary + status-mapping table) and §3.5
(cancellation and leases) are corrected in place to state the new contract
and retract the false "classified by the existing dispatch_error handling"
premise. No §6 flip was needed: rg across every P3b test file for
driveChildWorkflowUnit combined with .rejects/toThrow found zero
call sites depending on the old rethrow behavior, and the one test that
exercises a REAL abort-vs-lost-lease distinction near this code
(child-cancellation.test.ts's A-30) throws from the PARENT's own
heartbeat.assertAlive() call sites inside its OWN driveRun, strictly
AFTER driveChildWorkflowUnit has already returned a normal outcome — a
different code path this fix does not touch (traced in full during review;
recorded here so a future reader does not have to re-derive it).
Verification: bunx tsc --noEmit clean; every Lane A/C child-execution suite
green; a dispatch_error-labeled negative-control assertion
(child-execution.test.ts, row A-20's "never dispatch_error" check) is
unaffected since it targets a different failure reason entirely.
R2 — findActiveRunForScope, the top-level start guard, was not filtered on parent_run_id IS NULL — a FOURTH site B-N10 never named (RESOLVED — code-review round 4, finding 2)
Status: RESOLVED, code-review round 4.
Found by code review: B-N10 (§1.6) named three scope-keyed queries a child
run must be invisible to and filtered all three. It missed a fourth:
findActiveRunForScope (workflow-runs-repository.ts), the guard
publishWorkflowRunV4 uses to refuse starting a second active run of the
same ref in the same scope. Once P3b publishes child runs — which carry the
PARENT's scope_key (P3a §5.2) — this uncaught site actively misfired: on a
scope occupied only by a parent-driven child, resolveRunSpecifier's OWN
(already-filtered) getActiveRunRowForScope check correctly finds nothing to
attach to and proceeds to start a fresh top-level run, and THEN
findActiveRunForScope, unfiltered, finds the child and refuses with
RESOURCE_ALREADY_EXISTS naming the CHILD's own run id — inviting the
operator to akm workflow abandon a run a parent is actively driving. This
violates row B-42 end to end, not merely in the narrow sense the original
B-42 test checked (which asserted only getActiveRunRowForScope(...) === undefined — a real property, but one layer short of the actual bug).
Resolution: findActiveRunForScope's SELECT gains AND parent_run_id IS NULL, identical in form to the other three B-N10 sites. B-N10 in §1.6 is
amended from three sites to four, with the new site's row explaining the
misfire (not merely "would return a child") in full; the top-of-file owner-
artifacts line and the §10.1 head-reconciliation table's B-N10 row are cross-
referenced accordingly (the latter is left as an accurate historical record
of what was checked at adoption, with a note pointing at this entry rather
than being silently rewritten). No §6 flip was needed:
tests/integration/storage/child-run-publication.test.ts's only other
findActiveRunForScope assertion (its C-10 test) seeds its "occupying-run"
fixture as a genuine top-level run via the plain seedWorkflowRun helper
(parent_run_id implicitly NULL), so it continues to match and stays green
unmodified — verified by inspection and by the green suite. Row B-42's own
test (tests/integration/workflows/status-tree.test.ts) is extended (not
merely left as-is) with a full end-to-end startWorkflowRun drive against a
real, on-disk, freshly-loaded child ref, asserting the result is a NEW top-
level run id distinct from both the child's and the parent's — closing the
gap between "the narrow property held" and "the operator-visible bug is
fixed". publishChildWorkflowRun itself is untouched and, per P3a row C-10,
deliberately never consults this guard.
R3 — workflow plan's text-vs-JSON selection read the citty LEAF arg, violating (and breaking under) the one-parse rule (RESOLVED — code-review round 4, finding 3)
Status: RESOLVED, code-review round 4.
Found by code review: workflowPlanCommand.run selected its output branch
with getStringArg(args, "format") === undefined, reading the citty-parsed
LEAF format arg directly. GLOBAL_OUTPUT_ARGS's own doc comment
(src/cli/shared.ts) states the rule this violates: "no command body may
read these args" — the one-parse rule, because citty parses each command
level against only that level's own remaining argv. A GLOBAL, pre-subcommand
--format json (the position every other multi-word akm invocation
supports, e.g. akm --format json workflow list) is consumed by the ROOT
command's own declared format arg before the workflow/plan subcommand
tokens are even resolved, so the LEAF's args.format reads undefined in
that case too — indistinguishable, at the leaf, from "no format was named at
all". Reproduced live: akm --format json workflow plan <ref> printed the
human TEXT summary at exit 0 even though getOutputMode().format was already
"json", while the control akm --format json workflow list correctly
emitted JSON (that verb defers unconditionally to the shared output()
dispatcher, which reads the singleton, not the leaf arg). An explicitly
requested JSON envelope (row B-47) silently became text, and
tests/commands/workflow-plan.test.ts never exercised the global flag
position, so nothing caught it.
Resolution: the branch now detects "the caller named no format at all" with
getParsedInvocation().getFlagValue("--format") === undefined
(src/cli/invocation.ts) — the explicit-format signal threaded off the
invocation singleton, the SECOND of the two options this finding offered.
The finding's first-listed option, parseFlagValue(process.argv, "--format")
(src/output/context.ts), was tried first and is functionally equivalent —
same canonical, position-independent scan, same argv — but bun run lint's
lint-process-argv.ts boundary rejected it: "Only src/cli.ts and
src/cli/invocation.ts may read process.argv directly." getParsedInvocation()
is that boundary's sanctioned indirection, and it agrees with
getOutputMode() by construction for the same reason: src/cli.ts's startup
calls setParsedInvocation(process.argv) immediately before
initOutputMode(process.argv, …), from the identical (already-normalized)
argv value, so the two singletons are always built from the same snapshot.
The stale comment claiming args.format is undefined "exactly when the
caller passed no --format at all — never when they passed --format json
explicitly" is rewritten to state (and explain) the actual failure mode
instead. A regression suite is added to
tests/commands/workflow-plan.test.ts: the global-position invocation now
emits the JSON envelope; the existing leaf-position invocation is
unaffected (both positions now agree, asserted directly against each other);
and the akm --format json workflow list control is pinned alongside it for
contrast. getStringArg remains imported and used for workflow run's
--max-steps/--max-retries/--timeout (ordinary leaf-only args, outside
GLOBAL_OUTPUT_ARGS and therefore outside the one-parse rule) — only the
format read was wrong.
R4 — two PRE-EXISTING (P3a) tests were edited by P3b commits with no §6 entry, a review-blocking gap under §0/§9 (RESOLVED — code-review round 4, finding 4)
Status: RESOLVED, code-review round 4.
Found by code review: §0 states that editing a pre-existing test §6 does
not name is a review-blocking violation, and that a conflict between
preserving a behavior and an authorized change must be "stopped and
recorded" in the Review log. Two P3a-owned files were edited across the P3b
commit range with neither happening:
tests/integration/state-migration-023.test.ts(added by P3a497d3760, weakened by P3b77c656ea): bothids.at(-1)/classifiedIds.at(-1)finality pins were replaced withindexOf-relative checks, because P3b's own migration024-workflow-run-outputsis authorized to land after 023.tests/integration/storage/workflow-runs-repository.characterization.test.ts:113(added by P3a1adee4ef, edited by P3bc1dee6c4): the exhaustivegetRunByIdcharacterization row gainedoutputs_json: null,for the same migration.
Both edits are unavoidable, mechanical consequences of authorized §4.1/§4.3
changes — exactly the case §0 says to stop and record — yet docs/plans/ specs/ was untouched across the whole range and this spec's Review log was
empty before this entry. Both landing commits' own messages already
correctly named the change and its reason (77c656ea: "Narrowed the two
assertions to what the test actually owns … without asserting global
finality"; c1dee6c4: "Two pre-existing, non-P3b-owned tests are touched
only as the direct, mechanical consequence of an authorized change") — the
process gap was narrowly that this spec was never updated to match, which
left both edits §6-silent on the one document §0/§9 designate as
authoritative.
Resolution: both files are added to §6 as new flip entries — F-B6
(state-migration-023.test.ts, with the exact old and new expectations) and
F-B7 (workflow-runs-repository.characterization.test.ts:113, with the
exact added field and comment) — rather than left as bare Review-log
mentions, so a future reviewer finds them where every other flip lives. See
R5 immediately below for the coverage consequence F-B6 identifies (the
finality property state-migration-023.test.ts used to pin needed a new
home).
R5 — the migration-registry gate's finality pin was deleted by R4's fix and nothing replaced it — migration 024-workflow-run-outputs had zero direct assertions (RESOLVED — code-review round 4, finding 5)
Status: RESOLVED, code-review round 4.
Found by code review, as a direct consequence of R4: weakening
state-migration-023.test.ts (F-B6) deleted the only finality/ordering pin
in the repository for the tail of STATE_MIGRATIONS/
STATE_MIGRATION_SAFETY_BY_ID, and nothing replaced it for the migration
that actually owns "final" now. rg "024-workflow-run-outputs" tests/
returned exactly two hits, both prose comments (state-migration-023.test.ts's
own explanatory comment, and a docstring mention in
workflow-outputs-runtime.test.ts) — zero assertions. §7's own
preservation-gate checklist and §9's acceptance criteria both claim
"024-workflow-run-outputs is the last entry of STATE_MIGRATIONS and
the last key of STATE_MIGRATION_SAFETY_BY_ID, classified "additive"" —
a claim nothing in the test suite directly checked. A future migration
inserted out of order, or misclassified, would pass every existing test.
Resolution: tests/integration/state-migration-024.test.ts (new; not a §6
flip — it adds no code to a pre-existing file) mirrors
state-migration-023.test.ts's structure one migration later: 024- workflow-run-outputs is asserted as the FINAL entry of STATE_MIGRATIONS
and the FINAL key of STATE_MIGRATION_SAFETY_BY_ID, directly after 023- child-workflow-runs in both (the .at(-1)/.at(-2) pins F-B6 removed from
the 023 file, relocated here where they now belong), getStateMigrationSafety (...) returns "additive", the migration's SQL is additive-only (no
DROP/RENAME), a fresh state.db carries workflow_runs.outputs_json as a
nullable TEXT column outside the primary key, and an existing pre-024
database migrates additively with existing rows preserved and outputs_json
NULL. Verified: bunx tsc --noEmit clean; the new suite passes; state- migration-023.test.ts (as narrowed by F-B6) stays green alongside it.
R6 — the status tree's resume.then doc comment claimed re-driving the root "cascades back down through every intermediate composing step"; it does not, past depth 1 (RESOLVED — code-review round 4, finding 6)
Status: RESOLVED, code-review round 4.
Found by code review: childRunTree's doc comment (src/workflows/runtime/ runs.ts) asserted that re-driving the root "cascades back down through
every intermediate composing step", offered as the justification for why
every blocked node's resume.then names only <rootRunId> regardless of
nesting depth. This is false for composition depth ≥ 2.
driveChildWorkflowUnit never re-drives a child whose own status is already
blocked (row A-22 — no lease is even taken); an intermediate run is always
blocked when a descendant is (row A-21, applied recursively). So re-driving
the root just RE-OBSERVES an intermediate child's still-blocked status and
re-propagates the block upward — it never reaches the deepest blocked node.
Failure scenario: root R composes C, C composes G, G blocks (so C
blocks too, transitively, then R blocks). The operator follows G's
printed then field exactly as documented — resumes G, then resumes and
runs R. R's composing step re-publishes/re-drives C (same
invocation_key, idempotent); driveChildWorkflowUnit reads C.status === "blocked", skips the drive entirely, and maps straight to
child_workflow_blocked again with the SAME notes — R re-blocks with
identical output. G is never reached, C's own composing step (which is
what would actually re-drive G) was never itself resumed, and the printed
command cannot break the loop by construction:
resumeWorkflowRun (runs.ts) only reopens the run it is called on, never a
descendant.
Resolution: chosen the smaller of the two options this finding offered — no
envelope/field change (resume.then stays exactly as shaped today; widening
it to enumerate every ancestor would be a new field on a phase whose Stable-
tier guard already governs the rest of this surface, for a case the docs can
cover just as well). childRunTree's doc comment is corrected in place: it
now states plainly that the printed command clears exactly one level, states
the mechanism (row A-22 + row A-21 applied recursively) that makes a deeper
cascade impossible, and points at the docs for the multi-level sequence
instead of overclaiming inline. Both doc sites named by this finding gain a
"Nested blocks (composition depth 2+)" subsection immediately after the
existing three-command sequence: docs/guides/run-workflows.md's
"Recovering a blocked child", and docs/reference/workflow-schema.md's
"Blocked-child recovery" — both stating the rule verbatim: resume every
blocked run in the chain, deepest first, then re-run only the root, with a
worked <grandchildRunId>/<childRunId>/<rootRunId> example. Verified:
docs/architecture/workflow-engine.md and CHANGELOG.md were checked and
make no cascade claim of their own (rg cascade across docs/ before this
fix found only this doc comment's own language and unrelated route-skip
prose in workflow-schema.md), so no further doc site needed correction;
scripts/lint-doc-examples.ts passes against both edited doc files.
Review log
2026-08-27 — phase close-out (orchestrator). The run survived two container restarts (resumed from its journal both times; the tests-verification step's result was orchestrator-performed and journal-injected after dying twice) and was restructured mid-phase into restart-sized agents: three per-lane test reviews (3/3/3 CONFIRMED, each lane's findings fixed and pushed as 566eb526/473b6f1a/ffff024e) and three scoped code reviews (executor 1, outputs-cli 4, crosscut 1 CONFIRMED — all fixed by the shared fixer and verified). Implement landed as c1dee6c4 (Lane B: outputs, status tree, akm workflow plan) and 7d6a5ec2 (Lane A: child execution), integrator fallout + §8 docs as 77c656ea. Gate: lint green, tsc green, unit 4342/0 (317 files), integration 5834 pass / 57 skip / 1 fail — the single failure was npm-pack-contents' shipped-markdown link check: four shipped files linked the non-shipped docs/guides/run-workflows.md#child-runs. Fixed by the orchestrator per house convention (absolute GitHub URL for non-shipped guides, as prior changelog entries do); the test file passes 4/4 and lint/tsc re-verified green. With that one docs-link fix the phase gate is green.