Repository navigation
feat(approval): approval provenance and single-use execution mark - #716
Conversation
The approve workflow had no way to name the exact action approval it was
acting on: it learned the approver and the content digest from the grant,
but not the grant itself, so a flip could not be tied back to one aar_.
A grant-authorized mctl-agents-approve now passes approval_ref, set by the
server from the grant it verified and is about to consume. Like the other
grant-derived inputs it is never accepted from a request body, on any path,
so its presence always means the server spent that grant.
A consume records that a side effect was authorized, not that it ran. The
new consumed -> executed transition (POST /action-approvals/{id}/execute)
lets the requesting service record, exactly once, that the side effect was
carried out for the bound target and artifact. It is one compare-and-set
guarded by requester, target and a non-empty artifact hash, so racing or
repeated marks cannot record a second execution or a different artifact,
and each refusal has its own typed code. The state CHECK is widened in
place for tables created before the state existed.
Claude-Session: https://claude.ai/code/session_01HuqYdXZiB63R9bmak1DWDN
|
Claude finished @mashkovd's task in 7m 9s —— View job Review posted — 1 P2, 2 P3
FindingsP2 — P3 — P3 — the What holds upBoth compare-and-sets are correctly guarded and their loser paths are read-back-only, so the single-use claim survives the race (the losing racers deterministically read Not verified
|
There was a problem hiding this comment.
Has P1/P2 findings, changes requested: 1 P2, 2 P3.
P2: approval_ref is declared without OmitWhenEmpty, so ApplyDefaults + buildArgoParams send approval_ref="" on every mctl-agents-approve submit, not only grant-authorized ones. That is the hazard registry.go:79-88 documents, and it is what turns the mctl-gitops#2004 dependency into a hard, non-reversible deploy ordering. One field removes it; the grant path is unaffected.
P3: the new approval_executed code is reachable from the dev-loop approve route but missing from that route's documented 409 set (openapi.yaml:3099-3100, :3152, docs/principals.md:468); and the handler's sha256:<64 hex> guard on artifact_hash is stricter than the store/create contract, making /execute DevLoop-only without saying so.
The rest holds up. Both compare-and-sets are correctly guarded and the loser paths are read-back-only, so the single-use claim survives the race; executed was threaded through every state switch I could find (effectiveState, ValidApprovalState, the list filter's default branch, ConsumeActionApproval, ActionApprovalGrant, closedApprovalCodes); the body approval_ref refusal sits after StripUndeclared where the now-declared key survives, and covers grant, admin and service paths with an audit entry each; the in-place CHECK widening is guarded and idempotent across replica restarts. Test coverage is unusually complete, including the unbound-artifact case and 8-way concurrency at both layers.
Note: I could not run go build, go vet or go test in this sandbox (the Go toolchain required approval), so the verdict is from static review only.
Off the grant path approval_ref reached the workflow as "", so the argument's presence did not mean a grant was spent. It is now OmitWhenEmpty: absent unless the server set it from a grant. The execute mark compared artifact_hash against the stored value but also required the sha256 digest shape, which refused artifacts recorded in any other form. It now only requires a non-empty value; the store's compare-and-set already demands a non-empty stored hash equal to it. The dev-loop approve route's documented 409 set now names approval_executed, which it returns for an executed grant. Claude-Session: https://claude.ai/code/session_01HuqYdXZiB63R9bmak1DWDN
|
Claude finished @mashkovd's task in 2m 55s —— View job Re-review of
|
There was a problem hiding this comment.
No P1/P2 findings (0 P3). Good to merge.
All three findings from the review of b9af9bf are resolved in a669cda:
- P2
approval_ref/OmitWhenEmpty: the ParameterDef now setsOmitWhenEmpty: true, soApplyDefaults(registry.go:330-339) deletes the key when absent or empty andbuildArgoParamsno longer forwardsapproval_ref=""off the grant path. The grant path is unaffected —prepareApprovalGrantsets a non-emptyaar_id beforeApplyDefaultsruns (handlers_write.go:139 then :354), andValidateInputstill checks the pattern. The body refusal is unchanged in behaviour: it is a presence check on the stripped input at handlers_write.go:132, beforeApplyDefaults, so a body sendingapproval_ref: ""is still refused rather than silently deleted —TestApprovalGrant_BodyApprovalRefIsRefusedcovers that value explicitly.handlers_write_approver_test.gonow asserts absence (not "") for human admin and service alike. - P3 409 code set:
approval_executedadded to openapi.yaml:3099-3100, openapi.yaml:3152 and the typed-refusal table in docs/principals.md. - P3 artifact-hash guard: the handler's
sha256:<64 hex>check is replaced by a non-empty check, which tracks the store contract (checkText(..., MaxApprovalFieldBytes, true)plus equality against a non-empty stored hash), so a non-digest bound artifact now gets a typed 409approval_target_mismatchinstead of 400. OpenAPI relaxed tominLength: 1and the refusal test moved thesha256:diffcase from the 400 group to the 409 group;stringsis still used in that file.
Everything else from the previous pass still holds: both compare-and-sets are correctly guarded with read-back-only loser paths, executed is threaded through effectiveState, ValidApprovalState, the list filter, ConsumeActionApproval, ActionApprovalGrant and closedApprovalCodes, and the in-place CHECK widening is guarded and idempotent.
Note: the PR body's "Deploy order" section is now stale — with OmitWhenEmpty only grant-authorized submits carry approval_ref, so mctl-gitops#2004 is still needed for the grant path but no longer gates ordinary approvals, and a rollback is survivable.
Note: go build, go vet and go test all required approval in this sandbox, so this verdict is from static review only.
Antigravity (agy) review — informationalModel: gemini-3.8-flash-high · commit FindingsFinding 1 (P2)
Finding 2 (P3)
|
Antigravity (agy) review — informationalModel: gemini-3.8-flash-high · commit Findings[P2] Missing column list in test
|
|
Re agy P2 (missing column list in the test |
…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.
Summary
Two additions to action approvals:
Approval provenance. A grant-authorized
mctl-agents-approvenow passesapproval_ref, the id of the action approval it spends.prepareApprovalGrantsets it, next toapproverandexpected_content_digest.approval_refgets 400actor_not_acceptedand a denied audit entry on every path (grant, human admin, service).^aar_[A-Za-z0-9_-]{1,124}$.Single-use execution mark. A consume records that a side effect was authorized, not that it ran. The new
POST /api/v1/action-approvals/{id}/executewith body{target, artifact_hash}moves a consumed request toexecutedand recordsexecuted_at.approval_not_consumed,approval_executed,approval_deniedandapproval_target_mismatch, each audited asaction_approval.execute_refused.executedis final: it never expires, it is not a live delegation grant, and consuming it again answersapproval_consumed.executed_atand widens the state CHECK in place, only when it does not already admitexecuted, so a restart does nothing. I checked it on a database with the old constraint and an existing consumed row.Deploy order
mctl-gitops#2004 has to be live first. Every
mctl-agents-approvesubmit now carries anapproval_refargument (empty off the grant path, from the defaults), and the workflow template has to declare it.Test plan
go test ./... -p 1: 40 packages, all pass.handlers_action_approval_execute_test.go:approval_refrefusal tested on the grant, human-admin and service paths.🤖 Generated with Claude Code
https://claude.ai/code/session_01HuqYdXZiB63R9bmak1DWDN