| id | RFC-0010 | ||
|---|---|---|---|
| title | Parallel Execution and Worktree Pooling | ||
| status | Draft | ||
| lifecycle | Implemented | ||
| author | Dominique Legault | ||
| created | 2026-04-26 | ||
| updated | 2026-04-26 | ||
| targetSpecVersion | v1alpha1 | ||
| requires |
|
||
| amends |
|
||
| requiresDocs |
|
Document type: Normative (draft) Status: Draft v1 — pending review Lifecycle: Implemented (AISDLC-70.1 through 70.9 all Done; sign-off retained engineering-owner-only) Created: 2026-04-26 Authors: Dominique Legault (CTO / Engineering Authority) Reviewers: TBD Spec version: v1alpha1 Requires: RFC-0002 (Pipeline Orchestration), RFC-0008 (PPA Triad Integration) Amends: RFC-0002 §3 (Stage object), §5 (Branching), §9 (reconciliation semantics); RFC-0004 §4 (cost attribution per stage)
| Person | Role | Status | Date |
|---|---|---|---|
| Dominique Legault | CTO / Engineering Authority | ✅ Signed | 2026-04-26 |
| Morgan Hirtle | Chief of Design / Design Authority | ⏳ Pending | — |
| Alexander Kline | Head of Product Strategy / Product Authority | ⏳ Pending | — |
| Version | Date | Summary |
|---|---|---|
| v1 | 2026-04-26 | Initial draft. Defines worktree pool, deterministic port allocator, parallelism caps, merge coordination. Cites Archon (coleam00/Archon) as prior art for the deterministic port-hash and worktree adoption patterns. |
| v2 | 2026-04-26 | Added per-stage model routing (amends RFC-0004 §4) and the conditional review fan-out pattern. Both were originally scoped out; bundled in after CTO direction to keep parallel-execution wins in one document. |
| v3 | 2026-04-26 | Added per-stage harness selection. Harness (claude-code, codex, gemini-cli, opencode, aider, generic-api) is now orthogonal to model — Claude Code can drive non-Claude models via Bedrock/Vertex/custom endpoints, and OpenCode/Aider can drive Claude. Defines the HarnessAdapter interface, capability matrix, and fallback chain. Enables cross-harness review (e.g., Codex reviewing Claude's PR). |
| v4 | 2026-04-26 | Added subscription-aware scheduling. Reframes cost optimization from per-call unit pricing to "maximize utility of fixed subscription windows" (Claude Code 5-hour quotas, off-peak 2× multipliers, monthly Codex caps). Introduces the SubscriptionLedger, per-stage schedule hints, off-peak deferral, and burn-down pacing. Goal: end every billing window having processed the maximum possible PPA-ordered work without exceeding quota. |
| v5 | 2026-04-26 | Added per-worktree database isolation (resolves Q1). Defines DatabaseBranchAdapter interface, ships adapters for SQLite copy-per-worktree, Neon branching, generic Postgres snapshot-restore, and external (operator-managed). Adds DatabaseBranchPool resource, per-stage databaseAccess declaration, connection-string rewriting, and migration coordination. Production Postgres clients no longer have to wait for RFC-0011. |
| v6 | 2026-04-26 | Resolved Q1 (cap default value). Pipeline.spec.parallelism.maxConcurrent is now optional, with a tier-aware default derived from declared SubscriptionPlan: no plan → 1 (today's behavior, no surprise regressions); claude-code-pro → 3; claude-code-max-5x → 5; claude-code-max-20x → 10. Couples parallelism opt-in to the same signal that says "I want subscription utilization to be maximized." |
| v7 | 2026-04-26 | Resolved Q2 (parallel-agent budget aggregation) by clarifying the three-axis cost model. costBudget (RFC-0004) is shared dollar-denominated across all parallel agents at pipeline scope. SubscriptionPlan.windowQuotaTokens is per-harness token-denominated, not per-agent. Stage.maxBudgetUsd is a per-stage circuit breaker, independent of either. Adds new §14.10 explaining how subscription quota maps to dollars (it doesn't directly — subscription work is pre-paid; only spillover to pay-per-token decrements costBudget). |
| v8 | 2026-04-26 | Resolved Q3 (PPA re-scoring on requeue). Hybrid algorithm: re-score when (time since last triage > 24h) OR (failure type signals difficulty miscalibration) OR (operator-triggered requeue). Adds normative §9.4 with the failure-type taxonomy classifying each known failure as transient (trust score) or intrinsic (re-score). Triage history persisted to $ARTIFACTS_DIR/<issue-id>/triage-history.jsonl for audit. |
| v9 | 2026-04-26 | Resolved Q4 (classifier failure-open scope). Classifier emits BOTH confident: bool (drives dispatch — the binary fall-open trigger) AND confidence: float [0,1] (informational, fed to calibration analysis). Validation rejects outputs where the two contradict (e.g., confident: true with confidence < 0.7). Operators can audit classifier calibration via $ARTIFACTS_DIR/_classifier/calibration.jsonl to detect overconfident or underconfident prompts and iterate. |
| v10 | 2026-04-26 | Resolved Q5 (model-deprecation handling). Model registry gains deprecatedAt / removedAt per entry. Pipeline-load resolves all aliases to physical IDs and pins them to runtime.json for the run's lifetime. Deprecated models still resolve but emit ModelDeprecated warning naming the removal date; removed models fail pipeline-load with ModelRemoved. Adds cli-model-bump --dry-run for operators to preview new resolutions before starting a pipeline run that picks them up. |
| v11 | 2026-04-26 | Resolved Q6 (adapter capability discovery vs declaration). Hybrid: static declaration is authoritative for pipeline-load validation; startup version probe is a sanity check against the installed CLI. Adds requires: { binary, versionRange } to the HarnessAdapter interface. Defaults to open-ended upper bounds (>=X.Y.Z) — assume forward-compatibility unless a specific upstream version is known to break compat. Probe parsing failures emit HarnessProbeFailed warning but do not block validation (avoids breaking on undocumented --version output changes). |
| v12 | 2026-04-26 | Resolved Q7 (cross-harness artifact format compatibility). Each artifact type produced by stages now has TWO files: a human-narrative .md (operator-friendly, harness-natural style) AND a schema-conformant .json (downstream-machine-readable, validated against spec/schemas/artifacts/). Adapters MUST include the relevant JSON schema in their invocation prompt and MUST produce a valid JSON file. Schema validation failure is a stage failure (ArtifactSchemaInvalid); adapter MAY retry once with a sharpened prompt before failing. Schemas are versioned via the $schema field for forward-compat. |
| v13 | 2026-04-26 | Resolved Q8 (fallback chain audit). Default behavior is transparent fallback with audit (runtime.json records actual harness used). Adds optional Stage.requiresIndependentHarnessFrom: string[] for stages where harness independence is a load-bearing safety property (e.g., review-security requires independence from implement). When set, the orchestrator filters the stage's harness chain to exclude harnesses that ran the named upstream stages. If no harness preserves independence, emits IndependenceViolated event and applies the stage's onFailure policy (continue advisory by default; operator MAY set abort for security-critical pipelines). |
| v14 | 2026-04-26 | Resolved Q9 (authoritative quota API migration). Adds `SubscriptionPlan.spec.quotaSource: 'self-tracked' (default) |
| v15 | 2026-04-26 | Resolved Q10 (off-peak schedule freshness). Adds SubscriptionPlan.spec.offPeak.lastVerified: ISO 8601 date for operator-declared freshness. At pipeline-load, missing or >30-day-old lastVerified emits OffPeakScheduleStale warning, surfaced to Slack digest (not just logs). cli-status --subscriptions view shows freshness age per plan. Reference SubscriptionPlan examples shipped with maintainer-verified dates, refreshed quarterly. |
| v16 | 2026-04-26 | Resolved Q11 (estimate cold-start). Stage.estimatedTokens is optional; missing falls through to default { input: 50000, output: 10000 } with MissingEstimate warning. After first execution, rolling estimate replaces default and orchestrator emits one-time EstimateBootstrapped event recording the divergence. Operators MAY freeze the empirical estimate by writing it back to the pipeline YAML to opt out of rolling updates. Reference dogfood pipeline ships with estimatedTokens populated for all canonical stages so the default only affects novel operator-authored stages. |
| v17 | 2026-04-26 | Resolved Q12 (multi-tenant subscription pooling). SubscriptionLedger keys become (harness, accountId, tenant) instead of per-pipeline. accountId derived automatically from the harness's credentials via new HarnessAdapter.getAccountId() — auto-pools two pipelines on the same vendor account, auto-isolates two pipelines on different accounts. Optional Pipeline.spec.tenant + tenantQuotaShare carve a single account into virtual sub-windows for internal cost allocation. LedgerPooled event surfaces accidental account sharing; LedgerKeyAmbiguous warning when accountId can't be derived (degrades to per-pipeline ledger). |
| v18 | 2026-04-26 | Resolved Q13 (subscription tier upgrade signal). Adds weekly aggregated TierAnalysis writeup at $ARTIFACTS_DIR/_ledger/tier-analysis.jsonl per (harness, accountId, tenant). Slack daily-digest entry surfaces only when recommendedPlan differs from currentPlan AND confidence != 'low' (no fatigue from "your tier is fine" notifications). cli-tier-recommendation command for ad-hoc deeper analysis. Recommendations cover both upgrades AND downgrades — operators waste money in either direction. |
| v19 | 2026-04-26 | Resolved Q14 (database branch warm pool). Adds DatabaseBranchPool.spec.lifecycle.warmPoolSize: integer (default 0). When 0, behavior unchanged (current default — allocate on demand). When > 0, orchestrator maintains a pool of pre-allocated branches; stage allocation hands over a warm branch (sub-100ms) and asynchronously refills. Warm branches count against maxConcurrent and age out per branchTtl. Branches are single-use after reclaim (no recycle, avoids cross-contamination). Phase 6 integration test exercises the path even when nobody opts in. |
| v20 | 2026-04-26 | Resolved Q15 (migration rollback on PR abort). Adds DatabaseBranchPool.spec.allowBranchFromBranch: boolean (default false) topology guard — eliminates the divergence problem at the source for ~95% of pipelines that branch from stable upstream. When opt-in is set, reclamation of a branch with active children emits MigrationDiverged event naming the divergence; operator triages. No auto-reclaim, no auto-rebase, no auto-block at merge gate — divergence is informational, operator decides. All 16 open questions resolved (Q1 from the original list, then Q1–Q15 carried through the v6 renumbering). |
| v21 | 2026-04-30 | Phase 5 hardening shipped (AISDLC-70.8): operator runbook extended with five new failure-mode recovery playbooks (WorktreeOwnershipMismatch, RebaseConflict, stuck heartbeats, IndependenceViolated, MigrationDiverged, BranchQuotaExceeded), three-scenario chaos test plan, and the feature-flag promotion ritual. Flag remained off by default pending substantive readiness. |
| v22 | 2026-05-01 | Feature flag promoted to default-on per maintainer directive 2026-05-01 — corpus-driven not calendar-driven. The original Phase 5 AC #4 ("after 1 week of dogfood pipeline running with AI_SDLC_PARALLELISM=experimental, promote to default-on") was a calendar gate; it is dropped in favor of the substantive readiness gate "no parallelism-related incidents in the trailing observation window." Pre-flight scan of orchestrator/_events.jsonl and trailing 7-day commit history showed zero WorktreeOwnershipMismatch / RebaseConflict / merge-gate failures. AI_SDLC_PARALLELISM now defaults to 'on'; explicit 'experimental' opt-in (pre-promotion mode) and explicit 'off'/'disabled'/'false'/'0' opt-out paths are preserved (AISDLC-116). |
- Summary
- Motivation
- Goals
- Non-Goals
- Concepts
- Schema Amendments
- Worktree Pool Manager
- Deterministic Port Allocator
- Concurrency and Admission Control
- Merge Coordination
- Per-Stage Model Routing
- Conditional Review Fan-Out
- Harness Selection
- Subscription-Aware Scheduling
- Database Isolation
- Artifact Directory Convention
- Observability Requirements
- Backward Compatibility
- Implementation Plan
- Alternatives Considered
- Open Questions
- References
This RFC extends the AI-SDLC pipeline (RFC-0002) with declarative semantics for parallel execution of PPA-prioritized issues through a pooled git-worktree isolation model, amends the cost-governance contract (RFC-0004) to support per-stage model routing, introduces per-stage harness selection so each stage can run on the most appropriate coding agent (Claude Code, Codex CLI, Gemini CLI, OpenCode, Aider) regardless of the underlying LLM, and introduces subscription-aware scheduling that reframes cost optimization from "minimize per-call cost" to "maximize utility of fixed subscription windows."
Today, orchestrator/src/execute.ts drives one issue at a time through plan → build → review; every agent stage hardcodes Sonnet (ai-sdlc-plugin/agents/*-reviewer.md); the entire pipeline assumes Claude Code as the only harness; and the cost model assumes pay-per-token API pricing rather than the session-window quotas, off-peak multipliers, and monthly subscription caps that actually govern most coding-agent billing. To realize the dogfood pipeline vision at competitive unit economics, the orchestrator must run N agents concurrently without collisions, spend the right model on the right stage, fall over between harnesses, AND schedule work to fully consume each subscription window before it resets — including deferring lower-priority work to off-peak hours where Claude Code grants 2× the token allocation. This RFC defines the worktree pool, the port-allocation function, the concurrency cap, the merge serialization protocol, the artifact directory, the per-stage model and harness fields, the HarnessAdapter interface, the SubscriptionLedger that tracks remaining quota across windows, per-stage schedule hints that let operators say "this stage prefers off-peak" or "this stage MUST run within current quota," and the DatabaseBranchAdapter interface with shipped adapters for SQLite copy-per-worktree and Neon Postgres branching so parallel agents touching shared DB state don't corrupt each other's writes.
orchestrator/src/execute.ts dispatches a single agent against a single branch. With a PPA-ordered queue of N issues, end-to-end throughput is N × T_issue, regardless of how many models or seats are available. The Archon project (coleam00/Archon) demonstrates ~10× throughput gains by running parallel agents, each in its own worktree, against an ordered queue. We need the same capability.
Three concrete failure modes appear once two or more agents share a host:
- Port collisions — Two dev servers on
localhost:3000race; the second crashes and the agent loops on a misleading "port in use" error. - Branch collision in shared worktrees — If a worktree is reused for a different issue, the agent's first
git statusreports unfamiliar uncommitted changes and may try to "fix" them. - Cross-clone worktree adoption — A worktree pointing at a different clone of the same repo silently corrupts both. Archon guards against this with
verifyWorktreeOwnership()(packages/git/src/worktree.ts); we have no equivalent.
When two agents finish at the same time, both attempt to push to the remote. There is no protocol for "agent A's PR rebases cleanly onto main; agent B's PR must rebase onto A's now-merged result." Without a serialized merge gate, the second PR fails CI on stale base, the agent retries, and we burn tokens re-running CI for a problem that should have been resolved before the human reviewer woke up.
Stored in memory (feedback_observability.md): "black box execution destroys confidence; users need real-time visibility into agent progress." Today, an operator running 5 agents in parallel has no way to know which issue each is on or which stage they are in without tmux attach-ing to each session. This RFC requires each parallel branch to publish a structured state file the operator (and a future dashboard) can read.
ai-sdlc-plugin/agents/code-reviewer.md, test-reviewer.md, and security-reviewer.md all declare model: sonnet. The PPA triage stage and the review-classification step are pattern-matching tasks that Haiku handles well at ~10× lower cost. Conversely, complex implementation work in /fix-pr would benefit from Opus 1M but currently runs on Sonnet by default. The ratio of stage cost to stage difficulty is misaligned across the entire pipeline.
/review invokes the testing, critic, and security agents on every PR regardless of diff content. A README-only change runs the security reviewer; a config rename runs the test reviewer. Archon's flagship workflow (archon-fix-github-issue.yaml) uses a Haiku-cheap classifier (review-classify) that decides which reviewers to invoke based on diff scope. Adopting this pattern cuts review-stage cost by an estimated 40–60% on small PRs without losing coverage on PRs that need it.
Every stage today assumes Claude Code as the runtime. This creates four concrete risks:
- Vendor concentration. A Claude Code outage, rate-limit, or pricing change halts the entire dogfood pipeline. Multiple clients running this product cannot tolerate single-vendor failure.
- Lost cost arbitrage. Different harnesses + model providers have different unit economics for the same stage. An OpenRouter-routed DeepSeek call may cost 1/10th of a Sonnet call for a triage classification with comparable quality. Locking the harness forecloses this lever.
- Lost cross-harness review quality. The Cole Medin transcript explicitly recommends
/codex adversarial-reviewreviewing Claude's PR — a different model family in a different harness catches different classes of bugs. Single-harness pipelines lose this independence multiplier. - Different harnesses, different strengths. Codex CLI (OpenAI) excels at certain reasoning patterns; Gemini CLI handles long-context tasks well; Aider has strong refactoring; Claude Code has the richest skill/tool ecosystem. A pipeline that can route stages to the right harness extracts more value than one that uses any single tool for everything.
Notably, harness ≠ model. Claude Code can drive Bedrock-hosted Claude, Vertex-hosted Claude, or via ANTHROPIC_BASE_URL any OpenAI-compatible endpoint serving any model. OpenCode and Aider can drive Claude. The two axes are independent and must be configured independently.
The existing cost-governance model (RFC-0004) assumes pay-per-token API pricing, which is correct for direct Anthropic API calls but wrong for the billing model most clients actually use:
- Claude Code Pro/Max allocates tokens in 5-hour rolling windows. Unused capacity at window-end is forfeit. A pipeline that processes 3 issues in a window with capacity for 8 has effectively wasted 60% of that window's value.
- Off-peak multiplier: Claude Code grants approximately 2× token allocation during documented off-peak hours. The same Opus 1M call that consumes 1 unit of quota on-peak consumes 0.5 units off-peak. A pipeline that runs all work on-peak processes half the issues per dollar of subscription compared to one that defers low-priority work to off-peak.
- Codex Pro / Plus has monthly caps, not session windows. Pacing matters across the month, not within a 5-hour block.
- OpenRouter / generic-API is true pay-per-token with no windows or multipliers — the legacy assumption.
The optimization problem is not "minimize cost per issue" but "given a fixed subscription, process the maximum number of PPA-prioritized issues before the subscription period ends." This is a knapsack/scheduling problem, and the orchestrator currently solves none of it. A pipeline that processes 12 issues per week on a $200/month subscription is twice as valuable as one that processes 6, even if the per-issue cost is identical, because the marginal subscription cost of issues 7–12 is zero.
The user-visible goal: end every billing window having used the maximum useful share of available tokens, with the highest-PPA work front-loaded and the lowest-PPA work pushed into off-peak slots. Idle subscription capacity is the most expensive thing in the system after a missed deadline.
Worktree isolation gives each parallel agent its own filesystem checkout, but every agent connects to the same DATABASE_URL. Three concrete failure modes follow:
- Test pollution. Agent A inserts a fixture row; agent B's test asserts an empty table and fails; agent C's migration runs against rows agent A is mid-rewrite of and corrupts both.
- Schema race. Agent A and agent B both add a
users.preferencescolumn with different types in unrelated PRs. Whichever agent runs migrations first wins; the other agent's tests pass against the "wrong" schema and the bug only surfaces in CI on main. - Production data risk. A bug in an agent's seed script (or a hallucinated
DELETE FROM) executes against the shared dev DB, which may contain real customer data on staging or shared environments.
These are not hypothetical. Every client we have spoken to runs Postgres for their primary store, and every non-trivial pipeline includes at least one stage that writes to it. Without per-worktree DB isolation, the safe parallelism for these clients is 1 — defeating the entire purpose of this RFC.
The fix is well-understood: each worktree gets its own database branch (Neon, Supabase) or copy (SQLite, snapshot-restore), the agent's DATABASE_URL is rewritten to point at the branch, and the branch is reclaimed when the PR merges. This RFC ships the abstraction and two production-ready adapters.
- Define a
WorktreePoolresource that allocates, reuses, and reclaims worktrees across pipeline runs. - Add a
parallelismfield toPipeline.specdeclaring max concurrent issues. - Specify a deterministic port-allocation function so two agents in different worktrees never collide on dev-server ports without a central coordinator.
- Specify a merge coordination protocol that serializes the final merge gate while parallelizing all upstream stages.
- Specify an artifact directory layout (
$ARTIFACTS_DIR/<issue-id>/) that every parallel branch writes to, enabling resumability and observability. - Define a cross-clone worktree ownership guard.
- Add a
modelfield to the Stage object so each pipeline stage declares its target model (Haiku/Sonnet/Opus/Opus 1M), with cost attribution flowing into RFC-0004's accounting per stage. - Specify a conditional review fan-out: a Haiku-cheap classifier decides which of
{testing, critic, security}reviewers to invoke per PR. - Define a
harnessfield on the Stage object and a HarnessAdapter interface so each stage can run on a different coding-agent runtime (Claude Code, Codex CLI, Gemini CLI, OpenCode, Aider, generic-API). - Specify a capability matrix that adapters MUST self-declare so the orchestrator can validate stage requirements against available harnesses at pipeline-load time.
- Specify a fallback chain so a stage can declare an ordered preference list of harnesses, falling through on rate-limit, outage, or capability mismatch.
- Define a SubscriptionLedger that tracks per-harness window state (current usage, reset time, off-peak multipliers, monthly caps) so harness/model routing and pipeline dispatch decisions consult quota in real time.
- Add a
schedulefield on the Stage object (now|off-peak|quota-permitting|defer-if-low-priority) so operators can declaratively defer cost-flexible work to high-multiplier windows. - Specify burn-down pacing: the orchestrator MUST emit telemetry showing projected window utilization given queue depth and observed per-stage cost, so operators can intervene if the subscription is under- or over-pacing.
- Define a
DatabaseBranchAdapterinterface andDatabaseBranchPoolresource so each worktree gets isolated database state. Shipsqlite-copy,neon,pg-snapshot-restore, andexternaladapters at v1. - Define a per-stage
databaseAccessdeclaration (none|read|write|migrate) so the orchestrator can decide whether to provision a branch, share a read-only one, or run migration coordination. - Define connection-string rewriting so agents transparently see the per-worktree branch via the same env-var name (
DATABASE_URL, etc.) the application already uses. - Maintain backward compatibility — pipelines that omit
parallelism,model, orharnessexecute serially on Claude Code with Sonnet, exactly as today.
- Non-relational stateful resources. Redis, S3 buckets, message queues (Kafka, SQS), search indices (Elasticsearch, Algolia) — these have the same parallel-collision problem as databases but require different isolation primitives. The
DatabaseBranchAdapterinterface is deliberately scoped to relational stores; a future RFC will generalize to aStatefulResourceAdaptersuperset. - Distributed-transaction coordination across branches. A pipeline that needs an agent in branch A to commit a transaction visible to an agent in branch B is out of scope. Branches are isolated by definition.
- Branching from production data. All DB branches in v1 derive from a designated dev/staging upstream. Branching from production carries data-handling, PII, and compliance concerns that this RFC does not address.
- Cross-host distribution. This RFC scopes parallelism to a single host. Multi-host execution is a future concern.
- Dependency install caching.
pnpmcontent-addressable store is sufficient for our workloads; no special mechanism is specified here. - Speculative branching (running multiple plan variants per issue and picking the winner). Out of scope.
- Adaptive model selection. This RFC defines declarative per-stage routing (the operator picks the model in YAML). Learning-based selection (the orchestrator picks the model based on stage difficulty signals) is out of scope and would be a future RFC building on the per-stage-cost telemetry this RFC requires.
- Day-one parity across all harnesses. This RFC defines the HarnessAdapter interface and the capability matrix. The reference implementation ships only
claude-code(parity with today) andcodex(highest-value second harness for cross-harness review). Adapters forgemini-cli,opencode,aider, andgeneric-apiare deferred to follow-up work but the interface MUST be sufficient to implement them without further schema changes. - Cross-harness session migration. A stage that starts on Claude Code cannot mid-flight transfer its conversation to Codex. Each stage runs end-to-end on one harness; switching happens at stage boundaries.
- Authoritative subscription quota introspection. Anthropic does not currently expose Claude Code window state via API. The SubscriptionLedger is a self-tracked best-effort estimate based on observed token consumption against documented window caps, not a queried-from-vendor source of truth. The interface is forward-compatible with an authoritative API if/when one ships.
- Real-time spot-pricing arbitrage. Continuously rerouting between providers on minute-by-minute price changes is out of scope. We optimize over hours-to-days windows, not seconds.
- Billing reconciliation. This RFC tracks consumption for scheduling decisions, not for accounting accuracy. Authoritative billing comes from the provider invoice. The ledger's accuracy target is "within 10% of provider-reported usage" — sufficient for pacing, insufficient for accounting.
A directory (default: ~/.ai-sdlc/worktrees/<owner>/<repo>/) containing one git worktree per active issue. Worktrees are allocated on stage entry, reused if the issue's branch matches an existing worktree, and reclaimed when the PR merges or after a stale-threshold timeout (default 14 days).
A worktree's .git pointer file references its parent clone. The pool MUST verify the pointer matches the current clone before adopting an existing worktree, refusing to operate on worktrees from a different clone of the same upstream.
A pure function port(worktreePath, basePort) → int such that the same worktree path always receives the same port, and collision probability across N concurrent worktrees is bounded. This eliminates the need for a runtime port broker.
A single critical section through which all PRs serialize for the rebase-and-merge step. Upstream stages (plan, build, review) run in parallel; only the final merge eligibility check and the rebase-onto-main step hold the gate.
A per-issue directory ($ARTIFACTS_DIR/<issue-id>/) containing the plan, implementation, validation, and review outputs as durable files. Both the orchestrator and external observers (a future dashboard, the operator's tail -f) read from this directory.
A declarative model: field on each Stage object, accepting one of haiku, sonnet, opus, opus[1m], inherit, or an explicit model ID (claude-haiku-4-5-20251001). inherit is the default and resolves to the pipeline's defaultModel (today: Sonnet). The orchestrator MUST attribute token cost to the resolved model, not to inherit.
A Stage with kind: review-classifier that consumes the PR diff and emits a structured decision listing which downstream review agents to invoke. By default it runs on Haiku. The fan-out stage downstream reads this decision and dispatches only the selected reviewers. A pipeline that omits the classifier preserves today's behavior (run all configured reviewers).
A pluggable runtime implementation that abstracts a coding-agent CLI behind a uniform interface. Each adapter declares its capabilities (which models it can drive, whether it supports MCP/skills/streaming/worktree-aware cwd) and translates orchestrator-level invocations (prompt, context, tools, model) into harness-native commands. Adapters are registered at orchestrator startup and resolved per-stage by name.
A per-harness state store that tracks billing-window consumption against documented quota. For Claude Code Pro/Max it tracks the rolling 5-hour window, the off-peak multiplier currently in effect, and the projected window-end utilization given queue depth. For Codex Pro it tracks the monthly cap. For pay-per-token harnesses (generic-api) it tracks dollar spend against the pipeline's costBudget. The ledger is consulted on every stage dispatch and every harness-routing decision.
A declarative property on a Stage indicating its temporal flexibility: now (must dispatch immediately), off-peak (defer until off-peak window if available within reasonable wait), quota-permitting (run if there is sufficient headroom in the current window), or defer-if-low-priority (PPA-conditional — defer if other higher-priority work would consume more of the current window).
A pluggable runtime implementation that abstracts a database-branching mechanism behind a uniform interface. Each adapter declares its capabilities (branch creation latency, max concurrent branches, schema-migration support, multi-database support) and translates orchestrator-level operations (allocate, snapshot-from-upstream, attach to worktree, reclaim) into provider-native API calls. Adapters are registered at orchestrator startup and resolved per-pool by name. Parallel to the HarnessAdapter pattern (§5.8) — same registry shape, same fail-fast validation, same in-tree-only security posture.
An isolated, ephemeral copy of a designated upstream database, scoped to one worktree's lifetime. The branch is created when the worktree is allocated (or lazily on first stage that declares databaseAccess: write), receives any pending migrations from the upstream, exposes a connection string the agent uses transparently via the same env-var name (e.g., DATABASE_URL) the application expects, and is destroyed when the worktree is reclaimed.
| Field | Type | Required | Description |
|---|---|---|---|
parallelism |
object | MAY | Concurrency configuration. When omitted, pipelines execute serially (current behavior). |
parallelism.maxConcurrent |
integer | MAY | Maximum number of issues executing concurrently. Range: 1–20. When omitted, derived from the declared SubscriptionPlan per the resolution table in §9.1. |
parallelism.worktreePool |
string | MAY | Reference to a WorktreePool resource by name. Defaults to the pipeline's name. |
parallelism.mergeStrategy |
string | MAY | One of serialized-rebase (default), parallel-merge (forbidden in v1, reserved). |
apiVersion: ai-sdlc.dev/v1alpha1
kind: WorktreePool
metadata:
name: default-pool
spec:
rootDir: ~/.ai-sdlc/worktrees
layout: workspace-scoped # or "repo-local"
staleThresholdDays: 14
basePort: 3190
ownershipGuard: strict # "strict" | "advisory"
cleanup:
onMerge: true
onAbort: true
onTimeout: true
databaseBranchPools: # optional, references DatabaseBranchPool resources by name (§6.7)
- primary-postgres
- analytics-postgres
subscriptionPlans: # optional, references SubscriptionPlan resources by name (§6.6)
- claude-code-max-5x| Field | Type | Required | Description |
|---|---|---|---|
isolation |
string | MAY | One of worktree (default when parallelism is set), inplace. Stages that must operate on the main checkout (e.g., release tagging) MUST set inplace. |
holdsMergeGate |
boolean | MAY | When true, this stage acquires the pipeline's merge gate for the duration of its execution. Defaults to false. The final merge stage MUST set this to true. |
model |
string | MAY | One of haiku, sonnet, opus, opus[1m], inherit, or an explicit model ID. Defaults to inherit. |
kind |
string | MAY | One of agent (default), review-classifier, review-fanout. Drives stage-specific execution semantics. |
maxBudgetUsd |
number | MAY | Per-stage cost ceiling. When exceeded, the orchestrator MUST emit BudgetExceeded and apply the stage's onFailure policy. Hooks into RFC-0004 cost attribution. |
harness |
string | MAY | One of claude-code (default), codex, gemini-cli, opencode, aider, generic-api, inherit. Resolves against the orchestrator's adapter registry (§13.2). Pipeline-load MUST fail if an unregistered harness is named. |
harnessFallback |
array[string] | MAY | Ordered preference list. If the primary harness is unavailable (rate-limited, capability mismatch, runtime error during invocation), the orchestrator MUST attempt each fallback in order before applying onFailure. |
requiresIndependentHarnessFrom |
array[string] | MAY | List of upstream stage names. The orchestrator MUST exclude any harness that ran one of those upstream stages from this stage's effective harness + harnessFallback chain. See §13.10. |
schedule |
string | MAY | One of now (default), off-peak, quota-permitting, defer-if-low-priority. Drives subscription-aware dispatch (§14). |
estimatedTokens |
object | MAY | { input: number, output: number, frozen?: boolean }. Operator hint used by the SubscriptionLedger for window-headroom calculations. When omitted, the orchestrator uses the cold-start default { input: 50000, output: 10000 } and emits MissingEstimate warning. After first execution, rolling estimate from $ARTIFACTS_DIR/_ledger/stage-estimates.json supersedes the default UNLESS frozen: true, in which case the declared values are pinned and rolling updates are ignored. See §14.6 for bootstrap and freeze semantics. |
databaseAccess |
string | MAY | One of none (default), read, write, migrate. Drives database-branch provisioning (§15). none skips branch creation; read MAY share a single read-only branch across worktrees; write and migrate REQUIRE a per-worktree writable branch. |
| Field | Type | Required | Description |
|---|---|---|---|
defaultModel |
string | MAY | Resolution target for any stage with model: inherit. Defaults to sonnet. Same value space as Stage.model. |
| Field | Type | Required | Description |
|---|---|---|---|
defaultHarness |
string | MAY | Resolution target for any stage with harness: inherit. Defaults to claude-code. Same value space as Stage.harness. |
defaultHarnessFallback |
array[string] | MAY | Pipeline-wide default fallback chain applied to any stage that omits harnessFallback. |
tenant |
string | MAY | Tenant identifier for SubscriptionLedger keying (§14.12). When set, partitions a shared vendor account into virtual sub-windows. When omitted, all pipelines on the same (harness, accountId) share a single ledger. |
tenantQuotaShare |
number [0,1] | MAY | Fraction of the shared account's windowQuotaTokens allocated to this tenant. Required when tenant is set AND multiple tenants exist on the same (harness, accountId). Sum of shares across all tenants on the same account MUST equal 1.0; validated at orchestrator startup. |
accountId |
string | MAY | Override for the auto-derived account identity from HarnessAdapter.getAccountId(). Useful when the harness can't expose a stable identity (see §14.12 ambiguous-accountId path). When set, overrides the auto-derived value and pools/isolates ledgers based on this string. |
offPeakMaxWait |
string | MAY | Maximum time a schedule: off-peak stage may wait for the next off-peak window before falling through to on-peak dispatch with OffPeakDeferralExceeded warning. ISO 8601 duration. Defaults to PT8H. |
artifactSchemaVersion |
string | MAY | Pin a specific artifact-schema version (e.g., v1, v2). Defaults to v1. Downstream stages consume artifacts at the pinned version per §16.4 schema-evolution semantics. |
Declares the billing-window characteristics of a harness so the SubscriptionLedger can pace dispatch correctly.
apiVersion: ai-sdlc.dev/v1alpha1
kind: SubscriptionPlan
metadata:
name: claude-code-max-5x
spec:
harness: claude-code
billingMode: session-window # "session-window" | "monthly-cap" | "pay-per-token"
windowDuration: PT5H # ISO 8601, only for session-window
windowQuotaTokens: 1000000 # documented per-window cap
offPeak:
enabled: true
multiplier: 2.0 # 2× tokens during off-peak
schedule: # cron-style or simple time ranges
- { tz: 'America/Los_Angeles', hours: '22-06' }
- { tz: 'America/Los_Angeles', hours: '0-7', daysOfWeek: 'Sat,Sun' }
lastVerified: '2026-04-15' # operator confirmed against vendor docs on this date
pacingTarget: 0.85 # aim to consume 85% of window before reset
hardCap: 0.95 # MUST NOT dispatch new work above this fraction
quotaSource: self-tracked # 'self-tracked' (default) | 'authoritative-api' | 'authoritative-with-fallback'| Field | Type | Required | Description |
|---|---|---|---|
harness |
string | MUST | Name of the registered harness this plan applies to. |
billingMode |
string | MUST | One of session-window, monthly-cap, pay-per-token. |
windowDuration |
string | when session-window |
ISO 8601 duration of the rolling window. |
windowQuotaTokens |
integer | when session-window or monthly-cap |
Documented quota per window. |
offPeak |
object | MAY | Multiplier configuration. Absent → no off-peak preference. |
pacingTarget |
number [0,1] | MAY | Burn-down target. Defaults to 0.80. |
hardCap |
number [0,1] | MAY | Above this fraction of window quota, the orchestrator MUST NOT dispatch new work even if a stage has schedule: now. Defaults to 0.95. |
quotaSource |
string | MAY | Source of authoritative window state. Defaults to self-tracked. See §14.11 for migration semantics. |
SubscriptionPlan is referenced by WorktreePool.spec.subscriptionPlans[] (or pipeline-scoped). Multiple plans MAY exist per harness when an account has multiple seats; the orchestrator distributes work across them.
Declares a pool of database branches available for per-worktree allocation. Multiple pools MAY exist per pipeline when an application uses multiple databases (e.g., primary Postgres + analytics Postgres + Redis-replacement KeyDB).
apiVersion: ai-sdlc.dev/v1alpha1
kind: DatabaseBranchPool
metadata:
name: primary-postgres
spec:
adapter: neon # 'neon' | 'sqlite-copy' | 'pg-snapshot-restore' | 'supabase' | 'external'
upstream:
connectionStringEnv: DATABASE_URL_DEV # env var the orchestrator reads to find upstream
branchFrom: dev # named upstream branch (Neon) or 'main' for snapshot-restore
injection:
targetEnv: DATABASE_URL # env var rewritten in agent invocation
additionalEnvs: [PGHOST, PGDATABASE] # optional, parsed from connection string
lifecycle:
createOn: worktree-allocation # 'worktree-allocation' | 'first-write-stage'
reclaimOn: pr-merge # 'pr-merge' | 'worktree-reclaim' | 'manual'
maxConcurrent: 10
branchTtl: P14D
abandonAfter: P7D # destroy if no activity, even before TTL
warmPoolSize: 0 # pre-allocated branches; 0 = on-demand only (default)
migrations:
runOnBranchCreate: true
migrationCommand: 'pnpm db:migrate' # how to apply pending migrations to a fresh branch
migrationCwd: orchestrator # relative to worktree root
credentials:
apiTokenEnv: NEON_API_TOKEN # adapter-specific; see §15.3
projectId: prj_abc123 # Neon project ID| Field | Type | Required | Description |
|---|---|---|---|
adapter |
string | MUST | Name of a registered DatabaseBranchAdapter. Pipeline-load fails on unknown adapter. |
upstream |
object | MUST | Source database to branch from. |
upstream.connectionStringEnv |
string | MUST | Env var name the orchestrator reads at startup to discover upstream. The connection string itself is NEVER logged or persisted. |
upstream.branchFrom |
string | adapter-dependent | Named upstream branch (Neon, Supabase) or upstream identifier for snapshot-restore. By default MUST reference a stable, non-PR-feature upstream (e.g., dev, main). See allowBranchFromBranch below. |
allowBranchFromBranch |
boolean | MAY | Defaults to false. When false, the adapter MUST refuse to allocate a branch whose upstream is itself an in-flight feature branch — pipeline-load fails with BranchTopologyForbidden. When true, operator opts into branch-from-branch chains and accepts MigrationDiverged events on parent reclaim (§15.5.1). |
injection |
object | MUST | How the branch's connection string is exposed to agents. |
injection.targetEnv |
string | MUST | Env var name rewritten in the agent's environment. |
injection.additionalEnvs |
array[string] | MAY | Additional env vars to derive (host, port, database, user) from the rewritten connection string. |
lifecycle |
object | MAY | Branch lifecycle configuration. |
lifecycle.createOn |
string | MAY | When to provision the branch. worktree-allocation (default) creates eagerly; first-write-stage creates lazily and is cheaper if many runs only have read stages. |
lifecycle.reclaimOn |
string | MAY | When to destroy the branch. Defaults to pr-merge. |
lifecycle.maxConcurrent |
integer | MAY | Cap on concurrent branches. MUST NOT exceed the adapter's declared maxBranches capability. Defaults to the resolved Pipeline.spec.parallelism.maxConcurrent of the pipeline that owns the WorktreePool. |
lifecycle.branchTtl |
string | MAY | ISO 8601 max branch age. Branches older than TTL are reclaimed regardless of activity. |
lifecycle.abandonAfter |
string | MAY | ISO 8601 idle threshold. Defaults to P7D. |
lifecycle.warmPoolSize |
integer [0,20] | MAY | Pre-allocated branch count. Defaults to 0 (on-demand allocation). When > 0, see §15.4.1 for warm-pool semantics. |
migrations |
object | MAY | Migration coordination. |
migrations.runOnBranchCreate |
boolean | MAY | Run pending migrations against the branch immediately on creation. Defaults to true. |
migrations.migrationCommand |
string | when runOnBranchCreate: true |
Shell command executed inside the worktree, with the branch's connection string injected. |
migrations.migrationCwd |
string | MAY | Subdirectory relative to worktree root. Defaults to worktree root. |
credentials |
object | adapter-dependent | Adapter-specific configuration. See §15.3. |
On stage entry with isolation: worktree:
- Compute the issue's branch name from
Pipeline.spec.branching.pattern(existing behavior). - Search the pool for an existing worktree whose checked-out branch equals the computed name.
- If found and
verifyOwnership()passes → adopt and return. - If found and ownership fails → refuse with error
WorktreeOwnershipMismatch. Operator intervention required. - If not found → run
git worktree add <pool>/<branch-slug> -b <branch> origin/<targetBranch>and return.
verifyOwnership(worktreePath, currentRepoPath):
pointer = read("$worktreePath/.git") # ".git: <path>" file
declaredGitDir = parse(pointer)
return realpath(declaredGitDir).startsWith(realpath("$currentRepoPath/.git/worktrees/"))
Implementations MUST refuse to operate on worktrees that fail ownership verification when WorktreePool.spec.ownershipGuard: strict.
A worktree is eligible for reclamation when:
- The PR for its branch has merged (and
cleanup.onMerge: true), OR - The pipeline run aborted (and
cleanup.onAbort: true), OR - The worktree's mtime is older than
staleThresholdDaysand no active pipeline run references it.
Reclamation MUST be guarded by an uncommitted-changes check. Reclaiming a worktree with uncommitted changes is a destructive operation and MUST require operator confirmation, regardless of pool configuration.
Branch names like feat/issue-42 MUST be slugified to feat-issue-42 for the on-disk worktree directory name. The orchestrator MUST be able to round-trip from branch → slug → worktree path.
port(worktreePath, basePort = 3190):
digest = md5(absolute(worktreePath))
offset = (digest[0] << 8 | digest[1]) % 900 + 100
return basePort + offset
This produces ports in [basePort + 100, basePort + 999] (default 3290–4189). Same worktree path → same port, with no central coordinator.
The function's range (900 ports) bounds expected collisions. With the default WorktreePool.spec cap of maxConcurrent: 10, the birthday-paradox collision probability is < 6%. On collision, the orchestrator MUST log a PortCollision warning and probe the next ten consecutive ports for a free one. The probed port is recorded in $ARTIFACTS_DIR/<issue-id>/runtime.json so subsequent stages within the same run reuse it.
An explicit PORT environment variable in the agent's invocation environment MUST take precedence over the computed port. This preserves operator control for debugging.
When a stage requires more than one port (e.g., dev server + websocket), the additional ports are allocated as port + 1, port + 2, ... up to a maximum of 10 contiguous ports. Stages requiring more contiguous ports MUST declare a custom allocation strategy (out of scope for v1).
The orchestrator MUST NOT execute more than the resolved maxConcurrent value simultaneously. Excess issues queue in PPA-priority order (RFC-0008) and dispatch as slots free.
Resolution algorithm. When Pipeline.spec.parallelism.maxConcurrent is omitted, the orchestrator resolves it in this order:
- Explicit
Pipeline.spec.parallelism.maxConcurrent(if present). - Tier-aware default derived from any
SubscriptionPlanreferenced by the pipeline's harness:
| Declared SubscriptionPlan | Default maxConcurrent |
Rationale |
|---|---|---|
| (none declared) | 1 |
Backward-compatible with today's behavior; no surprise regressions on plugin upgrade. |
claude-code-pro |
3 |
Pro tier quota sustains ~3 concurrent Opus stages over a 5h window without exhausting hardCap. |
claude-code-max-5x |
5 |
5× quota → 5 concurrent stages without burndown alarm. |
claude-code-max-20x |
10 |
20× quota leaves headroom for the 10-cap ceiling we set in §6.1. |
codex-plus |
2 |
Lower monthly cap; conservative default. |
codex-pro |
5 |
Comparable to Max-5x. |
pay-per-token |
5 |
No quota constraint; cap chosen for host-resource sanity. |
| Multiple plans for the same harness | sum(per-plan default) |
Operator with multiple seats gets additive headroom. |
| Multiple harnesses across stages | max(per-harness default) |
The dispatcher caps total in-flight; per-harness contention is surfaced via the QuotaContention event for the operator to size separately. |
- Hard floor
1, hard ceiling20. Resolved value is clamped to this range and logged asResolvedParallelismat pipeline-load time.
The resolution is computed once at pipeline-load and recorded in $ARTIFACTS_DIR/_pipeline/runtime.json. Subscribing a SubscriptionPlan after pipeline-load does NOT change the resolved cap until the pipeline is reloaded — operators MUST restart the pipeline run to pick up a new tier.
Why couple parallelism to SubscriptionPlan declaration. Declaring a SubscriptionPlan is itself the operator signaling "I care about subscription utilization." It is the right opt-in trigger for parallelism — clients who never declare a plan get safe serial behavior; clients who declare a plan get parallelism appropriate to their quota. This avoids both the "ship 5 by default and surprise everyone" failure mode and the "ship 1 by default and underuse every subscription" failure mode.
A new issue is admitted to execution when:
- A worktree pool slot is available, AND
- The issue's PPA admission composite (RFC-0008 §A.6) meets the configured threshold, AND
- The merge gate is not held (or this stage does not require the gate), AND
- Cost governance budget headroom exists (RFC-0004).
If admission fails for any reason other than capacity, the orchestrator MUST emit a Delayed Requeue (RFC-0002 reconciliation semantics) rather than busy-waiting.
When an issue's stage fails and is requeued for retry, the orchestrator MUST decide whether to trust the original PPA admission composite (RFC-0008 §A.6) or re-run it against the latest signals before placing the issue back on the queue. The decision balances re-triage cost (one Haiku call, ~$0.001) against the risk of dispatching stale priority.
Decision algorithm. Re-score IF ANY of:
- Time threshold: more than 24 hours have elapsed since the last triage event recorded in
$ARTIFACTS_DIR/<issue-id>/triage-history.jsonl. - Failure-type signal: the failure that triggered the requeue indicates the issue is harder than the original triage estimated. See §9.4.1 taxonomy.
- Operator-triggered requeue: a human reached for the requeue button (e.g.,
cli-requeue <issue>or PR comment trigger). The act of manual requeue is itself a signal that priority should be refreshed.
ELSE trust the original score and requeue without re-triage. The orchestrator MUST emit a structured Requeue event naming whether re-scoring happened and why ("re-scored: time threshold exceeded" vs "trusted score: transient failure within 24h").
| Failure event | Class | Re-score on first occurrence? | Re-score after Nth occurrence? |
|---|---|---|---|
MergeConflict |
Transient (base moved) | No | No (still transient regardless of count) |
RebaseConflict |
Transient (base moved) | No | No |
CIFailure |
Transient (flaky tests, infra) | No | After 3rd: Yes (signals real bug, not flake) |
HarnessUnavailable |
External (vendor outage) | No | No |
BranchQuotaExceeded |
External (infra cap) | No | No |
PortCollision |
External (local race) | No | No |
BudgetExceeded |
Mixed | No | After 2nd: Yes (consistent under-estimate signals miscalibration) |
OffPeakDeferralExceeded |
External (timing) | No | No |
AgentTimeout |
Intrinsic (task harder than estimated) | No | After 2nd: Yes |
MaxRetriesExhausted |
Intrinsic (persistent failure) | Yes | Yes |
MigrationConflict |
Intrinsic (schema entanglement) | Yes | Yes |
MigrationFailed |
Intrinsic (approach mismatch) | Yes | Yes |
EstimateVariance (persistent flag) |
Intrinsic (calibration drift) | Yes | Yes |
WorktreeOwnershipMismatch |
External (operator config) | No | No |
Counts are tracked per-issue in triage-history.jsonl. Reset to zero on successful merge.
Failure-event semantics (referenced in the table above; mostly inherited from RFC-0002 reconciliation semantics with this RFC's additions):
MergeConflict— git merge conflict during the merge gate's rebase step (§10.2).RebaseConflict—git rebase origin/<targetBranch>failed mid-stage (§10.2).CIFailure— CI checks reported failure on the PR (perfeedback_review_severity_policy.md).AgentTimeout— stage execution exceeded itstimeout(RFC-0002 §3) without producing a final artifact.MaxRetriesExhausted— RFC-0002onFailure: retryreachedmaxRetrieswithout success.BudgetExceeded,BranchQuotaExceeded,HarnessUnavailable,WorktreeOwnershipMismatch,OffPeakDeferralExceeded,MigrationConflict,MigrationFailed,EstimateVariance,PortCollision— defined in their respective sections (§14.x, §15.x, §13.x, §7.2, §8.2).
Every triage event (original + every re-score) is appended to $ARTIFACTS_DIR/<issue-id>/triage-history.jsonl:
{
"timestamp": "2026-04-26T14:32:11Z",
"trigger": "original" | "time-threshold" | "failure-type" | "operator-requeue",
"triggerDetail": "AgentTimeout x2",
"score": { "Sα": 0.72, "Dπ": 0.55, "Eρ": 0.40, "HC": 0.85, "composite": 0.61 },
"deltaFromPrevious": { "composite": -0.14, "narrative": "Eρ dropped: agent timeouts indicate harder than estimated" },
"model": "claude-haiku-4-5-20251001",
"costUsd": 0.0011
}The deltaFromPrevious field surfaces score drift over time, which feeds the calibration analysis in the burn-down report (§14.4) — persistent under-estimation of difficulty across many issues signals the PPA scoring rubric itself needs adjustment.
Re-triage Haiku calls accumulate against the same costBudget and SubscriptionLedger as any other stage (§11.5). At ~$0.001 per re-triage, the cost is rounding error for normal pipelines but MAY become significant on a pipeline experiencing pathological retry storms — the orchestrator MUST emit a RetriageStorm warning if any single issue triggers more than 10 re-triage events within a 24h window, signaling the failure is not actually being addressed.
When a re-score changes an issue's composite, the orchestrator MUST re-insert the issue into the queue at its new priority position, not its original. Operators MAY observe the queue order shifting underneath them; the Requeue event includes the old and new queue positions for debuggability.
The pipeline run holds a single mutex (<pool>/.merge-gate.lock). A stage with holdsMergeGate: true acquires the lock on entry, performs its work (rebase + merge eligibility check + push), and releases on exit.
Before holding the merge gate, the pipeline run MUST verify the PR's base commit equals origin/<targetBranch>@HEAD. If stale:
- Acquire the merge gate.
- Run
git fetch origin && git rebase origin/<targetBranch>inside the issue's worktree. - On clean rebase →
git push --force-with-lease origin <branch>and re-run the merge eligibility check. - On rebase conflict → emit a
RebaseConflictevent, transition the pipeline run toSuspended, release the gate. Operator intervention required.
Per the project governance constraint (feedback_never_merge_prs.md): the orchestrator MUST NOT execute gh pr merge. The merge gate's purpose is to ensure that when the human reviewer clicks merge, the PR is in a known-mergeable state, not to perform the merge itself.
At pipeline-load (not stage entry), the orchestrator resolves every stage's alias to a physical model ID in this order:
- Explicit
Stage.model(if notinherit). Pipeline.spec.defaultModel.- The orchestrator's hardcoded default (
sonnet).
The resolved physical IDs are written to $ARTIFACTS_DIR/_pipeline/runtime.json and pinned for the lifetime of the pipeline run. Stages dispatched within the run use the pinned ID, regardless of registry changes between dispatch events. Per-issue runtime.json (§16.1) records the same resolution for the issue's stages.
Why pin at pipeline-load. Resolving on every stage entry would let the model swap underneath a running pipeline if the registry is updated mid-run (e.g., maintainer pushes a model-deprecation update). Pinning eliminates within-run inconsistency. New pipeline runs (started after the registry change) pick up the new resolution; in-flight runs complete on the model they started with.
Mid-stage model changes are NOT supported.
| Alias | Resolves to | deprecatedAt |
removedAt |
Use case |
|---|---|---|---|---|
haiku |
claude-haiku-4-5-20251001 |
null | null | Classification, routing, formatting, structured-output extraction |
sonnet |
claude-sonnet-4-6 |
null | null | Code review, refactoring, validation, default for everything else |
opus |
claude-opus-4-7 |
null | null | Complex implementation, multi-file refactors, design work |
opus[1m] |
claude-opus-4-7[1m] |
null | null | Implementation against a large codebase context (>200K tokens) |
Aliases are resolved at pipeline-load against a registry file (orchestrator/src/models/registry.ts). The two date columns drive the deprecation lifecycle defined in §11.6. Maintainer responsibility: keep both dates current as vendors publish deprecation announcements.
Non-normative guidance the reference implementation SHOULD ship as defaults. Harness defaults to claude-code unless cross-harness independence adds review value or cost arbitrage is meaningful:
| Stage | Model | Harness | Rationale |
|---|---|---|---|
triage (PPA scoring) |
haiku |
claude-code |
Pattern matching against fixed scoring rubric |
review-classify |
haiku |
claude-code |
Diff-shape classification |
plan |
sonnet |
claude-code |
Default-balance, uses our skills/MCP tools |
implement |
opus[1m] |
claude-code |
Large-context multi-file edits, full skill ecosystem |
validate |
sonnet |
claude-code |
Test execution + log interpretation |
review-testing |
sonnet |
claude-code |
Same harness as implementer is acceptable for testing review |
review-critic |
sonnet |
codex |
Cross-harness review — different model family catches different bugs |
review-security |
sonnet |
codex |
Cross-harness review — independence is a security property |
fix-pr |
sonnet |
claude-code |
Most fix actions are mechanical |
simplify |
sonnet |
claude-code |
Localized refactor |
Per-stage cost reporting MUST include the resolved model ID, not the alias. The cost-governance ledger (orchestrator/src/cost-governance.ts) gains a modelId column; aggregation queries SHOULD support grouping by alias, model ID, and stage. This is a strict superset of RFC-0004's existing reporting and is therefore backward compatible.
The orchestrator enforces three independent caps with distinct scopes and units. Confusing them is a common source of operator surprise; this section pins them down.
| Cap | Defined in | Unit | Scope | Aggregation across parallel agents |
|---|---|---|---|---|
Pipeline.spec.costBudget |
RFC-0004 | USD (or pipeline currency) | Pipeline (all parallel agents) | Shared — sum across agents MUST NOT exceed |
SubscriptionPlan.windowQuotaTokens |
§6.6 | tokens, multiplier-adjusted | Per-harness, per-window | Per-harness — agents on the same harness share; agents on different harnesses are independent |
Stage.maxBudgetUsd |
§6.3 | USD per single stage invocation | Per-stage, per-invocation | Independent — circuit breaker, never aggregated |
Admission rule. A stage admits only if ALL of:
- Adding its
estimatedTokens × resolvedModel.unitCostto current pipeline-shared spend keeps total ≤costBudget. - The stage's harness has subscription headroom per
ledger.admit()(§14.2), OR the harness ispay-per-token(no quota). - The stage's
estimatedTokens × resolvedModel.unitCost≤ its ownmaxBudgetUsd(if declared).
Failure of any check blocks admission. The orchestrator emits a structured AdmissionDenied event naming which cap blocked, so operators can tell "blocked on dollars" from "blocked on subscription window" from "blocked on per-stage circuit breaker."
Why three caps, not one. Dollar budgets are a Finance concern (don't overspend the month). Subscription windows are a vendor-imposed reality (don't exhaust Claude Code's 5h quota). Per-stage circuit breakers are a runaway-protection concern (kill a stage that 10×'d its estimate). Each has a different audience, a different unit, and a different correct response when breached. Conflating them produces confusing operator behavior — operators set "$50/day" and then can't understand why dispatch blocks when only $12 has been spent (answer: subscription window exhausted, unrelated to dollars).
The model registry tracks each entry's deprecatedAt: Date | null and removedAt: Date | null. Resolution behavior at pipeline-load:
| Registry state at load time | Behavior |
|---|---|
| Both null (active) | Resolve normally. No warning. |
deprecatedAt < now, removedAt > now (or null) |
Resolve normally. Emit ModelDeprecated warning naming removedAt and the recommended replacement alias. |
removedAt < now (removed) |
Pipeline-load FAILS with ModelRemoved. Operator MUST update the pipeline YAML, the registry, or both before the run can start. |
deprecatedAt < now < deprecatedAt + 30 days (grace period) |
Resolve normally + emit warning, AND emit a one-time ModelDeprecationGracePeriod event to the pipeline's primary observability channel (e.g., Slack per project_slack_integration.md) so the operator sees it even if they don't read pipeline-load logs. |
Replacement recommendation. When a registry entry is deprecatedAt, it MUST also declare replacementAlias: string pointing at the alias operators should bump to. The warning message includes this so the operator knows the migration target without consulting docs.
Operator workflow. The reference implementation SHOULD ship cli-model-bump:
$ cli-model-bump --dry-run
Pipeline `dogfood`:
Stage `triage` (model: haiku) currently resolves to claude-haiku-4-5-20251001
DEPRECATED 2026-08-01, REMOVED 2027-02-01
Replacement: claude-haiku-5-0-20270115 (via alias `haiku`)
Run without --dry-run to start a new pipeline run that picks up the replacement.
The dry-run exists so operators can preview a model swap in a non-production pipeline before letting it land in their default flow. There is intentionally no in-place model-swap on a running pipeline — pinning at pipeline-load is the safety property; bypassing it would defeat the purpose.
Registry maintenance. Maintainers update deprecatedAt / removedAt / replacementAlias in orchestrator/src/models/registry.ts from public vendor announcements. A periodic registry-freshness check (Phase 4 observability) compares the registry's listed dates against vendor public-docs URLs and warns on stale entries (default: warn if any non-null deprecatedAt is more than 60 days old without removedAt being scheduled).
Mid-flight protection. Because resolution is pinned at pipeline-load (§11.1), a pipeline run that started before a model was deprecated continues to use the deprecated physical ID until completion. The vendor's actual API may continue to accept the deprecated ID for a vendor-defined sunset window after removedAt — the orchestrator will not emit new requests against a removed alias for new runs, but in-flight runs use what they pinned. If the vendor returns a hard error mid-run, the request fails per the harness's normal error handling (§13.5 fallback chain).
This RFC introduces two new stage kinds (declared via the kind field added in §6.3):
review-classifier— Consumes the PR diff (viagh pr diff) and emits a structured decision document at$ARTIFACTS_DIR/<issue-id>/review/classifier.json.review-fanout— Readsclassifier.jsonand dispatches the selected reviewer agents in parallel.
A pipeline that omits the classifier and uses ordinary review stages preserves today's behavior.
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["reviewers", "rationale", "confident", "confidence"],
"properties": {
"reviewers": {
"type": "array",
"items": { "enum": ["testing", "critic", "security"] },
"uniqueItems": true
},
"rationale": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Map of reviewer name to one-sentence reason for inclusion or exclusion."
},
"confident": {
"type": "boolean",
"description": "Drives dispatch. When false, the orchestrator falls open to the full reviewer set regardless of `reviewers`."
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Informational self-assessment. Fed to calibration analysis at $ARTIFACTS_DIR/_classifier/calibration.jsonl. NOT consulted by the dispatcher."
},
"modelOverride": {
"type": "object",
"additionalProperties": {
"enum": ["haiku", "sonnet", "opus", "opus[1m]"]
},
"description": "Optional per-reviewer model bump (e.g., security-flagged diff escalates security-reviewer to opus)."
},
"harnessOverride": {
"type": "object",
"additionalProperties": {
"enum": ["claude-code", "codex", "gemini-cli", "opencode", "aider", "generic-api"]
},
"description": "Optional per-reviewer harness override (e.g., a security-flagged diff routes security-reviewer to a specific harness for cross-harness independence per §13.6 / §13.10)."
}
},
"allOf": [
{
"description": "Consistency: confident: true REQUIRES confidence >= 0.7. Validation failure triggers fall-open.",
"if": { "properties": { "confident": { "const": true } } },
"then": { "properties": { "confidence": { "minimum": 0.7 } } }
}
]
}The reference implementation SHOULD ship a Haiku classifier with these baseline rules (operators MAY override):
| Diff signal | Default reviewer set |
|---|---|
Only *.md, docs/** |
[critic] |
| Only formatting / whitespace | [] (skip review entirely; classifier MAY emit empty set) |
Touches src/**/*.{ts,js,py} without tests |
[testing, critic] |
Touches src/**/*.{ts,js,py} with tests |
[testing, critic] |
Touches auth/**, crypto/**, secrets, config that affects auth |
[testing, critic, security] |
Touches package.json / requirements.txt / lockfiles |
[security, critic] (supply-chain) |
Touches CI workflows (.github/workflows/**) |
[security, critic] |
| Default fallback | [testing, critic, security] (today's behavior) |
Fall-open triggers. The orchestrator MUST dispatch the full reviewer set ([testing, critic, security]) regardless of reviewers when ANY of:
- JSON parse error in classifier output.
- Schema validation failure (missing required field, type mismatch, or
confident: truewithconfidence < 0.7). confident: false.- Classifier invocation failed (timeout, harness unavailable, exhausted fallback chain).
Failing open is a non-negotiable safety property — the cost of an unneeded reviewer run is far less than the cost of a missed security finding.
Calibration log. Every classifier output (whether trusted or fallen-open) is appended to $ARTIFACTS_DIR/_classifier/calibration.jsonl:
{
"timestamp": "2026-04-26T14:32:11Z",
"issueId": "AISDLC-247",
"diffStats": { "filesChanged": 3, "linesAdded": 47, "linesRemoved": 12, "paths": ["src/auth/", "src/auth.test.ts"] },
"classifierOutput": { "reviewers": ["testing", "critic", "security"], "confident": true, "confidence": 0.91, "rationale": {...} },
"fellOpen": false,
"fellOpenReason": null,
"humanOverrideAfterMerge": null
}The humanOverrideAfterMerge field is back-filled later if a human reviewer added a missing reviewer to the PR after merge or flagged a finding that the skipped reviewer would have caught — the operator runs cli-classifier-feedback <pr-number> --add-reviewer security --reason "missed XSS" to attribute the miss back to a classifier output. This is the calibration ground truth.
Why both confident and confidence. The dispatch decision is binary — either run security review or don't. But the float confidence lets us audit calibration: if the classifier consistently outputs confident: true at confidence: 0.72 for PRs that turn out to need additional review (per humanOverrideAfterMerge), the prompt is overconfident and the operator MAY tighten the consistency threshold (e.g., require confidence ≥ 0.85 for confident: true) or rewrite the prompt. Without the float we cannot detect this.
The merge-readiness rules from feedback_review_severity_policy.md (merge on APPROVE with suggestions; only block on REQUEST_CHANGES with critical/major) MUST continue to apply unchanged. A reduced reviewer set means fewer reviewers can post REQUEST_CHANGES; it does not change how the orchestrator interprets what they post.
classifier.json is part of the per-issue artifact directory and is preserved alongside the review outputs. Operators can audit why a given PR ran a reduced reviewer set and tune the classifier rules accordingly. This feeds the self-healing layer described in feedback_observability.md.
Every harness adapter MUST implement the following TypeScript interface (declared at orchestrator/src/harness/types.ts):
interface HarnessAdapter {
readonly name: string; // 'claude-code', 'codex', etc.
readonly capabilities: HarnessCapabilities;
readonly requires: HarnessRequires; // §13.8 binary + version range
// Validate at pipeline-load time, before any execution.
validate(stage: ResolvedStage): ValidationResult;
// Execute one stage end-to-end. Streams progress via onEvent.
invoke(input: HarnessInput, onEvent: (e: HarnessEvent) => void): Promise<HarnessResult>;
// List models the harness can drive (after env-var introspection).
availableModels(): Promise<string[]>;
// Cheap liveness probe used by the fallback chain.
// Combines binary presence + version-range check + adapter-specific health probe.
// Result MAY be cached for the orchestrator's lifetime; operator restart picks up
// a freshly-installed binary.
isAvailable(): Promise<HarnessAvailability>;
// Stable identifier for the credential / account in scope. Used as the
// SubscriptionLedger key so two pipelines on the same vendor account auto-pool.
// MUST be a one-way derivation (e.g., SHA-256 of the API key + harness name)
// and MUST NOT leak the credential itself. Returns null when the harness
// cannot derive an account identity (e.g., generic-api with no auth scheme),
// in which case the orchestrator emits LedgerKeyAmbiguous and degrades to
// per-pipeline ledger keying.
getAccountId(): Promise<string | null>;
}
interface HarnessCapabilities {
freshContext: boolean; // Can spawn a clean session per invocation
customTools: boolean; // Supports MCP tools / custom tool definitions
streaming: boolean; // Emits incremental output (for heartbeats)
worktreeAwareCwd: boolean; // Honors a per-invocation cwd
skills: boolean; // Supports loadable skills/system prompts
artifactWrites: boolean; // Can write files to $ARTIFACTS_DIR mid-stage
maxContextTokens: number; // Largest context window across this harness's models
}
interface HarnessRequires {
binary: string; // Executable name resolved against PATH ('claude', 'codex', etc.)
versionRange: string; // semver range ('>=2.0.0'). Open-ended upper bound by default.
versionProbe: {
args: string[]; // e.g., ['--version']
parse: (stdout: string) => string; // extracts a semver-shaped version string
};
}
interface HarnessAvailability {
available: boolean;
reason?: 'binary-missing' | 'version-out-of-range' | 'probe-failed' | 'health-check-failed';
detail?: string; // operator-facing message naming installed version + required range
installedVersion?: string;
}
interface HarnessInput {
prompt: string;
cwd: string; // Worktree path
model: string; // Resolved model ID (not alias)
port?: number; // Allocated dev-server port
artifactsDir: string;
tools?: ToolDefinition[];
skills?: string[];
timeout?: string; // ISO 8601 duration
maxBudgetUsd?: number;
}
interface HarnessResult {
status: 'success' | 'failure' | 'timeout' | 'budget-exceeded' | 'unavailable';
exitCode: number;
costUsd: number;
inputTokens: number;
outputTokens: number;
artifactPaths: string[]; // Files the harness wrote
errorDetail?: string;
}The orchestrator maintains a registry at orchestrator/src/harness/registry.ts:
const HARNESSES = new Map<string, HarnessAdapter>([
['claude-code', new ClaudeCodeAdapter()],
['codex', new CodexAdapter()],
// 'gemini-cli', 'opencode', 'aider', 'generic-api' — registered when adapters land
]);Pipeline-load MUST fail with UnknownHarness if a stage names a harness not present in the registry. This is fail-fast by design — silent fallback to a different harness would obscure operator intent.
The reference implementation ships these two adapters at v1; the matrix below is the starting baseline and MUST be kept current as adapters evolve.
| Capability | claude-code |
codex |
gemini-cli (future) |
opencode (future) |
aider (future) |
generic-api (future) |
|---|---|---|---|---|---|---|
| freshContext | ✅ | ✅ | ✅ | ✅ | ✅ | |
| customTools (MCP) | ✅ | ❌ | ✅ | ❌ | ❌ | |
| streaming | ✅ | ✅ | ✅ | ✅ | ✅ | depends |
| worktreeAwareCwd | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| skills | ✅ | ❌ | ❌ | ❌ | ❌ | |
| artifactWrites | ✅ | ✅ | ✅ | ✅ | ✅ | |
| maxContextTokens | 1M (Opus) | 200K (GPT-5) | 2M (Gemini) | varies | varies | depends |
The matrix is normative: stage validation (§13.4) checks declared requirements against this table.
At pipeline-load time, for each stage:
- Resolve
harnessandharnessFallbackagainst the registry. - Call
adapter.isAvailable()for the primary and every fallback. This runs the version probe (§13.8). Ifavailable: falsefor the primary, pipeline-load FAILS withHarnessUnavailablenaming thereasonanddetail(e.g., "claude 1.8.2 installed, adapter requires >=2.0.0"). Fallbacks that report unavailable are removed from the chain with a warning, not a hard fail (pipeline can still start with a degraded fallback chain). - Resolve
modelagainst the harness'savailableModels(). - Verify the stage's declared requirements (e.g.,
requires: [skills, customTools]on the Stage object) are satisfied by the harness's staticcapabilities. - Verify the same for every remaining fallback harness — a fallback that cannot satisfy the stage's requirements MUST be removed from the chain (warning), not abort pipeline-load.
A failed validation on the primary harness is a pipeline-load error. The pipeline does not start. Failures on fallbacks degrade the fallback chain but allow the pipeline to start, with a warning naming each disabled fallback.
Why static capabilities are authoritative. The probe at step 2 confirms the binary's version is in range; it does not re-derive the capability matrix. Capabilities are whatever the adapter source declares for that version range. This gives operators a single, deterministic source of truth (the adapter source) for "what does this harness do," without parsing fragile CLI feature flags. The probe's job is only to confirm the installed binary is the one the static declaration was written against.
When a stage executes:
- Probe
primary.isAvailable(). If true, attemptinvoke(). - On
result.status === 'unavailable'OR rate-limit error during invocation, the orchestrator MUST log aHarnessFallbackevent and proceed to the next harness inharnessFallback. - If all fallbacks fail, apply the stage's
onFailurepolicy. - A successful fallback MUST be recorded in
$ARTIFACTS_DIR/<issue-id>/runtime.jsonso the operator can audit which harness actually ran.
Fallback MUST NOT trigger on stage content failures (the agent ran but produced a wrong answer). Fallback is exclusively for availability failures (harness CLI missing, API down, rate limit, capability mismatch detected at runtime). Distinguishing these is the adapter's responsibility — adapters MUST map provider errors into the HarnessResult.status taxonomy correctly.
The recommended routing in §11.3 places review-critic and review-security on codex while leaving implement on claude-code. This is deliberate: the review's value comes partly from being performed by a different model family than the one that wrote the code. The pattern generalizes:
- Code written by Claude (via
claude-code) → reviewed by GPT-5 (viacodex) - Code written by GPT-5 (via
codex) → reviewed by Claude (viaclaude-code)
Operators MAY configure the inverse routing (e.g., for clients with only OpenAI credentials available) by amending the pipeline's defaultHarness and the relevant stage harness fields. The classifier (§12) MAY emit harnessOverride in addition to modelOverride if a specific PR shape benefits from a specific reviewer harness.
Preserving independence under fallback. The recommended routing assumes the primary harness for implement is available. If claude-code is rate-limited and implement falls back to codex, the cross-harness independence property silently degrades — both implement and review-security would now run on codex. To prevent this degradation, the reference pipeline declares requiresIndependentHarnessFrom: [implement] on review-critic and review-security. The orchestrator then enforces the independence constraint per §13.10 — if no harness in the review stages' chain preserves independence after implement fell back, the orchestrator emits IndependenceViolated and applies the stage's onFailure policy. Security-critical pipelines SHOULD set onFailure: abort for these stages; advisory pipelines MAY set continue.
Adapters live in-tree at orchestrator/src/harness/adapters/. Third-party adapters are NOT supported in v1 — every adapter is reviewed by the maintainers because it executes external CLIs with full credentials in scope. A stage's credentials (RFC-0002 §3 credentials field) MUST be made available to the chosen harness without exposure to other harnesses in the fallback chain. Each adapter is responsible for scrubbing its own environment before invocation.
Each adapter declares the upstream binary it depends on and the version range it has been written against:
// orchestrator/src/harness/adapters/claude-code.ts
readonly requires: HarnessRequires = {
binary: 'claude',
versionRange: '>=2.0.0', // open-ended upper bound by default
versionProbe: {
args: ['--version'],
parse: (stdout) => stdout.match(/(\d+\.\d+\.\d+)/)?.[1] ?? '',
},
};Default policy: open-ended upper bounds. Adapters SHOULD declare >=X.Y.Z rather than >=X.Y.Z <X+1.0.0. We assume forward-compatibility unless a specific upstream version is known to break compat. This avoids the "pinned upper bound forces operators onto downgrades after every minor upstream release" trap.
When to pin an upper bound. Only when a specific upstream version is verified incompatible (e.g., Claude Code 3.0 ships a breaking CLI flag change that our adapter has not been updated for). The pin is removed in the same patch that updates the adapter for compat.
Probe semantics (called from isAvailable()):
| Outcome | Returned HarnessAvailability |
Validation impact |
|---|---|---|
| Binary not on PATH | { available: false, reason: 'binary-missing' } |
Pipeline-load fails for primary; fallback removed with warning. |
| Binary present, version in range | { available: true, installedVersion } |
Validation proceeds. |
| Binary present, version below range | { available: false, reason: 'version-out-of-range', detail: 'claude 1.8.2 installed, adapter requires >=2.0.0; run npm install -g @anthropic-ai/claude-code@latest' } |
Pipeline-load fails for primary; fallback removed with warning. |
| Binary present, version above pinned upper bound (rare) | { available: false, reason: 'version-out-of-range', detail: 'claude 3.0.1 installed, adapter pinned to <3.0.0; adapter update required' } |
Same as above. |
Probe parsing failed (e.g., --version output format changed) |
{ available: true, reason: 'probe-failed', detail: 'could not parse claude --version output: ...' } |
Validation proceeds with HarnessProbeFailed warning. |
| Adapter-specific health check failed (e.g., API token missing) | { available: false, reason: 'health-check-failed', detail } |
Pipeline-load fails. |
Why parse failures fall through to "available." Vendor --version output format is undocumented and can change without notice. Treating parse failure as "unavailable" would break every pipeline the moment a vendor rewords their version banner. The warning surfaces the issue to the maintainer (registry-freshness telemetry, §11.6) without disrupting operators.
Probe caching. isAvailable() MAY cache its result for the orchestrator's lifetime. Binaries on the PATH do not change underneath a running process. Operators upgrading a CLI MUST restart the orchestrator to pick up the new version. The cached result is invalidated on orchestrator restart.
Probe budget. Adapters MUST complete the version probe within 5 seconds. Slower probes are treated as probe-failed. This protects pipeline-load latency on hosts with degraded filesystems or slow CLIs.
Every adapter MUST produce schema-conformant JSON artifacts per §16.4. Concretely:
- The adapter's invocation prompt for any artifact-producing stage MUST include the JSON Schema for that artifact (read from
spec/schemas/artifacts/<name>.schema.json) inline in the prompt, with explicit instruction to emit a conformant JSON file at the documented path. - The adapter MUST validate the produced JSON against the schema before returning
HarnessResult.status = 'success'. - On first-attempt validation failure: the adapter MUST retry once with a sharpened prompt that includes the validator error message and re-emphasizes the schema requirement.
- On retry failure: the adapter MUST set
HarnessResult.status = 'failure'witherrorDetailnaming the schema validation error, and the orchestrator emitsArtifactSchemaInvalid.
This is the cross-harness contract that lets downstream stages consume any harness's output uniformly. Adapter authors who skip the validation step are introducing a silent-corruption hazard that invalidates the cross-harness review pattern (§13.6) — review never integrates without it.
When a stage declares requiresIndependentHarnessFrom: [stageA, stageB, ...], the orchestrator MUST filter the stage's effective harness chain to preserve independence from those upstream stages.
Algorithm (at dispatch time):
- Read each named upstream stage's
runtime.jsonto determine the harness that actually ran (which MAY differ from the declaredharnessif a fallback was used). - Build the forbidden set:
forbidden = { resolved harness for each named upstream stage }. - Build the candidate chain:
candidates = [stage.harness] + stage.harnessFallback. - Filter:
effective = candidates.filter(h => !forbidden.includes(h)). - If
effectiveis non-empty: dispatch usingeffectiveas the new harness chain. Record both the original chain and the filtered chain in this stage'sruntime.jsonfor audit. - If
effectiveis empty: emitIndependenceViolatedevent naming the forbidden set and the empty chain. Apply the stage'sonFailurepolicy.
Default onFailure for IndependenceViolated. When the stage does not declare onFailure, the orchestrator treats IndependenceViolated as continue (advisory) for the dogfood pipeline. Security-critical pipelines SHOULD declare onFailure: abort on stages where independence is load-bearing — IndependenceViolated then halts the pipeline with the operator deciding whether to wait for fallback availability or accept the degraded property.
Observability. IndependenceViolated events are surfaced in _events.jsonl (§17) and the Slack integration regardless of onFailure policy. Operators MUST be informed when independence is compromised even if the pipeline was configured to continue. Without this signal, "review caught nothing" could silently mean "review caught nothing because reviewer was the same harness as implementer."
Cyclic guard. If requiresIndependentHarnessFrom references a downstream stage (creating a cycle), pipeline-load FAILS with CyclicIndependenceConstraint. Validation runs at load time, not dispatch.
Example (from the reference pipeline):
- name: implement
harness: claude-code
harnessFallback: [codex]
- name: review-security
harness: codex
harnessFallback: [claude-code]
requiresIndependentHarnessFrom: [implement]
onFailure:
on: IndependenceViolated
strategy: continue # advisory; operator overrides for security-critical pipelinesIf implement runs on claude-code (no fallback): review-security candidates = [codex, claude-code], filtered = [codex]. Dispatch on codex. Independence preserved.
If implement falls back to codex: review-security candidates = [codex, claude-code], filtered = [claude-code]. Dispatch on claude-code. Independence preserved (different harness from implementer).
If both claude-code and codex are unavailable: implement falls back through its chain (or the pipeline aborts per its onFailure). The independence question becomes moot because the pipeline isn't progressing.
Maximize:
Σ(PPA_score(issue) × completion_indicator(issue))
over all issues in queue
subject to:
Σ(estimated_tokens(stage) / window_multiplier(time)) ≤ window_quota
for every billing window in the planning horizon
In plain language: process as many high-PPA issues as possible per subscription window, exploiting off-peak multipliers where they exist, without exceeding hard caps. This is a bounded knapsack problem solved on every dispatch decision.
interface SubscriptionLedger {
// Current state for a harness, summed across all SubscriptionPlans.
windowState(harness: string): WindowState;
// Can a stage with these estimated tokens be dispatched now without
// exceeding the hardCap? Returns 'yes' | 'wait-until-T' | 'no'.
admit(harness: string, estimatedTokens: TokenEstimate): AdmissionDecision;
// Record observed consumption from a completed stage invocation.
// Updates rolling estimates and pacing projections.
record(harness: string, actual: TokenUsage, when: Date): void;
// Is now within an off-peak window for this harness?
isOffPeak(harness: string, when?: Date): boolean;
// When does the next off-peak window start, if any?
nextOffPeakStart(harness: string): Date | null;
// Projected utilization at window-end given current pace + queued work.
projectedUtilization(harness: string): number; // [0, ∞)
}
interface WindowState {
windowStart: Date;
windowEnd: Date;
consumedTokens: number;
quotaTokens: number;
multiplier: number; // 1.0 on-peak, 2.0 off-peak (Claude Code)
utilizationFraction: number; // consumed / quota
pacingTarget: number;
hardCap: number;
}The ledger persists state at $ARTIFACTS_DIR/_ledger/<harness>-<accountIdShort>-<tenant>.json (where accountIdShort is the first 8 chars of getAccountId()'s hash and tenant is the resolved tenant or __default__, per the keying defined in §14.12) so it survives orchestrator restarts within a window. One file per ledger key.
When dispatching a queued issue's stage:
- Resolve the stage's effective harness (§13).
- Consult
ledger.admit(harness, estimatedTokens). - Apply the stage's
schedule:
schedule |
Behavior |
|---|---|
now |
Dispatch if admit returns yes; if wait-until-T < 60s wait; if no apply onFailure. |
off-peak |
If isOffPeak(harness), dispatch as now. Else compute nextOffPeakStart; if within Pipeline.spec.offPeakMaxWait (default PT8H), defer; else dispatch on-peak with a OffPeakDeferralExceeded warning. |
quota-permitting |
Dispatch only if projectedUtilization(harness) + this stage's tokens ≤ hardCap. Else requeue with backoff. |
defer-if-low-priority |
Dispatch immediately if PPA score is in the top quartile of the queue OR the window has > 30% headroom. Otherwise behave as off-peak. |
Every 5 minutes (configurable), the orchestrator MUST emit a BurnDownReport event for each harness:
{
"harness": "claude-code",
"ledgerKey": { "harness": "claude-code", "accountId": "a3f2c891", "tenant": "__default__" },
"windowEnd": "2026-04-26T22:30:00-07:00",
"subscriptionTokensConsumed": 412000,
"quotaTokens": 1000000,
"subscriptionUtilizationFraction": 0.412,
"dollarsSpent": 0.00,
"shadowCostUsd": 11.40,
"pacingTarget": 0.85,
"projectedUtilization": 0.71,
"queueDepth": 4,
"recommendation": "under-pacing — consider promoting deferred work"
}Field semantics per §14.10: subscriptionTokensConsumed is within-quota work (zero dollar impact), dollarsSpent is pay-per-token spillover that decremented costBudget, shadowCostUsd is the informational "what would this have cost on the API" value for ROI reporting, subscriptionUtilizationFraction is the headline metric for tier-fit decisions.
Three recommendation categories:
under-pacing: projectedUtilization < pacingTarget − 0.10 → emit hint to promotedefer-if-low-priorityandoff-peakwork even if outside off-peak window.on-pace: pacingTarget − 0.10 ≤ projectedUtilization ≤ pacingTarget + 0.05 → no action.over-pacing: projectedUtilization > pacingTarget + 0.05 → emit hint to deferdefer-if-low-priorityandquota-permittingwork; do not dispatch new such stages.
The SubscriptionPlan.spec.offPeak.schedule is operator-declared based on the vendor's documented off-peak hours (subject to change). The orchestrator MUST NOT assume off-peak hours from any other source. When the vendor publishes a quota-introspection API, this RFC's ledger interface is forward-compatible per §14.11: windowState() becomes a query against authoritative state.
Freshness signal. Operators declare offPeak.lastVerified: ISO 8601 date on each SubscriptionPlan. At pipeline-load:
| Condition | Behavior |
|---|---|
lastVerified absent |
Emit OffPeakScheduleStale warning naming the plan and recommending the operator verify against current vendor docs. Pipeline loads. |
lastVerified present, age ≤ 30 days |
No warning. |
lastVerified present, age > 30 days |
Emit OffPeakScheduleStale warning naming the plan and the verification age (e.g., "verified 47 days ago"). Pipeline loads. |
lastVerified present, age > 90 days |
OffPeakScheduleStale warning escalates to ERROR severity. Pipeline still loads, but the warning is highlighted in cli-status and surfaces immediately in Slack rather than the daily digest. |
Why advisory, not blocking. The cost of a stale schedule is bounded — burn-down projections become slightly off, off-peak deferrals dispatch into windows that may not actually be off-peak. None of this is a safety property. Blocking pipeline-load on a stale date would be alarm fatigue when the actual fix is a one-line YAML edit.
Surfacing. OffPeakScheduleStale events are part of the Slack daily-digest payload (project_slack_integration.md) so operators see them in their normal workflow. The cli-status --subscriptions view (§17) shows each plan's lastVerified age in a column, color-coded: green (≤30d), yellow (30–90d), red (>90d).
Reference plan maintenance. The reference SubscriptionPlan examples shipped at spec/examples/subscription-plans/*.yaml MUST include lastVerified populated from the date a maintainer last reviewed the example against vendor docs. The maintainer team SHOULD refresh these dates at least quarterly; CI MAY include a freshness check that fails if any reference example's lastVerified is older than 90 days.
The Stage.estimatedTokens field bootstraps the ledger's headroom math. After each stage execution, the orchestrator MUST update a per-stage rolling estimate (last 20 invocations, exponentially weighted) at $ARTIFACTS_DIR/_ledger/stage-estimates.json. Future dispatch decisions consult the rolling estimate, not the operator-declared bootstrap. Operators MAY override at any time by re-declaring the field; the override resets the rolling history.
When Stage.estimatedTokens is omitted, the orchestrator uses a flat default and self-calibrates from the first run:
| Source | Used when | Lifetime |
|---|---|---|
Stage.estimatedTokens (operator-declared) |
Field is present in pipeline YAML | Until rolling estimate has 1+ samples; then rolling supersedes UNLESS operator-declared value is treated as a frozen lock (see below) |
Cold-start default { input: 50000, output: 10000 } |
Field absent AND no rolling history exists | Single use; replaced by rolling estimate after first execution |
| Rolling estimate (last 20 invocations, exp-weighted) | Field absent AND rolling history has ≥1 sample | Until pipeline YAML adds estimatedTokens, which resets the rolling history |
At pipeline-load, every stage with absent estimatedTokens AND no rolling history emits MissingEstimate warning naming the stage and the default about to be applied. Pipeline loads regardless — the default is good enough for first-run admission decisions on most stages.
On the second dispatch of any stage that ran first with the cold-start default, BEFORE the admission check, the orchestrator MUST emit a one-time EstimateBootstrapped event:
{
"timestamp": "2026-04-26T16:00:00Z",
"stage": "implement",
"issueId": "AISDLC-247",
"coldStartDefault": { "input": 50000, "output": 10000 },
"firstRunActual": { "input": 184320, "output": 41200 },
"newRollingEstimate": { "input": 184320, "output": 41200 },
"divergenceFromDefault": { "inputRatio": 3.69, "outputRatio": 4.12 },
"interpretation": "first-run actuals 3.7× input default; future admission uses rolling estimate. Consider declaring `estimatedTokens` in pipeline YAML if first-run admission decisions matter."
}The event is also written as a header line to $ARTIFACTS_DIR/_ledger/stage-estimates.json so operators can see the bootstrap moment when reading the file later.
Some stages have bimodal consumption patterns where the rolling average produces bad admission decisions (e.g., 90% of fix-pr runs are tiny mechanical fixes; 10% are large refactors; the rolling mean undersizes the large case and oversizes the small one). Operators MAY freeze an estimate to opt out of rolling updates:
- name: fix-pr
estimatedTokens:
input: 200000
output: 30000
frozen: true # rolling estimates do NOT supersede this valueWhen frozen: true, the rolling estimate is still maintained for telemetry but the dispatcher uses the declared value for admission. This is rare — operators SHOULD only freeze after they have rolling-estimate data showing the rolling average is materially wrong.
The dogfood reference pipeline (spec/examples/pipelines/dogfood.yaml) declares estimatedTokens for every canonical stage so the cold-start default applies only to operator-authored novel stages. Reference values (subject to calibration after deployment):
| Stage | Input | Output | Notes |
|---|---|---|---|
triage |
8,000 | 2,000 | Haiku-sized; small prompt + small JSON output |
review-classify |
12,000 | 1,500 | Haiku; reads diff + emits classifier JSON |
plan |
80,000 | 8,000 | Sonnet; reads codebase context + emits plan |
implement |
250,000 | 40,000 | Opus 1M; multi-file edits |
validate |
60,000 | 6,000 | Sonnet; reads test output |
review-testing |
90,000 | 8,000 | Sonnet |
review-critic |
90,000 | 8,000 | Sonnet, cross-harness |
review-security |
90,000 | 8,000 | Sonnet, cross-harness |
fix-pr |
120,000 | 15,000 | Mechanical fixes; bimodal — operator MAY freeze |
simplify |
70,000 | 8,000 | Sonnet; localized refactor |
Non-normative guidance the reference implementation SHOULD ship as defaults:
| Stage | schedule |
Rationale |
|---|---|---|
triage (PPA scoring) |
now |
Cheap, blocks queue ordering |
review-classify |
now |
Cheap, gates the review fan-out |
plan |
now |
Blocks downstream implementation |
implement |
quota-permitting |
Most expensive stage; burst when window allows |
validate |
now |
Tightly coupled to implement |
review-* |
defer-if-low-priority |
High-PPA PRs get reviewed immediately; low-PPA can wait for off-peak |
simplify (cleanup pass) |
off-peak |
Always cost-flexible; pure off-peak workload |
fix-pr |
now for critical, quota-permitting otherwise |
Bug-fix urgency varies |
PPA score (RFC-0008) and schedule are orthogonal but composable:
- The dispatch queue is PPA-ordered.
- Within a single dispatch decision,
scheduleconstrains when a stage can run. - A high-PPA stage with
schedule: nowalways wins over a low-PPA stage withschedule: nowfor the next available slot. - A high-PPA stage with
schedule: nowblocked onhardCapdoes NOT cede priority to a lower-PPA stage that would fit — the orchestrator emits aQuotaContentionevent so the operator can decide whether to upgrade the subscription tier or wait. - Off-peak dispatch reorders: during off-peak,
defer-if-low-priorityandoff-peakstages from the back half of the PPA queue MAY dispatch ahead of mid-queuenowstages if the latter's harness is at hardCap.
When the same model is available on multiple harnesses with different SubscriptionPlans (e.g., Claude on Claude Code session-window AND on Anthropic API pay-per-token), the orchestrator routes to the harness with:
- Available headroom in current window (session-window plans only).
- Off-peak multiplier currently active (preferred).
- Lower projected unit cost given remaining quota.
This creates a soft preference for "free" subscription capacity over paid API capacity, with automatic spillover when subscription is exhausted.
The relationship between subscription quota and dollar spend is not 1:1, and operators routinely confuse the two. This subsection pins down the model.
Subscription work is pre-paid. A claude-code-max-5x subscription has been billed at month-start. Every token consumed within the window's quota costs $0 at the moment of consumption. The dollar cost was sunk when the subscription was purchased; the marginal cost of the next token is zero until the window's windowQuotaTokens is exhausted.
Spillover work is pay-per-token. When a stage cannot be admitted on a subscription harness (window exhausted, hardCap reached, harness unavailable), the orchestrator falls over per §13.5 to the next harness in harnessFallback. If that harness is pay-per-token (e.g., Anthropic API direct, OpenRouter), the stage's tokens DO incur dollar cost at the model's per-token rate.
Implication for costBudget accounting:
| Work type | Decrements costBudget? |
Decrements subscription window? |
|---|---|---|
| Stage on subscription harness, within window quota | No (sunk cost) | Yes |
| Stage on subscription harness, hardCap reached → spills to pay-per-token | Yes | No (the spillover stage runs on pay-per-token, not on subscription) |
Stage on pay-per-token harness directly |
Yes | N/A |
| Stage on subscription harness in off-peak window | No | Yes, but at 1 / multiplier rate (a 100K-token call consumes 50K of quota when multiplier: 2.0) |
Shadow cost reporting. For analytics and "would this have cost $X on the API" comparisons, the cost-attribution ledger (§11.4) MAY record a shadowCostUsd per stage that reflects what the work would have cost on pay-per-token. This value is informational ONLY — it does NOT decrement costBudget. Operators use it to evaluate "am I getting more value from this subscription than I would from pay-per-token?" — the implicit ROI metric on the subscription itself.
Why costBudget does not include shadow cost. A pipeline with costBudget: $50/day should not block when shadow cost crosses $50 if all the work was done on a paid-up subscription. That would defeat the entire point of paying for the subscription. The dollar budget enforces against actual incremental spend the operator will see on a credit-card statement, not against accounting fictions.
When subscription quota maps directly to dollars. Only at the spillover threshold. When the orchestrator is about to dispatch a stage and the projected windowQuotaTokens consumption would push utilization past hardCap, the dispatcher computes the spillover cost (the same work routed to pay-per-token) and treats THAT as the value to check against costBudget. If spillover would breach costBudget, the stage waits for the next window rather than spilling. This couples the two caps cleanly: subscription work is free (within quota), spillover work is dollar-budgeted, and the operator never silently overspends.
Reporting view. The burn-down report (§14.4) MUST distinguish:
subscriptionTokensConsumed— within-quota work (no dollar impact).dollarsSpent— pay-per-token work that decrementedcostBudget.shadowCostUsd— informational; sum of (subscription tokens × pay-per-token rate) for the same window.subscriptionUtilizationFraction— how much of the pre-paid subscription was actually used.
The fourth metric is the headline number for "is my subscription paying for itself?" and is the most actionable for tier-upgrade decisions.
The SubscriptionLedger (§14.2) tracks window state from one of three sources, declared per-plan via SubscriptionPlan.spec.quotaSource:
| Value | Behavior |
|---|---|
self-tracked (default) |
Ledger maintains its own running tally from observed token consumption. Used today because no vendor exposes a quota-introspection API. Estimate accuracy depends on the calibration loop (§14.6). |
authoritative-api |
Ledger queries the vendor API on every admission decision. If the API is unavailable, admission FAILS CLOSED — no dispatch until the API responds. Strict but expensive (one extra round-trip per admission). |
authoritative-with-fallback |
Ledger prefers authoritative API; on API unavailable, falls back to self-tracked state with a LedgerSourceFallback warning. Soft mode for production migrations where availability matters more than strict accuracy. |
Pinning. quotaSource is read at pipeline-load and pinned for the run's lifetime, like model resolution (§11.1) and harness chains. Switching mid-run is not supported — operators restart the pipeline to change sources.
First-switch reconciliation. When a pipeline starts with a quotaSource other than self-tracked AND the ledger has prior self-tracked state from a previous run on the same plan, the orchestrator MUST emit a one-time LedgerReconciliation event:
{
"timestamp": "2026-04-26T15:00:00Z",
"plan": "claude-code-max-5x",
"previousSource": "self-tracked",
"newSource": "authoritative-api",
"selfTrackedUtilization": 0.60,
"authoritativeUtilization": 0.85,
"absoluteDivergence": 0.25,
"relativeDivergence": 0.42,
"interpretation": "self-tracker was UNDER-estimating consumption by 25 points; future dispatch decisions tighten under authoritative source",
"recommendation": "review pre-migration burn-down reports — they may have over-reported headroom"
}The event is also written to $ARTIFACTS_DIR/_ledger/<plan>-reconciliation.jsonl for audit. Operators MUST review divergence after first switch — significant gaps (>10 points) signal the self-tracker was mis-calibrated and historical decisions warrant re-examination.
Backfill is out of scope. If/when an authoritative API exposes per-window historical state ("give me my consumption from 4h ago"), the orchestrator MAY backfill the ledger's history. v1 does not attempt this; the reconciliation event captures the moment-of-switch divergence only. Future enhancement.
Drift detection (soft prompt). When a plan with quotaSource: self-tracked runs against a vendor that has since shipped an authoritative API (orchestrator detects the API endpoint exists per harness adapter signal), the orchestrator MAY emit a QuotaSourceUpdateRecommended warning. This does not change behavior — operators control when to flip the opt-in. The signal exists so the soft-default doesn't become invisibly stale years after the API ships.
Why opt-in. Vendor APIs ship with their own bugs, rate limits, and consistency models. Silently flipping every operator's pipeline to the new source on first orchestrator startup after the API ships would be a behavior change underneath them — exactly the property §11.1 (model pinning) and Q5 resolution went out of their way to prevent. Operator-controlled opt-in respects the same invariant.
The SubscriptionLedger is keyed by (harness, accountId, tenant) so multiple pipelines correctly share or isolate quota based on what the vendor sees as one account.
Resolution:
| Component | Source | Behavior |
|---|---|---|
harness |
Stage's resolved harness (§13) | Different harnesses → different ledgers always (different vendors / different windows) |
accountId |
harnessAdapter.getAccountId() (§13.1) |
Auto-derived from credentials. Two pipelines using the same API key get the same accountId → same ledger. Different keys → different ledgers. When getAccountId() returns null, see "ambiguous" below. |
tenant |
Pipeline.spec.tenant |
Optional. When set, partitions the (harness, accountId) ledger into virtual sub-windows. When omitted, defaults to __default__ so all untenanted pipelines on the same account share. |
Auto-pooling example: Two pipelines dogfood and client-onboarding both run on the same operator's Anthropic API key. getAccountId() returns the same hash for both. Both omit Pipeline.spec.tenant. Ledger key is (claude-code, hash(key), __default__) — single shared ledger. Burn-down across both pipelines correctly reflects the shared window.
Auto-isolation example: Client A and Client B run on the same orchestrator host but with separate Anthropic accounts. Their API keys differ → getAccountId() returns different hashes → separate ledgers. Client A's runaway dispatch cannot affect Client B's headroom because the ledgers don't share state.
Tenant overlay example: A parent organization has one Anthropic account but wants to attribute spend to two internal teams (team-platform and team-product). Both pipelines declare:
# pipeline-platform.yaml
spec:
tenant: team-platform
tenantQuotaShare: 0.6
# pipeline-product.yaml
spec:
tenant: team-product
tenantQuotaShare: 0.4Each tenant gets a virtual sub-window of windowQuotaTokens × share. Team-platform sees 60% of the account's quota; team-product sees 40%. The vendor still sees one account; the orchestrator enforces internal partitioning. Ledger keys are (claude-code, hash(key), team-platform) and (claude-code, hash(key), team-product).
Validation at orchestrator startup:
- For each loaded pipeline, derive
(harness, accountId, tenant). - Group pipelines by
(harness, accountId). - For each group:
- If all pipelines omit
tenant→ single shared ledger keyed(harness, accountId, __default__). No share validation. - If any pipeline declares
tenant→ ALL pipelines in the group MUST declaretenantANDtenantQuotaShare. Sum of shares MUST equal 1.0 (±0.001 tolerance). Mixed declared/undeclared OR sum ≠ 1.0 → orchestrator startup FAILS withTenantShareInvalidnaming the conflicting pipelines.
- If all pipelines omit
Ambiguous accountId. When getAccountId() returns null (e.g., generic-api harness with an opaque token, or a credential scheme that doesn't expose stable identity):
- Orchestrator emits
LedgerKeyAmbiguouswarning at pipeline-load naming the harness and pipeline. - Ledger degrades to per-pipeline keying:
(harness, 'pipeline:' + pipeline.metadata.name, tenant). - This means two pipelines on the same actual vendor account but using an ambiguous-id harness will NOT auto-pool — operators get the per-pipeline isolation behavior of pre-§14.12 systems.
- Operators can recover correct pooling by declaring
Pipeline.spec.accountId: <stable identifier>to override the auto-derivation. This is rare; documented for completeness.
LedgerPooled audit event. When a pipeline loads and its (harness, accountId, tenant) matches an existing active ledger from another pipeline, the orchestrator MUST emit LedgerPooled event naming both pipelines. This is informational, not a warning — pooling is the correct behavior on shared accounts. The event lets operators spot-check that pooling matches their intent (i.e., they didn't accidentally share keys across truly separate concerns).
cli-status --subscriptions view. Groups rows by (harness, accountId) so operators see at a glance which pipelines pool. Each tenanted row shows its tenantQuotaShare and the share of the parent window currently consumed. Account IDs in the display are truncated hashes (first 8 chars) to avoid leaking credential identity in screenshots/logs while remaining useful for operator pattern-matching.
The orchestrator aggregates QuotaContention events per (harness, accountId, tenant) over each billing window and writes a TierAnalysis record weekly. The analysis surfaces "should you upgrade or downgrade your subscription tier" recommendations grounded in observed contention.
Aggregation cadence. Once per SubscriptionPlan.spec.windowDuration for session-window plans (typically every Monday morning for weekly review), or once per calendar month for monthly-cap plans. Records are written to $ARTIFACTS_DIR/_ledger/tier-analysis.jsonl (append-only).
TierAnalysis record structure:
{
"billingPeriod": "2026-W17",
"ledgerKey": { "harness": "claude-code", "accountId": "a3f2c891", "tenant": "__default__" },
"currentPlan": "claude-code-pro",
"currentPlanCostUsd": 20,
"contentionEvents": 47,
"cumulativeContentionDuration": "PT12H",
"issuesDeferredOffPeak": 14,
"issuesBlockedOnHardCap": 3,
"recommendedPlan": "claude-code-max-5x",
"recommendedPlanCostUsd": 100,
"projectedTimeSaved": "PT9H",
"projectedAdditionalIssuesProcessed": 11,
"projectedSpilloverSavingsUsd": 18,
"confidence": "high",
"reasoning": "Pro tier hit hardCap on 3 issues this week, totaling 12 hours of cumulative wait. At Max-5x, projected utilization stays below pacingTarget with current dispatch rate. Marginal cost +$80/week unlocks 11 additional issues processed plus $18/week reduced spillover-to-pay-per-token."
}Confidence buckets:
| Bucket | Trigger | Recommendation strength |
|---|---|---|
low |
<5 contention events in window | Suppressed from Slack digest; visible only via CLI |
medium |
5–20 contention events | Surface in digest IF recommendedPlan != currentPlan |
high |
>20 contention events | Surface in digest IF recommendedPlan != currentPlan |
Qualitative buckets (rather than continuous probability) avoid precision-theater on a recommendation derived from inherently noisy signals. Operators understand "high confidence based on 47 events" better than "0.83 confidence."
Slack digest suppression rules. A TierAnalysis is included in the daily digest only when:
confidence != 'low'(enough data to support a recommendation), ANDrecommendedPlan != currentPlan(there's an actual change to recommend).
Recommendations confirming the current plan are silenced — operators don't need a weekly "your tier is fine" message. The CLI command (below) shows them on demand.
CLI command. cli-tier-recommendation renders the most recent TierAnalysis for each ledger key. Flags:
--last <N>— show the last N records, not just the most recent.--details— include the per-event contention breakdown (which stages, which issues, what hour of day).--all-tenants— across all(harness, accountId, tenant)keys; default shows only the current pipeline's key.
Bidirectional recommendations. The analysis MAY recommend downgrading. When currentPlan: max-5x runs at <40% utilization for 4 consecutive billing periods AND confidence: high, the recommendation MAY be claude-code-pro with projectedSavingsUsd. The orchestrator does not bias toward upgrades — operators waste money in either direction. The Slack digest entry for downgrade recommendations uses softer language ("you may be over-provisioned") to avoid implying urgency.
Why not real-time alerts. Quota contention is rarely an emergency requiring same-day action. Real-time TierUpgradeRecommended events would train operators to ignore the channel. The weekly digest cadence matches typical subscription-review workflow. Operators who want sharper feedback can lower the bucketing thresholds via configuration in a future enhancement.
Plan corpus. Recommendations are drawn from the registered SubscriptionPlan resources available to the orchestrator. The reference implementation ships plans for claude-code-pro, claude-code-max-5x, claude-code-max-20x, codex-plus, codex-pro, and pay-per-token (per Phase 2.8 implementation plan). Operators with custom plans MUST register them as SubscriptionPlan resources for the recommender to consider them.
Every adapter MUST implement the following TypeScript interface (declared at orchestrator/src/database/types.ts):
interface DatabaseBranchAdapter {
readonly name: string; // 'neon', 'sqlite-copy', etc.
readonly capabilities: DatabaseBranchCapabilities;
// Validate at pipeline-load: credentials present, upstream reachable,
// no obvious misconfiguration.
validate(pool: ResolvedDatabaseBranchPool): Promise<ValidationResult>;
// Provision a new branch from the pool's upstream. Returns the
// connection string the agent should use.
allocate(pool: ResolvedDatabaseBranchPool, branchKey: string): Promise<DatabaseBranchHandle>;
// Destroy a branch and free its quota slot.
reclaim(handle: DatabaseBranchHandle): Promise<void>;
// List currently active branches in the upstream account, scoped to this pool.
// Used for stale-branch sweep on orchestrator startup.
list(pool: ResolvedDatabaseBranchPool): Promise<DatabaseBranchHandle[]>;
// Liveness probe.
isAvailable(pool: ResolvedDatabaseBranchPool): Promise<boolean>;
}
interface DatabaseBranchCapabilities {
branchCreationLatencyP50Ms: number; // 'fast' < 5000, 'slow' >= 60000
maxBranches: number; // adapter or vendor cap
supportsMigrations: boolean; // can adapter run migrations during allocate
supportsReadOnlyBranches: boolean; // can multiple worktrees share a read branch
supportsBranchFromBranch: boolean; // can branch from another branch (Neon: yes)
multiDatabase: boolean; // can pool serve more than one DB per branch
costModel: 'per-branch-storage' | 'per-snapshot' | 'free';
}
interface DatabaseBranchHandle {
branchKey: string; // stable key, derived from worktree branch name
connectionString: string; // URL the agent will use (NEVER logged in cleartext)
createdAt: Date;
upstream: string; // upstream branch this was derived from
upstreamCommitId?: string; // adapter-specific snapshot identifier
metadata: Record<string, string>; // adapter-specific (Neon: branch_id, project_id)
}Connection strings MUST be treated as secrets: never written to state.json, never appearing in event-stream messages, never logged outside the adapter. The orchestrator passes them only as agent-process env vars, never as command-line arguments.
The reference implementation ships these four adapters at v1:
| Capability | sqlite-copy |
neon |
pg-snapshot-restore |
external |
|---|---|---|---|---|
branchCreationLatencyP50Ms |
~50 | ~1500 | ~30000–180000 | declared |
maxBranches |
filesystem | 5000 (Neon free), more on paid | RDS-account quota | declared |
supportsMigrations |
✅ | ✅ | ✅ | declared |
supportsReadOnlyBranches |
✅ (hardlink) | ✅ | ❌ | declared |
supportsBranchFromBranch |
✅ | ✅ | ❌ | declared |
multiDatabase |
❌ (per-file) | ❌ (per-project) | ❌ | declared |
costModel |
free |
per-branch-storage |
per-snapshot |
declared |
| Recommended for | local dev, SQLite apps | Neon/Postgres production | RDS/vanilla Postgres | escape hatch |
sqlite-copy — copies the upstream .sqlite file to <worktree>/.ai-sdlc/db/<branchKey>.sqlite. Hardlinks for read-only branches. Reclamation deletes the file. No external dependencies.
neon — calls Neon's API to create a branch from the upstream branch. Uses NEON_API_TOKEN and projectId. Branch creation is ~1.5s P50; reclamation is immediate. Branches share storage with upstream until divergent writes occur (cheap).
pg-snapshot-restore — for vanilla Postgres or AWS RDS. Uses pg_dump + pg_restore (vanilla) or RDS snapshot/restore APIs (AWS). Creation latency 30s–3min; cost is full storage per branch. Recommended only when Neon/Supabase aren't available.
external — operator declares allocate.command and reclaim.command shell hooks that the orchestrator invokes. The hook is responsible for printing the connection string on stdout. Escape hatch for proprietary or custom branching mechanisms.
The external adapter executes operator-controlled shell commands with full credential scope; pipeline-load MUST require an explicit acknowledgeUntrusted: true field on the DatabaseBranchPool to opt into it.
Credentials are passed via DatabaseBranchPool.spec.credentials and are adapter-specific:
# neon
credentials:
apiTokenEnv: NEON_API_TOKEN
projectId: prj_abc123
# pg-snapshot-restore
credentials:
adminConnectionStringEnv: ADMIN_DATABASE_URL # privileged user with CREATEDB
storageVolume: /var/lib/postgresql/branches # where snapshots are restored
# sqlite-copy
credentials: {} # none required; upstream file path is read from upstream.connectionStringEnv
# external
credentials:
allocateCommand: './scripts/db-allocate-branch.sh'
reclaimCommand: './scripts/db-reclaim-branch.sh'
acknowledgeUntrusted: truePipeline-load MUST validate that all required credentials for the chosen adapter are present in the orchestrator's environment. Missing credentials are a load-time error, not a runtime error.
For each worktree allocation:
- The WorktreePool manager allocates the worktree (§7).
- For each
DatabaseBranchPoolreferenced inWorktreePool.spec.databaseBranchPools[]:- If
lifecycle.createOn: worktree-allocation: invokeadapter.allocate()immediately. - If
lifecycle.createOn: first-write-stage: defer until the first stage withdatabaseAccess: writeormigratedispatches.
- If
- If
migrations.runOnBranchCreate: true, afterallocate()returns, the orchestrator runs the migration command inside the worktree with the branch's connection string injected. Migration failure is fatal — the branch is reclaimed and the pipeline run aborts. - On stage dispatch, the agent's environment is augmented with the rewritten env vars per
injection. - On worktree reclamation (PR merge, abort, timeout), all branches associated with the worktree are reclaimed via
adapter.reclaim().
Stale-branch sweep runs on orchestrator startup: adapter.list() enumerates active branches; any with createdAt + branchTtl < now or lastActivity + abandonAfter < now are reclaimed.
When lifecycle.warmPoolSize > 0, the orchestrator maintains a pool of pre-allocated branches to amortize adapter.allocate() latency off the dispatch critical path. Behavior:
| Event | Action |
|---|---|
| Orchestrator startup | If count(active branches) < warmPoolSize, asynchronously allocate warmPoolSize - count warm branches. Tagged with pool: warm in branch metadata. |
| Stage requests allocation | If a warm branch is available: hand over (sub-100ms; rename pool: warm → pool: active); asynchronously allocate one replacement. If no warm branch: fall through to on-demand allocation (current behavior). |
| Branch reclaimed (PR merge / worktree reclaim / abort) | Branch is destroyed, NOT recycled into the warm pool. Next refill is a fresh allocation from upstream. |
Warm branch ages out (branchTtl or abandonAfter) |
Reclaim and asynchronously re-allocate to maintain pool size. |
lifecycle.maxConcurrent reached |
Warm-pool refills BLOCK until an active branch is reclaimed. Warm branches count against maxConcurrent along with active ones. |
| Migrations land on upstream main | Existing warm branches are stale (their schema is older). The orchestrator MUST mark them stale: true and drain-replace them as soon as their replacements have completed migration. Stage allocations during the drain prefer non-stale warm branches; if none available, fall through to on-demand allocation. |
Why no recycle. Reusing a reclaimed branch would require resetting its state (truncating tables, re-running fixtures), which is fragile and risks cross-pipeline contamination of test data. Allocating a fresh branch from upstream is simpler and the latency is hidden behind async refill anyway. Single-use branches preserve the §2.9 isolation property.
Slot accounting. A pool with maxConcurrent: 10 and warmPoolSize: 3 reserves 3 of 10 slots for warm branches. Effective active capacity is 7. Operators MUST size maxConcurrent accordingly when enabling a warm pool — sizing both the same starves dispatch.
Operator guidance. Default to warmPoolSize: 0 until you observe latency complaints. Symptoms that warrant flipping it on:
BranchAllocationLatencyevents showing P99 > 5s for >10% of allocations.- Operator reports of "first stage of every issue feels slow."
- Dispatch queue backed up despite available
parallelismslots.
Reasonable starting value: warmPoolSize: ceil(parallelism.maxConcurrent / 3). Tune up if dispatch latency stays elevated; tune down if BranchQuotaExceeded starts firing on the warm-pool refill path.
Observability. The cli-status --branches view (§17) MUST distinguish warm vs active branches in its output, with column headers for pool (warm | active) and refill state (steady | refilling | draining-stale).
When multiple parallel branches each add migrations, eventual merge ordering matters:
- Each branch's PR contains its own migration files.
- On PR merge to main, the migration is part of the merged code.
- The next branch allocated AFTER that merge runs migrations against an upstream that already has the new schema — no conflict.
- Branches allocated BEFORE the merge run against the upstream-at-allocation-time schema. Their tests pass, their PR merges, and the next branch picks up both migrations in order.
The hazard: if branch A and branch B both add a users.preferences column with different types, both PRs may pass review independently, and the second to merge will conflict at the migration step in main. This is a content conflict, not an infrastructure problem — the orchestrator's job is to surface it via the merge gate (§10.2 stale-base detection): if branch B's base is no longer at main HEAD, branch B rebases, the migration conflict surfaces, the orchestrator emits MigrationConflict, and the pipeline run suspends for operator review.
A separate hazard arises when branches are created from other in-flight branches (rather than from a stable upstream like dev):
- T0: PR-A is created. Branch-A allocated from
dev, runs migration-A. - T1: PR-B is created with
upstream.branchFrom: branch-A(chained). Branch-B inherits the migration-A schema. - T2: Operator abandons PR-A. Branch-A is reclaimed. Migration-A no longer exists in any merged code.
- T3: Branch-B is now running tests against a schema that includes migration-A — a migration that lives nowhere except in branch-B's own state. When PR-B eventually merges, main has neither migration-A nor whatever PR-B was building on top of it.
Topology guard (default). DatabaseBranchPool.spec.allowBranchFromBranch defaults to false. The adapter MUST refuse to allocate a branch whose upstream.branchFrom resolves to an in-flight feature branch. Pipeline-load fails with BranchTopologyForbidden naming the offending pool. This eliminates the divergence hazard at the source for ~95% of pipelines that have no need for branch-from-branch chains.
When opt-in is appropriate. Operators with genuine stacked-PR workflows (long-lived feature branches where multiple PRs build sequentially on each other) can set allowBranchFromBranch: true. The opt-in is friction by design — it forces operators to acknowledge the divergence risk before taking it on.
MigrationDiverged event. When allowBranchFromBranch: true AND a branch with active children is reclaimed, the orchestrator MUST emit MigrationDiverged for each child:
{
"timestamp": "2026-04-26T18:00:00Z",
"reclaimedBranch": { "branchKey": "feat-add-prefs-PR-A", "reason": "pr-abandoned", "issueId": "AISDLC-247" },
"divergentChildren": [
{ "branchKey": "feat-prefs-ui-PR-B", "issueId": "AISDLC-249", "lastActivity": "2026-04-26T17:55:00Z" },
{ "branchKey": "feat-prefs-api-PR-C", "issueId": "AISDLC-251", "lastActivity": "2026-04-26T16:30:00Z" }
],
"interpretation": "child branches inherit a migration that no longer exists in any merged code. Their PRs may pass tests but break on merge to main.",
"recommendation": "operator triage: rebase children onto current dev, accept divergence (children ship their own copy of the parent's migration), or reclaim children and start over."
}No automated action. The orchestrator does NOT auto-reclaim divergent children, auto-rebase, or auto-block them at the merge gate. Divergence is informational; the operator decides because each option has tradeoffs the orchestrator cannot judge:
- Auto-reclaim would destroy potentially-valid in-flight work without operator consent.
- Auto-rebase could surface migration conflicts that are content disputes between the operator and the abandoned-PR's author — neither resolvable by automation.
- Auto-block at merge gate delays the merge gate semantics from "is the base current" to "is the migration ancestry consistent" — adds complexity to the gate without clearly better outcomes.
Surfacing. MigrationDiverged events appear in _events.jsonl (§17), the Slack daily digest, and cli-status --branches shows a divergent: true flag on affected branches. cli-status --branches --divergent filters to divergent branches only for fast triage.
Why this resolution. The topology guard (default false) eliminates the problem for the common case where it doesn't need to exist. The advisory event handles the niche where operators consciously took on the complexity. Together they avoid both the "operators get bitten by an obscure failure mode" trap and the "we wrote complex auto-recovery logic that does the wrong thing in 30% of cases" trap.
The agent sees DATABASE_URL (or whatever the application expects) pointing at the branch. The mechanism:
- The orchestrator reads
upstream.connectionStringEnvfrom its own environment to discover the upstream connection string. This is done once at startup; the value is cached for the orchestrator's lifetime and never logged. - On stage dispatch, the orchestrator constructs the agent's environment as a copy of its own, then overlays:
injection.targetEnv= the branch's connection string fromDatabaseBranchHandle.- For each name in
injection.additionalEnvs(e.g.,PGHOST,PGDATABASE), parse the connection string and inject the corresponding component.
- The agent process is spawned with this environment. The agent never sees the upstream connection string — only the branch's.
- If a stage's
databaseAccess: none, no injection occurs; the agent inherits whatever the orchestrator's environment had, which by convention is a non-functional placeholder for stages that should not touch DB.
Multi-database support: when WorktreePool.spec.databaseBranchPools[] references multiple pools, each pool injects independently. A pipeline using primary-postgres and analytics-postgres injects both DATABASE_URL and ANALYTICS_DATABASE_URL via two pool definitions with distinct injection.targetEnv values.
Stage.databaseAccess (§6.3) drives provisioning decisions:
| Value | Behavior |
|---|---|
none |
No branch provisioned for this stage's invocation. Env vars not injected. Cheapest. |
read |
If the adapter supportsReadOnlyBranches, a single shared read-only branch MAY serve multiple worktrees. Connection string injected. |
write |
A per-worktree writable branch is required. Branch is provisioned (eagerly or lazily per lifecycle.createOn). |
migrate |
Same as write, plus the orchestrator MUST acquire the merge gate (§10) before stage execution to serialize schema changes against the upstream HEAD. |
A pipeline whose stages all declare databaseAccess: none does not need any DatabaseBranchPool — the resource is unused and the adapter is never invoked.
| Failure | Adapter behavior | Orchestrator behavior |
|---|---|---|
Adapter API unavailable on allocate() |
Throw AdapterUnavailable |
Apply stage's onFailure policy. No fallback chain for DB adapters in v1 — branching is too stateful for transparent failover. |
| Migration command fails | N/A (orchestrator runs it) | Reclaim the branch, abort the pipeline run with MigrationFailed. |
| Branch quota exhausted (vendor cap reached) | Throw BranchQuotaExceeded |
Run stale-branch sweep, retry once. If still exhausted, suspend the pipeline run and alert the operator. |
| Connection string leaks into logs | N/A (caller bug) | This is a P0 security incident. Adapters MUST mask credentials in any error returned. |
| Orchestrator crash mid-allocate | Branch may exist orphaned | Stale-branch sweep on next startup reclaims it. |
The orchestrator's own state lives in SQLite. Most client pipelines we have onboarded use Neon. Reference defaults the implementation SHOULD ship:
# Local dev (SQLite)
- name: orchestrator-state
adapter: sqlite-copy
upstream:
connectionStringEnv: ORCHESTRATOR_DB
injection:
targetEnv: ORCHESTRATOR_DB
lifecycle:
createOn: worktree-allocation
reclaimOn: worktree-reclaim
migrations:
runOnBranchCreate: true
migrationCommand: 'pnpm orchestrator:migrate'
# Production client (Neon)
- name: primary-postgres
adapter: neon
upstream:
connectionStringEnv: DATABASE_URL_DEV
branchFrom: dev
injection:
targetEnv: DATABASE_URL
lifecycle:
createOn: worktree-allocation
reclaimOn: pr-merge
maxConcurrent: 10
branchTtl: P14D
migrations:
runOnBranchCreate: true
migrationCommand: 'pnpm db:migrate'
credentials:
apiTokenEnv: NEON_API_TOKEN
projectId: prj_dogfoodPer-stage defaults:
| Stage | databaseAccess |
|---|---|
triage |
none |
review-classify |
none |
plan |
read (browse schema, don't write) |
implement |
write (most stages need to add migrations or seed data for tests) |
validate |
write (runs the test suite, which writes) |
review-* |
none (review reads code diffs, not DB) |
simplify |
read |
fix-pr |
write |
Every stage that produces a consumed-by-downstream artifact writes TWO files: a human-narrative .md and a schema-conformant .json. Operators read the markdown; downstream stages parse the JSON. See §16.4 for the schema contract.
$ARTIFACTS_DIR/<issue-id>/
├── runtime.json # ports, worktree path, resolved model, resolved harness (incl. fallback used), database branch keys (NOT connection strings), start time
├── plan.md # human-narrative
├── plan.json # schema-conformant; consumed by implement stage
├── implementation.md # human-narrative
├── implementation.json # schema-conformant; consumed by validate, review-classify, review-* stages
├── validation.md # human-narrative
├── validation.json # schema-conformant; consumed by review-* and merge-gate stages
├── review/
│ ├── classifier.json # output of review-classifier stage (§12); already schema-conformant by design
│ ├── testing.md
│ ├── testing.json # schema-conformant verdict + findings
│ ├── critic.md
│ ├── critic.json
│ ├── security.md
│ └── security.json
├── pr.json # PR number, URL, base SHA, head SHA at PR creation
└── state.json # current stage, started_at, last_heartbeat, status
Each parallel branch MUST update state.json at minimum every 60 seconds while a stage is executing. Stale state.json (no update for > 5 minutes) signals the operator that the agent has likely hung.
Pipeline reconciliation MAY resume an interrupted run by reading $ARTIFACTS_DIR/<issue-id>/state.json and re-entering at the recorded stage. Stages MUST be idempotent against artifact replay.
The contract between stages is the JSON file, not the markdown. Markdown is for operators; JSON is for the orchestrator and downstream stages. This separation lets each adapter produce harness-natural narrative for human consumption while guaranteeing a uniform machine-readable surface for automation.
Schema location. Each artifact type's JSON Schema lives at spec/schemas/artifacts/<artifact-name>.schema.json. v1 ships:
plan.schema.json— proposed approach, files-to-touch, test strategy, expected difficulty, open questionsimplementation.schema.json— files modified (with diff stats), commands run, tests added/changed, summary, deviations from planvalidation.schema.json— test results (counts + failure detail), build status, lint/type-check results, manual checks performedreview.schema.json— verdict (approve|approve-with-suggestions|request-changes), findings (severity, file, line range, description, suggestion), summarypr.schema.json— PR number, URL, base SHA, head SHA, branch name, title, draft status
Every JSON artifact MUST include a $schema field naming the schema URI it conforms to (e.g., "$schema": "spec/schemas/artifacts/implementation.schema.json#v1"). Schema versioning lets downstream stages declare which version they expect and gives us a forward-compat path when schemas evolve.
Adapter contract. Each HarnessAdapter (§13) MUST:
- Include the relevant schema in its invocation prompt (read from
spec/schemas/artifacts/) so the harness produces conformant JSON in one shot. - Write the JSON file atomically (write to
<file>.tmp, thenrename— partial writes MUST NOT be visible to downstream). - Validate the produced JSON against the schema before declaring stage success.
- On validation failure: retry once with a sharpened prompt that includes the schema-validator's error message. If the retry also fails, the stage fails with
ArtifactSchemaInvalidand the orchestrator applies the stage'sonFailurepolicy.
Markdown is unconstrained. Adapters MAY produce any markdown structure. Cross-harness review (Codex critiquing Claude's PR) reads the JSON, not the markdown. Operators reading both styles is acceptable variation; downstream automation reading both styles is not.
Schema evolution. When a schema needs a breaking change, ship <artifact>.schema.json#v2 alongside v1. Downstream stages consume whichever version they declare. Adapters MAY produce either; operators control the schema version per pipeline via Pipeline.spec.artifactSchemaVersion: v1 | v2 | latest. Default v1 until the migration window closes.
Audit trail. When an adapter's first attempt produces invalid JSON and the retry succeeds, the orchestrator MUST emit an ArtifactSchemaRetry event recording the validator error from the first attempt and the resolved JSON from the retry. Persistent retries on the same adapter signal a prompt-template bug; the event stream surfaces this for maintainer triage.
Implementations MUST expose:
- A read-only view of all active
$ARTIFACTS_DIR/*/state.jsonfiles (e.g., acli-statuscommand summarizing N parallel branches in one screen). - A structured event stream (
$ARTIFACTS_DIR/_events.jsonl) recording stage transitions across all parallel branches, suitable for tailing or piping to a future dashboard. - Per-branch terminal access. Operators MUST be able to attach to any single agent's session for debugging. The mechanism (tmux, screen, log tail) is implementation-defined.
- A real-time burn-down view for each harness's SubscriptionLedger (§14.4): current window utilization, projected end-of-window utilization, queue depth, and pacing recommendation. Implementations SHOULD ship a
cli-status --subscriptionsview that summarizes one row per harness. - Per-stage cost-vs-estimate variance reporting. When observed token consumption deviates from
estimatedTokensby > 50%, the orchestrator MUST log aEstimateVarianceevent. Persistent variance signals stale rolling estimates or a model/prompt change worth investigating. - A read-only view of all active database branches per pool: branch key, age, upstream, last-activity timestamp, owning worktree. Implementations SHOULD ship
cli-status --branchesfor this view. Branch connection strings MUST NEVER appear in any observability output — only branch keys and metadata.
The Slack integration described in project_slack_integration.md is a candidate consumer of _events.jsonl and the burn-down stream — operators get a daily "you used 73% of your subscription this week" message with a link to under-pacing recommendations.
- All new fields are optional. Pipelines that omit
parallelismexecute serially as today. Pipeline.spec.parallelism.maxConcurrentis now optional (was MUST in v1 of this RFC). Omitting it falls through to the tier-aware default (§9.1). Pipelines that explicitly setmaxConcurrentare unaffected.- Pipelines that declare
parallelismbut noSubscriptionPlanresolve tomaxConcurrent: 1, which is functionally equivalent to omittingparallelismentirely. This is intentional — opt-in to parallelism requires opt-in to a SubscriptionPlan, the same signal that says "I want subscription utilization to be maximized." - The RFC-0004
costBudgetsemantics are clarified, not changed: it has always been pipeline-scoped dollar-denominated; the v7 amendment makes explicit that subscription-harness work within quota does NOT decrement it (such work is pre-paid). Existing pipelines that setcostBudgetand ran on pay-per-token harnesses continue to behave identically. Pipelines that newly adopt subscription harnesses will see lowercostBudgetconsumption than they would under a naive interpretation — this is the correct behavior, but operators should be informed so they don't reducecostBudgetbelow the spillover headroom they need. - The default value of
Stage.isolationwhenparallelismis unset isinplace, preserving current behavior. - Existing reference implementations MUST continue to validate against the amended schema without modification.
WorktreePoolis a new resource; absence is equivalent to "no pooled execution available" and pipelines requestingparallelism > 1without a pool MUST fail validation with a clear error.- The new
Stage.modelfield defaults toinherit, which resolves toPipeline.spec.defaultModel, which defaults tosonnet— matching the current hardcoded behavior. Existing pipelines and agent skills that omitmodelcontinue to run on Sonnet. - The
kindfield defaults toagent, preserving today's stage semantics. The newreview-classifierandreview-fanoutkinds are only active when explicitly declared. RFC-0004cost-attribution amendment is additive: the newmodelIdcolumn on cost ledger entries does not break existing readers; existing aggregation queries continue to work and may be updated to group by it.- The new
Stage.harnessfield defaults toinherit, which resolves toPipeline.spec.defaultHarness, which defaults toclaude-code— matching the only harness used today. Existing pipelines and skills that omitharnesscontinue to run on Claude Code with no behavior change. - The HarnessAdapter interface is internal to the orchestrator. External-facing pipeline YAML only references harnesses by name; the adapter implementation can evolve without schema changes.
- The new
Stage.schedulefield defaults tonow, preserving today's "dispatch immediately" behavior. Pipelines that omitscheduleare unaffected by subscription-aware scheduling. SubscriptionPlanis a new resource; absence is equivalent tobillingMode: pay-per-tokenwith no quota — preserves today's behavior of "dispatch as fast as the orchestrator can manage."- The SubscriptionLedger is queried but not enforced when no
SubscriptionPlanreferences a harness. Operators opt in by declaring a plan; nothing forces them to. - The new
Stage.databaseAccessfield defaults tonone, preserving today's behavior of "agent inherits orchestrator's environment, no branch creation." Pipelines that omitdatabaseAccesscontinue to share a single DB exactly as today, which is correct forparallelism: 1runs. DatabaseBranchPoolis a new resource; absence is equivalent to "no DB isolation available." A pipeline that runs withparallelism > 1AND has any stage withdatabaseAccessother thannoneAND no DatabaseBranchPool MUST fail validation at pipeline-load withDatabaseIsolationRequired. This fails fast rather than silently corrupting shared state.- Existing
WorktreePooldeclarations withoutdatabaseBranchPools[]continue to validate. The field is optional; absence equals "no DB isolation infrastructure attached to this pool."
Phased delivery to land low-risk wins first.
- Implement deterministic port allocator as a standalone function in
orchestrator/src/runtime/port-allocator.tswith unit tests covering distribution and collision-probe behavior. - Implement worktree slug normalization + ownership verification in
orchestrator/src/runtime/worktree.tswith unit tests against a fixture repo. - Add JSON schema validation for the new
Pipeline.spec.parallelismandWorktreePoolresource.
- Implement
WorktreePoolManager(orchestrator/src/runtime/worktree-pool.ts): allocate, adopt, reclaim, with the cleanup-on-merge hook. - Wire the manager into
execute.tsbehind a feature flag (AI_SDLC_PARALLELISM=experimental). - Integration test: dispatch 3 issues against a fixture repo, verify isolated worktrees + distinct ports + clean reclamation.
- Add
Stage.model,Stage.kind,Stage.maxBudgetUsd, andPipeline.spec.defaultModelto the JSON schemas. - Implement the model-alias registry (
orchestrator/src/models/registry.ts) and resolution function with unit tests. - Update
ai-sdlc-plugin/agents/{code,test,security}-reviewer.mdfrontmatter to usemodel: inheritinstead of hardcodedsonnet. - Update the
triageskill to declaremodel: haiku. - Amend
orchestrator/src/cost-governance.tsto recordmodelIdper stage entry; update aggregation queries. - Implement
review-classifierstage kind with the default rules from §12.3. - Implement
review-fanoutstage kind that readsclassifier.jsonand dispatches selected reviewers in parallel. - Integration test: a docs-only PR runs only the critic reviewer; an auth-touching PR runs all three with security bumped to Opus.
Phase 2.7 — Harness adapter framework + Codex adapter (2 weeks, parallelizable with Phases 2 and 2.5)
- Implement
HarnessAdapterinterface, registry, and capability matrix atorchestrator/src/harness/{types.ts, registry.ts}. - Migrate today's hardcoded Claude Code invocation into a
ClaudeCodeAdapterimplementing the interface (no behavior change, just refactor — covered by existing tests). - Implement
CodexAdapterdriving the OpenAI Codex CLI; verify it can run against a fixture worktree end-to-end. - Add
Stage.harness,Stage.harnessFallback,Pipeline.spec.defaultHarness,Pipeline.spec.defaultHarnessFallbackto the JSON schemas. - Implement pipeline-load validation per §13.4 (unknown harness, model unavailable on harness, capability mismatch).
- Implement runtime fallback per §13.5; integration test that takes Claude Code "offline" via env var and verifies Codex picks up the stage.
- Update
review-criticandreview-securityskills to declareharness: codexper §11.3 recommended routing. - Integration test: end-to-end review where Claude implements and Codex critiques, verify both artifacts land in
$ARTIFACTS_DIR. - Document the adapter-authoring guide for future
gemini-cli/opencode/aider/generic-apiadapters; do NOT ship those adapters in v1.
- Implement
SubscriptionLedgerinterface and persistent state atorchestrator/src/scheduling/{ledger.ts, types.ts}with file-backed$ARTIFACTS_DIR/_ledger/storage. - Implement
SubscriptionPlanresource validation; ship reference plans forclaude-code-pro,claude-code-max-5x,claude-code-max-20x,codex-plus,codex-pro,pay-per-tokenatspec/examples/subscription-plans/. - Implement off-peak schedule evaluation with timezone-aware time-range matching. Unit tests against fixture clocks.
- Add
Stage.scheduleandStage.estimatedTokensto JSON schemas. - Implement schedule-aware dispatcher: queue ordering by PPA × schedule × ledger admission. Unit tests covering the four schedule modes.
- Implement burn-down report emitter (every 5 min, configurable) and
cli-status --subscriptionsview. - Implement rolling per-stage token-estimate calibration; tests for variance detection.
- Integration test: 20-issue queue with mixed PPA scores and schedule hints, fixture clock advancing through peak→off-peak transitions, verify off-peak issues defer correctly and burn-down recommendations match expected.
- Document operator runbook: how to declare a SubscriptionPlan, how to read burn-down reports, what to do when
under-pacingvsover-pacingrecommendations fire.
- Convert
execute.tsfrom single-issue to a worker-pool model bounded byparallelism.maxConcurrent. - Implement the file-based merge gate with stale-base detection and rebase-on-conflict.
- Integration test: 5 concurrent issues, deliberately overlapping touched files, verify all PRs land mergeable.
- Define the artifact directory schema and migrate existing review outputs to the new layout.
- Implement heartbeat writer in the agent harness.
- Add a
cli-status --allview that summarizes every active branch. - Emit
_events.jsonlfor the Slack integration to subscribe to in a follow-up.
- Chaos test: kill agents mid-stage, verify reclamation + resumability.
- Document operator runbook for
WorktreeOwnershipMismatch,RebaseConflict, and stuck heartbeats. - Promote the feature flag to default-on after one week of dogfood stability.
Sequenced after the merge gate (Phase 3) because the migrate access mode requires the merge gate to serialize schema changes against upstream HEAD.
- Implement
DatabaseBranchAdapterinterface, registry, capability matrix atorchestrator/src/database/{types.ts, registry.ts}. - Implement
SqliteCopyAdapter(~2 days). File copy on allocate, optional hardlink for read-only branches, delete on reclaim. Integration test with a fixture SQLite database and 3 parallel worktrees. - Implement
NeonAdapter(~5 days). Wraps Neon REST API for branch create/delete/list. UsesNEON_API_TOKEN. Integration test against a real Neon project (a sandbox project the dogfood pipeline owns). - Implement
PgSnapshotRestoreAdapter(~5 days).pg_dump+pg_restoreflow for vanilla Postgres;aws rds restore-db-instance-from-db-snapshotfor AWS RDS. Integration test against a local Postgres in CI. - Implement
ExternalAdapter(~1 day). Operator-declared shell hooks. Pipeline-load validation requiresacknowledgeUntrusted: true. - Add
Stage.databaseAccess,DatabaseBranchPoolresource,WorktreePool.spec.databaseBranchPools[]to JSON schemas. - Implement connection-string injection (env-var rewriting) in the agent dispatch path. Unit tests for parsing connection strings into component env vars.
- Implement migration coordination: run
migrationCommandagainst newly-allocated branch; surface failure asMigrationFailed; integration with merge gate fordatabaseAccess: migratestages. - Implement stale-branch sweep at orchestrator startup; respects
branchTtlandabandonAfter. - Implement
cli-status --branchesview. - Security review: confirm connection strings never appear in
state.json, event stream, logs, or error messages. Static-analysis check on adapter code for accidental log leaks. - Document operator runbook for
BranchQuotaExceeded,MigrationConflict,MigrationFailed, and orphan-branch cleanup. - Integration test: 5 parallel worktrees against Neon, each adding a different migration, verify isolation and that merged migrations apply cleanly to subsequent branches.
- Migrate the dogfood pipeline's orchestrator-state SQLite to use
sqlite-copyadapter; verify no regressions. - Implement warm pool per §15.4.1 (allocate-on-startup, async refill, single-use after reclaim, stale-drain on upstream migrations). Default
warmPoolSize: 0so opt-in only. Integration test exerciseswarmPoolSize: 3against a fixture Neon project, verifies sub-100ms dispatch latency and correct refill behavior. The path ships tested even if nobody enables it in production.
We could deploy Archon as our orchestrator instead of extending our own. Rejected because Archon is a standalone server that runs Claude Code as a subprocess; we are a Claude Code plugin and operate inside the harness, not outside it. The architectural mismatch is too costly to bridge. We borrow patterns instead.
Use Docker containers (one per issue) instead of git worktrees. Rejected for v1 because it adds a Docker dependency to the dogfood pipeline and an order-of-magnitude longer cold-start (image build, volume mount). Worktrees give us the isolation we need at near-zero startup cost. Container isolation may revisit if we move to multi-host execution.
Run a daemon that allocates ports on request. Rejected because the deterministic-hash function is simpler, requires no daemon lifecycle, and Archon's production usage validates the approach. The 6% collision probability at our cap is acceptable given the probe fallback.
Let all PRs push in parallel and let GitHub serialize via update-branch. Rejected because GitHub's update-branch uses merge commits, violating the project's rebase-only policy (feedback_rebase_not_merge.md). The explicit merge gate gives us rebase semantics under our own control.
Have the orchestrator choose the model per stage based on diff size, file types, and historical cost/quality signals. Rejected for v1 because we lack the cost/quality telemetry to train a useful selector. Declarative routing now generates the per-stage cost ledger that adaptive selection would later need; this is a deliberate sequencing decision, not a permanent rejection.
Replace the three reviewer agents with a single agent that does its own scoping. Rejected because losing the bias-isolation property of independent reviewers (each in a fresh context) erodes the review quality argument from the Archon transcript and from RFC-0008's review composite. The classifier-then-fanout pattern keeps reviewer independence intact while skipping unneeded ones.
Continue assuming Claude Code is the only harness, optimize for its specific capabilities, accept the lock-in. Rejected for the four risks enumerated in §2.7: vendor concentration, lost cost arbitrage, lost cross-harness review independence, and inability to route stages to the harness with the right strengths. The cost of the adapter framework (~2 weeks per Phase 2.7) is amortized across every future harness we add and every client deployment that needs harness flexibility for credential or vendor reasons.
Allow third parties to ship harness adapters as separately-installable plugins. Rejected for v1 because adapters execute external CLIs with full credential scope — running an unreviewed adapter is equivalent to running an unreviewed credential-stealing daemon. Maintainer-reviewed in-tree adapters only, until we have a meaningful sandboxing story (which is a non-trivial future RFC of its own).
Continue treating each call as pay-per-token and optimize for minimum cost per issue. Rejected because it ignores how clients actually pay. A pipeline optimized for unit cost will be tempted to skip stages, downgrade models, or reduce review coverage to "save money" — but those savings are imaginary when the subscription has already been paid and the saved tokens evaporate at window-end. Subscription utility is the correct objective function for the dominant billing mode (Claude Code Pro/Max).
Build the SubscriptionLedger as a standalone daemon that the orchestrator queries via RPC. Rejected for v1 because the ledger needs tight integration with stage dispatch, harness fallback, and queue ordering — RPC overhead and split-brain failure modes between two processes outweigh the modularity benefits at our current scale. The interface defined in §14.2 is RPC-friendly if we ever need to extract it.
What v4 of this RFC said: ship parallel execution now, ship DB isolation later. Rejected after CTO direction because every onboarded client is on Postgres and would be stuck at parallelism: 1 until RFC-0011 lands. The marginal cost of including DB isolation in this RFC (Phase 6, ~3 weeks, +400 lines of spec) is small compared to the value of shipping a complete parallel-execution story.
Instead of provisioning separate database branches, give each worktree a separate schema in a shared database. Rejected because: (a) it requires application code changes to use the right schema, breaking the "agent transparently uses DATABASE_URL" property; (b) shared connection pools can leak across schemas if pooling configuration is wrong; (c) it doesn't isolate database-level state like sequences, extensions, or settings; (d) reclamation requires DROP SCHEMA CASCADE which is destructive enough that bugs cause production incidents. Branches are cleaner.
Common pattern in test frameworks: each test runs in a transaction, no commits actually persist, the next test sees a clean DB. Rejected as the primary mechanism because: (a) it doesn't work for the agent's own iterative development (the agent inserts a row, queries it back, expects to see it across processes); (b) it doesn't isolate migrations; (c) it requires application/test cooperation we cannot assume across all client codebases. Branches don't require any application changes. Test-transaction-rollback remains a valid complement to branches inside a single agent's test suite.
All 16 open questions raised during the v1 draft and walkthrough (the original Q1 about per-worktree DB isolation, then Q1–Q15 in the post-v6 renumbering) have been resolved through normative additions in v5–v20. See the revision history (top of document) for the resolution of each.
The walkthrough that produced these resolutions is preserved as design rationale; the resolutions themselves are normative and live in the relevant sections (§9.1, §9.4, §11.6, §12.2/§12.3, §13.4/§13.8/§13.10, §14.5/§14.6/§14.10/§14.11/§14.12/§14.13, §15.4.1/§15.5.1).
New questions discovered during implementation MAY be added back to this section. A frozen list of "all resolved" is not a stable equilibrium for a living spec; new failure modes will surface during Phase 1–6 build-out and operator-trial deployment. Track them here as they emerge.
- Prior art:
coleam00/Archon(packages/git/src/worktree.ts,packages/core/src/utils/port-allocation.ts,packages/isolation/src/resolver.ts,.archon/workflows/defaults/archon-fix-github-issue.yaml). Specifically borrows the deterministic port-hash, the cross-clone ownership guard, and the artifact-directory convention. - Companion talk: "Parallel Agentic Development" by Cole Medin (2026). The accompanying
w.sh/.ps1worktree-setup scripts referenced in the talk are NOT in the Archon repo and were independently re-derived for this RFC. - Internal specs: RFC-0002 (Pipeline Orchestration), RFC-0004 (Cost Governance and Attribution), RFC-0008 (PPA Triad Integration).
- Internal code touched by this RFC:
orchestrator/src/execute.ts(single-issue → worker-pool migration; harness- and schedule-aware dispatch),orchestrator/src/cost-governance.ts(modelId + harnessId columns; ledger integration),orchestrator/src/review-runner.ts(classifier integration),orchestrator/src/harness/{types.ts, registry.ts, adapters/{claude-code,codex}.ts}(NEW — adapter framework),orchestrator/src/scheduling/{ledger.ts, types.ts, off-peak.ts}(NEW — SubscriptionLedger + scheduler),ai-sdlc-plugin/agents/{code,test,security}-reviewer.md(model: inherit; security & critic move toharness: codex),ai-sdlc-plugin/commands/triage.md(model: haiku),ai-sdlc-plugin/commands/review.md(classifier-aware fan-out, harness-aware dispatch),spec/examples/subscription-plans/*.yaml(NEW — reference plans for common tiers). - External billing documentation: Claude Code Pro/Max plan limits and off-peak schedule (
docs.claude.com/claude-code/billing— operator MUST verify against current docs when declaring SubscriptionPlans), OpenAI Codex Plus/Pro monthly cap details (platform.openai.com/docs/codex). The off-peak multiplier value (~2×) and exact window hours are subject to vendor change; SubscriptionPlans MUST be updated when vendor terms change. - External database-branching documentation: Neon branching API (
neon.tech/docs/manage/branches), Supabase branching (supabase.com/docs/guides/platform/branching), AWS RDS snapshot/restore (docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_CreateSnapshot.html). Adapter implementations MUST cite the upstream API version they target. - Database isolation code added:
orchestrator/src/database/{types.ts, registry.ts, adapters/{sqlite-copy,neon,pg-snapshot-restore,external}.ts}(NEW), connection-string injection in agent dispatch path,cli-status --branchescommand, stale-branch sweep on orchestrator startup,spec/examples/database-branch-pools/*.yaml(NEW — reference pool definitions for SQLite, Neon, RDS). - External harness documentation: Claude Code (
claude.com/claude-code), OpenAI Codex CLI (github.com/openai/codex), Gemini CLI (github.com/google-gemini/gemini-cli), OpenCode (github.com/sst/opencode), Aider (aider.chat), OpenRouter (openrouter.ai). Adapter implementations MUST cite the upstream version they target. - Prior art on multi-harness orchestration: Archon's
packages/providers/src/registry.ts(claude / codex / community providers) demonstrates the registry pattern at production scale. Theirpi-coding-agentprovider exposes ~20 LLMs through a single harness — useful inspiration for the futuregeneric-apiadapter. - Project memories incorporated:
feedback_observability.md(real-time visibility),feedback_never_merge_prs.md(merge gate ≠ auto-merge),feedback_rebase_not_merge.md(rejects GitHubupdate-branch),feedback_review_severity_policy.md(classifier preserves severity rules),project_dogfood_pipeline_vision.md(overall framing),project_slack_integration.md(event stream consumer).