P0 — invariant inventory for the task/workflow refactor
Status: ready for test authoring Phase: P0 of the akm task/workflow refactor Owner artifacts: tests only — P0 changes no production code
This document is the single source of truth for the three P0 test-writing
lanes. Lanes do not re-derive these facts from the codebase and do not read
the parent plan. Every file:line reference below was verified at the current
head of claude/breaking-changes-0-9-2-3cfyvp (== release/0.9.2).
0. What P0 is
P0 pins current behavior — including behavior we have already decided is wrong — with passing characterization tests. Nothing is fixed here.
The contract that makes the later phases cheap:
- Every row in the two tables below gets at least one test that passes today.
- A row in Behavior to replace is a test that a later phase will deliberately flip — exactly one pinned test per deliberate change, so a reviewer of P1–P4 can see the behavior change as a test diff rather than having to reason about it.
- A row in Behavior to preserve is a test that must keep passing through every later phase. A later phase breaking one of these is a regression, not a decision.
Therefore:
- Pin the observable effect (thrown error type +
code+ message text, stored string, env var seen by a child, resulting shape), never the internal call path. Tests that assert on private helpers make the later phases harder, not safer. - Where a message string is quoted below, pin it verbatim. Byte-identical duplicate messages (R-03) are pinned at each site separately so a later phase cannot collapse two rejections into one without a visible test diff.
- Do not "improve" anything you find while writing these tests. A defect you notice that is not in these tables belongs in the Review log, not in a fix.
ID scheme
P-nn = preserve. R-nn = replace. Lanes cite these IDs in test names so the
phase that flips a row can find its test with a grep.
Behavior to preserve
These are load-bearing today and stay load-bearing after the refactor.
| # | Behavior | Evidence (file:line) | Observable surface to pin | Lane |
|---|---|---|---|---|
| P-01 | Task-layer with on a command ref is rejected loudly. The task layer, unlike the workflow layer (R-01), never silently drops with. |
src/tasks/runtime-v3.ts:397-401 |
UsageError, code INVALID_FLAG_VALUE, message exactly:Task v3 command refs do not accept with; use akm/command with {ref, arguments} for portable arguments. |
B |
| P-02 | Task-layer with on a script ref is rejected loudly. |
src/tasks/runtime-v3.ts:437-439 |
UsageError, code INVALID_FLAG_VALUE, message exactly:Task v3 script refs do not accept with. |
B |
| P-03 | Task-layer with on a workflow ref is accepted and becomes the child workflow's params. This is the one with consumer that works, and it is the shape P2b generalizes to workflow steps. |
src/tasks/runtime-v3.ts:432 — params: Object.freeze({ ...(document.target.with ?? {}) }) |
prepareTaskV3Execution returns { kind: "workflow", ref, params } where params deep-equals the authored with mapping; absent with yields {} (not undefined); the object is frozen. |
B |
| P-04 | A workflow-target task with any env: is rejected. Adjacent to P-03 and read at the same site during spec authoring; lane B will hit it while building P-03 fixtures, so it is pinned rather than left as a surprise. |
src/tasks/runtime-v3.ts:415-421 |
UsageError, code INVALID_FLAG_VALUE, message exactly:Task v3 workflow env cannot be consumed by the durable workflow runtime in 0.9.2; remove env or use a command target.Triggered when Object.keys(environment).length > 0. |
B |
| P-05 | Task-runner provenance, workflow arm: AKM_EVENT_SOURCE is stamped on the global process.env for the duration of the run, then restored. This arm executes in-process, so child akm invocations made by workflow steps inherit the stamp. A more specific pre-existing value wins. |
src/tasks/runner.ts:534-535 (stamp), :552-555 (restore in finally) |
During the run, process.env.AKM_EVENT_SOURCE === "task" when it was previously unset; it is deleted again afterward when it was previously undefined, and restored to the prior string otherwise — including on the throwing path (finally). A pre-set AKM_EVENT_SOURCE=improve survives untouched. |
B |
| P-06 | Task-runner provenance, native shell/script arm: AKM_EVENT_SOURCE is passed in the child env only — process.env is never mutated. |
src/tasks/runner.ts:389-393 — AKM_EVENT_SOURCE: process.env.AKM_EVENT_SOURCE ?? "task" inside the runManagedSubprocess env bag |
The env handed to the spawn contains AKM_EVENT_SOURCE="task" (or the inherited more-specific value); the parent's process.env.AKM_EVENT_SOURCE is unchanged before, during, and after. |
B |
| P-07 | resolveUsageEventSource defaults an unset/empty AKM_EVENT_SOURCE to "user", and maps an unrecognized value to "unknown" rather than to "user". This default is what turns R-07 into a real defect; pin it so R-07's flip is unambiguous. |
src/indexer/usage/usage-events.ts:28-32 |
undefined/"" → "user"; each of user/improve/task/audit/unknown → itself; anything else → "unknown". |
B |
| P-08 | Multi-job YAML parsing is well-formed and canonical: 1–256 jobs, needs validated, jobs emitted in one canonical topological order. R-05 replaces only the execution-time rejection; the parser's job model is the thing the P4 adapter boundary will be built on, so its behavior must not drift in the meantime. |
src/workflows/source-ir/github-yaml.ts:464-536; src/workflows/source-ir/ordering.ts |
0 jobs or >256 jobs → failure code job-count-limit, message workflow.jobs must contain 1 through 256 jobs.Unknown needs target → missing-job-dependency, message Job <id> needs missing job <dep>.Cycle → job-dependency-cycle, message Workflow jobs contain a dependency cycle.Duplicate needs entries → duplicate-job-dependency. needs is sorted. Ordering: emit the lexically-first ready job, then recompute readiness (canonicalTopologicalJobs) — pin the exact emitted order for a fixture with two independently-ready jobs. |
A |
Behavior to replace
Each row is a deliberate future change. P0 pins it passing; the named phase flips exactly the pinned tests.
| # | Behavior (current, wrong) | Evidence (file:line) | Observable surface to pin | Flips in | Lane |
|---|---|---|---|---|---|
| R-01 | A workflow step's with: on uses: tasks/<ref> is accepted by the decoder and then silently DROPPED. The decoder shape-checks with for every uses step but only the builtin-command target ever validates or consumes it; taskDispatch reads source.with nowhere. The authored mapping vanishes with no error, no warning, and no trace in the frozen plan. |
Accepted: src/workflows/source-ir/schema.ts:144 (field), :389-393 (shape-check only), :371 (target validation reached only for builtin-command).Dropped: src/workflows/ir/source-freeze-v4.ts:211-272 (taskDispatch — no source.with read); the sole consumption of source.with is the builtin-command branch at :148. |
(a) A step uses: tasks/x with with: {a: 1} decodes without error.(b) scalarRecord(step.with, …, true) still rejects a non-scalar with value and :393 still fails a with without uses (step <id> with is legal only with uses).(c) Freezing that step produces a resolved dispatch byte-identical to the same step with no with: at all — assert equality of the two frozen results, which is what makes the drop visible. |
P1a rejects it; P2b implements real bindings | A |
| R-02 | directScript fabricates synthetic task YAML and re-parses it. A uses: scripts/<ref> workflow step is executed by writing a task document as a string — including a fake @daily schedule that exists only to satisfy R-06 — and running it back through parseTaskV3Yaml. |
src/workflows/ir/source-freeze-v4.ts:274-298 |
The synthetic document is exactly:version: 3\nuses: <owned.ref>\nakm:\n schedule: "@daily"\nwith filePath = \${context.asset.path}#${source.id}`, workspaceRoot = context.asset.sourcePath, taskId = source.id, taskRef = `${context.asset.ref}#${source.id}`. These identity fields are computed by directScript()but are **not observable** anywhere production surfaces:scriptResult()reads only.sourceRef/.interpreter/.extension/.bytesBase64/.byteLength/.sha256/.cwdIdentityoff the prepared execution intoFrozenWorkflowScriptTarget— never.taskId/.taskRef — and no persisted plan carries them. Pin **only** the observable surface: a script step freezes to a script dispatch carrying the script's own qualified ref (FrozenWorkflowScriptTarget.ref), its exact bytes, and its interpreter. The bare invariant at :296 (if (prepared.kind !== "script") throw new Error("direct script did not project as a script"), exit 70, not a UsageError) is unreachable from directScript`'s own call site — see the Review log — and is not pinned. |
P1b (typed preparer) | A |
| R-03 | Nested-workflow targets are rejected at three call sites in source: two reachable independently, and one a documented-dead duplicate of the second. | src/workflows/source-ir/semantics.ts:141-146; src/workflows/ir/source-freeze-v4.ts:220-222; src/workflows/ir/source-freeze-v4.ts:237-239 (unreachable in practice — see the Review log) |
Pin the two independently reachable sites; record the third rather than pinning it.semantics.ts: WorkflowSourceSemanticError, code nested-workflow-unsupported, message Nested workflow target "<value>" is unsupported in a workflow step. (value JSON-stringified).source-freeze-v4.ts:220-222: UsageError, code INVALID_FLAG_VALUE, message exactly A workflow task step cannot compose a nested workflow target., firing on the parsed task document (task.target.uses.kind === "workflow").source-freeze-v4.ts:237-239 guards the byte-identical message one call later in the same taskDispatch, on the condition prepared.kind === "workflow" — but prepareTaskV3Execution returns kind: "workflow" only when document.target.uses.kind === "workflow" (runtime-v3.ts:415), the exact fact the :220 guard already tested on the same parsed task earlier in the same call. No fixture can pass :220 and still reach :237 with prepared.kind === "workflow"; it is a dead duplicate, not independently pinnable. |
P3 (child workflows) | A |
| R-04 | GitHub Action locators are parsed into a full typed target, then rejected downstream. The grammar is live: owner/repo[/path]@rev classifies successfully with owner/repository/path/revision fields — and is then refused at both consumers. |
Parse: src/tasks/source-v3.ts:523 (classifyTaskV3Uses), :562-588 (locator branch).Reject (task prepare): src/tasks/runtime-v3.ts:366-371.Reject (workflow step): src/workflows/source-ir/semantics.ts:134-139. |
(a) classifyTaskV3Uses("owner/repo@v1") returns a frozen { kind: "github-action", ref, owner, repository, revision }, and with a path owner/repo/sub/dir@v1 adds path: "sub/dir". Pin at least one accepted shape and one rejected locator falling through to the trailing throw: Task v3 uses must be akm/command, a canonical commands/, workflows/, or scripts/ asset ref, or owner/repo[/path]@ref. Agent/task/local/Docker/ambiguous targets are not executable.(b) Preparing such a task throws UsageError / INVALID_FLAG_VALUE: GitHub action "<ref>" is recognized but remote action acquisition is unsupported in 0.9.2.(c) The same locator in a workflow step throws WorkflowSourceSemanticError code remote-action-acquisition-out-of-scope: Remote action acquisition is out of scope for "<value>". |
P4 (grammar removal) | A + B |
| R-05 | Multi-job YAML parses fully, then execution refuses it. Two independent rejection points on two paths, both after a complete parse. | Parse: src/workflows/source-ir/github-yaml.ts:464-536 + src/workflows/source-ir/ordering.ts (see P-08).Reject (freeze path): src/workflows/ir/source-freeze-v4.ts:105-110.Reject (direct compile path): src/workflows/ir/compile.ts:117-127. |
(a) A 2-job source parses clean and yields 2 ordered jobs (P-08 covers ordering). (b) Freezing it throws UsageError, code INVALID_FLAG_VALUE, message exactly:Multi-job workflow cannot execute until job boundaries and needs have a durable runtime representation.(c) compileWorkflowPlan on the same IR returns (does not throw) { ok: false, errors: [{ line, message: "Current workflow execution requires exactly one source-IR job." }] }, with line from the second job's span. Pin the return-vs-throw asymmetry — it is easy to lose in P4. |
P4 (adapter-boundary rejection) | A |
| R-06 | Task v3 requires exactly one scheduling source. Declaring neither akm.schedule nor on: is an error, and declaring both is the same error. This is what forces R-02's fake @daily. |
src/tasks/source-v3.ts:636-651 |
Both the neither-case and the both-case fail with the same source error text: must declare exactly one scheduling source: akm.schedule or on. (raised via sourceError(ctx, [], …) — pin the rendered message and the empty path).Also pin the success shape of the akm.schedule arm: { manual: false, schedules: [{ cron: <schedule>, source: "akm.schedule", ordinal: 0 }] }, frozen. |
P2a (optional schedule) | B |
| R-07 | Task-runner provenance, prompt arm: AKM_EVENT_SOURCE is NEVER set — anywhere. runPreparedCommandTask neither mutates process.env (as P-05 does) nor passes a child env (as P-06 does). Combined with P-07's "user" default, a scheduled prompt-target task records its nested akm usage as user demand. This is a DEFECT, pinned as-is. |
src/tasks/runner.ts:679-737 (whole arm — no AKM_EVENT_SOURCE occurrence); default at src/indexer/usage/usage-events.ts:28-32 |
Run a prompt-target task with AKM_EVENT_SOURCE unset. Assert: (a) process.env.AKM_EVENT_SOURCE is still undefined at every point of the run, including inside the injected runAgent/chatCompletion impl; (b) no AKM_EVENT_SOURCE reaches the dispatch; (c) consequently resolveUsageEventSource() observed from inside the arm returns "user". Point (c) is the row's whole reason to exist — write it explicitly so P1b's flip to "task" is a one-line test diff. |
P1b (defect fix) | B |
| R-08 | Task result vocabulary is inverted, and the inversion is persisted. A prepared command (agent/LLM) becomes result kind "prompt"; a prepared shell or script becomes result kind "command". Those exact strings are written to task_history.target_kind and read straight back out. |
Mapping: src/tasks/runner.ts:256-260 (preparedResultTarget) — :257 workflow, :258 command→prompt, :259 shell/script→command.Persist: src/tasks/runner.ts:1081 — target_kind: result.target.kind === "unknown" ? null : result.target.kind.Read back: src/tasks/runner.ts:1134-1158. |
Per arm, pin the stored string and the round-trip: • prepared workflow → target_kind "workflow", read back as { kind: "workflow", ref } (ref falls back to "" when the column is null).• prepared command → target_kind "prompt", read back as { kind: "prompt", engine } (engine from metadata, null when absent).• prepared shell → target_kind "command", read back as { kind: "command" }.• prepared script → target_kind "command" (same string as shell — the two arms are indistinguishable in history).• target_kind null / unrecognized → { kind: "unknown" } on read. |
P1b (read-boundary mapping) | B |
| R-09 | Legacy "stash" naming survives at the task boundary. RunTaskOptions.stashDir is the option name for what is now a bundle root, and the bundle-name resolution ends in the literal "stash". |
src/tasks/runner.ts:111 (stashDir); src/tasks/runner.ts:173 — options.bundleName ?? config.defaultBundle ?? "stash" |
Pin observable effects only — do not pin the identifier name. With no options.bundleName and no config.defaultBundle, the task's fully-qualified ref is built against bundle stash (i.e. stash//tasks/<id>), and that is the bundle identity threaded into preparation. Pin the resulting ref string, not the option key: the option key is renamed in a later phase and a test asserting on it would fail for the wrong reason. |
P1b / P4b (boundary rename) | B |
Lane pin checklists
Three lanes work in parallel. Each lane owns its rows end to end; no row is shared between lanes except R-04, which is split explicitly below.
Lane A — workflows
Owns everything under src/workflows/.
- [x] R-01 (a) A workflow step
uses: tasks/<ref>withwith: {…}of scalar values decodes with no error and no diagnostic. - [x] R-01 (b) Guardrails that do fire today still fire: a non-scalar
withvalue fails viascalarRecord(schema.ts:389), andwithwithoutusesfails withstep <id> with is legal only with uses(schema.ts:393). - [x] R-01 (c) The drop is proven by equality: freeze the same task step twice, once with
with:and once without, and assert the two resolved dispatches are equal. This is the test P1a flips to an expected rejection. - [x] R-01 (d) Contrast case: the same
with:onuses: akm/commandis consumed (source-freeze-v4.ts:148) and target-validated (schema.ts:371). Pin it so P1a's rejection cannot accidentally cover the builtin-command path too. - [x] R-02 A
uses: scripts/<ref>step freezes to a script dispatch carrying the script's own qualified ref, exact bytes, and interpreter — the identity shape the synthetic document produces is not observable anywhere production surfaces (see the Review log) and is not pinned; likewise the bare-Errorinvariant atsource-freeze-v4.ts:296is unreachable fromdirectScript's own call site and is not pinned. - [x] R-03 Two tests, one per reachable site:
semantics.ts:141-146(codenested-workflow-unsupported) andsource-freeze-v4.ts:220-222.source-freeze-v4.ts:237-239guards the byte-identical message one call later in the sametaskDispatch, but is unreachable in practice — no fixture can pass the:220guard and still reach:237withprepared.kind === "workflow"(see the Review log) — so it is recorded as a dead duplicate rather than pinned by a third test. - [x] R-04 (c) A GitHub-action locator in a workflow step throws code
remote-action-acquisition-out-of-scope. - [x] R-05 (a) A 2-job source parses clean into 2 jobs.
- [x] R-05 (b) Freezing it throws the exact multi-job
UsageError. - [x] R-05 (c)
compileWorkflowPlanreturnsok: falsewith the exact message and the second job's line — not a throw. - [x] P-08 Job-count bounds (0, 1, 256, 257),
needsvalidation (missing / cycle / duplicate),needssorting, and the canonical emission order for two independently-ready jobs.
Lane B — tasks
Owns everything under src/tasks/ plus the usage-events default.
- [x] P-01
with+ command ref → exactUsageErrormessage, codeINVALID_FLAG_VALUE. - [x] P-02
with+ script ref → exactUsageErrormessage, codeINVALID_FLAG_VALUE. - [x] P-03
with+ workflow ref →paramsdeep-equals the authored mapping; absentwith→{}; result frozen. - [x] P-04 workflow ref + any
env:→ exactUsageErrormessage. - [x] P-05 Workflow arm stamps and restores global
process.env.AKM_EVENT_SOURCE; restoration happens on the throwing path too; a pre-set more-specific value survives. - [x] P-06 Native shell/script arm sets it in the child env only; parent
process.envnever mutated. - [x] P-07
resolveUsageEventSourcetable: unset/""→"user"; each valid value → itself; garbage →"unknown". - [x] R-06 Neither-and-both scheduling sources produce the same error text; the
akm.schedulesuccess shape (source: "akm.schedule",ordinal: 0,manual: false) is pinned. - [x] R-07 Prompt arm sets
AKM_EVENT_SOURCEnowhere, andresolveUsageEventSource()observed from inside it returns"user". Assert (c) explicitly. - [x] R-08 Four arms × (stored
target_kindstring, read-back shape), plus the null/unrecognized →{ kind: "unknown" }case. Shell and script must both be pinned even though they store the same string. - [x] R-09 With no
bundleNameand nodefaultBundle, the qualified task ref resolves against bundlestash. Assert the ref string, never the option key.
Harness note for P-05 (and P-06's negative half): tests/_preload.ts installs
a tripwire that throws if a test leaks an AKM_* env var that was not
present at preload time (AGENTS.md, "Test-isolation harness"). P-05 exercises a
production path that mutates process.env.AKM_EVENT_SOURCE on purpose. Observe
the stamp from inside the run (via an injected runWorkflowStepsImpl) and
let the production finally restore it; never leave the var set across the test
boundary and never set it by hand outside the sandbox helpers.
Lane C — fixtures
Owns the shared fixture surface both other lanes build on. Lane C lands first; A and B consume it.
- [x] A task-step-with-
withworkflow fixture pair: identical sources differing only by thewith:block (R-01 c needs both halves). - [x] A builtin-command-with-
withworkflow fixture (R-01 d), so the consumed path and the dropped path sit side by side. - [x] A script-step workflow fixture (R-02).
- [x] Two nested-workflow fixtures, one per reachable R-03 site: a workflow step naming a workflow directly (
semantics.ts:141-146) and a task document whoseusesis a workflow (source-freeze-v4.ts:220-222).source-freeze-v4.ts:237-239is a documented-dead duplicate of the second site (see the Review log) and is not a fixture requirement. - [x] GitHub-action fixtures (R-04): a task document with an action
uses, and a workflow step with the same locator. Include one near-miss locator that falls through to the trailingclassifyTaskV3Usesthrow. - [x] Multi-job fixtures (R-05, P-08): a valid 2-job source with
needs; a 0-job source; a >256-job source; a missing-needssource; a cycle; a duplicate-needssource; and a two-independently-ready-jobs source for canonical ordering. - [x] Scheduling fixtures (R-06): neither source, both sources,
akm.scheduleonly,on:only. - [x] Task target fixtures covering all four prepared arms — workflow, command (agent/LLM), shell, script — reused by R-08 and by P-05/P-06/R-07.
- [x] Fixtures use
tests/_helpers/sandbox.ts(sandboxStashDir(),writeSandboxConfig()); noprocess.env.HOMEmutation, noprocess.chdir, noglobalThis.fetch =(scripts/lint-tests-isolation.tsenforces this). - [x] Every new test file carries the MPL-2.0 header (
scripts/lint-license-headers.ts).
Acceptance criteria
- [x]
docs/plans/specs/p0-invariants.mdexists and is the only file changed by the spec commit. - [x] No file under
src/is modified in P0.git diff --stat release/0.9.2..HEAD -- src/is empty at the end of the phase. - [x] Every row P-01…P-08 has at least one test that passes at current head.
- [x] Every row R-01…R-09 has at least one test that passes at current head.
- [x] Each test names its row ID (e.g.
R-08) in itsdescribe/ittitle, so the phase that flips a row can find it with a grep. - [x] R-03's two reachable sites (
semantics.ts:141-146,source-freeze-v4.ts:220-222) are each covered by a distinct test reaching that distinct code site, distinguishable by fixture rather than by message text; the third site (source-freeze-v4.ts:237-239) is a documented-dead duplicate recorded in the Review log rather than pinned. - [x] R-05 pins both the throwing freeze path and the non-throwing
compileWorkflowPlanreturn path. - [x] R-07 asserts the
"user"resolution explicitly, not merely the absence of the env var. - [x] R-08 pins the stored
target_kindstring for all four prepared arms plus the null/unrecognized read-back. - [x] R-09 asserts a resulting ref string, not the
stashDiroption name. - [x] No test asserts on a private helper where an observable surface (error type + code + message, stored string, child env, returned shape) was available.
- [x] All new tests use
tests/_helpers/sandbox.ts;bun scripts/lint-tests-isolation.tspasses. - [x] Every new test file carries the MPL-2.0 header;
bun scripts/lint-license-headers.tspasses. - [x]
bunx biome check --write src/ tests/produces no further changes;bunx tsc --noEmitis clean. - [x]
bun run checkpasses (lint + typecheck +test:unit+test:integration). - [x] Any defect discovered while writing these tests but not listed in the tables is recorded in the Review log below and left unfixed.
Review log
2026-08-25 — R-03's third site (src/workflows/ir/source-freeze-v4.ts:237-239) is unreachable in
practice. That guard fires on prepared.kind === "workflow", where prepared is the return of
prepareTaskV3Execution. prepareTaskV3Execution (src/tasks/runtime-v3.ts:415) returns
kind: "workflow" only when the parsed task document's target.uses.kind === "workflow" —
exactly the condition the :220-222 guard in the same taskDispatch call already tests, on the
same parsed task object, before prepareTaskV3Execution is ever invoked. No fixture can pass the
:220 guard and still reach :237 with prepared.kind === "workflow": the two sites guard one
condition twice, not two independently reachable failures. The R-03 row and the Lane A checklist
have been amended to name two reachable sites (semantics.ts:141-146,
source-freeze-v4.ts:220-222) plus this documented-dead duplicate, rather than requiring a third,
unreachable pin. Left unfixed — no production change in P0.
2026-08-25 — R-02's bare invariant (src/workflows/ir/source-freeze-v4.ts:296,
if (prepared.kind !== "script") throw new Error("direct script did not project as a script")) is
unreachable from directScript's own call site. directScript is invoked only from resolveStep
(source-freeze-v4.ts:144) when target.kind === "script", and the synthetic task document it
fabricates always sets uses: <owned.ref> where owned.ref is built with plural = "scripts"
(resolveOwnedAssetCore) — so the synthetic document's uses: always classifies, and always
prepares, as kind: "script". No fixture reachable through directScript's own call site can make
prepareTaskV3Execution return anything else. Exercising the :296 throw would require calling
directScript (or prepareTaskV3Execution) with an argument shape it never receives in production.
The R-02 row has been amended to not require pinning this invariant. Left unfixed — no production
change in P0.
2026-08-25 — R-02's synthetic-document identity fields (filePath, taskId, taskRef) are
computed but not observable anywhere production surfaces. directScript (source-freeze-v4.ts:274-298)
fabricates filePath = \${asset.path}#${step.id}`, taskId = step.id, and taskRef = `${asset.ref}#${step.id}`, and threads them through prepareTaskV3Executionvia the synthetic document. ButscriptResult()— the only reader of that prepared execution — copies just.sourceRef/.interpreter/.extension/.bytesBase64/.byteLength/.sha256/.cwdIdentityintoFrozenWorkflowScriptTarget; .taskId/.taskRefare dropped. No seam at head re-exposes them either: the one other exported entry point over this path,resolveWorkflowSourceV4, returns the same FrozenWorkflowTargetshape. A characterization test can therefore only pin these fields by hand-constructing the same literalsdirectScriptcomputes and feeding them toparseTaskV3Yaml/prepareTaskV3Executiondirectly (astests/workflows/characterization-classification.test.ts's R-02 identity-contract test does) — which asserts the test's own inputs round-trip, not a surface directScript` itself could break. The
R-02 row has been amended to name only the surfaces that are observable (script ref, bytes,
interpreter), pinned by the row's companion end-to-end test. Left unfixed — no production change in
P0.
2026-08-25 — phase close-out (orchestrator). Three adversarial review rounds ran: 6 → 5 → 3
CONFIRMED findings, each round's findings distinct (deepening precision, not reshaped repeats), each
fixed in its own fix(p0) commit. The round budget expired before a fourth confirming round, so the
orchestrator verified the round-3 fixes directly against the reviewer's required changes instead of
dispatching another round — rationale: all three were narrow, mechanical, and independently
checkable (P-05 during-throw observation via observedDuring; R-08 null-fallback rows
{kind:"workflow", ref:""} / {kind:"prompt", engine:null}; R-04 near-miss locator owner/repo
pinned to the trailing message). All three land in fix(p0): address review findings (round 3) and
match the required changes exactly.
Two ADVISORY findings were applied at close-out because they protect known upcoming phases from
spurious failures: R-01(c)'s plan-JSON substring probe now uses the distinctive sentinel
r01-dropped-sentinel instead of the generic word scope (protects P3a's plan-shape changes), and
characterization-fixture-contracts.test.ts's generic flip comments now name their flipping phases
(P2a/P2c for v3-migration fixtures, P3a for the irVersion-4 assertion; the read-set test is
reclassified PRESERVE). Remaining advisories recorded, not applied: R-05(a) grep-anchor placement;
P-08 1-job bound cited only via fixture tests; classifyWorkflowStepUses delegation call-count
assertions pin an internal seam; P-06 child-env observed via stdout log rather than a spawn seam;
R-07 positional args[2] env capture couples to runAgent argument order; R-05(b) skips akmIndex
where siblings call it. Later phases may harden these opportunistically when touching those files.
Gate: bun run lint green, bunx tsc --noEmit green, unit 3879 pass / 0 fail (sharded runners).
The full integration suite runs as the final gate step; its result is appended below when it
completes. git diff origin/release/0.9.2..HEAD -- src/ is empty — P0 changed no production code.
All acceptance boxes ticked.
2026-08-26 — integration gate confirmed green. Full integration suite: 5622 pass / 57 skip /
0 fail across 419 files (sharded runners). An initial run had 15 environment-caused failures in
registry-pinned-transport, registry-network-boundary, and package-launcher — all traced to
the container's default Node being v22 while akm 0.9.2 requires Node >= 24 (the transport helper
and npm launcher enforce it). Fixed at the environment level by installing Node v24.19.0 and making
it the default; no repository change was needed and the failures predate P0 (they reproduce at the
untouched baseline in a Node-22 environment). P0 gate: lint green, tsc green, unit 3879/0,
integration 5622/0.
2026-08-26 — P-05 reclassification (P1b Lane C, required by D5, spec
docs/plans/specs/p1b-model-extraction.md §1.2). P-05 pinned the pre-P1b MECHANISM: the workflow
dispatch arm (runner.ts:534-535, :552-555 at P0 head) stamped process.env.AKM_EVENT_SOURCE
GLOBALLY for the duration of an in-process workflow-task run, then restored the prior value (or
deleted the key) in a finally, so a child akm invocation made by a workflow step inherited the
stamp through ambient environment inheritance. D5 always scheduled this mechanism for replacement —
the global mutation was a correctness liability in its own right (P0's own row named it a
cross-run-leakage risk that only worked because the stamp/restore pair happened to be
synchronous-safe), and the design decision (D5, phase P1b) was to thread the resolved event source
through an explicit, typed ExecutionProvenanceContext instead. P1b (F-1) deletes the global
process.env stamp and its finally restore outright — nothing under src/ writes
process.env.AKM_EVENT_SOURCE any more (grep-provable: rg "process\.env\.AKM_EVENT_SOURCE\s*=" src/ and rg "delete process\.env\.AKM_EVENT_SOURCE" src/ both return zero hits) — and threads the
value through a new optional eventSource option instead: run-task.ts's
ExecutionProvenanceContext → run-workflow-task.ts → RunWorkflowOptions.eventSource →
src/workflows/exec/run-workflow.ts → native-executor.ts's StepExecutionContext.eventSource →
unit-dispatch.ts's UnitDispatchRequest.eventSource → exec-unit.ts's childEnv, applied to the
allowlisted base only when the ambient passthrough left the name absent.
The mechanism changed; the four contract clauses P-05 protected did not:
- (a) in-process workflow execution and usage recording still observe event source
"task"for an unscheduled or scheduled task run alike (D5-N1) — now via the expliciteventSourceoption read from inside the orchestrator, rather than viaprocess.envread from inside the same process; - (b) no cross-run leakage — strengthened, not merely preserved:
process.env.AKM_EVENT_SOURCEis now NEVER mutated at all (no set, no delete), so there is no restore step to get wrong and no window, however small, in which a concurrently-running unrelated task could observe the stamp; - (c) child-env stamping is unchanged for the arm P-06 pins (native shell/script) and is now ALSO correct for the arm R-07 pinned as a defect (prompt/command) and for an exec-unit child of a real, non-injected workflow run — all three reach the dispatched child's environment through an explicit value, never through inherited ambient state;
- (d) a pre-set, more-specific ambient
AKM_EVENT_SOURCEstill wins everywhere it won before — the expliciteventSourcevalue is only ever a FALLBACK (process.env.AKM_EVENT_SOURCE ?? provenance.eventSourceat each dispatch arm, andexec-unit.ts'schildEnvapplies its stamp only when the allowlisted-base collection left the name absent), so an already-present ambient value is never overwritten.
Verified end to end by tests/integration/tasks-provenance-characterization.test.ts's flipped P-05
rows (including a REAL, non-injected runWorkflowSteps → exec-unit child observing the stamp) and
by tests/integration/tasks-provenance-context.test.ts's new RunTaskOptions.provenance coverage.
No production change to P0 itself; this entry documents P1b Lane C's implementation of the flip P0's
own R-07/P-05 rows named as pending.
2026-08-26 — P-05 reclassified by P1b (D5). P-05 pinned the workflow arm's global
process.env.AKM_EVENT_SOURCE stamp-and-restore as then-current MECHANISM; the refactor plan
always scheduled D5 to replace that mechanism with an explicit ExecutionProvenanceContext
threaded through dispatch. P1b flipped P-05's mechanism-level assertions per its authorized-flips
table; the preserved CONTRACT — in-process execution and usage recording observe "task", no
cross-run leakage (the global is now never mutated: before, during, after), child-env stamping
(P-06) unchanged, pre-set ambient values still win — is pinned by the amended
tests/integration/tasks-provenance-characterization.test.ts and the new
tasks-provenance-context.test.ts. R-07's defect (prompt arm recording scheduled usage as
"user") is fixed as planned.
2026-08-26 — R-06 close-out: the replacement is scoped to task source v4, not a flip of the row
itself (P2a, docs/plans/specs/p2a-task-source-v4.md §1.5 D2-N6, §6 F-0). The R-06 row's "Flips
in" cell reads "P2a (optional schedule)"; that cell names the phase that introduces the
first document shape the rule does not apply to, not a change to the rule task v3 itself pins. Task
source v4 (version: 4) makes schedule: OPTIONAL — an absent schedule: parses as valid and
projects to triggers = { manual: true, schedules: [] } (D2-N6) — but compileTriggers
(src/tasks/source-v3.ts:450-464 as of this entry — :636-651 at the R-06 row's own
"verified at head" commit, before P2a's D2-N4 extraction moved code within the file; same
function, unchanged body) is untouched by P2a: a version: 3 document that declares
neither akm.schedule nor on: (or declares both) still fails with the byte-identical message
must declare exactly one scheduling source: akm.schedule or on., still via sourceError(ctx, [], …) with the empty path, and still with the three frozen-shape assertions on the akm.schedule
success arm. All three of tests/integration/tasks-scheduling-characterization.test.ts's R-06 tests
(the neither-case at :45, the both-case at :57, and the akm.schedule success shape at :73,
including its Object.isFrozen assertions) pass unchanged after P2a — verified as part of P2a's
preservation gates. R-06 therefore stands, unflipped, for every version: 3 document; only a
version: 4 document gets the new, additive optional-scheduling row (B-06/B-07 in the P2a spec's
behavior table). v3's exactly-one-scheduling-source rule stands until P4 removes v3 acceptance
outright — this row should not be read as "replaced" in the sense of no longer holding for any
document, only as "superseded for the one new document shape that did not exist before P2a."
2026-08-28 — P4 close-out (Lane C, docs/plans/specs/p4-deletions-closeout.md §5.5): final disposition of every R row, and of the P rows P4 supersedes. P4 is the last phase in the refactor ladder; the three deletion families (§3) and the vocabulary/documentation close-out (§4) are landed at Lane C's head. This entry appends §5.5's disposition table verbatim — no pinned row's text above is edited, this is prose-only closure:
| Row | Final disposition |
|---|---|
| R-01 | RESOLVED — P1a rejected the silent with: drop (COMPOSITION_INVALID); P2b replaced the rejection with real bindings when the target declares inputs:. |
| R-02 | RESOLVED — P1b's prepareScriptTarget() deleted the synthetic task YAML; no src file fabricates a task-source header (tests/workflows/direct-script-typed.test.ts's source scan). |
| R-03 | RESOLVED — P3a replaced all three nested-workflow rejections with the ONE recursive child-workflow resolver; the documented-dead duplicate went with them. |
| R-04 | RESOLVED by deletion — P4 §3.1. The grammar, both consumers and the remote-action-acquisition-out-of-scope code are gone from src; the frozen migrator keeps a copy so it can still name the target when it blocks a file. |
| R-05 | RESOLVED by deletion — P4 §3.3. Parse-then-reject becomes one adapter-boundary rejection; the return-vs-throw asymmetry the row pinned no longer exists. |
| R-06 | RESOLVED by deletion — P2a made scheduling optional for task source v4; P4 §3.2 removed v3 acceptance, so the exactly-one-scheduling-source rule has no document left to apply to. |
| R-07 | RESOLVED — P1b threaded ExecutionProvenanceContext; the prompt/command arm records "task". |
| R-08 | RESOLVED — P1b's D8 vocabulary + read-boundary mapping. The mapping itself is a PRESERVE surface forever (row B-51). |
| R-09 | RESOLVED at the boundary — P1b renamed stashDir → bundleDir; P4 §4.1 consolidated the stray literals onto DEFAULT_BUNDLE_NAME, whose VALUE stays "stash" for user-data compatibility. src/indexer/** was out of scope from P0 onward. |
| P-01 / P-02 | SUPERSEDED by P4 §3.2 — unreachable from any parseable source (task source v4 accepts with: only on uses: akm/command). The seam guards are retained as invariants (P4-N4); the pinned tests are deleted with floor accounting. |
| P-03 | SUPERSEDED by P4 §3.2 — a task document can no longer author with: on a workflow target; prepared.params is the task's defaulted declared inputs, or {}. |
| P-04 | PRESERVED, re-fixtured — task source v4 still has a top-level env:, so the workflow-target env rejection stays reachable and stays pinned. |
| P-05 / P-06 / P-07 | PRESERVED (P-05 reclassified by P1b's D5 — see the 2026-08-26 entries). |
| P-08 | SUPERSEDED by P4 §3.3 — the multi-job parser's job model was pinned as the thing the P4 adapter boundary would be built on; the boundary was built by deleting it instead, per brief §10. Job-count bounds, needs validation, cycle/duplicate detection and canonical ordering are all unreachable with exactly one job. |
Two dispositions above are worth flagging as observed, not merely asserted, at P4 Lane C's head:
P-04's "stays pinned" held through §5.2's own INVALID_FLAG_VALUE re-coding sweep — the workflow-
target env: guard's code was measured, found pinned by
tests/integration/tasks-with-classification-characterization.test.ts's P-04 block, and left
INVALID_FLAG_VALUE rather than recoded to COMPOSITION_INVALID as §5.2's generic target table
would otherwise have applied, exactly as this table's row anticipates. R-08's "PRESERVE surface
forever" held the same way against src/tasks/source/bounded-document.ts's six-throw front-end
recode: the D8 legacy read mapping in src/tasks/run/task-history.ts was not among the sites
touched, and its own byte-unchanged tests stayed green throughout.