|
| 1 | +--- |
| 2 | +name: ADR 31418 — Move engine.max-runs to top-level max-runs with AWF enforcement |
| 3 | +description: Canonicalize run-cap configuration as top-level `max-runs` and route enforcement through AWF (`apiProxy.maxRuns`) for all engines. |
| 4 | +type: project |
| 5 | +--- |
| 6 | + |
| 7 | +# ADR-31418: Move `engine.max-runs` to Top-Level `max-runs` with AWF Enforcement |
| 8 | + |
| 9 | +**Date**: 2026-05-11 |
| 10 | +**Status**: Draft |
| 11 | +**Deciders**: Unknown |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## Part 1 — Narrative (Human-Friendly) |
| 16 | + |
| 17 | +### Context |
| 18 | + |
| 19 | +Run-cap configuration was previously expressed as the nested frontmatter field `engine.max-runs`, and enforcement was handled inconsistently across engines (Copilot/Claude/Codex/Gemini/Crush/OpenCode). The new AWF (Agent Workflow Framework) API proxy already centralizes other invocation-time policies (for example, `apiProxy.maxEffectiveTokens` and `apiProxy.modelMultipliers`), so it is the natural enforcement point for invocation caps as well. Without a single canonical place to declare the cap and a single enforcement point, behavior drifts per engine and the field is effectively unsupported on engines that have not implemented it. The repository already ships a codemod framework that can rewrite deprecated frontmatter automatically, which makes a deprecation path low-friction for existing workflows. |
| 20 | + |
| 21 | +### Decision |
| 22 | + |
| 23 | +We will canonicalize the run-cap configuration as a **top-level** frontmatter field `max-runs` and **route enforcement through AWF** by emitting it as `apiProxy.maxRuns` in the AWF config. The value is parsed once at the top level (accepting either an integer or a numeric string), stored on `EngineConfig.MaxRuns`, and forwarded to AWF; `engine.max-runs` is deprecated and migrated automatically via a new codemod (`engine-max-runs-to-top-level`). The field is omitted from `apiProxy` when unset so that no implicit cap is written. |
| 24 | + |
| 25 | +### Alternatives Considered |
| 26 | + |
| 27 | +#### Alternative 1: Keep `engine.max-runs` and implement per-engine enforcement |
| 28 | + |
| 29 | +Continue to expose the cap as a nested `engine.max-runs` field and have each engine wrapper enforce it. Rejected because this is the status quo that motivated the change: enforcement diverges between engines, several engines do not implement the cap at all, and there is no single place to reason about invocation limits. |
| 30 | + |
| 31 | +#### Alternative 2: Dual-support both `engine.max-runs` and top-level `max-runs` |
| 32 | + |
| 33 | +Accept both forms indefinitely and have the compiler merge them. Rejected because long-term dual support adds documentation surface, parsing precedence rules, and ongoing user confusion. The existing codemod infrastructure (see `pkg/cli/fix_codemods.go`) lets us migrate in place automatically, so the cost of deprecation is small. |
| 34 | + |
| 35 | +#### Alternative 3: Introduce a new name (e.g., `awf.maxRuns` or `invocation-cap`) at the top level |
| 36 | + |
| 37 | +Pick a different top-level name to signal the AWF-routed semantics. Rejected because users already think of the value as `max-runs`; renaming would force a larger documentation churn and a more disruptive codemod without changing behavior. The internal AWF field is already named `maxRuns`, so the mapping is transparent. |
| 38 | + |
| 39 | +### Consequences |
| 40 | + |
| 41 | +#### Positive |
| 42 | +- Run-cap enforcement is uniform across all engines because it is applied by the AWF API proxy, eliminating per-engine drift. |
| 43 | +- Workflow authors have a single, discoverable top-level field (`max-runs`) instead of a nested engine option. |
| 44 | +- Existing workflows are migrated automatically by the `engine-max-runs-to-top-level` codemod, so the deprecation is low-friction. |
| 45 | +- The schema, autocomplete metadata, and engine-feature table now advertise the cap as supported by every engine, which matches the new reality. |
| 46 | + |
| 47 | +#### Negative |
| 48 | +- `engine.max-runs` is now deprecated; users on older workflow files who do not run `fix` will see the nested field silently removed by the codemod next time it runs, which is a behavior change. |
| 49 | +- A new top-level frontmatter key expands the public configuration surface and must be kept in sync across schema, autocomplete, docs, parser, AWF config emitter, and codemods (six artifacts updated in this PR). |
| 50 | +- Enforcement now depends on AWF being in the request path; engines that bypass AWF would silently ignore `max-runs`. The PR assumes AWF is always present for runs that need the cap. |
| 51 | + |
| 52 | +#### Neutral |
| 53 | +- The omission semantics are preserved: when `max-runs` is unset, no `apiProxy.maxRuns` key is emitted (verified by `TestBuildAWFConfigJSON/max-runs omitted when not configured`). |
| 54 | +- The codemod preserves an already-present top-level value when both nested and top-level forms exist, choosing the top-level value (verified by `TestEngineMaxRunsToTopLevelCodemod_RespectsExistingTopLevel`). |
| 55 | +- Both integer and numeric-string inputs are accepted, matching the existing convention used for `max-effective-tokens`. |
| 56 | + |
| 57 | +--- |
| 58 | + |
| 59 | +## Part 2 — Normative Specification (RFC 2119) |
| 60 | + |
| 61 | +> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). |
| 62 | +
|
| 63 | +### Frontmatter Contract |
| 64 | + |
| 65 | +1. The canonical run-cap field **MUST** be the top-level frontmatter key `max-runs`. |
| 66 | +2. The value of `max-runs` **MUST** be either a positive integer or a numeric string that parses to a positive integer (minimum `1`). |
| 67 | +3. The compiler **MUST** reject or ignore values of `max-runs` that are not positive integers; non-positive or non-numeric values **MUST NOT** be propagated to `EngineConfig.MaxRuns`. |
| 68 | +4. The compiler **MUST NOT** require `max-runs` to be set; it is **OPTIONAL** frontmatter. |
| 69 | + |
| 70 | +### Engine Configuration Wiring |
| 71 | + |
| 72 | +1. `EngineConfig` **MUST** carry the parsed run-cap value on the `MaxRuns` field. |
| 73 | +2. `EngineConfig.MaxRuns` **MUST** be sourced from the top-level frontmatter `max-runs`, not from any nested `engine.max-runs` field. |
| 74 | +3. `EngineConfig.GetMaxRuns()` **MUST** return `0` when `MaxRuns` is unset or non-positive. |
| 75 | +4. The compiler **SHOULD** populate `MaxRuns` for every engine extraction path (string form, inline form, and named-engine form) so that the field is available regardless of how the engine is declared. |
| 76 | + |
| 77 | +### AWF API Proxy Emission |
| 78 | + |
| 79 | +1. When `EngineConfig.GetMaxRuns()` returns a positive integer, `BuildAWFConfigJSON` **MUST** emit `apiProxy.maxRuns` with that value. |
| 80 | +2. When `EngineConfig.GetMaxRuns()` returns `0`, `BuildAWFConfigJSON` **MUST NOT** emit the `maxRuns` key in the `apiProxy` section. |
| 81 | +3. The emitted JSON key **MUST** be exactly `maxRuns` (camelCase), matching the existing `apiProxy` field naming convention. |
| 82 | + |
| 83 | +### Deprecation and Migration |
| 84 | + |
| 85 | +1. The nested field `engine.max-runs` **MUST** be treated as deprecated. |
| 86 | +2. The codemod `engine-max-runs-to-top-level` **MUST** be registered in `GetAllCodemods()` and **MUST** run after `engine-steps-to-top-level` and before `steps-run-secrets-to-env` to preserve the established codemod ordering. |
| 87 | +3. When applied, the codemod **MUST** remove `engine.max-runs` from the nested `engine` block. |
| 88 | +4. When a top-level `max-runs` is absent, the codemod **MUST** insert a top-level `max-runs` line carrying the migrated value. |
| 89 | +5. When a top-level `max-runs` already exists, the codemod **MUST** preserve the existing top-level value and **MUST NOT** overwrite it with the nested value. |
| 90 | +6. The codemod **MUST** be a no-op when neither `engine.max-runs` nor a top-level `max-runs` is present. |
| 91 | + |
| 92 | +### Documentation and Tooling |
| 93 | + |
| 94 | +1. The workflow JSON schema (`pkg/parser/schemas/main_workflow_schema.json`) **MUST** declare `max-runs` as a top-level property with `oneOf` integer-or-numeric-string typing and a minimum of `1`. |
| 95 | +2. The autocomplete metadata (`docs/public/editor/autocomplete-data.json`) **MUST** advertise `max-runs` as a top-level leaf field. |
| 96 | +3. The engine feature table in `docs/src/content/docs/reference/engines.md` **MUST** mark `max-runs` as supported (`✅`) for every engine listed. |
| 97 | +4. New or updated documentation **SHOULD** describe `max-runs` as mapping to `awf.maxRuns` / `apiProxy.maxRuns`. |
| 98 | + |
| 99 | +### Conformance |
| 100 | + |
| 101 | +An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance. In particular: emitting `apiProxy.maxRuns` when `max-runs` is unset, sourcing `MaxRuns` from `engine.max-runs` at runtime, or skipping codemod migration when both forms are present all constitute non-conformance. |
| 102 | + |
| 103 | +--- |
| 104 | + |
| 105 | +*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/25650796734) workflow. The PR author must review, complete, and finalize this document before the PR can merge.* |
0 commit comments