#!/usr/bin/env python3 """Approve a strict kb_stage proposal for guarded canonical apply. Stage 1 of the KB apply pipeline: REVIEW -> approve -> apply -> render. This tool does not create canonical rows. It only moves exactly one strict, pending, applyable proposal from ``pending_review`` to ``approved`` after a human/operator supplies a reviewer handle and verbatim review note. The apply step remains separate and is performed by ``scripts/apply_proposal.py``. The guardrails intentionally mirror the apply worker: * proposal must be ``pending_review``; * proposal_type must be one of the worker-supported applyable types; * payload must contain a strict ``apply_payload`` contract; * the strict payload must build valid apply SQL before approval is recorded; * the UPDATE must affect exactly one row. ``--dry-run`` validates the proposal and prints the SQL without writing. """ from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) import apply_proposal as ap # noqa: E402 def load_review_note(args: argparse.Namespace) -> str: if args.review_note and args.review_note_file: raise SystemExit("use either --review-note or --review-note-file, not both") note = Path(args.review_note_file).read_text(encoding="utf-8") if args.review_note_file else args.review_note or "" note = note.strip() if not note: raise SystemExit("review note is required and must be non-empty") return note def assert_approvable(proposal: dict[str, Any], reviewed_by: str, review_note: str) -> None: if not reviewed_by.strip(): raise SystemExit("--reviewed-by is required and must be non-empty") if not review_note.strip(): raise SystemExit("--review-note is required and must be non-empty") status = proposal.get("status") proposal_id = proposal.get("id") if status == "approved": raise SystemExit(f"proposal {proposal_id} is already approved") if status == "applied": raise SystemExit(f"proposal {proposal_id} is already applied") if status != "pending_review": raise SystemExit( f"proposal {proposal_id} has status {status!r}; only 'pending_review' proposals can be approved" ) ptype = proposal.get("proposal_type") if ptype not in ap.APPLYABLE_TYPES: raise SystemExit( f"proposal {proposal_id} has proposal_type {ptype!r}; only {ap.APPLYABLE_TYPES!r} are applyable" ) payload = proposal.get("payload") or {} if not isinstance(payload, dict) or payload.get("apply_payload") is None: raise SystemExit( f"proposal {proposal_id} has no payload.apply_payload; run/repair normalization before approval" ) # Validate the strict contract before recording approval. This is pure SQL # building: it does not execute canonical writes. ap.build_apply_sql(proposal, ap.SERVICE_AGENT_HANDLE) def build_approve_sql(proposal_id: str, reviewed_by: str, review_note: str) -> str: allowed_types_sql = ", ".join(ap.sql_literal(t) for t in ap.APPLYABLE_TYPES) return f""" begin; with reviewed as ( update kb_stage.kb_proposals set status = 'approved', reviewed_by_handle = {ap.sql_literal(reviewed_by)}, reviewed_at = now(), review_note = {ap.sql_literal(review_note)}, updated_at = now() where id = {ap.sql_literal(proposal_id)}::uuid and status = 'pending_review' and proposal_type in ({allowed_types_sql}) and payload ? 'apply_payload' returning id, proposal_type, status, reviewed_by_handle, reviewed_at, review_note ) select coalesce( jsonb_agg(jsonb_build_object( 'id', id::text, 'proposal_type', proposal_type, 'status', status, 'reviewed_by_handle', reviewed_by_handle, 'reviewed_at', reviewed_at::text, 'review_note', review_note )), '[]'::jsonb )::text from reviewed; commit; """.lstrip() def parse_reviewed_rows(output: str, proposal_id: str) -> list[dict[str, Any]]: rows = json.loads(output.strip() or "[]") if not isinstance(rows, list): raise SystemExit("approval update returned non-list JSON") if len(rows) != 1: raise SystemExit( f"approval update affected {len(rows)} rows for proposal {proposal_id}; expected exactly 1" ) return rows def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("proposal_id", help="UUID of the pending strict proposal to approve") parser.add_argument("--reviewed-by", required=True, help="reviewer handle to record") note = parser.add_mutually_exclusive_group(required=True) note.add_argument("--review-note", help="verbatim review note to record") note.add_argument("--review-note-file", help="path containing the verbatim review note") parser.add_argument("--dry-run", action="store_true", help="print the SQL, do not execute the approval") parser.add_argument("--secrets-file", default=ap.DEFAULT_SECRETS_FILE) parser.add_argument("--container", default=ap.DEFAULT_CONTAINER) parser.add_argument("--db", default=ap.DEFAULT_DB) parser.add_argument("--host", default=ap.DEFAULT_HOST) parser.add_argument("--role", default=ap.DEFAULT_ROLE) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(sys.argv[1:] if argv is None else argv) review_note = load_review_note(args) password = ap.load_password(args.secrets_file) proposal = ap.load_proposal(args, password) assert_approvable(proposal, args.reviewed_by, review_note) sql = build_approve_sql(args.proposal_id, args.reviewed_by.strip(), review_note) if args.dry_run: print(sql) return 0 out = ap.run_psql(args, sql, password) rows = parse_reviewed_rows(out, args.proposal_id) print(json.dumps(rows[0], sort_keys=True)) return 0 if __name__ == "__main__": raise SystemExit(main())