fix(harness): atomic claim for auto-approval and rejected proposals stay rejected

보안 리뷰 반영. 자동 승인은 approved_at·executed_at·rejected_at 이 비어 있고 만료 전인
행만 조건부 UPDATE 로 선점한 경우에만 실행하므로, 사람의 승인·실행·거절이 먼저 오면
이중 실행이 없다. proposals 에 rejected_at 열을 추가해 거절 경로가 기록하고,
approve/authorize/자동 승인이 거절된 제안을 거부한다.
This commit is contained in:
seon committed 2026-10-15 23:41:44 +09:00
1 parent ca933a646e
commit 85d99324a1
5 files changed
+34 -8

No files matched your search

+21 -3
View File
@@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS runs (
);
ALTER TABLE runs ADD COLUMN IF NOT EXISTS model text; -- LLM requested for the run's diagnoses (campaign-level model selection, 2026-10-14)
ALTER TABLE runs ADD COLUMN IF NOT EXISTS auto_approve_after_s int; -- measurement mode: the harness approves + executes layer-2 proposals after this delay (2026-10-15)
ALTER TABLE proposals ADD COLUMN IF NOT EXISTS rejected_at timestamptz; -- POST /proposals/{token}/reject (a rejected proposal can no longer be approved or executed)
CREATE TABLE IF NOT EXISTS scenarios (
scenario_id text PRIMARY KEY,
path text NOT NULL,
@@ -287,13 +288,14 @@ type ProposalRow struct {
ApprovedAt *time.Time
ApprovedBy *string
ExecutedAt *time.Time
RejectedAt *time.Time
ExpiresAt time.Time
}
func (s *Store) GetProposal(ctx context.Context, token string) (*ProposalRow, error) {
var p ProposalRow
err := s.pool.QueryRow(ctx, `SELECT token,run_id,scenario_id,action_id,approved_at,approved_by,executed_at,expires_at FROM proposals WHERE token=$1`, token).
Scan(&p.Token, &p.RunID, &p.ScenarioID, &p.ActionID, &p.ApprovedAt, &p.ApprovedBy, &p.ExecutedAt, &p.ExpiresAt)
err := s.pool.QueryRow(ctx, `SELECT token,run_id,scenario_id,action_id,approved_at,approved_by,executed_at,rejected_at,expires_at FROM proposals WHERE token=$1`, token).
Scan(&p.Token, &p.RunID, &p.ScenarioID, &p.ActionID, &p.ApprovedAt, &p.ApprovedBy, &p.ExecutedAt, &p.RejectedAt, &p.ExpiresAt)
if err != nil {
return nil, err
}
@@ -301,7 +303,23 @@ func (s *Store) GetProposal(ctx context.Context, token string) (*ProposalRow, er
}
func (s *Store) ApproveProposal(ctx context.Context, token, by string) error {
_, err := s.pool.Exec(ctx, `UPDATE proposals SET approved_at=now(), approved_by=$2 WHERE token=$1 AND approved_at IS NULL`, token, by)
_, err := s.pool.Exec(ctx, `UPDATE proposals SET approved_at=now(), approved_by=$2 WHERE token=$1 AND approved_at IS NULL AND rejected_at IS NULL`, token, by)
return err
}
// ClaimProposal approves and marks executed in one conditional update, so exactly one caller (auto-approval or a
// human/bot execute racing with it) wins: it reports whether this call claimed the proposal.
func (s *Store) ClaimProposal(ctx context.Context, token, by string) (bool, error) {
tag, err := s.pool.Exec(ctx, `UPDATE proposals SET approved_at=now(), approved_by=$2, executed_at=now()
WHERE token=$1 AND approved_at IS NULL AND executed_at IS NULL AND rejected_at IS NULL AND expires_at > now()`, token, by)
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (s *Store) RejectProposal(ctx context.Context, token string) error {
_, err := s.pool.Exec(ctx, `UPDATE proposals SET rejected_at=now() WHERE token=$1 AND approved_at IS NULL AND rejected_at IS NULL`, token)
return err
}