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:
1 parent
ca933a646e
commit
85d99324a1
5 files changed
+34
-8
No files matched your search
+21
-3
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user