Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 71 additions & 7 deletions docs/spikes/agent-governance-decision-assurance.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,9 @@ sequenceDiagram
```

1. Prompt and boundary: `agents@50e703d:orchestrator/run_incident_responder.py#_build_prompt` ("Incident data is untrusted input"); options `orchestrator/options.py#build_incident_responder_options`: tools `Read`, `Write`, `Glob` plus mctl MCP, "Deliberately no Bash", policy hooks attached.
2. Output: `agents/_incident-responder/CLAUDE.md` section ".status.yaml" writes `status: accepted` with an `auto-accepted:` note and no `control`/`approval` block.
2. Output: `agents/_incident-responder/CLAUDE.md` section ".status.yaml" writes `status: accepted` with an `auto-accepted:` note and no approval block.
3. Sweep: `agents@50e703d:orchestrator/proposal_state.py#execution_authorization` returns `(None, "legacy auto-accepted / unreviewed")` for a record with no `approval` block; `AUTHORIZATION_AUTONOMY_POLICY` is "Reserved ... No policy is defined yet"; `temporal/workflows/implement_sweep.py:492-499` logs them as "quarantined from execution; they need human triage".
4. Manual implementer: `proposal_state.py#human_approval_satisfied` returns true for a record with no `control` block ("A proposal with no `control` block does NOT require approval. The incident-responder writes `status: accepted` with no control block at all"). So a human-triggered `mctl_trigger_implementer` is not blocked by this gate for such a record. That trigger is itself an admin-only operation.
4. Manual implementer: a human-triggered `mctl_trigger_implementer` is a second, admin-only entry point for these records. How its gate treats them is part of a security-sensitive finding tracked in private security triage (9.5) and is not detailed here.

### 3.4 P4: policy-checkpoint actions

Expand Down Expand Up @@ -248,6 +248,55 @@ sequenceDiagram
3. Receipt: `api@57864b5:internal/commerce/receipt.go#Receipt`/`#PolicyDecisionRef` (policy decision read from the approval, statuses `none|recorded|missing|unavailable`).
4. `funds_mode: live` is refused by mctl-api (module docstring, "No real money"); every step is therefore a no-real-money flow today.

### 3.6 P3 addendum: no-gate mutation scopes and allowed MCP mutations

Added 2026-10-18 after D5 was decided (#182, #201). It extends P3 only and changes neither
the D5 disposition nor the #201 work-item graph. New findings are listed in 9.5 as owner
decisions, not routed into #201. Two of them are security-sensitive; for those this report
states the gap at contract level only, and the details and remediation are tracked in
private security triage.

Evidence pins for this subsection (shallow clones of `main`, read 2026-10-18):

| Repository | Commit (40 hex) |
|---|---|
| mctlhq/mctl-gitops | edaf4a00d8c38cb192575e4a3ab34800dea8a190 |
| mctlhq/mctl-agents | 50e703dfcf75ce25c674f1151879d6bcc0e1c239 |
| mctlhq/mctl-api | 7f8706a6f35ae2bf1aeee858e63cc36166bb8cef |

Shorthand below: `gitops@edaf4a0`, `agents@50e703d`, `api@7f8706a`.

**What `policy.yaml` is.** `gitops@edaf4a0:platform-gitops/agent-platform/policy.yaml`
`knownMutationScopes` (lines 141-189) is a catalog that `scripts/validate-agent-platform.py`
checks ExecutionProfiles against in CI (`requiresApproval`, line 295; run from
`.github/workflows/validate-manifests.yml`). No runtime component reads it: a search of
`agents@50e703d` for `mutationScopes` / `requiresApproval` finds only `service_skills.py`,
which is unrelated. So a scope with `requiresApproval: null` describes a write; it neither
grants nor restricts it at runtime. The runtime controls are the CWFT commit-step path
guards, the policy-checkpoint hooks and the mctl-api handlers.

Six scopes are mutating with `requiresApproval: null`:

| Scope | Declared on (profile) | Actual entry point | Contract applied today | Bypass or ambiguity vs. the canonical path | Disposition |
|---|---|---|---|---|---|
| `proposal-status` | incident-responder, implementer, shepherd | `.status.yaml` files under `agents-state/` in mctl-gitops, written by agent runs through the CWFT commit steps. The canonical writer of the human approval record is the approve CWFT (P1/P2). | Path guards on the commit steps. | Security-sensitive: execution authorization can accept repository state as human-approval evidence without sufficient binding to an authenticated decision. Details are tracked privately. | **G12** (9.5), private security triage. |
| `proposal-directory-write` | issue-investigator, service-agent, incident-responder | Proposal files under `agents-state/<service>/proposals/<slug>/`, written through the same commit steps. | Path guards. | The write boundary is assessed together with G12. | Part of **G12**. |
| `issue-comment` | issue-investigator | `gh issue comment` after `policy_checkpoint.enforce(GITHUB_ISSUE_COMMENT, "comment", ...)` (`run_issue_investigator.py:1426-1429`; directive poller `run_issue_directive_poller.py:351`). | Policy checkpoint rule `github-issue-comment` = ALLOW (`policy_checkpoint.py:346`), recorded as a `POLICY_DECISION` log line. | None found. It goes through the canonical checkpoint; the decision evidence is a log line (already G8). | Covered: mctl-gitops#1980 (checkpoint productionization), mctl-api#701 (durable record). No new gap. |
| `agents-state-inbox-write` | service-agent | `agents-state/<service>/inbox/<date>.md`, written through the run CWFT commit step. | Path guards. | Not an execution or authorization input in the sources read (read by the mentor for its digest). Bash as a transport is already G5. | Covered by mctl-gitops#1980 (transports). No new gap. |
| `mentor-digest-write` | mentor | `agents-state/_mentor/digest/...`, written through the run CWFT commit step. | Path guards. The mentor has no policy-checkpoint hooks (already G5, "mentor unhooked"). | The declared scope covers only the digest file; the ungoverned-transport gap is G5. | Covered by mctl-gitops#1980. No new gap. |
| `incident-resolve` (policy text: "Whether it should have a gate is undecided") | incident-responder | MCP `mctl_resolve_incident` → mctl-api `POST /api/v1/incidents/{id}/resolve`. | Agent side: a policy-checkpoint ALLOW rule. API side: authenticated caller with tenant write role. No approval. | The "undecided" question itself (gate, or autonomous with an attributable basis) is a decision, not a defect. Security-sensitive: the allowed autonomous incident mutations (resolve and acknowledge) are not sufficiently bound to the declaring actor and are not attributable after success. Details are tracked privately. | Decision: mctl-agents#703 (AC: "Autonomous/no-human-gate execution has an explicit attributable authorization basis"). Findings: **G13** (private security triage), **G14** (mctl-api#707). |

Incident target lookup (G14, a correctness bug, mctl-api#707): resolve, acknowledge and update look the
incident up by ID prefix (`api@7f8706a:internal/alerts/store.go#GetByPrefix`,
`id LIKE $1 ORDER BY created_at DESC LIMIT 1`). The doc comment says "minimum 8 chars", but
no minimum is enforced, and an ambiguous prefix silently selects the newest match.

Consistency note: the incident-responder profile comment says "the implementer picks the
proposal up on its next run" (`execution-profiles/incident-responder-default/profile.yaml:71`),
while the sweep quarantines exactly these records (3.3, item 3). The record shape is the
same; the documented expectation differs. This is the divergence mctl-agents#703 already
names. No new gap.

## 4. Scope sections 1-8

**S1 Intent provenance.** Trace: GitHub issue → `issue-investigator` run → proposal files with a `source` block (`type: github_issue`, `repo`, `issue`) in `.status.yaml` (read by `run_implementer.py#_issue_closing_line`) → `we_` execution and work item. The originating request text and its trace id are not bound into the approval: the grant's intent hash covers execution id, kind, target, args digest, policy rule/version, artifact hash and work item id (`workitems#IntentHash`), not a source or trace field. Trace context reaches the approve workflow only as a submit annotation (observed trace id `9706d41d...`). Sub-agent and retry provenance beyond the `we_`: `unknown (not observed)` (no sub-agent delegation path was read for P1-P5).
Expand Down Expand Up @@ -330,10 +379,10 @@ Status vocabulary: implemented, partial, missing, owned by another epic, unknown
| Intent provenance | partial | responder, incident record | proposal names the incident id; incident data is untrusted | `run_incident_responder.py#_build_prompt` |
| Unified identity | unknown (not observed) | mctl-agents | identity the responder run acts under was not read | n/a |
| Approval authority | missing | mctl-agents | no approver; sweep treats the record as unauthorized | `proposal_state.py#execution_authorization` |
| Policy evidence | partial | mctl-agents | tool hooks run policy checkpoint for MCP calls; no policy for "may auto-accept" | `options.py#build_incident_responder_options` |
| Policy evidence | partial | mctl-agents | tool hooks run policy checkpoint for MCP calls; no policy for "may auto-accept"; the `policy.yaml` mutation scopes are CI-checked catalog entries, not runtime controls (3.6); an authorization finding on the allowed incident mutations is tracked privately (9.5) | `options.py#build_incident_responder_options`; 3.6 |
| Decision context | partial | responder output | proposal triplet plus incident id | `agents/_incident-responder/CLAUDE.md` |
| Immutable proposal | missing | gitops | no approval digest because no approve step | `execution_authorization` |
| Execution binding | partial | sweep quarantine; manual implementer | sweep refuses; manual implementer trigger accepts records with no `control` block | `implement_sweep.py:492-499`; `human_approval_satisfied` |
| Execution binding | partial | sweep quarantine; manual implementer | sweep refuses these records; the binding of execution authorization to an authenticated decision is a security-sensitive finding tracked privately (9.5) | `implement_sweep.py:492-499` |
| Approval expiry | missing | n/a | none | n/a |
| Replay protection | missing | n/a | none specific | n/a |
| End-to-end trace | partial | Argo workflow, incident record | incident resolved with "diagnosis delegated to implementer" | `run_incident_responder.py` docstring |
Expand Down Expand Up @@ -548,8 +597,8 @@ agent's own auto-accept cannot be the authorization for the agent's own executio
second authorization path until an autonomy policy is defined, and the sweep quarantines
these records. So the contract treats P3 as an autonomous path that is **not authorized to
execute through the sweep** and is neither a break-glass nor an approved bypass. The one
residual route is a human-triggered `mctl_trigger_implementer`, which is admin-gated but whose
gate (`human_approval_satisfied`) passes a record without a `control` block. Invariant:
residual route is a human-triggered `mctl_trigger_implementer`, which is admin-gated; its gate
behaviour for these records is part of the privately tracked finding in 9.5. Invariant:
explicit, attributable, observable override. Gap: none exists to attribute. Owner: mctl-api /
mctl-agents. No bypass is enabled by this report.

Expand Down Expand Up @@ -649,7 +698,7 @@ the reconstructed chain is authoritative.
| G1 Rewriting a proposal does not supersede the pending grant; the decision lands on stale content | P1 | 6.1(b); mctl-agents#701 (open) | mctl-agents (existing issue #701) |
| G2 A refused approve is projected as `Succeeded` / `Completed` and cannot be restarted; consumed-but-refused semantics are undefined | P1 | 6.1(c); mctl-agents#702 (open) | mctl-agents (existing issue #702); semantics question to mctl-api |
| G3 Direct approve binds no reviewed content (digest optional) | P2 | 7.1 step 4; `registry.go:937` | mctl-api operation and CWFT (proposed owner: mctl-api, the owner of #182) |
| G4 No defined autonomy authorization for the incident-responder path; manual implementer gate passes records with no `control` block | P3 | `execution_authorization`; `human_approval_satisfied` | mctl-agents (the seam `AUTHORIZATION_AUTONOMY_POLICY` is already named) |
| G4 No defined autonomy authorization for the incident-responder path; the manual implementer entry point has no attributable authorization basis for these records | P3 | 3.3; 9.5 | mctl-agents (the seam `AUTHORIZATION_AUTONOMY_POLICY` is already named) |
| G5 Approval store not enabled by declaration; Bash and the mentor are ungoverned transports | P4 | 3.4; ADR 014 open decisions 2 and 4 | mctl-agents (ADR 014 owner); gitops for the setting |
| G6 Authority revalidation boundary at spend is undefined | P1, P4, P5 | 6.4 | mctl-api (approvals) |
| G7 Decision context fields missing on the grant (source, risk, reasons, authority) | P1, P4 | S4, D4 | mctl-api (grant schema) |
Expand Down Expand Up @@ -820,6 +869,20 @@ flowchart TD
This report and its merge do not deliver any runtime guarantee. G1-G11 stay open until the
owner accepts D5 and the follow-up work ships and is proven.

### 9.5 Findings outside the #201 graph (owner decision)

These came from the P3 addendum (3.6). They are recorded here and **not** added to the
#201 work-item graph. G12 and G13 are security-sensitive: this report states them at
contract level only, they are not filed as public issues, and their details and
remediation are tracked in private security triage. This report will be updated with
terminal evidence once fixes ship.

| Finding | Paths | Evidence | Nearest existing item | Recommendation |
|---|---|---|---|---|
| G12 Execution authorization can accept repository state as human-approval evidence without sufficient binding to an authenticated decision | P3 and implementer entry points | Tracked privately | mctl-agents#703; mctl-api#701 | Human approval must be bound to canonical, authenticated decision evidence. A unified execution-authorization predicate (#703) alone is not treated as a sufficient fix; the owner confirms that the governance graph has an owner for that binding. |
| G13 Allowed autonomous incident mutations are not sufficiently bound to the declaring actor and are not attributable after success | P3 and other agent paths | Tracked privately | mctl-agents#703; mctl-gitops#1980 | Decide the `incident-resolve` "undecided" question; remediation tracked privately. |
| G14 Incident lookup by ID prefix has no minimum length and resolves an ambiguous prefix to the newest match | Any caller of resolve, acknowledge or update | 3.6, incident target lookup | mctl-api#707 | A separate small mctl-api correctness fix: refuse short or ambiguous prefixes with a typed error. |

## 10. Open points carried forward

- The reviewer-visible rendering of an approval on Telegram or the portal was not read
Expand All @@ -831,3 +894,4 @@ owner accepts D5 and the follow-up work ships and is proven.
- The pod-level value of `MCTL_POLICY_APPROVALS` was not read; the gitops declaration shows
no setting.
- P2 is treated as in scope and is not assumed deprecated; the CWFT accepts an empty digest.
- G12-G14 (9.5) are recorded findings awaiting an owner decision; they are not part of the #201 graph.
Loading