akm docs

Workflow Format Unification

Status: PROPOSAL v2 — owner decisions from review round 1 applied Date: 2026-07-30 Supersedes: the dual markdown/YAML-program authoring surface (both unreleased)

Owner decisions incorporated in v2: no interpolation syntax in prose (§2.3), gate rubrics live in the body (§2.4), steps have no titles — bare ids only (§2.2).

0. Why now

The workflow feature has never shipped (experimental.workflowEngine opt-in, 0.9.0-rc). There are currently two authoring formats for one asset type, and the seams between them have started leaking into unrelated systems — the OKF provenance work (#730) tripped over workflow's closed frontmatter allowlist, which exists only because the markdown format grew its own bespoke validation instead of sharing the asset envelope every other type uses. This is the moment to unify: nothing on disk to migrate, no users to break.

1. Review of the two current formats

1.1 Format A — markdown workflow (src/workflows/parser.ts, ~500 lines)

A bespoke prose grammar: one magic # Workflow: H1, magic ## Step: H2s, a magic Step ID: body line, magic ### Instructions / ### Completion Criteria subsection names. Capabilities: linear steps only — no routing, fan-out, retry, timeout, engine/model selection, output schemas, or budgets. The compile path (ir/compile.ts compileWorkflowPlan) lowers every step to one fail-fast unit node with verbatim instructions.

1.2 Format B — YAML program (src/workflows/program/*, ~1,300 lines)

The full orchestration surface: unit | map | route steps, ${{ … }} template expressions, retries keyed on the failure taxonomy, timeouts, per-unit engine/model/llm overrides, JSON-Schema-typed params and step outputs, gates with max_loops, budgets, worktree isolation. Instructions are embedded YAML block scalars.

1.3 The divergences (each one is a standing cost)

Concern Markdown YAML program
Step id grammar [A-Za-z0-9][A-Za-z0-9._-]* — dots allowed [A-Za-z_][A-Za-z0-9_-]* — dots forbidden
Params name → description string, untyped name → JSON Schema
Templating none — instructions verbatim ${{ … }} expressions, real substitution
Gate ### Completion Criteria bullets; no max_loops gate: with criteria/max_loops
Capabilities linear only full orchestration
Frontmatter validation closed hand-maintained allowlist closed key lists, JSON Schema published
Title required # Workflow: H1 prefix name: field duplicating the filename
Version internal schemaVersion 1 version: 2 (IR is v3)

Beyond the table, four structural costs:

  1. The fake templating trap. Every shipped example workflow (scripts/akm-eval/example-stash/workflows/*.md) uses {{ repo }}-style moustaches that the engine never substitutes — markdown instructions compile templating: "verbatim". The YAML format substitutes ${{ … }} for real. Two syntaxes; one is decorative and exists only as a convention the executing agent is hoped to honor.
  2. Content sniffing everywhere. Two structural probes (looksLikeWorkflow, looksLikeWorkflowProgram) consulted by the indexer matchers, the workflow adapter (recognize() returns "markdown" | "yaml-program"), the asset loader (extension dispatch + probe), and the proposal validator (which sniffs content when no path is available). canonicalizeWorkflowName collapses foo.md / foo.yaml / foo.yml to one identity, so the two formats can silently collide on one ref.
  3. The closed-allowlist special case. Workflow markdown is the only AKM type validating frontmatter against a closed hand-maintained key set. It is why #730's provenance stamping needed a re-validation fallback in the promotion path, why the allowlist had to learn generated/verified/ provenance by name, and why its error message is now stale (validator.ts:94 still enumerates the pre-#730 keys). Every future machine-stamped key repeats this.
  4. Double everything. Two parsers, two templates, two doc sections, two test suites, two JSON representations feeding one WorkflowPlanDraft. ~1,800 lines of format code for one asset type.

1.4 What is genuinely good and must survive

2. Design

2.1 Principle

One file. Machine surface in frontmatter, prose surface in the body, joined by step id. Prose is never templated — data reaches units as attached context, not string splices.

A workflow is an ordinary AKM markdown asset — same envelope as every other type, OKF-conformant frontmatter + body — whose frontmatter carries the entire orchestration graph and whose body carries per-step instructions and gate rubrics under plain headings.

2.2 The format

---
type: workflow
description: Drive a batch of GitHub issues to merged PRs.
params:
  repo:   { type: string, description: Target repository owner/name }
  issues: { type: array,  description: Issue numbers to implement }
defaults: { timeout: 10m, on_error: fail }
budget: { max_units: 60 }
steps:
  - id: intake
    output: { type: object }
  - id: implement
    map:
      over: steps.intake.output.issues
      concurrency: 3
      unit: { isolation: worktree, retry: { max: 2, on: [timeout] } }
    # `output` describes the REDUCER RESULT: the default `collect` reducer
    # folds per-item unit results into an array.
    output: { type: array }
    # Retry lives here, not in a backward route: a failed gate re-runs this
    # step with the judge's feedback, bounded by max_loops.
    gate: { max_loops: 2 }
  - id: verdict
    inputs: [steps.implement.output]
    output: { type: object }
  - id: pick-outcome
    route:
      input: steps.verdict.output.status
      when: [{ match: clean, step: announce }]
      default: escalate       # both targets are LATER steps — routes are forward-only
  - id: escalate
    inputs: [steps.verdict.output]
  - id: announce
    inputs: [steps.implement.output]
---

# GitHub Issues Parallel Implementer

Free preamble prose. Indexed for search, shown in `akm show`, never
dispatched. Any headings except level-2 are fine here.

## intake

Everything under this heading, byte-exact, is the step's instructions —
sub-headings, fences, lists, all of it. The run's params arrive as attached
context; refer to them by name in prose ("clone the `repo` parameter's
repository").

## implement

This section is the **map unit template**. The engine attaches each unit's
item (and its index) as context; the prose says "the issue you were given."

### gate

The gate rubric — as long as it needs to be. Full prose, bullets, examples
of passing and failing artifacts. The judge receives this whole section.

- Every issue in the working set has a mergeable PR or a recorded blocker.
- No PR was opened against a branch other than the declared base.

## verdict

Inspect the implementation results and report whether the batch is clean or
needs escalation.

## escalate

Escalate blockers from the verdict, with the verdict artifact attached as
context (declared via `inputs:` above).

## announce

Post the summary. The `implement` step's artifact is attached as context
(declared via `inputs:` above).

Frontmatter (validated by one published JSON Schema):

Body (three rules):

  1. Every level-2 heading must be ## <step-id> for a declared step, exactly. (Fenced code blocks are skipped when scanning for headings.)
  2. A unit or map step must have a section (its instructions / per-item template, byte-exact to the next H2 or EOF). A route step may have one (documentation, plus a gate rubric if gated). Everything before the first H2 is free preamble.
  3. Inside a step section, an optional ### gate sub-heading starts the step's gate rubric (running to the section end). It is the format's single reserved marker. Omitted or empty rubric text skips validation; a non-empty rubric enables optional validation. Frontmatter gate: without a rubric is valid but inert.

No Step ID: lines, no # Workflow: prefix, no reserved H3s beyond gate.

2.3 No interpolation syntax — references and attached context

v1 of this proposal kept ${{ … }} templating in prose and added a $${{ escape. The owner asked the right question — why have the syntax at all? — and the answer is that only the engine ever needed deterministic value resolution, and the engine only reads whole-value frontmatter positions:

Whole-value positions need no delimiters. These become bare reference strings with the same closed grammar, shrunk from four roots to two:

params.<name>
steps.<id>.output( .<ident> | [<int>] )*

item and item_index are deleted from the language: with no splicing there is nothing to substitute — the engine attaches each map unit's item and index as context alongside the prompt.

Prose is never templated. Each dispatched unit receives, as structured attached context: the run params (params are run-scoped and documented non-secret), its item + index (map units), and the artifacts named by its step's inputs:. Instructions refer to these in plain language — which is exactly how the classic driver and all nine example workflows already functioned, since their moustaches were never substituted.

What this buys:

Determinism and replay identity are preserved: unit identity hashes the frozen template bytes + canonical item JSON + declared-input artifact hashes

The honest trade-off: prose can no longer compose a value mid-string with engine-guaranteed fidelity ("run git checkout <item>") — the executing agent performs that mapping from attached context. Every current executor is an agent/LLM engine, so this is the same trust the instructions already extend everywhere else. If a non-agent unit kind (raw shell/exec) is ever added, that unit kind reintroduces substitution as its own need — scoped there, not in prose.

2.3a Routes are forward-only; gates are the retry mechanism

Two invariants inherited unchanged from the program format, doubly enforced today (program/parser.ts:459 and ir/schema.ts:227), and preserved here:

A backward default: therefore does not mean "loop" — it is a lint error.

2.3b output: describes the reducer result

For a map step, output: validates the promoted artifact, i.e. what the reducer produced — not one unit's result. The default collect reducer folds per-item results into an array, so a collected map step's schema is { type: array }. (vote folds to the winning value.) A unit step's output: describes that single unit's structured result.

2.4 Gates: control in frontmatter, rubric in the body

Owner decision: rubrics are often long — real rubric documents, not one-liners — and belong in the body. The split:

2.5 Validation model — and the end of the allowlist special case

One published JSON Schema (schemas/akm-workflow.json, repurposed) validates the entire frontmatter. Its top level $refs a new shared asset-envelope schema — the common keys every AKM markdown type carries, including the machine-stamped OKF families. Consequences:

Semantic passes keep their existing implementations from the program parser, retargeted at frontmatter: duplicate ids, route targets exist / are forward-only / are unique, reference strings resolve to earlier steps, timeout format, retry-reason taxonomy, resource limits. Body checks are the three rules of §2.2.

Recognition simplifies to: frontmatter type: workflow, or residence under workflows/ — no content sniffing. WORKFLOW_EXTENSIONS shrinks to .md.

2.6 OKF cohesion

The unified asset is an OKF-shaped document: frontmatter + markdown body, no inline syntax. Provenance stamping applies with zero special-casing. A third-party OKF reader sees a normal document — prose overview, sections per step — degrading gracefully to readable procedure documentation. status: draft / stale_after become meaningful on workflows for free (e.g. a future rule that draft workflows refuse run — noted, not proposed here).

3. What gets deleted

Net: two formats' worth of parsing/dispatch (~1,800 lines) replaced by one frontmatter schema + semantic passes + a three-rule body binder, with the IR, engine, and journal reused unchanged — and the template language itself reduced to a reference-string parser.

4. Blast radius (all unreleased surfaces)

5. Decision log and remaining questions

Resolved by owner (review round 1):

  1. Escape syntax — moot: no interpolation in prose at all (§2.3).
  2. Gate criteria location — body, under the single reserved ### gate marker; control fields stay in frontmatter (§2.4).
  3. Step titles — none; a step is its id.
  4. Heading form — bare ## <id>.

Resolved by owner (review round 2):

  1. inputs: accepts sub-paths. steps.x.output and steps.x.output.issues are both legal — the reference grammar already parses the path form, and a narrower declared input means a narrower replay hash, so a step re-dispatches only when the slice it actually consumes changes.
  2. All run params attach to every unit. No per-step params declaration. Params are run-scoped and documented non-secret (docs/reference/ workflows.md, "Params are not secret"); secrets travel as env: refs, which remain per-unit. This keeps the attachment model uniform: every unit receives params, its item + index if it is a map unit, and the artifacts its step's inputs: names.

No open questions remain. This proposal is ready to implement.

6. Non-goals