Repository navigation
Conversation
Add the authority and decision-context contract for action approvals (mctl-api#700, #182 F3/F4/F6/F9): a mandatory decision context and context digest shown to and bound by the reviewer, authority evidence and a freshness bound captured at decision time, and revalidation of context, freshness, decider authority and policy version right before the approval is spent. Refusals are typed and carry details.reapproval_required. Proposal: platform-gitops/agents-state/mctl-api/proposals/issue-700-governance-define-authority-and-decision/ Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Claude finished @mctl-agents[bot]'s task in 8m 7s —— View job Claude Code Review
Verdict: changes requested — 1 P1, 2 P2, 4 P3. Findings are posted as inline comments bound to P1 — pre-existing approval rows can never be approved
This also hits P2 — completeness gate outranks the terminal-state refusals
P2 — no handler-level test for revalidation
P3
What looks rightLength-prefixed canonical digest (no field-separator ambiguity, and the |
| ALTER TABLE action_approval_requests ADD COLUMN IF NOT EXISTS side_effect TEXT NOT NULL DEFAULT ''; | ||
| ALTER TABLE action_approval_requests ADD COLUMN IF NOT EXISTS intent_source TEXT NOT NULL DEFAULT ''; | ||
| ALTER TABLE action_approval_requests ADD COLUMN IF NOT EXISTS policy_decision TEXT NOT NULL DEFAULT ''; | ||
| ALTER TABLE action_approval_requests ADD COLUMN IF NOT EXISTS policy_reasons TEXT[] NOT NULL DEFAULT '{}'; |
There was a problem hiding this comment.
P1 — The new context columns are added with DEFAULT /{} and never backfilled, so every approval row that already exists has side_effect=, intent_source=, policy_decision= and policy_reasons={}. guardDecision then computes context_complete=false for all of them and refuses approve with 422 approval_context_incomplete (internal/api/handlers_approval_governance.go:88).
Failure scenario: a DevLoop is waiting on a pending mctl-agents-approve grant created before this deploy. After the deploy a platform admin calls POST /api/v1/agents/dev-loop/{workflow_id}/approve; decideDevLoopApproval finds the pending grant, guardDecision returns 422 with missing_fields: [side_effect, intent_source, policy_decision, policy.reasons], and because the grant is pending the noGrant legacy-payload fallback is never reached. The grant can only be denied or left to expire, so every in-flight human-approval gate is stuck until its TTL (up to 7 days) runs out. There is no env var to relax the completeness gate.
Suggestion: backfill in the same migration block — for state=pending rows, set side_effect/policy_decision/policy_reasons from the registered policy for policy_rule_id and intent_source from coalesce(work_item_id, execution_id) — or grandfather rows whose created_at predates the column. Same class of back-compat handling the patch already does for fresh_until and decided_context_digest.
| writeActionApprovalError(w, err) | ||
| return | ||
| } | ||
| digest, ok := h.guardDecision(w, r, user, cur, body.Decision, body.ContextDigest) |
There was a problem hiding this comment.
P2 — guardDecision now runs before DecideActionApproval, which is where the self-decision, expired and already-decided checks live (internal/workitems/action_approvals.go:655-666). For any request with an incomplete context the completeness refusal therefore wins over the real reason.
Failure scenario: a pending approval created before this change (empty side_effect etc.) expires. An admin calls POST /api/v1/action-approvals/{id}/decision with approve. Before this PR the answer was 409 approval_expired with details.reapproval_required=true; now it is 422 approval_context_incomplete, which carries no reapproval_required, and both docs/decision-context-contract.md (quote: the request stays pending) and the openapi text are wrong for that row — it is expired, not pending. The same applies to an already approved/denied/consumed row (422 instead of 409 approval_already_decided) and to a requester self-decision (422 instead of 403 approval_self_decision), so a client branching on the code picks the wrong recovery.
Fix: gate on a.State == workitems.ApprovalPending (and a.RequestedBy != principalOf(user)) before the completeness check, so the terminal-state refusals keep precedence.
| Policies: h.approvalPolicies(), | ||
| StrictPolicy: h.opts.RequireRegisteredPolicy, | ||
| Registry: h.opts.Registry, | ||
| Now: h.approvalNow(), |
There was a problem hiding this comment.
P2 — No test exercises revalidateApproval through an HTTP handler, yet it is new refusal behaviour on POST /api/v1/action-approvals/{id}/consume and on spendApprovalGrant (handlers_approval_grant.go:191): five new 409 codes plus a 503, each of which must leave the approval unspent.
The internal/governance unit tests cover Revalidate in isolation, but nothing asserts the wiring: that approval_authority_revoked / approval_decider_disabled / approval_stale / approval_policy_drift come back as 409 with details.reapproval_required=true, that approval_authority_unavailable comes back as 503, that the row is still approved (not consumed) after a refusal, and that revalidation lands in the consume audit params. handlers_approval_governance_test.go only covers the two F6 decide paths.
Two store-level behaviours are untested too:
- the new CAS predicate
AND (fresh_until IS NULL OR fresh_until > $3)inConsumeActionApprovaland theErrApprovalStalefallback ataction_approvals.go:728; - the
fresh_until = min(expires_at, now+FreshFor)clamp inDecideActionApproval— in particular that aFreshForlonger than the remaining TTL does not pushfresh_untilpastexpires_at.
The Options.Policies, Options.Clock and Options.ApprovalMaxDecisionAge injection points were added for exactly this, so these are cheap to write against workItemsEnv.
| "github.com/mctlhq/mctl-api/internal/workitems" | ||
| ) | ||
|
|
||
| // Typed refusals. The first six mean the approval must be re-requested |
There was a problem hiding this comment.
P3 — This comment says "the first six mean the approval must be re-requested (reapproval_required)", but actionApprovalRefusals marks ErrContextIncomplete and ErrContextInvalid as reapproval: false (internal/api/handlers_action_approvals.go:114-115), which matches docs/decision-context-contract.md. Since the contract tells callers to key on details.reapproval_required rather than the code text, the comment should say "the four refusals from ErrContextChanged to ErrDeciderDisabled" (plus ErrPolicyDrift) instead of "the first six".
| return res, fmt.Errorf("%w: %s", ErrAuthorityRevoked, a.ID) | ||
| } | ||
| } else { | ||
| res.Revalidation = "skipped_legacy_mode" |
There was a problem hiding this comment.
P3 — In shadow/legacy mode with a recorded decided_by_principal_id, CheckHumanPrincipal has just run and passed, yet Revalidation is overwritten with skipped_legacy_mode. Only step 4 (ADMIN_PRINCIPALS membership) was skipped. The consume audit then records revalidation=skipped_legacy_mode for a spend where the decider was in fact re-verified as an active human, understating what was checked. Consider a distinct value (e.g. admin_check_skipped) so the audit distinguishes "nothing was re-checked" (line 365, no principal at all) from "the human check ran, membership did not".
Related, in the same function: Result.PolicyStatus is set but never read by any caller — revalidateApproval only forwards res.Revalidation — so a spend allowed under an unregistered policy rule (strict mode off) leaves no trace in the audit. And LiveAuthority.Mode (line 322) is assigned in revalidateApproval but never read anywhere; either audit it or drop the field.
| // source of truth with governance.Policies. | ||
| func (h *Handlers) devLoopPolicy() (rule, version string) { | ||
| rule = governance.DevLoopPolicyRule | ||
| version = h.approvalPolicies()[rule].Version |
There was a problem hiding this comment.
P3 — devLoopPolicy returns a silently empty version when the rule is absent from h.opts.Policies (map index on a missing key yields the zero Policy). The value feeds IntentHash in bindApprovalGrant, so a Policies set that omits dev-loop.human-approval produces a hash computed over policy_version="" and the grant spend fails with 409 approval_intent_mismatch — a confusing symptom for a configuration mistake. Falling back to governance.Policies[DevLoopPolicyRule].Version (or logging when the lookup misses) would fail loudly instead.
| err error | ||
| status int | ||
| code string | ||
| reapproval bool |
There was a problem hiding this comment.
P3 — Two small things in the audit changes:
-
The risk override makes
risk_levelon anaction_approval.createentry the risk of the approved action rather than of the API operation that produced the entry (the call site passesRiskLow). Creating a pending request is a low-risk write whatever the action is; reporting it ashighconflates the two and makes the audit risk column less useful for filtering. Consider recording the action risk under a separate param (e.g.action_risk_level) and leavingrisk_levelas the operation risk. -
params["policy_version"] = a.PolicyVersionis unconditional while every neighbouring optional field is guarded by a non-empty check, so legacy rows emitpolicy_version: "".
| // governance.DefaultMaxDecisionAge. | ||
| ApprovalMaxDecisionAge time.Duration | ||
| // Clock replaces time.Now for approval freshness; nil is time.Now. | ||
| Clock func() time.Time |
There was a problem hiding this comment.
P3 — Clock is never wired in cmd/api/main.go, so there are now two independent clocks on the freshness path: Revalidate compares fresh_until against h.approvalNow() (this field, else time.Now) while Store.ConsumeActionApproval compares it against Store.now(). A test that injects one but not the other sees the two disagree, and the handler check can pass while the store CAS rejects (or vice versa). Worth either threading the store clock into Options.Clock or dropping the field and reading the store clock.
There was a problem hiding this comment.
Has P1/P2 findings, changes requested: 1 P1, 2 P2, 4 P3.
P1: the new decision-context columns are added with empty defaults and never backfilled, so every approval row that predates the deploy fails the context_complete gate and can never be approved (422 approval_context_incomplete). That includes POST /api/v1/agents/dev-loop/{workflow_id}/approve, where a pending grant bypasses the legacy-payload fallback, so in-flight human-approval gates are stuck until their TTL expires. No kill switch exists to relax the gate.
P2: guardDecision runs before the store state checks, so an expired / already-decided / self-decision request with an incomplete context answers 422 instead of 409/403 and loses details.reapproval_required, contradicting the contract doc. P2: revalidateApproval is new refusal behaviour on consume and on the grant spend (five 409 codes plus a 503) with no handler-level test, and the new fresh_until CAS predicate and the min(expires_at, now+FreshFor) clamp are untested at the store level.
The design itself is sound — length-prefixed digest, fail-closed authority check, ordered revalidation, and the legacy-row handling for fresh_until/decided_context_digest is careful. The gap is that the same legacy-row care was not applied to context completeness.
Note: go build and go test were not available in this run, so compilation and the new tests were not executed; findings are from static review only.
Antigravity (agy) reviewagy returned a result this workflow could not use; see the workflow log. Treated as a failed review. |
…ine-authority-and-decision Backfill the decision-context columns of legacy approval rows so pending approvals are not stuck at approval_context_incomplete, apply the completeness gate only to a decision that could otherwise succeed, and add tests for revalidation before spend, the fresh_until bound and the backfill. Proposal: platform-gitops/agents-state/mctl-api/proposals/issue-700-governance-define-authority-and-decision/ Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
@claude review |
…-governance-define-authority-and-decision # Conflicts: # internal/api/handlers_action_approvals.go # internal/workitems/action_approvals.go # internal/workitems/action_approvals_test.go
|
Claude finished @mashkovd's task in 7m 29s —— View job Claude Code Review
Verdict: changes requested — 0 P1, 2 P2, 3 P3. Findings are posted as inline comments bound to Previous round's findings: fixed
P2 — consume revalidates before the store's state checks
P2 — create accepts a context that can never be completed
P3
What looks rightLength-prefixed canonical digest; fail-closed |
| if !decodeWorkItemBodyLimit(w, r, &body, maxActionApprovalBodyBytes) { | ||
| return | ||
| } | ||
| reval, ok := h.revalidateApproval(w, r, user, cur) |
There was a problem hiding this comment.
P2 — revalidateApproval runs before Store.ConsumeActionApproval, so a revalidation refusal outranks the store's state checks (approval_not_approved, approval_denied, approval_consumed, approval_expired). This is the same precedence bug that was just fixed on the decide path in fa980ef, now on the consume path.
Failure scenario, TENANT_AUTHZ_MODE=principal: the requesting service polls POST /api/v1/action-approvals/{id}/consume on an approval that is still pending. visibleActionApproval returns the pending row whatever its state, then Revalidate reaches case prn == "" && live.PrincipalMode (internal/governance/governance.go:365) — a pending row has no decided_by_principal_id — and answers 409 approval_authority_revoked with details.reapproval_required=true. The caller, which docs/decision-context-contract.md tells to key on that flag, abandons a perfectly live pending approval and asks a human for a new one. The correct answer is 409 approval_not_approved, which carries reapproval_required: false.
Same class for terminal rows: an already-consumed approval whose fresh_until has passed answers approval_stale instead of approval_consumed, losing the replay signal; a denied row whose denier has since been disabled answers approval_decider_disabled instead of approval_denied.
Fix: if cur.State == workitems.ApprovalApproved { reval, ok = h.revalidateApproval(...) }, mirroring the cur.State == workitems.ApprovalPending guard added at line 481. Revalidation only has meaning for an approved row.
| cc, err := governance.FillCreateContext(governance.CreateContext{ | ||
| RiskLevel: body.RiskLevel, SideEffect: body.SideEffect, IntentSource: body.IntentSource, | ||
| PolicyDecision: body.PolicyDecision, PolicyReasons: body.PolicyReasons, | ||
| }, intent, h.opts.Registry, h.approvalPolicies()) |
There was a problem hiding this comment.
P2 — Create persists a row whose decision context can never be completed, and the only signal is a 422 at approve time. FillCreateContext fills side_effect, policy_decision and policy_reasons only if p, ok := ps[intent.PolicyRuleID]; ok (internal/governance/governance.go:286), and governance.Policies has exactly one entry, dev-loop.human-approval. risk_level is filled only for an action kind in the operation registry. Nothing checks completeness here, and validateContext only validates non-empty values.
Failure scenario: a service principal posts a valid request with policy_rule_id: "merge-needs-human" and no side_effect/policy_decision/policy_reasons — all three documented as Optional; defaults from the registered policy in internal/openapi/openapi.yaml:4437-4441. It gets 201 and a pending aar_. Every subsequent approve is then 422 approval_context_incomplete with missing_fields: [side_effect, policy_decision, policy.reasons], forever: there is no PATCH, the new backfill at internal/workitems/action_approvals.go:169 deliberately skips it (intent_source is non-empty), and no env var relaxes the gate. The only exits are deny or waiting out expires_at. Before this PR the same request approved fine, so an unchanged client is silently broken at the point a human tries to act.
This repo's own tests show the shape of the problem: approvalBody in handlers_action_approvals_test.go:30-36 had to add all four fields with the comment "github.merge_pr is no registered operation and merge-needs-human no registered policy, so the client supplies what a reviewer must be shown". That is a reasonable contract — but it should be enforced where it can still be fixed. Refuse at create with 422 approval_context_incomplete (details.missing_fields) when DecisionContextOf is not complete, rather than accepting a 201 that becomes a dead row.
| WHEN intent_source <> '' THEN intent_source | ||
| WHEN work_item_id <> '' THEN 'work_item:' || work_item_id | ||
| ELSE 'execution:' || execution_id END | ||
| WHERE intent_source = '' AND decided_context_digest = ''; |
There was a problem hiding this comment.
P3 — Two gaps in the backfill (which does fix the earlier P1 for the common case: execution_id is required, so intent_source is a sound legacy marker and the UPDATE is idempotent).
-
risk_levelis not backfilled.RiskOfrecovers it only for anaction_kindin the operation registry; a legacy pending row for any other kind still reportsmissing_fields: [risk_level]and stays unapprovable.mctl-agents-approveis registered (internal/operations/registry.go:939), so the DevLoop path is covered, but the generic create endpoint takes an arbitraryaction_kind. AWHEN risk_level = THEN mediumarm would close the same hole the other four columns got. -
The
WHEREclause has no state predicate, so the UPDATE also rewritesdenied,consumedandexecutedrows, assertingside_effect=writeandpolicy_decision=require_approvalabout side effects that already happened and were never classified.policy_reasons=[legacy.pre-governance]does mark them, which keeps it honest, but the P1 only neededstate IN (pending, approved)— narrowing it keeps the durable record of past decisions untouched.
Worth a line in docs/decision-context-contract.md either way: a reviewer looking at a backfilled row sees side_effect: write with no indication it is a placeholder unless they know what legacy.pre-governance means.
| return newest.ID, true | ||
| case workitems.ApprovalPending: | ||
| decided, err := h.opts.WorkItems.DecideActionApproval(ctx, workitems.ActionDecisionInput{ | ||
| digest, ok := h.guardDecision(w, r, user, &newest, workitems.DecisionApprove, "") |
There was a problem hiding this comment.
P3 — This call site did not get the precedence guard that DecideActionApproval gained in fa980ef. The state is pending by construction here, so the terminal-state half does not apply, but the self-decision half does: if the caller is also newest.RequestedBy, an incomplete context answers 422 approval_context_incomplete instead of 403 approval_self_decision. Not reachable today (create is isDirectService-only and the approve route is admin-only), but it makes the two decide paths disagree about precedence, and a future caller that can both request and approve would hit it. Extracting the cur.State == workitems.ApprovalPending && cur.RequestedBy != principalOf(user) condition into guardDecision itself would keep both sites in step.
| "github.com/mctlhq/mctl-api/internal/workitems" | ||
| ) | ||
|
|
||
| // Typed refusals. The first six mean the approval must be re-requested |
There was a problem hiding this comment.
P3 (carried over from the previous round, still open) — "The first six mean the approval must be re-requested" contradicts actionApprovalRefusals, where ErrContextIncomplete and ErrContextInvalid are reapproval: false (internal/api/handlers_action_approvals.go:117-118) in agreement with docs/decision-context-contract.md. The six that do set the flag are ErrContextChanged, ErrApprovalStale, ErrAuthorityRevoked, ErrDeciderDisabled, ErrPolicyDrift and workitems.ErrApprovalExpired — not a contiguous prefix of this block. Naming them, or dropping the count, would stop the comment from contradicting the code.
Also still open from the previous round, all P3 and non-blocking:
Result.PolicyStatusis set byRevalidatebut read by nobody, so a spend allowed under an unregistered rule withACTION_APPROVAL_REQUIRE_REGISTERED_POLICYoff leaves no trace in the consume audit;LiveAuthority.Modeis assigned inrevalidateApprovaland never read.Revalidationis overwritten withskipped_legacy_modeat line 383 even thoughCheckHumanPrincipaljust ran and passed — only the ADMIN_PRINCIPALS membership step was skipped.devLoopPolicy(internal/api/handlers_approval_governance.go:50) yields an empty version for aPoliciesset missingdev-loop.human-approval, surfacing later as 409approval_intent_mismatch.Options.Clockis still unwired incmd/api/main.go, so handler freshness and the store CAS read two clocks;TestF3_ConsumeRevalidatesBeforeSpend/stalenow depends on the handler winning that race.auditActionApprovaloverrides the call site's operation risk with the approved action's risk, andparams["policy_version"]is unconditional while every neighbouring optional field is guarded.
There was a problem hiding this comment.
Has P1/P2 findings, changes requested: 0 P1, 2 P2, 3 P3.
The previous round's P1 (unbackfilled context columns) and both P2s (gate precedence on decide, missing handler/store tests for revalidation and fresh_until) are addressed in fa980ef. The backfill marker is sound — execution_id is required, so an empty intent_source really does identify a pre-governance row and the UPDATE is idempotent — and the new tests cover the five revalidation refusals end to end, the min(expires_at, now+FreshFor) clamp, the CAS, and that a refusal leaves the row approved.
Two P2s remain, both residue of the same precedence/fail-fast question:
ConsumeActionApprovalrevalidates before the store's state checks, so inTENANT_AUTHZ_MODE=principala consume of a still-pending approval answers 409approval_authority_revokedwithreapproval_required=true(nodecided_by_principal_idon a pending row) instead of 409approval_not_approved. A caller following the documented rule of keying on that flag discards a live approval. Terminal rows are similarly masked (approval_staleinstead ofapproval_consumed). The fix is the mirror of the one just applied to decide: only revalidate when the state isapproved.- Create accepts an incomplete decision context and persists an unapprovable row.
FillCreateContextdefaultsside_effect/policy_decision/policy_reasonsonly for a rule ingovernance.Policies, which holds one entry, yet openapi documents all three as optional. Any otherpolicy_rule_idyields 201 followed by a permanent 422 at approve, with no PATCH, no backfill and no kill switch. Refusing at create withapproval_context_incompletemakes the contract enforceable where it can still be met.
P3: the backfill leaves risk_level empty for an unregistered action_kind and rewrites terminal rows; decideDevLoopApproval did not get the self-decision half of the new precedence guard; the previous round's five P3 nits are still open (the "first six" comment, unread PolicyStatus/Mode, skipped_legacy_mode overstated, silent empty devLoopPolicy version, unwired Options.Clock, audit risk override).
Note: go build, go vet and go test were not permitted in this run, so nothing was compiled or executed; findings are from static review of 841d2e7...05bc250 only.
The decision gate added a pre-read of the approval before the decide. An unknown id was refused there without the decision_refused audit entry the store-side refusal used to write, so a failed decide on a non-approval id left no trace. Audit pre-read refusals the same way. The other failures were fixtures that predate the gate: loginPrincipals lacked CheckHumanPrincipal, so Revalidate reported the authority as unavailable (503); the DevLoop grant env had no registry, so grants carried no risk level and approves were refused as incomplete; and the other-kind grant needs a client-supplied risk level because its kind is not registered.
Antigravity (agy) review — informationalModel: gemini-3.8-flash-high · commit Finding 1: Calling
|
|
Claude finished @mashkovd's task in 5m 9s —— View job Claude Code Review
Verdict: changes requested — 0 P1, 2 P2, 3 P3. Findings are posted as inline comments bound to What this round changed
P2 — consume revalidates before the store's state checks
P2 — create accepts an incomplete context and persists an unapprovable row
P3
What looks rightLength-prefixed canonical digest with a |
| if !decodeWorkItemBodyLimit(w, r, &body, maxActionApprovalBodyBytes) { | ||
| return | ||
| } | ||
| reval, ok := h.revalidateApproval(w, r, user, cur) |
There was a problem hiding this comment.
P2 (carried from the previous round, still open) — revalidateApproval runs before Store.ConsumeActionApproval, so a revalidation refusal outranks the store's own state checks. DecideActionApproval got the precedence guard in this PR (line 488, cur.State == workitems.ApprovalPending && cur.RequestedBy != principalOf(user)); the consume path did not.
Failure scenario, TENANT_AUTHZ_MODE=principal: the requesting service calls POST /api/v1/action-approvals/{id}/consume on an approval that is still pending. visibleActionApproval (line 528) returns the row whatever its state, then Revalidate hits case prn == "" && live.PrincipalMode (internal/governance/governance.go:360) — a pending row has no decided_by_principal_id — and answers 409 approval_authority_revoked with details.reapproval_required=true. The store would have answered 409 approval_not_approved (internal/workitems/action_approvals.go:774-775), which carries reapproval_required: false. docs/decision-context-contract.md tells callers to key on that flag, so the caller discards a live pending approval and asks a human for a new one.
internal/openapi/openapi.yaml:4612 still documents the pending case as approval_not_approved (pending), so the spec and the code disagree for exactly this row.
Terminal rows are masked the same way: an already-consumed approval whose fresh_until has passed answers approval_stale instead of approval_consumed (losing the replay signal), and a denied row whose denier has since been disabled answers approval_decider_disabled instead of approval_denied. The grant path is partly shielded — prepareApprovalGrant refuses a row with no human decider — but a consumed or denied grant row reaches spendApprovalGrant (internal/api/handlers_approval_grant.go:197) and is masked there too.
Fix: mirror the decide guard — only revalidate an approved row.
reval := governance.Result{}
if cur.State == workitems.ApprovalApproved {
var ok bool
if reval, ok = h.revalidateApproval(w, r, user, cur); !ok {
return
}
}Revalidation only has meaning for an approved row; every other state has a store refusal that is strictly more accurate.
| ArtifactHash: body.ArtifactHash, | ||
| WorkItemID: body.WorkItemID, | ||
| } | ||
| cc, err := governance.FillCreateContext(governance.CreateContext{ |
There was a problem hiding this comment.
P2 (carried from the previous round, still open) — create persists a row whose decision context can never be completed, and the only signal is a 422 at approve time.
FillCreateContext fills side_effect, policy_decision and policy_reasons only if p, ok := ps[intent.PolicyRuleID]; ok (internal/governance/governance.go:295), and governance.Policies holds exactly one entry, dev-loop.human-approval. risk_level is filled only for an action_kind in the operation registry. Nothing checks completeness here; validateContext only validates non-empty values.
Failure scenario: a service principal posts a request with policy_rule_id: "merge-needs-human" and no side_effect/policy_decision/policy_reasons — all three documented as Optional; defaults from the registered policy at internal/openapi/openapi.yaml:4437-4441. It gets 201 and a pending aar_. Every subsequent approve is 422 approval_context_incomplete with missing_fields: [side_effect, policy_decision, policy.reasons], permanently: there is no PATCH, the new backfill at internal/workitems/action_approvals.go:169 deliberately skips the row (intent_source is non-empty), and no env var relaxes the gate.
Retrying create does not recover it either: the decision-context fields are not part of IntentHash, so a retry with the same idempotency key and a corrected context matches on existing.IntentHash == hash (internal/workitems/action_approvals.go:517) and silently returns the old, incomplete row with created=false. Recovery needs a deny plus a brand-new idempotency key — i.e. a client change. Before this PR the same request approved fine, so an unchanged client is silently broken at the point a human tries to act.
TestF6_IncompleteContextCannotBeApproved codifies exactly this shape, and approvalBody in handlers_action_approvals_test.go:30-36 had to add all four fields with the comment "github.merge_pr is no registered operation and merge-needs-human no registered policy, so the client supplies what a reviewer must be shown". That contract is reasonable — it should just be enforced where it can still be met.
Fix: after FillCreateContext, refuse at create when the resulting context is not complete, with 422 approval_context_incomplete and details.missing_fields, rather than accepting a 201 that becomes a dead row. The handler already has everything it needs — build the prospective workitems.ActionApprovalRequest (or factor the mandatory-field loop out of DecisionContextOf) and check ContextComplete.
| WHEN intent_source <> '' THEN intent_source | ||
| WHEN work_item_id <> '' THEN 'work_item:' || work_item_id | ||
| ELSE 'execution:' || execution_id END | ||
| WHERE intent_source = '' AND decided_context_digest = ''; |
There was a problem hiding this comment.
P3 — the backfill does close the previous round's P1 for the common case (execution_id is required, so an empty intent_source really is a sound pre-governance marker, and the UPDATE is idempotent). Two gaps remain.
-
risk_levelis not backfilled.governance.RiskOfrecovers it only for anaction_kindin the operation registry; a legacy pending row for any other kind still reportsmissing_fields: [risk_level]and stays unapprovable.mctl-agents-approveis registered, so the DevLoop path is covered, but the generic create endpoint accepts an arbitraryaction_kind. AWHEN risk_level = '' THEN 'medium'arm would close the same hole the other four columns got. -
The
WHEREhas no state predicate, so the UPDATE also rewritesdenied,consumedandexecutedrows, assertingside_effect=writeandpolicy_decision=require_approvalabout side effects that already happened and were never classified.policy_reasons=[legacy.pre-governance]does mark them, which keeps it honest, but the P1 only neededstate IN ('pending','approved'); narrowing it leaves the durable record of past decisions untouched.
Either way, worth a line in docs/decision-context-contract.md: a reviewer looking at a backfilled row sees side_effect: write with no indication it is a placeholder unless they already know what legacy.pre-governance means.
| "github.com/mctlhq/mctl-api/internal/workitems" | ||
| ) | ||
|
|
||
| // Typed refusals. The first six mean the approval must be re-requested |
There was a problem hiding this comment.
P3 (carried, still open) — "The first six mean the approval must be re-requested" contradicts actionApprovalRefusals, where ErrContextIncomplete and ErrContextInvalid are reapproval: false (internal/api/handlers_action_approvals.go:118-119), in agreement with docs/decision-context-contract.md. The six that do set the flag are ErrContextChanged, ErrApprovalStale, ErrAuthorityRevoked, ErrDeciderDisabled, ErrPolicyDrift and workitems.ErrApprovalExpired — not a contiguous prefix of this block. Naming them, or dropping the count, would stop the comment from contradicting the code.
The rest of the open P3s from the previous rounds, all non-blocking, grouped here rather than reposted individually:
Result.PolicyStatus(line 395) is set but read by nobody —revalidateApprovalonly forwardsres.Revalidation— so a spend allowed under an unregistered rule withACTION_APPROVAL_REQUIRE_REGISTERED_POLICYoff leaves no trace in the consume audit.LiveAuthority.Mode(line 322) is assigned inrevalidateApprovaland never read anywhere; either audit it or drop the field.- Line 383:
Revalidationis overwritten withskipped_legacy_modeeven thoughCheckHumanPrincipaljust ran and passed — only theADMIN_PRINCIPALSmembership step was skipped. A distinct value (e.g.admin_check_skipped) would let the audit distinguish "nothing was re-checked" (line 365, no principal at all) from "the human check ran, membership did not". devLoopPolicy(internal/api/handlers_approval_governance.go:52) yields an empty version for aPoliciesset missingdev-loop.human-approval— map index on a missing key gives the zeroPolicy. That value feedsIntentHashinbindApprovalGrant, so the grant spend fails later with 409approval_intent_mismatch, a confusing symptom for a configuration mistake.Options.Clockis still unwired incmd/api/main.go, so the handler freshness check (h.approvalNow()) and the store CAS (Store.now()) read two independent clocks.TestF3_ConsumeRevalidatesBeforeSpend/staleonly passes because the handler wins that race; a test that injects the store clock instead would see them disagree.auditActionApproval(handlers_action_approvals.go:182) overrides the call site's operation risk with the approved action's risk, so anaction_approval.createentry reportshighfor what is a low-risk write; andparams["policy_version"]is unconditional while every neighbouring optional field is guarded by a non-empty check, so legacy rows emitpolicy_version: "".
| return newest.ID, true | ||
| case workitems.ApprovalPending: | ||
| decided, err := h.opts.WorkItems.DecideActionApproval(ctx, workitems.ActionDecisionInput{ | ||
| digest, ok := h.guardDecision(w, r, user, &newest, workitems.DecisionApprove, "") |
There was a problem hiding this comment.
P3 (carried, still open) — this call site did not get the precedence guard DecideActionApproval gained in fa980ef. The state is pending by construction here, so the terminal-state half does not apply, but the self-decision half does: if the caller is also newest.RequestedBy, an incomplete context answers 422 approval_context_incomplete instead of 403 approval_self_decision. Not reachable today (create is isDirectService-only and the approve route is admin-only), but it leaves the two decide paths disagreeing about precedence. Folding a.State == workitems.ApprovalPending && a.RequestedBy != principalOf(user) into guardDecision itself would keep both sites in step and remove the duplicated condition at handlers_action_approvals.go:488.
There was a problem hiding this comment.
Has P1/P2 findings, changes requested: 0 P1, 2 P2, 3 P3.
This round (f7b249c) adds the decide pre-read audit entry and fixes four test fixtures. The previous round's P1 (unbackfilled context columns) and the decide-path precedence P2 stay fixed, and the revalidation/store tests added in fa980ef hold up. Both remaining P2s are the ones carried from the last round, unchanged.
- Consume revalidates before the store's state checks (
handlers_action_approvals.go:536). The decide path got the state guard; consume did not. InTENANT_AUTHZ_MODE=principal, consuming a still-pending approval answers 409approval_authority_revokedwithreapproval_required=true— a pending row has nodecided_by_principal_id— instead of 409approval_not_approved. A caller following the documented rule of keying on that flag discards a live approval;openapi.yaml:4612still documentsapproval_not_approved (pending)for this case. Terminal rows are masked too (approval_staleoverapproval_consumed). The fix is the mirror of the one applied to decide: only revalidate when the state isapproved. - Create accepts an incomplete decision context and persists an unapprovable row (
handlers_action_approvals.go:314).FillCreateContextdefaultsside_effect/policy_decision/policy_reasonsonly for a rule ingovernance.Policies, which holds one entry, while openapi documents all three as optional. Any otherpolicy_rule_idyields 201 followed by a permanent 422 at approve. Retrying create does not recover it either: the context fields are not covered byintent_hash, so the same idempotency key returns the old incomplete row. Refusing at create withapproval_context_incompleteenforces the contract where it can still be met.
P3: the backfill leaves risk_level empty for an unregistered action_kind and also rewrites terminal rows; decideDevLoopApproval did not get the self-decision half of the precedence guard; the earlier nits are still open (the "first six" comment, unread PolicyStatus/Mode, overstated skipped_legacy_mode, silent empty devLoopPolicy version, unwired Options.Clock, audit risk override).
What holds up well: the length-prefixed canonical digest with a policy_reasons regex that excludes ,; fail-closed ErrAuthorityUnavailable; ordered revalidation pinned by a test; risk taken from the live registry so a client cannot downgrade a registered operation; fresh_until enforced both in the CAS predicate and post-hoc; context limited to references, digests and enums; and boot-time validation of ACTION_APPROVAL_MAX_DECISION_AGE.
Note: go build, go vet, go test and gofmt were all refused in this run, so nothing was compiled or executed; findings are from static review of 841d2e7...f7b249c only.
Antigravity (agy) reviewagy returned a result this workflow could not use; see the workflow log. Treated as a failed review. |
…reate Consume revalidated the decider's authority before looking at the approval's state, so in principal mode a pending request answered approval_authority_revoked with reapproval_required (a pending row records no decider), and a spent or executed one approval_stale. A caller keying on reapproval_required would discard a live request. The state refusal now comes first on the consume route and on the grant spend; revalidation runs only for an approved request. ConsumeStateRefusal is the store's own state switch, so executed stays final and answers approval_consumed as before (the execution-mark invariant from #716). Create accepted a decision context that an approve can never accept: the policy defaults cover registered rules only, so any other rule was stored, then refused at every approve with no way to complete it. Create now refuses it with 422 approval_context_incomplete and the missing fields, where the client can still supply them. The approve-time gate stays for rows created before this check.
|
Claude finished @mashkovd's task in 6m 26s —— View job Claude Code Review
Verdict: approved — 0 P1, 0 P2, 3 P3. Findings are posted as inline comments bound to Both previous P2s are fixed, and in the right shape
The DevLoop path is unaffected: P3 (inline)
P3 carried from earlier rounds, still open (not reposted)Backfill omits What holds upLength-prefixed canonical digest with a |
| // decision context's own names. A request created without them could never | ||
| // be approved, so create refuses it. intent_source is not checked: it is | ||
| // derived from the execution id, whose absence the store refuses as invalid. | ||
| func (c CreateContext) Missing() []string { |
There was a problem hiding this comment.
P3 — Missing() is a second, independent copy of the mandatory-field list that DecisionContextOf uses for ContextComplete (governance.go:234-252). The two agree today only by inspection: Missing() checks four fields, DecisionContextOf nine plus policy.reasons, and the gap is covered by reasoning stated in the doc comment (intent_source follows from the required execution_id; target/args_digest/intent_hash/policy.rule_id/policy.version are refused by ActionApprovalInput.validate).
That reasoning holds, but nothing pins it. A field added to the DecisionContextOf loop without a matching arm here silently reopens exactly the hole this commit closes: a 201 at create followed by a permanent 422 at approve, with no PATCH and a backfill that deliberately skips the row. A test that creates through the handler and asserts decision_context.context_complete == true on the 201 body would pin the invariant from the outside; deriving both lists from one table would remove the duplication altogether.
Related, same function: risk_level is required here but still not backfilled by the migration, so a pre-governance pending row whose action_kind is outside the operation registry remains unapprovable — new rows are now guarded, legacy ones are not.
| } | ||
| // Refused here, where the client can still supply what is missing: | ||
| // created incomplete, the request would only fail later at approve. | ||
| if missing := cc.Missing(); len(missing) > 0 { |
There was a problem hiding this comment.
P3 — the completeness gate runs before Store.CreateActionApproval, so it now outranks that method's own validation. A body that is both malformed and context-incomplete answers 422 approval_context_incomplete rather than the more actionable refusal: a missing target or args_digest would be 400 invalid_request, a credential in target 400 secret_in_text, an expires_at outside the window 400, an intent_hash that does not match 400 intent_hash_mismatch. Same shape as the precedence problem fixed on decide and consume in this PR, just benign here — the client has to fix both fields either way. Running the gate after in.validate() (or validating the intent fields first) would keep the most specific refusal.
One behaviour worth confirming is intended: the gate also precedes the idempotency lookup, so a retry under an already-stored key that omits a context field now gets 422 instead of the 200 replay of the stored row. The context fields are not part of intent_hash, so before this commit such a retry replayed fine. A client that reconstructs the full body each time is unaffected; one that retries from a stored intent only is not.
|
|
||
| `GET /api/v1/action-approvals/{id}` (and each list entry) returns | ||
| `decision_context`. An `approve` is refused with 422 | ||
| `approval_context_incomplete` (`details.missing_fields`, request stays pending) |
There was a problem hiding this comment.
P3 — this contract doc is the normative description of the behaviour and it no longer matches the code after 5ad100d. internal/openapi/openapi.yaml:4448 was updated; this file was not.
- The mandatory context is now enforced at create (422
approval_context_incomplete,details.missing_fields, nothing stored), not only at approve. This section and the refusal table at line 81 (approval_context_incomplete(decide)) still describe the approve gate as the only one, and lines 24/27 still callside_effect,policy.decisionandpolicy.reasons"request, else the registered policy" without saying that an unregistered rule makes them required, nor thatrisk_levelis required for an unregisteredaction_kind. - The spend ordering at lines 55-65 lists the revalidation steps as the first thing a consume does. The state refusal (
workitems.ConsumeStateRefusal) now precedes step 1 on both spend paths, which is the point of this commit: a pending row answersapproval_not_approvedand a spent oneapproval_consumed, neverapproval_authority_revoked. Worth being step 0 in that list, since the doc is what tells callers to key ondetails.reapproval_required.
There was a problem hiding this comment.
No P1/P2 findings (3 P3 posted inline, plus carried nits listed below). Good to merge.
Both P2s from the previous round are fixed in 5ad100d, and the fixes are the right shape.
- Consume precedence.
workitems.ConsumeStateRefusalis now a single exported function used by the store itself and by both spend paths (ConsumeActionApprovalhandler,spendApprovalGrant) before revalidation. A pending row answers 409approval_not_approvedrather thanapproval_authority_revokedwithreapproval_required=true, and a consumed/executed/denied/expired row keeps its own code.TestF3_ConsumeStateRefusalPrecedesRevalidationdrives pending, consumed and executed through the handler inTENANT_AUTHZ_MODE=principalwith the decider's authority removed, asserting the code and thatreapproval_requiredis absent; it also checks the row stays pending and that an executed row stays final. Code andopenapi.yaml:4612agree again. - Create completeness.
CreateContext.Missing()plus the refusal athandlers_action_approvals.go:325means an unapprovable row is no longer persisted; the 422 carriesdetails.missing_fieldsand the openapi field descriptions and the new422response were updated to match.TestF6_IncompleteContextIsRefusedAtCreatecovers the refusal, the exactmissing_fields, and that the same idempotency key with the context supplied is a fresh 201 rather than a replay of a stored incomplete row.TestF6_IncompleteContextCannotBeApprovednow builds its row through the store, so the approve-time gate is still covered for pre-governance rows.
The DevLoop path is unaffected: mctl-agents-approve is registered (RiskMedium) and the grant's intent hash is rebuilt from dev-loop.human-approval/v1, which is in governance.Policies, so both the registry and the policy defaults fill. Create does now refuse what it used to accept for any other policy_rule_id, which is the intended contract change but is a client-visible break with no kill switch — worth confirming no producer outside the DevLoop flow creates approvals under an unregistered rule.
P3, posted inline: Missing() duplicates the mandatory-field list in DecisionContextOf with nothing pinning the two together; the create gate outranks the store's own invalid_request/secret_in_text refusals and the idempotency replay; docs/decision-context-contract.md was not updated for the create-time 422 or the new state-refusal-first ordering, so it now disagrees with the openapi spec it describes.
P3 carried from earlier rounds, still open and not reposted: the backfill omits risk_level and has no state predicate; decideDevLoopApproval lacks the self-decision half of the precedence guard; the "first six" comment at governance.go:30 contradicts actionApprovalRefusals; Result.PolicyStatus and LiveAuthority.Mode are written and never read; skipped_legacy_mode overstates what was skipped at governance.go:404; devLoopPolicy yields a silent empty version; Options.Clock is still unwired in cmd/api/main.go, leaving two clocks on the freshness path; and the audit risk override conflates action risk with operation risk while policy_version is emitted unguarded.
Note: go build, go vet and go test were all refused in this run, so nothing was compiled or executed. Findings are from static review of 841d2e7...5ad100d only; I did check the new create gate against every HTTP create fixture in internal/api and they all supply (or default) a complete context.
Antigravity (agy) reviewagy returned a result this workflow could not use; see the workflow log. Treated as a failed review. |
Implements accepted proposal
mctl-api/issue-700-governance-define-authority-and-decision.Spec: https://github.com/mctlhq/mctl-gitops/tree/main/platform-gitops/agents-state/mctl-api/proposals/issue-700-governance-define-authority-and-decision/
Generated by mctl-agents Tier 2 implementer. Review carefully — this PR was opened automatically and the spec may have gaps.
Closes #700