From de8d184a4b037107c222bc162459eb0e51ca0805 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 15 May 2026 16:56:42 +0200 Subject: [PATCH] Add trusted auto-approval migration inventory --- docs/acceptance-policy.md | 3 + docs/migrations/trusted-auto-approval.md | 62 ++++++++++++++++++ src/repo_registry/cli.py | 50 ++++++++++++++ src/repo_registry/core/models.py | 16 +++++ src/repo_registry/core/service.py | 59 ++++++++++++++++- src/repo_registry/web_api/app.py | 15 +++++ src/repo_registry/web_api/schemas.py | 15 +++++ tests/test_cli.py | 34 ++++++++++ tests/test_trusted_auto_approval_migration.py | 65 +++++++++++++++++++ tests/test_web_api.py | 51 +++++++++++++++ ...-0014-agentic-characteristic-acceptance.md | 14 +++- 11 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 docs/migrations/trusted-auto-approval.md create mode 100644 tests/test_trusted_auto_approval_migration.py diff --git a/docs/acceptance-policy.md b/docs/acceptance-policy.md index 0b13657..dba353b 100644 --- a/docs/acceptance-policy.md +++ b/docs/acceptance-policy.md @@ -91,6 +91,9 @@ legacy path must be identifiable in review decisions and self-scoping assessment artifacts. They should be treated as review debt, not as evidence that deterministic approval is allowed. +The migration inventory and rebuild procedure are documented in +`docs/migrations/trusted-auto-approval.md`. + ## Quality Criteria Relationship The quality criteria registry in `docs/quality-criteria/` defines the formal diff --git a/docs/migrations/trusted-auto-approval.md b/docs/migrations/trusted-auto-approval.md new file mode 100644 index 0000000..a6282cf --- /dev/null +++ b/docs/migrations/trusted-auto-approval.md @@ -0,0 +1,62 @@ +# Trusted Auto-Approval Migration + +`trusted_auto_approve_candidate_graph` is historical migration behavior, not an +allowed acceptance path. Deterministic analysis may generate facts and +candidates, and deterministic quality gates may block or require review, but +approval now requires human judgement or configured agentic review. + +## Identify Historical Runs + +Use the inventory surfaces before rebuilding a repository with approved maps: + +```bash +repo-scoping list-legacy-auto-approvals --format json +``` + +The API exposes the same inventory at: + +```text +GET /review/migrations/trusted-auto-approvals +``` + +Each record identifies the repository, analysis run, review decision, current +approved ability count, scanner version when available, and the recommended next +step. These records are derived from review decisions whose action is +`trusted_auto_approve_candidate_graph`. + +## Rebuild Without Losing Audit History + +Historical review decisions are retained. Rebuilding characteristics creates a +new analysis run and can clear the currently approved characteristic tree, but it +does not delete the old review-decision audit trail. + +1. Run a dry run: + + ```bash + repo-scoping rebuild-characteristics --repo --dry-run --no-llm + ``` + +2. Inspect candidate output, quality-gate outcomes, and existing review + decisions. + +3. Confirm the rebuild only when ready: + + ```bash + repo-scoping rebuild-characteristics --repo --confirm --agentic-review + ``` + +4. If no agentic reviewer is configured, complete human review through the + candidate graph approval/edit/reject flow. + +## Compatibility Notes + +- `AnalysisRunCreate.trusted_auto_approve` remains as a deprecated API input + for older callers, but requests are routed to agentic review and do not + deterministically approve candidates. +- The CLI does not expose deterministic trusted auto-approval. Use + `--agentic-review` during rebuild or approve after human review. +- The service method `trusted_auto_approve_candidate_graph()` is guarded by + `allow_deprecated_migration_mode=True` and should only be used to replay or + inspect historical migration behavior in controlled tests or migration tools. +- Self-scoping assessment artifacts continue to flag + `trusted_auto_approve_candidate_graph` as review debt. diff --git a/src/repo_registry/cli.py b/src/repo_registry/cli.py index c4b6905..b66157e 100644 --- a/src/repo_registry/cli.py +++ b/src/repo_registry/cli.py @@ -1,6 +1,8 @@ from __future__ import annotations import argparse +import json +from dataclasses import asdict from pathlib import Path from typing import Sequence @@ -171,6 +173,19 @@ def build_parser() -> argparse.ArgumentParser: default="markdown", help="Criteria output format.", ) + legacy = subparsers.add_parser( + "list-legacy-auto-approvals", + help="List historical trusted deterministic auto-approval records.", + ) + legacy.add_argument("--database-path", help="Override REPO_REGISTRY_DATABASE_PATH.") + legacy.add_argument("--checkout-root", help="Override REPO_REGISTRY_CHECKOUT_ROOT.") + legacy.add_argument("--output", help="Write inventory output to this path instead of stdout.") + legacy.add_argument( + "--format", + choices=["json", "markdown"], + default="markdown", + help="Inventory output format.", + ) return parser @@ -187,6 +202,8 @@ def main(argv: Sequence[str] | None = None) -> int: return self_assess_command(args, parser) if args.command == "list-quality-criteria": return list_quality_criteria_command(args) + if args.command == "list-legacy-auto-approvals": + return list_legacy_auto_approvals_command(args) parser.error(f"unknown command: {args.command}") return 2 @@ -254,6 +271,39 @@ def list_quality_criteria_command(args: argparse.Namespace) -> int: return 0 +def list_legacy_auto_approvals_command(args: argparse.Namespace) -> int: + service = service_from_args(args) + records = service.list_trusted_auto_approval_migration_records() + if args.format == "json": + content = json.dumps([asdict(record) for record in records], indent=2) + "\n" + else: + content = legacy_auto_approval_records_markdown(records) + if args.output: + write_text(args.output, content) + else: + print(content, end="" if content.endswith("\n") else "\n") + return 0 + + +def legacy_auto_approval_records_markdown(records) -> str: + if not records: + return "No legacy trusted auto-approval records found.\n" + lines = ["# Legacy Trusted Auto-Approval Records", ""] + for record in records: + lines.extend( + [ + ( + f"- repo={record.repository_id}:{record.repository_name} " + f"run={record.analysis_run_id} decision={record.review_decision_id}" + ), + f" status={record.analysis_run_status} scanner={record.scanner_version or 'unknown'}", + f" approved_abilities={record.current_approved_ability_count}", + f" next={record.recommended_next_step}", + ] + ) + return "\n".join(lines) + "\n" + + def self_assess_command( args: argparse.Namespace, parser: argparse.ArgumentParser, diff --git a/src/repo_registry/core/models.py b/src/repo_registry/core/models.py index 22aef0f..51a08c8 100644 --- a/src/repo_registry/core/models.py +++ b/src/repo_registry/core/models.py @@ -63,6 +63,22 @@ class ReviewDecision: decision_kind: str = "other" +@dataclass(frozen=True) +class TrustedAutoApprovalMigrationRecord: + repository_id: int + repository_name: str + repository_url: str + repository_status: str + analysis_run_id: int | None + analysis_run_status: str + scanner_version: str + review_decision_id: int + decision_created_at: str + notes: str + current_approved_ability_count: int + recommended_next_step: str + + def enrich_review_decision(decision: ReviewDecision) -> ReviewDecision: fields = review_decision_audit_fields(decision.action, decision.notes) return replace_review_decision(decision, **fields) diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index b46b6ea..307c313 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -40,6 +40,7 @@ from repo_registry.core.models import ( ReviewDecision, ScanSummary, SearchResult, + TrustedAutoApprovalMigrationRecord, enrich_review_decision, ) from repo_registry.candidate_graph.generator import CandidateGraphGenerator @@ -52,7 +53,7 @@ from repo_registry.repo_ingestion.git import GitIngestionService from repo_registry.repo_ingestion.metadata import RepositoryMetadataExtractor from repo_registry.repo_scanning.scanner import DeterministicScanner from repo_registry.semantic import EmbeddingProvider, cosine_similarity -from repo_registry.storage.sqlite import RegistryStore +from repo_registry.storage.sqlite import NotFoundError, RegistryStore class RegistryService: @@ -426,6 +427,54 @@ class RegistryService: ) ] + def list_trusted_auto_approval_migration_records( + self, + ) -> list[TrustedAutoApprovalMigrationRecord]: + records: list[TrustedAutoApprovalMigrationRecord] = [] + for repository in self.list_repositories(): + approved_ability_count = len(self.ability_map(repository.id).abilities) + for decision in self.list_review_decisions(repository.id): + if decision.action != "trusted_auto_approve_candidate_graph": + continue + run_status = "unknown" + scanner_version = "" + if decision.analysis_run_id is not None: + try: + run = self.get_analysis_run( + repository.id, + decision.analysis_run_id, + ) + except NotFoundError: + run_status = "missing" + else: + run_status = run.status + scanner_version = run.scanner_version + records.append( + TrustedAutoApprovalMigrationRecord( + repository_id=repository.id, + repository_name=repository.name, + repository_url=repository.url, + repository_status=repository.status, + analysis_run_id=decision.analysis_run_id, + analysis_run_status=run_status, + scanner_version=scanner_version, + review_decision_id=decision.id, + decision_created_at=decision.created_at, + notes=decision.notes, + current_approved_ability_count=approved_ability_count, + recommended_next_step=( + "Dry-run rebuild-characteristics, then confirm a " + "rebuild with manual or agentic review before " + "publishing replacement registry truth." + ), + ) + ) + return sorted( + records, + key=lambda record: (record.decision_created_at, record.review_decision_id), + reverse=True, + ) + def record_expectation_gap( self, repository_id: int, @@ -626,7 +675,15 @@ class RegistryService: analysis_run_id: int, *, notes: str = "", + allow_deprecated_migration_mode: bool = False, ) -> RepositoryAbilityMap: + if not allow_deprecated_migration_mode: + raise ValueError( + "trusted deterministic auto-approval is deprecated. Use " + "request_agentic_review() or approve_candidate_graph() after " + "human review; pass allow_deprecated_migration_mode=True only " + "when replaying historical migration behavior." + ) graph = self.store.get_candidate_graph(repository_id, analysis_run_id) approved_count = 0 skipped_count = 0 diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index 7a3bb55..03bbd1b 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -75,6 +75,7 @@ from repo_registry.web_api.schemas import ( ReviewDecisionResponse, ScanSummaryResponse, SearchResultResponse, + TrustedAutoApprovalMigrationRecordResponse, ) @@ -208,6 +209,20 @@ def list_quality_criteria() -> dict[str, object]: return criteria_registry_dict(load_quality_criteria()) +@app.get( + "/review/migrations/trusted-auto-approvals", + tags=["review"], + response_model=list[TrustedAutoApprovalMigrationRecordResponse], +) +def list_trusted_auto_approval_migration_records( + service: RegistryService = Depends(get_service), +) -> list[dict[str, object]]: + return [ + asdict(record) + for record in service.list_trusted_auto_approval_migration_records() + ] + + @app.post( "/repos", status_code=201, diff --git a/src/repo_registry/web_api/schemas.py b/src/repo_registry/web_api/schemas.py index 988278e..032643a 100644 --- a/src/repo_registry/web_api/schemas.py +++ b/src/repo_registry/web_api/schemas.py @@ -524,6 +524,21 @@ class ReviewDecisionResponse(BaseModel): decision_kind: str = "other" +class TrustedAutoApprovalMigrationRecordResponse(BaseModel): + repository_id: int + repository_name: str + repository_url: str + repository_status: str + analysis_run_id: int | None + analysis_run_status: str + scanner_version: str + review_decision_id: int + decision_created_at: str + notes: str + current_approved_ability_count: int + recommended_next_step: str + + class QualityCriterionResponse(BaseModel): id: str title: str diff --git a/tests/test_cli.py b/tests/test_cli.py index aae4620..64b8098 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -182,6 +182,40 @@ def test_list_quality_criteria_cli_writes_json(tmp_path): ) +def test_list_legacy_auto_approvals_cli_writes_json_inventory(tmp_path): + service = make_service(tmp_path) + source = write_repo(tmp_path) + repository = service.register_repository(name="Legacy CLI", url=str(source)) + summary = service.analyze_repository(repository.id, use_llm_assistance=False) + service.trusted_auto_approve_candidate_graph( + repository.id, + summary.analysis_run.id, + allow_deprecated_migration_mode=True, + ) + output_path = tmp_path / "legacy-auto-approvals.json" + + exit_code = main( + [ + "list-legacy-auto-approvals", + "--format", + "json", + "--output", + str(output_path), + "--database-path", + str(tmp_path / "registry.sqlite3"), + "--checkout-root", + str(tmp_path / "checkouts"), + ] + ) + + records = json.loads(output_path.read_text(encoding="utf-8")) + assert exit_code == 0 + assert records[0]["repository_id"] == repository.id + assert records[0]["repository_name"] == "Legacy CLI" + assert records[0]["analysis_run_id"] == summary.analysis_run.id + assert records[0]["current_approved_ability_count"] == 1 + + def test_self_assess_cli_exports_challenger_and_comparison(tmp_path): source = write_repo(tmp_path) golden_path = tmp_path / "golden.json" diff --git a/tests/test_trusted_auto_approval_migration.py b/tests/test_trusted_auto_approval_migration.py new file mode 100644 index 0000000..d239dc8 --- /dev/null +++ b/tests/test_trusted_auto_approval_migration.py @@ -0,0 +1,65 @@ +from repo_registry.core.service import RegistryService +from repo_registry.repo_ingestion.git import GitIngestionService +from repo_registry.storage.sqlite import RegistryStore + + +def make_service(tmp_path): + store = RegistryStore(tmp_path / "registry.sqlite3") + store.initialize() + return RegistryService(store, ingestion=GitIngestionService(tmp_path / "checkouts")) + + +def write_api_repo(tmp_path): + source = tmp_path / "api-repo" + source.mkdir() + (source / "README.md").write_text("# API Repo\nReports health.\n", encoding="utf-8") + (source / "app.py").write_text('@app.get("/health")\ndef health():\n return {}\n', encoding="utf-8") + return source + + +def test_trusted_auto_approval_requires_explicit_migration_mode(tmp_path): + service = make_service(tmp_path) + repository = service.register_repository( + name="Legacy Guard Repo", + url=str(write_api_repo(tmp_path)), + ) + summary = service.analyze_repository(repository.id, use_llm_assistance=False) + + try: + service.trusted_auto_approve_candidate_graph( + repository.id, + summary.analysis_run.id, + ) + except ValueError as exc: + assert "deprecated" in str(exc) + else: + raise AssertionError("trusted auto-approval should be guarded by default") + + assert service.ability_map(repository.id).abilities == [] + + +def test_legacy_auto_approval_inventory_identifies_review_debt(tmp_path): + service = make_service(tmp_path) + repository = service.register_repository( + name="Legacy Inventory Repo", + url=str(write_api_repo(tmp_path)), + ) + summary = service.analyze_repository(repository.id, use_llm_assistance=False) + + service.trusted_auto_approve_candidate_graph( + repository.id, + summary.analysis_run.id, + notes="Historical migration replay.", + allow_deprecated_migration_mode=True, + ) + + records = service.list_trusted_auto_approval_migration_records() + assert len(records) == 1 + record = records[0] + assert record.repository_id == repository.id + assert record.repository_name == "Legacy Inventory Repo" + assert record.analysis_run_id == summary.analysis_run.id + assert record.review_decision_id > 0 + assert record.analysis_run_status == "completed" + assert record.current_approved_ability_count == 1 + assert "rebuild" in record.recommended_next_step diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 7ec423b..61a7ec7 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -3,6 +3,9 @@ import sqlite3 from fastapi.testclient import TestClient +from repo_registry.core.service import RegistryService +from repo_registry.repo_ingestion.git import GitIngestionService +from repo_registry.storage.sqlite import RegistryStore from repo_registry.web_api import app as app_module from repo_registry.web_api.app import Settings, app, get_service, get_settings @@ -191,6 +194,48 @@ def test_quality_gate_override_api_records_auditable_override(tmp_path): app.dependency_overrides.clear() +def test_trusted_auto_approval_migration_inventory_api(tmp_path): + source = tmp_path / "legacy-api-repo" + source.mkdir() + (source / "README.md").write_text("# Legacy API Repo\nReports health.\n", encoding="utf-8") + (source / "app.py").write_text('@app.get("/health")\ndef health():\n return {}\n', encoding="utf-8") + database_path = str(tmp_path / "legacy-auto-approval.sqlite3") + store = RegistryStore(database_path) + store.initialize() + service = RegistryService( + store, + ingestion=GitIngestionService(tmp_path / "checkouts"), + ) + repository = service.register_repository(name="Legacy API Repo", url=str(source)) + summary = service.analyze_repository(repository.id, use_llm_assistance=False) + service.trusted_auto_approve_candidate_graph( + repository.id, + summary.analysis_run.id, + allow_deprecated_migration_mode=True, + ) + + def override_settings(): + return Settings( + database_path=database_path, + checkout_root=str(tmp_path / "checkouts"), + ) + + app.dependency_overrides[get_settings] = override_settings + client = TestClient(app) + try: + response = client.get("/review/migrations/trusted-auto-approvals") + + assert response.status_code == 200 + records = response.json() + assert records[0]["repository_id"] == repository.id + assert records[0]["repository_name"] == "Legacy API Repo" + assert records[0]["analysis_run_id"] == summary.analysis_run.id + assert records[0]["current_approved_ability_count"] == 1 + assert "rebuild" in records[0]["recommended_next_step"] + finally: + app.dependency_overrides.clear() + + def test_openapi_contract_snapshot_for_stable_agent_paths(): client = TestClient(app) @@ -246,6 +291,12 @@ def test_openapi_contract_snapshot_for_stable_agent_paths(): "success_schema": "QualityCriteriaRegistryResponse", } }, + "/review/migrations/trusted-auto-approvals": { + "get": { + "tags": ["review"], + "success_schema": "list[TrustedAutoApprovalMigrationRecordResponse]", + } + }, "/repos": { "get": {"tags": ["repositories"], "success_schema": "list[RepositoryResponse]"}, "post": {"tags": ["repositories"], "success_schema": "RepositoryResponse"}, diff --git a/workplans/RREG-WP-0014-agentic-characteristic-acceptance.md b/workplans/RREG-WP-0014-agentic-characteristic-acceptance.md index bb3764b..2142f44 100644 --- a/workplans/RREG-WP-0014-agentic-characteristic-acceptance.md +++ b/workplans/RREG-WP-0014-agentic-characteristic-acceptance.md @@ -4,7 +4,7 @@ type: workplan title: "Agentic Characteristic Acceptance" domain: capabilities repo: repo-scoping -status: active +status: done owner: codex topic_slug: foerster-capabilities created: "2026-05-15" @@ -258,7 +258,7 @@ metadata, and manual approval still produces human review decisions. ```task id: RREG-WP-0014-T08 -status: todo +status: done priority: medium state_hub_task_id: "3d5475f6-71a7-4ca7-aa69-573e91d1fe1e" ``` @@ -272,6 +272,16 @@ Acceptance criteria: - The old behavior is either removed or guarded behind an explicit deprecated migration mode that cannot run by default. +Implementation note 2026-05-15: added a guarded migration compatibility layer +for historical `trusted_auto_approve_candidate_graph` records. The service now +requires `allow_deprecated_migration_mode=True` before replaying the legacy +auto-approval method, exposes an inventory of legacy auto-approval review debt, +and documents the dry-run/rebuild/re-review path in +`docs/migrations/trusted-auto-approval.md`. Added +`repo-scoping list-legacy-auto-approvals` and +`GET /review/migrations/trusted-auto-approvals` so existing maps produced by the +legacy path can be identified before rebuilds. + ## Completion Criteria - Deterministic rules no longer approve candidate characteristics.