feat(governance): 新增 fixture dry-run 證據包
All checks were successful
CD Pipeline / tests (push) Successful in 1m34s
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / build-and-deploy (push) Successful in 4m22s
CD Pipeline / post-deploy-checks (push) Successful in 1m42s

This commit is contained in:
Your Name
2026-06-11 23:08:42 +08:00
parent f94e394a28
commit 53fdbd252f
15 changed files with 1186 additions and 13 deletions

View File

@@ -70,6 +70,9 @@ from src.services.ai_agent_learning_writeback_approval_package import (
from src.services.ai_agent_live_read_model_gate import (
load_latest_ai_agent_live_read_model_gate,
)
from src.services.ai_agent_owner_approved_fixture_dry_run import (
load_latest_ai_agent_owner_approved_fixture_dry_run,
)
from src.services.ai_agent_owner_approved_learning_dry_run import (
load_latest_ai_agent_owner_approved_learning_dry_run,
)
@@ -754,6 +757,34 @@ async def get_agent_owner_approved_learning_dry_run() -> dict[str, Any]:
) from exc
@router.get(
"/agent-owner-approved-fixture-dry-run",
response_model=dict[str, Any],
summary="取得 AI Agent owner-approved fixture dry-run 批准包",
description=(
"讀取最新已提交的 owner-approved fixture dry-run 批准包;此端點只回傳 fixture-only dry-run 證據,"
"不寫 KM、不更新 PlayBook trust、不寫 timeline / replay score、不寫 Gateway queue、不呼叫 Telegram Bot API、"
"不啟動 runtime worker、不開 Redis consumer group、不執行 DB migration、不觸發 workflow、"
"不執行主機或 cluster 指令、不使用 secrets 或付費 API、不回傳未核准內部細節。"
),
)
async def get_agent_owner_approved_fixture_dry_run() -> dict[str, Any]:
"""Return the latest read-only AI Agent owner-approved fixture dry-run package."""
try:
return await asyncio.to_thread(load_latest_ai_agent_owner_approved_fixture_dry_run)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except (json.JSONDecodeError, ValueError) as exc:
logger.error("ai_agent_owner_approved_fixture_dry_run_invalid", error=str(exc))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI Agent owner-approved fixture dry-run 批准包無效",
) from exc
@router.get(
"/agent-proactive-operations-contract",
response_model=dict[str, Any],

View File

@@ -0,0 +1,213 @@
"""
AI Agent owner-approved fixture dry-run snapshot.
Loads the latest committed P2-403F fixture-only dry-run package. This module
never writes KM, updates PlayBook trust, writes timeline or replay scores,
writes Gateway queues, sends Telegram messages, opens Redis consumer groups,
starts workers, runs workflows, invokes secrets or paid APIs, or executes host
and cluster commands.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from src.services.snapshot_paths import default_evaluations_dir
_DEFAULT_EVALUATIONS_DIR = default_evaluations_dir(Path(__file__))
_SNAPSHOT_PATTERN = "ai_agent_owner_approved_fixture_dry_run_*.json"
_SCHEMA_VERSION = "ai_agent_owner_approved_fixture_dry_run_v1"
_RUNTIME_AUTHORITY = "owner_approved_fixture_dry_run_only_no_live_write"
def load_latest_ai_agent_owner_approved_fixture_dry_run(
evaluations_dir: Path | None = None,
) -> dict[str, Any]:
"""Load the newest committed AI Agent owner-approved fixture dry-run package."""
directory = evaluations_dir or _DEFAULT_EVALUATIONS_DIR
candidates = sorted(directory.glob(_SNAPSHOT_PATTERN))
if not candidates:
raise FileNotFoundError(
f"no AI Agent owner-approved fixture dry-run snapshots found in {directory}"
)
latest = candidates[-1]
with latest.open(encoding="utf-8") as handle:
payload = json.load(handle)
if not isinstance(payload, dict):
raise ValueError(f"{latest}: expected JSON object")
_require_schema(payload, str(latest))
_require_read_only_boundaries(payload, str(latest))
_require_package_safety(payload, str(latest))
_require_rollup_consistency(payload, str(latest))
return payload
def _require_schema(payload: dict[str, Any], label: str) -> None:
actual = payload.get("schema_version")
if actual != _SCHEMA_VERSION:
raise ValueError(f"{label}: expected schema_version={_SCHEMA_VERSION}, got {actual!r}")
status = payload.get("program_status") or {}
if status.get("read_only_mode") is not True:
raise ValueError(f"{label}: program_status.read_only_mode must be true")
if status.get("runtime_authority") != _RUNTIME_AUTHORITY:
raise ValueError(f"{label}: runtime_authority must stay {_RUNTIME_AUTHORITY}")
if status.get("current_task_id") != "P2-403F":
raise ValueError(f"{label}: current_task_id must stay P2-403F")
if status.get("next_task_id") != "P2-403G":
raise ValueError(f"{label}: next_task_id must stay P2-403G")
def _require_read_only_boundaries(payload: dict[str, Any], label: str) -> None:
boundaries = payload.get("approval_boundaries") or {}
enabled = sorted(key for key, value in boundaries.items() if value is not False)
if enabled:
raise ValueError(f"{label}: approval boundaries must remain false: {enabled}")
truth = payload.get("dry_run_truth") or {}
if truth.get("owner_fixture_scope_approved") is not True:
raise ValueError(f"{label}: owner fixture scope must be approved for fixture-only dry-run")
if truth.get("fixture_dry_run_allowed") is not True:
raise ValueError(f"{label}: fixture dry-run must remain explicitly allowed")
false_flags = {
"production_write_approved",
"km_write_allowed",
"playbook_trust_write_allowed",
"timeline_learning_write_allowed",
"agent_replay_score_write_allowed",
"gateway_queue_write_allowed",
"telegram_send_allowed",
"redis_consumer_group_allowed",
"db_migration_allowed",
"workflow_trigger_allowed",
"runtime_worker_allowed",
"host_or_cluster_command_allowed",
"secret_or_paid_api_allowed",
}
unsafe = sorted(flag for flag in false_flags if truth.get(flag) is not False)
if unsafe:
raise ValueError(f"{label}: dry-run truth flags must remain false: {unsafe}")
zero_counts = {
"live_learning_write_count",
"live_playbook_trust_update_count",
"live_km_update_count",
"live_timeline_write_count",
"live_replay_score_write_count",
"live_gateway_queue_write_count",
"live_telegram_send_count",
}
non_zero = sorted(key for key in zero_counts if truth.get(key) != 0)
if non_zero:
raise ValueError(f"{label}: live dry-run counts must remain zero: {non_zero}")
def _require_package_safety(payload: dict[str, Any], label: str) -> None:
package = payload.get("fixture_package") or {}
required_fields = set(package.get("required_fields") or [])
required_minimum = {
"fixture_event_id",
"source_contract_ref",
"scenario_type",
"owner_scope_ref",
"agent_owner",
"target_surface",
"proposed_delta_summary",
"redacted_evidence_ref",
"dry_run_expected_output",
"no_write_proof_ref",
"rollback_plan_ref",
}
missing = sorted(required_minimum - required_fields)
if missing:
raise ValueError(f"{label}: fixture package missing required fields: {missing}")
if package.get("owner_review_required") is not True:
raise ValueError(f"{label}: owner review must be required")
if package.get("rollback_required") is not True:
raise ValueError(f"{label}: rollback must be required")
if package.get("no_write_proof_required") is not True:
raise ValueError(f"{label}: no-write proof must be required")
redaction = payload.get("display_redaction_contract") or {}
if redaction.get("redaction_required") is not True:
raise ValueError(f"{label}: frontend redaction must be required")
for flag in (
"raw_payload_display_allowed",
"private_reasoning_display_allowed",
"secret_value_display_allowed",
"action_button_allowed",
):
if redaction.get(flag) is not False:
raise ValueError(f"{label}: {flag} must remain false")
rollback = payload.get("rollback_contract") or {}
if rollback.get("rollback_required") is not True:
raise ValueError(f"{label}: rollback_contract.rollback_required must be true")
if not rollback.get("rollback_steps"):
raise ValueError(f"{label}: rollback steps must not be empty")
if not payload.get("fixture_sets"):
raise ValueError(f"{label}: fixture sets must not be empty")
if not payload.get("simulation_steps"):
raise ValueError(f"{label}: simulation steps must not be empty")
def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None:
rollups = payload.get("rollups") or {}
fixture_sets = payload.get("fixture_sets") or []
gates = payload.get("dry_run_gates") or []
steps = payload.get("simulation_steps") or []
package = payload.get("fixture_package") or {}
truth = payload.get("dry_run_truth") or {}
expected_counts = {
"fixture_set_count": len(fixture_sets),
"dry_run_gate_count": len(gates),
"simulation_step_count": len(steps),
"approved_fixture_only_count": sum(
1 for fixture in fixture_sets if fixture.get("status") == "approved_for_fixture_only"
),
"blocked_runtime_action_count": len(
{
action
for action in [gate.get("blocked_runtime_action") for gate in gates]
if action
}
),
"required_field_count": len(package.get("required_fields") or []),
"forbidden_field_count": len(package.get("forbidden_fields") or []),
}
mismatched = {
key: {"expected": expected, "actual": rollups.get(key)}
for key, expected in expected_counts.items()
if rollups.get(key) != expected
}
if mismatched:
raise ValueError(f"{label}: rollup counts must match payload sections: {mismatched}")
approval_required = sorted(
gate.get("gate_id") for gate in gates if gate.get("status") == "approval_required"
)
if sorted(rollups.get("approval_required_gate_ids") or []) != approval_required:
raise ValueError(f"{label}: rollups.approval_required_gate_ids mismatch")
live_write_total = sum(
int(truth.get(key) or 0)
for key in (
"live_learning_write_count",
"live_playbook_trust_update_count",
"live_km_update_count",
"live_timeline_write_count",
"live_replay_score_write_count",
"live_gateway_queue_write_count",
)
)
if rollups.get("live_write_count_total") != live_write_total:
raise ValueError(f"{label}: rollups.live_write_count_total mismatch")
if rollups.get("live_send_count_total") != int(truth.get("live_telegram_send_count") or 0):
raise ValueError(f"{label}: rollups.live_send_count_total mismatch")
if rollups.get("live_receipt_count_total") != 0:
raise ValueError(f"{label}: rollups.live_receipt_count_total must remain zero")