Architecture decisions

Every record in adrs/ (PRD §14), computed — not hand-curated. A record that fails to parse is listed with its parse error rather than dropped. Requirement citations link to the traceability matrix; a citation naming a requirement not in requirements/index.md is marked ⚠ rather than silently linked.

10
records
10
accepted
0
proposed
0
superseded
0
parse errors

By status

Accepted (10)

IDTitleStatusDateDecided byWork itemRequirements
ADR-0001 Per-role credentials; only DevOps holds `workflow` scope accepted 2026-09-02 (reconstructed from PR #43's merge commit timestamp; not stated explicitly in the artefacts) human (issue #42), implemented in PR #43 Shashank2577/foundry-program#42 REQ-002, REQ-012
ADR-0002 Cost is the enforced budget ceiling; tokens are a reported, non-breaching tripwire accepted 2026-09-02 (reconstructed from PR #24's merge commit timestamp) human (issue #23), implemented in PR #24 Shashank2577/foundry-program#23 REQ-007, REQ-012
ADR-0003 The governance owner keeps `policies/**` but not other roles' packs accepted 2026-09-03 human operator, 2026-09-03 (per PR #102, "Two governance decisions, both settled by the human operator on 2026-09-03") Shashank2577/foundry-program#101 REQ-007, REQ-012
ADR-0004 The approval gate scopes to write capability, not to path mentions accepted 2026-09-03 human operator, 2026-09-03 (PR #102, decision 2) Shashank2577/foundry-program#101 REQ-007, REQ-012
ADR-0005 `qa:rejected` returns a story to dispatch eligibility rather than excluding it accepted 2026-09-02 (reconstructed from PR #81's merge commit timestamp) architect/developer session, implemented in PR #81, closing Shashank2577/foundry-program#75, #79 REQ-002, REQ-009
ADR-0006 Role packs are harness-neutral; the compiler writes rules it cannot express to `UNMAPPABLE.md` instead of dropping them accepted 2026-09-02 (reconstructed from PR #12's merge commit timestamp) architect/developer session, implemented in PR #12, closing Shashank2577/foundry-program#4 REQ-002
ADR-0007 Approval is proven by the identity of who applied the label, never by the label's presence accepted 2026-09-02 (reconstructed from PR #46's merge commit timestamp, `4ed0c48`, in git log — not stated in the PR body itself) architect/developer session, implemented in PR #46, closing Shashank2577/foundry-program#45 REQ-007, REQ-012
ADR-0008 Add security (veto) and operations (advisory) roles — scaffold proposed here, built by a developer story accepted 2026-09-22 Shashank2577 — accepted on the repository owner's instruction in an interactive Claude Code session (#223); both packs had already been built under a developer story (#238) and the veto's structural enforcement had landed (#237), so accepting records what is now true rather than proposing it Shashank2577/ai-sdlc#211 REQ-002, REQ-009
ADR-0009 A required check runs its enforcing script from `main`, never from the pull request's own merge ref accepted 2026-09-08 devops session, implemented in PR (this one), closing #225 Shashank2577/ai-sdlc#225 REQ-005, REQ-007, REQ-012
ADR-0010 The learning loop detects recurring corrections with fixed mechanical matchers over tracker records, and promotion is always a human-approved proposal, never a write accepted 2026-09-22 Shashank2577 — reviewed and accepted on the repository owner's instruction in an interactive Claude Code session; renumbered from ADR-0008 on rebase because ADR-0008 (security and operations roles) had landed first, then to ADR-0010 (#275) because ADR-0009 (required checks run from `main`) had landed first too Shashank2577/ai-sdlc#212 REQ-013

Chronological

IDTitleStatusDateDecided byWork itemRequirements
ADR-0001 Per-role credentials; only DevOps holds `workflow` scope accepted 2026-09-02 (reconstructed from PR #43's merge commit timestamp; not stated explicitly in the artefacts) human (issue #42), implemented in PR #43 Shashank2577/foundry-program#42 REQ-002, REQ-012
ADR-0002 Cost is the enforced budget ceiling; tokens are a reported, non-breaching tripwire accepted 2026-09-02 (reconstructed from PR #24's merge commit timestamp) human (issue #23), implemented in PR #24 Shashank2577/foundry-program#23 REQ-007, REQ-012
ADR-0005 `qa:rejected` returns a story to dispatch eligibility rather than excluding it accepted 2026-09-02 (reconstructed from PR #81's merge commit timestamp) architect/developer session, implemented in PR #81, closing Shashank2577/foundry-program#75, #79 REQ-002, REQ-009
ADR-0006 Role packs are harness-neutral; the compiler writes rules it cannot express to `UNMAPPABLE.md` instead of dropping them accepted 2026-09-02 (reconstructed from PR #12's merge commit timestamp) architect/developer session, implemented in PR #12, closing Shashank2577/foundry-program#4 REQ-002
ADR-0007 Approval is proven by the identity of who applied the label, never by the label's presence accepted 2026-09-02 (reconstructed from PR #46's merge commit timestamp, `4ed0c48`, in git log — not stated in the PR body itself) architect/developer session, implemented in PR #46, closing Shashank2577/foundry-program#45 REQ-007, REQ-012
ADR-0003 The governance owner keeps `policies/**` but not other roles' packs accepted 2026-09-03 human operator, 2026-09-03 (per PR #102, "Two governance decisions, both settled by the human operator on 2026-09-03") Shashank2577/foundry-program#101 REQ-007, REQ-012
ADR-0004 The approval gate scopes to write capability, not to path mentions accepted 2026-09-03 human operator, 2026-09-03 (PR #102, decision 2) Shashank2577/foundry-program#101 REQ-007, REQ-012
ADR-0009 A required check runs its enforcing script from `main`, never from the pull request's own merge ref accepted 2026-09-08 devops session, implemented in PR (this one), closing #225 Shashank2577/ai-sdlc#225 REQ-005, REQ-007, REQ-012
ADR-0008 Add security (veto) and operations (advisory) roles — scaffold proposed here, built by a developer story accepted 2026-09-22 Shashank2577 — accepted on the repository owner's instruction in an interactive Claude Code session (#223); both packs had already been built under a developer story (#238) and the veto's structural enforcement had landed (#237), so accepting records what is now true rather than proposing it Shashank2577/ai-sdlc#211 REQ-002, REQ-009
ADR-0010 The learning loop detects recurring corrections with fixed mechanical matchers over tracker records, and promotion is always a human-approved proposal, never a write accepted 2026-09-22 Shashank2577 — reviewed and accepted on the repository owner's instruction in an interactive Claude Code session; renumbered from ADR-0008 on rebase because ADR-0008 (security and operations roles) had landed first, then to ADR-0010 (#275) because ADR-0009 (required checks run from `main`) had landed first too Shashank2577/ai-sdlc#212 REQ-013

Records

ADR-0001: Per-role credentials; only DevOps holds `workflow` scope

accepted · date 2026-09-02 (reconstructed from PR #43's merge commit timestamp; not stated explicitly in the artefacts) · decided by human (issue #42), implemented in PR #43 · work item Shashank2577/foundry-program#42 · requirements REQ-002, REQ-012

Context

Issue #38 needed a push to `.github/workflows/**` and failed: the default `GITHUB_TOKEN` cannot write that path. The obvious fix — hand every dispatched session the one credential that can (`FOUNDRY_TOKEN`, carrying `repo` + `workflow` + `project`) — was rejected in #42 for two reasons: it widens every role's reach to unblock one, and it contradicts PRD §3, which names `.github/workflows/` and `infra/` as DevOps's write scope, not Developer's. At the time, `role-packs/developer/policy.yaml` allowed `.github/workflows/**` under the comment "DevOps-adjacent; cross-reviewed" — a rationalisation, not a boundary.

Options considered

Option A: One shared credential, scoped to the union of all roles' needs
Option B: Per-role credentials, declared in each pack

Recommendation

Recommend B: the credential boundary is the only one a required check cannot be edited around, because the check is exactly what a wider credential could edit.

Decision

Decided: Option B **Decided by:** human (issue #42), implemented in PR #43 **Date:** 2026-09-02 (reconstructed from PR #43's merge commit timestamp; not stated explicitly in the artefacts)

`.github/workflows/**` and `infra/**` denied to the developer pack; `FOUNDRY_DEVOPS_TOKEN` (`repo` + `workflow`) given only to `role-packs/devops/`. The PR that implemented this also relabelled 21 prior commits' provenance: under the corrected model, every past commit touching `.github/workflows/` should have carried `Agent-Role: devops`, not `developer`.

Consequences

Adding a role never means widening an existing role's token — a new `pack.yaml` just declares `identity.token_secret`, and the dispatcher reads it with no role→secret table to edit. A pack whose declared secret is not configured falls back to `GITHUB_TOKEN` with a loud warning rather than a silent grant. Any future pack that needs `.github/workflows/` or `infra/` write access is a decision on the scale of this one, not a policy-file edit.

ADR-0002: Cost is the enforced budget ceiling; tokens are a reported, non-breaching tripwire

accepted · date 2026-09-02 (reconstructed from PR #24's merge commit timestamp) · decided by human (issue #23), implemented in PR #24 · work item Shashank2577/foundry-program#23 · requirements REQ-007, REQ-012

Context

The repo's first live agent dispatch (run 33583977341, issue #22) cost $0.61 and was reported as a 1,414,300-token, 3.5x budget breach against a 400k-token ceiling. Of those tokens, 1,351,447 (96%) were cache reads — re-reads of context already paid for once. Fresh tokens were ~63k, well inside budget. The escalation ladder fired on an accounting artifact, not on overspend, and its own advice ("read the transcript before spending again") was unfollowable: the transcript is written to `$RUNNER_TEMP` and destroyed with the runner, so the run had zero artifacts to read.

Options considered

Option A: Keep tokens (including cache reads) as a hard breach line
Option B: Cost becomes the enforced ceiling; tokens are reported and flagged, never breach; cache reads excluded from the token count

Recommendation

Recommend B: cost is the number that is actually scarce here (a fixed dollar ceiling per work item); a token count that's 96% cache reads tells a reviewer nothing about spend.

Decision

Decided: Option B **Decided by:** human (issue #23), implemented in PR #24 **Date:** 2026-09-02 (reconstructed from PR #24's merge commit timestamp)

Replaying the same session's real numbers under the new rules: $0.61 of $5.00, 31 of 60 turns, 63k of 400k fresh tokens — no breach, correctly. The developer turn budget was also raised 30 -> 60 in the same change, on the evidence that 31 turns with no branch pushed was a session still reading code, not one looping.

Consequences

Every role pack's `policy.yaml` budget model (see `role-packs/architect/policy.yaml`) now states `cost_usd` as the breaching ceiling and `tokens` explicitly as a tripwire only ("reported, never a breach"). Any future budget dimension added to a pack should name, up front, whether it is enforced or advisory — this decision is the reason that distinction exists in the budget schema at all.

ADR-0003: The governance owner keeps `policies/**` but not other roles' packs

accepted · date 2026-09-03 · decided by human operator, 2026-09-03 (per PR #102, "Two governance decisions, both settled by the human operator on 2026-09-03") · work item Shashank2577/foundry-program#101 · requirements REQ-007, REQ-012

Context

`role-packs/delivery-lead/policy.yaml` allowed `write_scope` on both `policies/**` and `role-packs/**`. The second grant meant the governance pack could rewrite any other role's (including its own) budget, tool denials and write scope — which made two of its own `forbidden` entries, `granting_itself_more_scope` and `change_widens_another_roles_permissions`, prose rather than enforced scope. This shipped unnoticed (`d04c52f`) because the CI job that tests it, `approval-gate.yml`, was path-filtered to exclude `role-packs/**` — the exact directory the bug is about (tracked separately as #100).

Options considered

Option A: Keep `role-packs/**` as delivery-lead's write scope
Option B: Narrow to `role-packs/delivery-lead/**`; other packs change only via a human-merged PR
Option C: Remove `policies/**` from delivery-lead entirely

Recommendation

Recommend B: it keeps governance's reason to exist (an agent owner for the gates) while removing the one grant that made its own self-scoping rules unenforceable. C is the safer static state but undoes the pack's purpose; A is the status quo the bug report exists to fix.

Decision

Decided: Option B **Decided by:** human operator, 2026-09-03 (per PR #102, "Two governance decisions, both settled by the human operator on 2026-09-03") **Date:** 2026-09-03

`role-packs/delivery-lead/policy.yaml` write scope narrowed to `policies/**` and `role-packs/delivery-lead/**`, nothing wider — matching how `devops` is scoped to its own pack. The `policies/**` exception is kept deliberately, and is asserted safe under two conditions that are now tests rather than prose: every `role:delivery-lead` story is critical by its label alone (new `governance_role` gate rule, see ADR-0004), and the pack's `shell.allow` omits both merge and approve commands so it structurally cannot self-approve.

Consequences

Any future widening of `role-packs/delivery-lead/**` toward another pack's directory is, by this ADR, the kind of change that needs its own ADR and explicit human sign-off — not a policy tweak folded into an unrelated story. `scripts/test_gate_check.py` now asserts `test_no_pack_may_write_another_packs_directory` as the general form, so this specific failure mode cannot recur in a different pack without a test failing first. `approval-gate.yml`'s `pull_request.paths` filter was widened to include `role-packs/**` in the same change, so this class of bug running green and unnoticed cannot repeat undetected.

ADR-0004: The approval gate scopes to write capability, not to path mentions

accepted · date 2026-09-03 · decided by human operator, 2026-09-03 (PR #102, decision 2) · work item Shashank2577/foundry-program#101 · requirements REQ-007, REQ-012

Context

`policies/gates.yaml`'s critical-work rules (`touches_governance`, `changes_requirements`, etc.) matched on whether a story's text named a protected path, e.g. `.github/workflows/`. Measured across all 53 issues in the repo at the time, this held 41 (77%) for human approval. `touches_governance` alone fired on 50%, because this repo's stories routinely *name* `.github/workflows/` or `policies/` without proposing to change them — it is a workflow-and-policy repo, so those strings are everywhere. A `role:developer` story mentioning `.github/workflows/` was held even though the developer pack denies that path and its token lacks `workflow` scope (ADR-0001) — a gate on a change that cannot happen. The policy's own header had predicted this: "a gate on everything is a gate nobody reads — and a gate nobody reads is worse than no gate, since it launders whatever passes through it."

Options considered

Option A: Keep text/substring matching on protected path names
Option B: Scope each `guards_paths` rule to fire only when the story's role could actually write one of those paths, per that role's own `write_scope`

Recommendation

Recommend B: it keeps the gate meaningful without loosening what it actually protects — the residual holds are exactly the two roles (devops, delivery-lead) that PRD §13 says should always be held.

Decision

Decided: Option B **Decided by:** human operator, 2026-09-03 (PR #102, decision 2) **Date:** 2026-09-03

Rules carrying `guards_paths` now fire only when the story's role's own `write_scope` reaches one of those paths — the same boundary the credential model (ADR-0001) already uses, checked through the same code rather than a second, independently-drifting copy of it. Conservative-on-unknowns is explicit policy, not an incidental default: an unrefined or ambiguously labelled story is exactly when least is known, and scoping must not become a way past the gate for exactly that case.

Consequences

Any new `critical_when` rule added to `policies/gates.yaml` in future is expected to carry `guards_paths` and go through this same capability check, not a bare text match — a rule that skips it reintroduces the 77% noise problem this ADR exists to fix. `scripts/test_gate_check.py`'s `test_every_guarded_path_names_a_real_scope_pattern` and `test_a_pack_cannot_reach_its_own_gate_through_write_capability` are the enforcement; a change to `gates.yaml` that fails either is reverting this decision, not extending it.

ADR-0005: `qa:rejected` returns a story to dispatch eligibility rather than excluding it

accepted · date 2026-09-02 (reconstructed from PR #81's merge commit timestamp) · decided by architect/developer session, implemented in PR #81, closing · work item Shashank2577/foundry-program#75, #79 · requirements REQ-002, REQ-009

Context

`role-packs/orchestrator/policy.yaml` listed `qa:rejected` in `eligibility.exclude_labels`, so the loop would never dispatch a story carrying that label — a deadlock, since QA's own charter says "do not fix the code, reject with specifics; a developer session fixes it," but nothing could dispatch that developer session while the rejecting label was still attached. Issue #75 names its own origin honestly: "I wrote the exclusion and the comment justifying it... and never built the 'back through its author' half." Issue #52 was stuck in exactly this state: rejected, PR unmergeable, no path back through the loop. A second defect (#79) compounded it: even with `qa:rejected` removed from `exclude_labels`, QA dispatch itself used a fixed `eligibility.require_labels: [status:ready]` for every role, so QA — dispatchable from `status:in-review` per the dispatcher's own guard — could still never be *planned* by the loop, only run by hand.

Options considered

Option A: Keep `qa:rejected` in `exclude_labels`; build a separate re-dispatch path for rejected work
Option B: Drop `qa:rejected` from `exclude_labels`; fix eligibility to read each role's own `dispatchable_from`

Recommendation

Recommend B: the state gate (`status:ready`, human-applied per ADR-0004's approval model) already governs when a rejected story can be replanned; having the label do the same job too was redundant and, on top of it, the thing actually deadlocking #52.

Decision

Decided: Option B **Decided by:** architect/developer session, implemented in PR #81, closing #79 **Date:** 2026-09-02 (reconstructed from PR #81's merge commit timestamp)

PR #81 states this plainly as a judgment call beyond the issue's own Gherkin scenarios — none of which names `qa:rejected` directly: "This is an interpretive call... but it's what made `scripts/test_assign.py: test_qa_rejected_blocks` need inverting per the issue's own note, and it's consistent with the issue's `#52 -> missing status:ready` diagnostic (confirmed live, see Evidence)."

Consequences

`qa:rejected` now means exactly one thing — the PR cannot merge and the issue cannot close — enforced in two places (`scripts/qa-gate.sh`, `scripts/qa-verdict.sh`), not three. Eligibility for dispatch is read per-role from `dispatchable_from` rather than one repo-wide `require_labels` default, which is the mechanism any future role with a non-standard dispatch state (like QA's `status:in-review`) is expected to use instead of adding another special case to `scripts/assign.py`.

ADR-0006: Role packs are harness-neutral; the compiler writes rules it cannot express to `UNMAPPABLE.md` instead of dropping them

accepted · date 2026-09-02 (reconstructed from PR #12's merge commit timestamp) · decided by architect/developer session, implemented in PR #12, closing · work item Shashank2577/foundry-program#4 · requirements REQ-002

Context

PRD §3 requires role packs to be harness-neutral — one definition of a role's charter, skills, budget and tool scope, usable across whichever harness (claude-code, codex, deepseek, ...) actually runs a session. The first real pack (`role-packs/developer/`) needed something to turn that neutral definition into a harness's native config, and needed a stated answer for the case where a harness's config format cannot express a rule the neutral pack declares — most concretely, a tool-deny rule with an interior wildcard, which not every harness's allow/deny syntax supports.

Options considered

Option A: Write each role pack directly in every supported harness's native format
Option B: One neutral pack per role; a compiler renders it per harness, and a rule the target cannot express is written to `UNMAPPABLE.md`

Recommendation

Recommend B: a deny rule that silently fails to compile is worse than no rule at all, because the operator believes a control exists when it does not. `UNMAPPABLE.md` turns a silent gap into a visible one, at the cost of building and maintaining the compiler once, centrally, instead of drifting per-harness packs forever.

Decision

Decided: Option B **Decided by:** architect/developer session, implemented in PR #12, closing #4 **Date:** 2026-09-02 (reconstructed from PR #12's merge commit timestamp)

`compiler/compile-pack.py` renders a neutral pack for a target harness, rejecting a malformed one (missing required file, role/directory mismatch, unbudgeted role, dangling escalation template) rather than compiling it partially. A tool rule with, for example, an interior wildcard that the target harness's allow/deny syntax cannot express is written to `UNMAPPABLE.md` in the compiled output directory instead of being dropped.

Consequences

Every future harness target (codex's `compile_codex`, added later, is the first proof of this) is expected to plug into the same compiler and the same `UNMAPPABLE.md` convention rather than inventing its own silent-drop behaviour. Any pack change is validated once, centrally, by `compiler/test_compile_pack.py`'s `test_every_committed_pack_compiles`, so a pack that stops compiling for one harness fails CI before it reaches a dispatched session.

ADR-0007: Approval is proven by the identity of who applied the label, never by the label's presence

accepted · date 2026-09-02 (reconstructed from PR #46's merge commit timestamp, `4ed0c48`, in git log — not stated in the PR body itself) · decided by architect/developer session, implemented in PR #46, closing · work item Shashank2577/foundry-program#45 · requirements REQ-007, REQ-012

Context

`policies/gates.yaml`'s `dispatch_approval` gate needed a mechanical definition of "approved to run unsupervised" for critical work. A label alone (`status:ready`) is not proof of anything: any session with write access to issues can apply a label, including an agent session — so a gate that only checks for the label's presence is a gate an agent can satisfy for itself, which is not a gate.

Options considered

Option A: Treat `status:ready` as approval whenever present, regardless of who applied it
Option B: Enforce via the `issues: labeled` event payload's sender — dispatch proceeds only when a human account applied the label; an agent-applied label on a critical rule reverts the story and posts `needs-human`

Recommendation

Recommend B: it is the only option where "approved" is a fact the gate observes rather than a claim the gated thing can make about itself.

Decision

Decided: Option B **Decided by:** architect/developer session, implemented in PR #46, closing #45 **Date:** 2026-09-02 (reconstructed from PR #46's merge commit timestamp, `4ed0c48`, in git log — not stated in the PR body itself)

``` bot + critical -> HELD — reverted to status:needs-refinement, needs-human, comment names every rule bot + routine -> no action, no comment human + critical -> approved (a person applied it) ```

`is_human()` is a deny-list, not an allow-list, and an empty actor is treated as non-human — both deliberate, since erring toward "approved" on an unrecognised actor would defeat the gate.

Consequences

Any future approval-shaped gate in this repo (this ADR's mechanism is reused, not just precedent, by ADR-0003's `governance_role` rule and ADR-0004's capability scoping) is expected to check actor identity from the event payload, not label presence, or it inherits this exact forgeability gap. `scripts/test_gate_check.py` is where that expectation is checked in code.

ADR-0008: Add security (veto) and operations (advisory) roles — scaffold proposed here, built by a developer story

accepted · date 2026-09-22 · decided by Shashank2577 — accepted on the repository owner's instruction in an interactive Claude Code session (#223); both packs had already been built under a developer story (#238) and the veto's structural enforcement had landed (#237), so accepting records what is now true rather than proposing it · work item Shashank2577/ai-sdlc#211 · requirements REQ-002, REQ-009

Context

Eight role packs exist. Every merged PR is reviewed for correctness by QA and, for critical work, by a human governance gate. **Nothing reviews a change for security** — credential handling, injection surface, dependency risk, permission widening, secret exposure — even though this system holds credentials, mints tokens, runs arbitrary agent sessions on runners, and publishes a public site. Nothing owns what happens after a merge either: whether a deploy is safe to promote, whether a rollback plan is real, what a failure in `prod` means. `policies/environments.yaml` declares a rollback owner and a human-reviewer rule, but nobody's charter is to judge whether either is true for a specific change.

`awslabs/aidlc-workflows` names fourteen roles for a human-gated 33-stage flow, including `aidlc-devsecops-agent`, `aidlc-compliance-agent`, `aidlc-operations-agent`, and `aidlc-pipeline-deploy-agent`. This ADR credits that roster as the prior art for "security and operations should have named owners" without adopting its scale — see Option B.

This is a structural decision, not a code review: it sets a new role's credential tier, decides whether its write scope can be narrower than the roles it reviews, and — for security specifically — creates a verdict label the system is not yet wired to enforce. It also crosses the architect pack's own write scope: building the packs means writing `role-packs/security/**` and `role-packs/operations/**`, and registering them means writing `requirements/coverage.yaml`. `role-packs/architect/charter.md` is explicit that this pack does not write another role's pack, "not even the scaffold for the thing you just decided" — the scaffold belongs in this ADR's Consequences, built by a developer session dispatched against a work item that links back here. `role-packs/architect/pack.yaml` itself was built exactly that way, under `Agent-Role: developer` (commit 3fddd3e), as was `role-packs/delivery-lead/` (commit d04c52f, whose message states it followed the same pattern). This ADR follows that precedent rather than breaking it a third time.

Options considered

Option A: Do nothing — leave the gap as prose in this ADR
Option B: Adopt `awslabs/aidlc-workflows`'s roster wholesale (14 roles, including 4 that split this ADR's two concerns further)
Option C: Two new roles — `security` (binding veto, unenforced pending a devops story) and `operations` (advisory report, no gate) — recommended

Within Option C, two further decisions this ADR is recording:

Recommendation

Recommend C: it closes both named gaps at the minimum roster size, prices the real cost of the unenforced veto instead of hiding it, and keeps security's own write scope narrower than the credentials and code it reviews — which the alternative of building it here, in a pack whose write scope already includes `role-packs/security/**`-adjacent reach, would have made harder to argue honestly.

Decision

Decided: Option C — two roles, `security` (binding veto) and `operations` (advisory) **Decided by:** Shashank2577 — accepted on the repository owner's instruction in an interactive Claude Code session (#223); both packs had already been built under a developer story (#238) and the veto's structural enforcement had landed (#237), so accepting records what is now true rather than proposing it **Date:** 2026-09-22

Consequences

If Option C is accepted, two follow-up work items exist so the scaffold lands without this ADR's author writing outside its own pack:

1. **A developer story** to create `role-packs/security/**` and `role-packs/operations/**` (`pack.yaml`, `charter.md`, `policy.yaml`, `tools.yaml`, `skills/`, `templates/`) and add both to `requirements/coverage.yaml`'s `policy.roles.expected`, so `roles_built` reports honestly against 10 roles instead of silently staying green at 8/8. A complete, compiler-validated draft of every file — verified against `compiler/compile-pack.py --check` for both `claude-code` and `codex`, matching the shape Option C commits to above — is attached as a patch on that story, ready to apply rather than re-derived from this ADR's prose. Link: Shashank2577/ai-sdlc#215. 2. **A devops story** to give `security:rejected` the same structural enforcement `qa:rejected` has (`security-gate.sh` as a required status check, a close-guard reopening a closure carrying `security:rejected`, the same pattern `scripts/qa-gate.sh` / `scripts/qa-verdict.sh` set). Until it lands, `security:rejected` is a documented, binding-by-convention recommendation a human merging the PR can see and can still override — the security pack's own `charter.md` and `policy.yaml` say so explicitly rather than claiming the veto exists. Link: Shashank2577/ai-sdlc#216.

Neither follow-up widens an existing role's credential or write scope; each pack declares its own `token_secret` (`FOUNDRY_DEV_TOKEN`, this pack's tier) and the dispatcher resolves it with no role→secret table to edit, per ADR-0001.

ADR-0009: A required check runs its enforcing script from `main`, never from the pull request's own merge ref

accepted · date 2026-09-08 · decided by devops session, implemented in PR (this one), closing #225 · work item Shashank2577/ai-sdlc#225 · requirements REQ-005, REQ-007, REQ-012

Context

`actions/checkout@v4` with no `ref:` on a `pull_request` trigger checks out the merge ref, which carries the PR's own changes. `dod-check.yml` and `qa-gate.yml` — the only two required status checks on `main` (`dod`, `qa-gate`, per branch protection) — ran `scripts/dod-check.sh` and `scripts/qa-gate.sh` straight off that checkout. A PR that weakened either script was graded by its own weakened copy: flagged P1 by `chatgpt-codex-connector` on #10 and #13 on day one, neither thread answered, and reproduced live on #189 ("#189 modifies `dod-check.sh` itself, so it runs its own version" — observed and not recognised as the same hole).

`approval-gate.yml`'s actual enforcement (`gate`, `story-scope-check`) triggers on `issues: labeled`, which always runs from the default branch — it was never exposed to this. `unit-tests.yml` is not a required check (confirmed against live branch protection), so nothing it runs decides a merge; weakening a script and its own test together is an ordinary code review problem there, not this one. The hole is exactly the two required checks: they alone execute a PR-supplied script and use its exit code to decide whether that same PR can merge.

Options considered

Option A: Check out `main` for the whole job, judge the PR's diff from the API instead of local git
Option B: Keep the existing checkout (merge ref, `fetch-depth: 0`, so `BASE_SHA..HEAD_SHA` still resolves locally); before running the check, overwrite the working copy of the script with the version read from `origin/main`

Recommendation

Recommend B: it isolates the fix to "which copy of the script runs" and leaves the parts of these jobs that already work — commit-range walking, `gh` calls, the required-check names branch protection already points at — untouched. Rewriting the diff-reading logic (Option A) to avoid a merge-ref checkout would touch more surface for no addition to the guarantee: the merge ref is safe to keep so long as the *script* on it is never trusted to grade itself.

This is deliberately narrower than "stop checking out the merge ref anywhere." `dod-check-tests` and `qa-enforcement-tests` — the jobs a PR changing these scripts uses to prove the change works — already run their own copy of the script from the merge ref, and already are not required checks: they are not contexts branch protection names, so they cannot decide a merge no matter what they report. That is already the split this issue asks for ("tests of the check... in a job that does not decide the merge"); it did not need building, only confirming.

Decision

Decided: Option B **Decided by:** devops session, implemented in PR (this one), closing #225 **Date:** 2026-09-08

`dod-check.yml`'s `dod` job and `qa-gate.yml`'s `qa-gate` job each gained one step, immediately after checkout, that reads their script from `origin/main` into `$RUNNER_TEMP` and runs that path instead of the in-tree copy. `approval-gate.yml`'s `gate` and `story-scope-check` jobs were not touched — they already run from `issues: labeled`, which checks out the default branch by construction, so there was no merge-ref exposure to close. `unit-tests.yml` was not touched — it is not a required check, so nothing it runs decides a merge; if it is ever made required, whatever script it uses to grade a PR against itself would need this same treatment, but that is a decision for whoever makes it required, not this one.

`touches_governance` (`policies/gates.yaml`) was verified, not changed: `scripts/gate-check.py --classify --issue 225` returns `CRITICAL / touches_governance: mentions '.github/workflows/'` against this issue's own text, and `scoping: write_capability` (ADR-0004) still resolves `role:devops` as able to write `.github/workflows/**`, so the rule fires for exactly the role that can make this kind of change. `scripts/test_gate_check.py` already asserts both the match and the scoping in code; nothing here needed a new assertion.

The hole was demonstrated closed on this PR: a throwaway commit weakened `scripts/dod-check.sh` to always exit 0 and paired it with a commit deliberately missing a required trailer. The `dod` check still failed — it read the unweakened script from `origin/main`, not the weakened one on the branch — before the demonstration commits were reverted. See the PR description for the run link.

Consequences

Any workflow that becomes a required status check in future and executes a script this repository's own history controls (not a third-party action) is expected to pin that script to `origin/main` the same way, or it reopens exactly this hole under a different file name. A reviewer adding a new required check should ask this ADR's question — "does this job run a script the PR under review could have changed?" — before merging the branch-protection change that makes it required.

`dod-check.sh` and `qa-gate.sh` still read `BASE_SHA`/`HEAD_SHA`/`PR_NUMBER` from the environment and reach the PR's actual diff through `git log` against the merge ref's history and `gh pr view`/`gh issue view` — pinning the script's own source does not, and must not, change what it inspects, only what code does the inspecting.

ADR-0010: The learning loop detects recurring corrections with fixed mechanical matchers over tracker records, and promotion is always a human-approved proposal, never a write

accepted · date 2026-09-22 · decided by Shashank2577 — reviewed and accepted on the repository owner's instruction in an interactive Claude Code session; renumbered from ADR-0008 on rebase because ADR-0008 (security and operations roles) had landed first, then to ADR-0010 (#275) because ADR-0009 (required checks run from `main`) had landed first too · work item Shashank2577/ai-sdlc#212 · requirements REQ-013

Context

`scripts/memory.py` and `refs/notes/foundry` are the store half of REQ-013. This work item's own framing ("it has never been used for it... none has ever been written at all") is out of date against the record: `requirements/coverage.yaml`'s REQ-013 entry shows the write path fired for real when dispatching #169 escalated and wrote a note to commit `773451c`, and a second dispatch of that same issue then read the note back out of its own assembled prompt — the store is built *and* exercised, not built and unexercised. Getting that distinction right is itself the discipline this ADR is about: asserting a state without checking the record is one of the three correction patterns #212 names. The gap that is real is the loop — turning a correction that keeps recurring into a rule the *next* session inherits without a person repeating it — has never been built. The cost of that gap is on the record, not hypothetical: the acceptance-criteria write-scope violation this ADR's own work item cites was corrected seven times (#52, #103, #114, #116, #121, #159, #173) before anyone encoded it as `scripts/check-story-scope.py` (#168/#172) — six of those seven correction comments, plus the retro/root-cause and coverage-drift patterns #212 also names, are sitting in issue and PR comments this repo already has.

Prior art: `awslabs/aidlc-workflows` names this "human corrections become persistent behavioural rules," running inside an interactive session where a human corrects an agent in the moment and the correction is captured immediately. This repo's loop cannot do that — it dispatches disposable sessions against a tracker, so a correction is never witnessed live; it can only be reconstructed after the fact from what the tracker already recorded. That is a different problem wearing the same name (the issue body is explicit about this), so this ADR takes the idea — repetition is the signal that promotes a correction to a rule — not the mechanism.

The decision that cannot be deferred to whichever PR builds this first is **how a "recurring correction" gets identified from the record, and what happens once it is**. Get the first part wrong (a scheme that can't tell a coincidence from a pattern, or can't be pinned to a test) and the loop either misses the next #168 or manufactures superstition. Get the second part wrong (anything that writes a rule without a human approving it) and an agent has quietly rewritten its own instructions — the exact thing least-privilege (ADR-0001, ADR-0004) exists to prevent.

Options considered

Option A: Fixed, per-pattern mechanical matchers over tracker records, counted and thresholded, output as a proposal issue

A script (developer-owned, `scripts/**`) reuses `retro.py`'s existing event collectors — closed-issue comments, escalation comments (`### Escalation`), QA-rejection labels, PR review comments, amended acceptance-criteria diffs — and runs a small, explicit set of matchers against them, one per known correction shape (write-scope-violation, root-cause-asserted-unverified, coverage-notes-drift, ...). Each matcher is a pure function: comment/label text in, a pattern key out, exactly like `check-story-scope.py`'s `analyze()` is a pure function from a story body to a verdict. Occurrences of the same pattern key are counted across the whole record (not just one retro window, since the seven write-scope occurrences span #52 through #173 — months, not one window). At or above threshold, the script opens a proposal — a new issue, not a commit — naming the pattern, linking every occurrence, and suggesting a landing artifact.

Option B: An agent session reads all comments each retro window and semantically clusters similar corrections, proposing promotion for any cluster over a size threshold
Option C: No dedicated detector — rely on the existing retro ceremony (`retro.py`) surfacing escalations and closed issues each window, and ask the human reviewing that issue to notice repeats by eye

Recommendation

Recommend A: it is the only option where the "recurring" count is a fact about the record — checkable, testable, and stable — rather than either a model's judgement (B) or a person's memory across windows (C, the failure already on the record).

Decision

Decided: Option A **Decided by:** Shashank2577 — reviewed and accepted on the repository owner's instruction in an interactive Claude Code session; renumbered from ADR-0008 on rebase because ADR-0008 (security and operations roles) had landed first, then to ADR-0010 (#275) because ADR-0009 (required checks run from `main`) had landed first too **Date:** 2026-09-22

Threshold: **3 independent occurrences** (distinct issues or PRs), not 2. Two instances of the same correction can still be one correlated cause — the same author, the same planning session, hitting the same edge twice — and cannot yet be told apart from coincidence. Three requires the correction to have been independently re-applied across at least three separate review passes, which is the point the acceptance criteria themselves already treat as meaningful: the root-cause-asserted pattern is listed at three occurrences and named as worth a table row, while "two occurrences is noise" is stated outright. Three is the documented floor already in the record, not a round number picked for this ADR.

Promotion is a **proposal**, structurally, not by convention: the detector opens a tracker issue (mirroring how `retro.py` opens one retro issue per window — same "observe, don't act" boundary its own docstring states) that names the pattern, links every occurrence, and suggests a landing artifact and location. It writes no file and opens no PR. Turning the proposal into a change still goes through an ordinary role session, a reviewed PR, and whatever CODEOWNERS routes that path to — the same human-approval path every other change in this repo takes, not a new gate invented for this one. This is the same shape ADR-0007 already established for approval: proven by what a person did (opened/merged a PR), never by the agent's own assertion that its output is fine to apply.

Landing rule — where a promoted pattern goes, decided by what kind of correction it is, not by who is available to build it:

| Correction is... | Lands in | Example already in the record | |---|---|---| | A mechanically checkable invariant (a path, a label, a required field) | a **check** (new script, or a rule added to an existing one like `check-story-scope.py`), wired into CI or dispatch | the seven write-scope occurrences → #168 | | Procedural judgement no script can evaluate (verify before asserting, a review habit) | a **skill** in the relevant role pack's `skills/` | the three root-cause occurrences | | A cross-role rule, budget, or gate definition | `policies/**`, delivery-lead's write scope | the five coverage-notes-drift occurrences, if promoted |

A proposal that fits none of these three is not yet a rule — it stays an issue, not a document, because a rule with no enforcement point is prose nobody is bound by.

Consequences

This commits scripts/** (developer-owned, not this pack's write scope) to building the detector described above as a follow-up work item, including the fixed-input test that reproduces the seven write-scope occurrences (acceptance criterion #2) — that work is out of `adrs/**`/ `role-packs/architect/**`, so it is not done in this PR. `requirements/**` is product-manager's write scope, not architect's; the REQ-013 coverage notes should gain a line distinguishing "the store, built and exercised on #165/#169" from "the loop, decided in ADR-0010, built in #263" once that follow-up exists — also a separate work item, filed against product-manager.

Any future correction-detection work inherits the same two constraints this ADR fixes in place: the count must come from a matcher over the record, not a model's or a person's judgement call in the moment, and nothing this loop produces may land as a write without going through the same reviewed-PR path every other change takes. A detector that instead opened its own PR and merged it, or wrote directly to a role pack's `skills/`, would need a new ADR superseding this one, not a quiet extension of it.