akm docs

0007 — Recursive child-workflow composition bounds

Context

P3a made a workflow step able to compose another workflow as a child — via a direct uses: workflows/<ref> step or a task-wrapped uses: tasks/<ref> step whose target is itself a workflow — and made that composition recursive: a child can itself compose a grandchild, bounded only by policy. Recursion without a bound is a corruption/DoS surface at both ends of the plan's lifecycle: an attacker-controlled or merely buggy source could either recurse arbitrarily deep at freeze time, or (once persisted) blow up the aggregate bytes a parent plan carries once every descendant's frozen plan is embedded inside it.

Decision

Moved verbatim from src/workflows/resource-limits.ts's composition-bounds section:

Enforced ONCE, at freeze, before publication, in src/workflows/freeze/targets/child-workflow.ts — the ONE resolver both the direct uses: workflows/<ref> form and the task-wrapped form route through — and re-enforced as a corruption gate whenever a parent plan is DECODED (src/workflows/ir/schema-v4.ts's recursive decodeChildWorkflowTarget).

WORKFLOW_MAX_COMPOSITION_DEPTH (= 8): max workflow composition depth — the root workflow plus this many descendant levels (root is depth 0, so 8 descendant levels freeze and a 9th fails). Deep enough that no legible authored composition hits it (the per-workflow bounds — 256 steps, 64 engines, 10 000 map expansion — are the practical ceilings), shallow enough that the worst case is bounded recursion during freeze and decode.

The max AGGREGATE canonical-JSON bytes of every embedded child plan in ONE root freeze (the sum across the whole composition tree, not per child) is deliberately HALF of WORKFLOW_MAX_PLAN_BYTES rather than a larger value layered on top of it: an embedded child plan lives INSIDE its parent's own plan bytes, so a cap independent of (and comparable to) the total plan cap would let composition alone exhaust it. Halving keeps the parent's own content always able to claim at least half the budget, and lets this actionable, freeze-time COMPOSITION_INVALID (which names the offending child ref and the running total) fire before the terse, unlocated decoder message a reviewer would otherwise have to debug. Rejected alternative: raising WORKFLOW_MAX_PLAN_BYTES itself, which would relax a corruption/DoS bound for every plan — including ones with no children — to serve a bound only composition needs (A-N6).

Consequences

Provenance