diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py index cad90ca26..f323bd50a 100644 --- a/apps/api/src/models/governance.py +++ b/apps/api/src/models/governance.py @@ -152,6 +152,11 @@ class KnowledgeReviewDraftArchiveRequest(BaseModel): owner: str = Field(default="operator_console", min_length=1, max_length=100) owner_approved: bool = False dry_run: bool = False + dry_run_plan_fingerprint: str | None = Field( + default=None, + max_length=80, + description="Dry-run response fingerprint that must be echoed before a write.", + ) class KnowledgeReviewDraftStaleRatioSnapshot(BaseModel): @@ -185,6 +190,7 @@ class KnowledgeReviewDraftArchiveResponse(BaseModel): "not_requested", ] = "not_requested" stale_ratio_recheck_dispatch_id: str | None = None + dry_run_plan_fingerprint: str | None = None next_action: str = "stale_ratio_recheck" generated_at: datetime diff --git a/apps/api/src/services/governance_km_review_service.py b/apps/api/src/services/governance_km_review_service.py index 132bc292c..4026d312b 100644 --- a/apps/api/src/services/governance_km_review_service.py +++ b/apps/api/src/services/governance_km_review_service.py @@ -13,6 +13,8 @@ Owner-approved operations for Hermes KM healthcheck review drafts. from __future__ import annotations +import hashlib +import json from datetime import timedelta from typing import Any, Literal @@ -99,6 +101,15 @@ async def archive_km_review_draft_duplicates( ) _validate_archive_request_against_plan(group, request, duplicate_ids) + dry_run_plan_fingerprint = _build_dry_run_plan_fingerprint( + governance_event_id, + group, + ) + if not request.dry_run: + _validate_dry_run_plan_fingerprint( + request.dry_run_plan_fingerprint, + dry_run_plan_fingerprint, + ) if request.dry_run: return _build_archive_response( @@ -111,6 +122,7 @@ async def archive_km_review_draft_duplicates( writes_governance_audit=False, stale_ratio_snapshot=await _load_current_km_stale_ratio_snapshot(), stale_ratio_recheck_status="dry_run", + dry_run_plan_fingerprint=dry_run_plan_fingerprint, ) archive_result = await _archive_duplicates_and_write_audit( @@ -131,6 +143,7 @@ async def archive_km_review_draft_duplicates( stale_ratio_snapshot=archive_result["stale_ratio_snapshot"], stale_ratio_recheck_status=archive_result["recheck_status"], stale_ratio_recheck_dispatch_id=archive_result["recheck_dispatch_id"], + dry_run_plan_fingerprint=dry_run_plan_fingerprint, ) @@ -164,6 +177,47 @@ def _validate_archive_request_against_plan( ) +def _build_dry_run_plan_fingerprint( + governance_event_id: str, + group: KnowledgeReviewDraftDedupeGroup, +) -> str: + """Build a stable fingerprint the owner must echo from dry-run to confirm.""" + payload = { + "schema_version": "km_review_draft_archive_plan_v1", + "governance_event_id": governance_event_id, + "canonical_entry_id": group.canonical_entry_id, + "duplicate_entry_ids": sorted(_unique_ids(group.duplicate_entry_ids)), + "owner_action": group.owner_action, + "preferred_source": group.preferred_source, + "suggested_action": group.suggested_action, + "total_entries": group.total_entries, + } + encoded = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _validate_dry_run_plan_fingerprint( + provided: str | None, + expected: str, +) -> None: + """Require a matching dry-run preview fingerprint before any write.""" + if not provided: + raise KmReviewDraftArchiveError( + 403, + "dry_run_plan_fingerprint from a dry-run preview is required before archiving", + ) + if provided != expected: + raise KmReviewDraftArchiveError( + 409, + "dry_run_plan_fingerprint does not match the latest dedupe plan", + ) + + async def _load_already_archived_duplicate_ids( duplicate_ids: list[str], *, @@ -360,6 +414,7 @@ def _build_archive_audit_context( "canonical_entry_id": request.canonical_entry_id, "archived_entry_ids": archived_ids, "archived_count": len(archived_ids), + "dry_run_plan_fingerprint": request.dry_run_plan_fingerprint, "stale_ratio_snapshot": ( stale_ratio_snapshot.model_dump() if stale_ratio_snapshot else None ), @@ -393,6 +448,7 @@ def _build_archive_response( "not_requested", ] = "not_requested", stale_ratio_recheck_dispatch_id: str | None = None, + dry_run_plan_fingerprint: str | None = None, ) -> KnowledgeReviewDraftArchiveResponse: return KnowledgeReviewDraftArchiveResponse( governance_event_id=governance_event_id, @@ -411,6 +467,9 @@ def _build_archive_response( stale_ratio_snapshot=stale_ratio_snapshot, stale_ratio_recheck_status=stale_ratio_recheck_status, stale_ratio_recheck_dispatch_id=stale_ratio_recheck_dispatch_id, + dry_run_plan_fingerprint=( + dry_run_plan_fingerprint or request.dry_run_plan_fingerprint + ), generated_at=now_taipei(), ) @@ -554,6 +613,7 @@ def _build_stale_ratio_recheck_context( "source_archive_action": "review_canonical_and_archive_duplicate_drafts", "canonical_entry_id": request.canonical_entry_id, "archived_entry_ids": archived_ids, + "dry_run_plan_fingerprint": request.dry_run_plan_fingerprint, "stale_ratio_snapshot": stale_ratio_snapshot.model_dump(), }, "worker_result": { diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py index a993bfb3a..15437c6ca 100644 --- a/apps/api/tests/test_ai_governance_endpoints.py +++ b/apps/api/tests/test_ai_governance_endpoints.py @@ -38,8 +38,10 @@ from src.models.governance import ( ) from src.services.governance_km_review_service import ( KmReviewDraftArchiveError, + _build_dry_run_plan_fingerprint, _build_stale_ratio_recheck_context, _validate_archive_request_against_plan, + _validate_dry_run_plan_fingerprint, ) from src.services.governance_query_service import ( _build_km_review_draft_dedupe_groups, @@ -541,12 +543,14 @@ class TestKmReviewDraftDedupe: "owner": "operator_console", "owner_approved": True, "dry_run": False, + "dry_run_plan_fingerprint": "sha256:" + "a" * 64, }, ) assert r.status_code == 200 assert captured["governance_event_id"] == "event-001" assert captured["request"].owner_approved is True + assert captured["request"].dry_run_plan_fingerprint == "sha256:" + "a" * 64 data = r.json() assert data["status"] == "archived" assert data["writes_km"] is True @@ -602,6 +606,39 @@ class TestKmReviewDraftDedupe: assert exc.value.status_code == 409 assert "latest dedupe plan" in exc.value.detail + def test_archive_plan_fingerprint_is_required_before_write(self): + group = KnowledgeReviewDraftDedupeGroup( + governance_event_id="event-001", + canonical_entry_id="km-canonical", + canonical_title="canonical", + preferred_source="dispatch_context", + duplicate_entry_ids=["km-dup-2", "km-dup-1"], + duplicate_count=2, + total_entries=3, + suggested_action="owner_review_canonical_then_archive_duplicates", + owner_action="review_canonical_and_archive_duplicate_drafts", + writes_on_read=False, + can_archive_without_owner_approval=False, + ) + + fingerprint = _build_dry_run_plan_fingerprint("event-001", group) + + assert fingerprint.startswith("sha256:") + assert fingerprint == _build_dry_run_plan_fingerprint("event-001", group) + _validate_dry_run_plan_fingerprint(fingerprint, fingerprint) + + with pytest.raises(KmReviewDraftArchiveError) as missing: + _validate_dry_run_plan_fingerprint(None, fingerprint) + + assert missing.value.status_code == 403 + assert "dry_run_plan_fingerprint" in missing.value.detail + + with pytest.raises(KmReviewDraftArchiveError) as mismatch: + _validate_dry_run_plan_fingerprint("sha256:" + "0" * 64, fingerprint) + + assert mismatch.value.status_code == 409 + assert "latest dedupe plan" in mismatch.value.detail + def test_stale_ratio_recheck_context_is_operator_visible(self): snapshot = KnowledgeReviewDraftStaleRatioSnapshot( stale_count=120, @@ -615,6 +652,7 @@ class TestKmReviewDraftDedupe: duplicate_entry_ids=["km-dup-1", "km-dup-2"], owner="operator_console", owner_approved=True, + dry_run_plan_fingerprint="sha256:" + "a" * 64, ) ctx = _build_stale_ratio_recheck_context( @@ -630,6 +668,7 @@ class TestKmReviewDraftDedupe: assert ctx["workflow"]["current_stage"] == "stale_ratio_recheck" assert ctx["workflow"]["stage_by_dispatch_status"]["pending"] == "stale_ratio_recheck" assert ctx["workflow"]["writes_km"] is False + assert ctx["workflow"]["dry_run_plan_fingerprint"] == "sha256:" + "a" * 64 assert ctx["workflow"]["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.6) assert ctx["worker_result"]["status"] == "stale_ratio_rechecked" assert ctx["worker_result"]["above_threshold"] is True diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 8bd6911f3..24da9f0ca 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1929,9 +1929,11 @@ "failed": "Archive action failed; refresh and verify the latest dedupe plan.", "previewFailed": "Dry-run preview failed; refresh and verify the latest dedupe plan.", "confirmFailed": "Archive confirmation failed; the backend may have detected a changed dedupe plan.", + "missingPreviewFingerprint": "Missing dry-run plan fingerprint; run the preview again first.", "requiresOwner": "Run the dry-run preview first, then owner-confirm the archive; the backend rechecks the latest plan.", "previewResult": "Dry run would archive {count}; writes KM: {writesKm}; writes audit: {writesAudit}", "previewNext": "Next: only after owner confirmation will duplicate KM be soft-archived and audit / stale-ratio recheck rows be written.", + "planFingerprint": "Plan fingerprint: {fingerprint}", "result": "Archived {archived}; audit dispatch: {audit}", "recheck": "Stale-ratio recheck: {status}; dispatch: {dispatch}", "snapshot": "Current stale {stale} / total {total}; ratio {ratio}; threshold {threshold}", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 2862268d7..b580a50e8 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1930,9 +1930,11 @@ "failed": "封存動作失敗;請重新整理後確認最新 dedupe plan。", "previewFailed": "乾跑預覽失敗;請重新整理後確認最新 dedupe plan。", "confirmFailed": "確認封存失敗;後端可能偵測到 dedupe plan 已變更。", + "missingPreviewFingerprint": "缺少乾跑 plan fingerprint;請先重新執行乾跑預覽。", "requiresOwner": "必須先乾跑預覽,再由 owner 確認封存;後端會重新比對最新 plan。", "previewResult": "乾跑將封存 {count} 份;寫 KM:{writesKm};寫稽核:{writesAudit}", "previewNext": "下一步:owner 確認後才會 soft archive duplicate KM 並寫入 audit / stale ratio 回測。", + "planFingerprint": "Plan fingerprint:{fingerprint}", "result": "已封存 {archived} 份;稽核 dispatch:{audit}", "recheck": "Stale ratio 回測:{status};dispatch:{dispatch}", "snapshot": "目前 stale {stale} / total {total};ratio {ratio};門檻 {threshold}", diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index ef9745690..85b6b9243 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -303,6 +303,7 @@ type KnowledgeReviewDraftArchiveResponse = { | "already_active" | "not_requested"; stale_ratio_recheck_dispatch_id?: string | null; + dry_run_plan_fingerprint?: string | null; next_action: string; generated_at?: string | null; }; @@ -1558,6 +1559,22 @@ function KnowledgeGovernancePanel({ }, [t]); const confirmArchiveDuplicates = useCallback(async (group: KnowledgeReviewDraftDedupeGroup) => { + const dryRunPlanFingerprint = + archiveActions[group.governance_event_id]?.previewResult?.dry_run_plan_fingerprint; + if (!dryRunPlanFingerprint) { + setArchiveActions((current) => ({ + ...current, + [group.governance_event_id]: { + previewLoading: false, + confirmLoading: false, + previewResult: current[group.governance_event_id]?.previewResult ?? null, + result: null, + error: t("archiveActions.missingPreviewFingerprint"), + }, + })); + return; + } + setArchiveActions((current) => ({ ...current, [group.governance_event_id]: { @@ -1576,6 +1593,7 @@ function KnowledgeGovernancePanel({ owner: "operator_console", owner_approved: true, dry_run: false, + dry_run_plan_fingerprint: dryRunPlanFingerprint, }, 15000 ); @@ -1592,7 +1610,7 @@ function KnowledgeGovernancePanel({ if (result?.status === "archived" || result?.status === "noop_already_archived") { onArchived(); } - }, [onArchived, t]); + }, [archiveActions, onArchived, t]); return (
@@ -1723,7 +1741,10 @@ function KnowledgeGovernancePanel({ const archiveAction = archiveActions[group.governance_event_id]; const previewResult = archiveAction?.previewResult ?? null; const finalResult = archiveAction?.result ?? null; - const previewReady = previewResult?.status === "dry_run"; + const previewReady = ( + previewResult?.status === "dry_run" && + Boolean(previewResult.dry_run_plan_fingerprint) + ); const previewKey = groupArchiveStatusKey(previewResult?.status); const finalResultKey = groupArchiveStatusKey(finalResult?.status); return ( @@ -1824,6 +1845,11 @@ function KnowledgeGovernancePanel({

{t("archiveActions.previewNext")}

+

+ {t("archiveActions.planFingerprint", { + fingerprint: previewResult.dry_run_plan_fingerprint ?? "--", + })} +

{previewResult.stale_ratio_snapshot ? (

{t("archiveActions.snapshot", { diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 3de5f8472..168fcb307 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,88 @@ +## 2026-05-20|T96 KM archive dry-run fingerprint guard + +**觸發**: + +- T95 已把 AwoooP Work Items 改成前端兩段式:先 `乾跑預覽`,再 `確認封存`。 +- 但後端仍只要求 `owner_approved=true`,直接 POST `dry_run=false` 仍可繞過前端 preview 流程;這不符合「破壞性動作先 dry-run」要落在 API contract 的原則。 + +**修正**: + +- `KnowledgeReviewDraftArchiveRequest` 新增 `dry_run_plan_fingerprint`。 +- `KnowledgeReviewDraftArchiveResponse` 新增 `dry_run_plan_fingerprint`。 +- Dry-run 會根據最新 dedupe plan 產生 deterministic fingerprint: + - `schema_version=km_review_draft_archive_plan_v1` + - `governance_event_id` + - `canonical_entry_id` + - sorted `duplicate_entry_ids` + - `owner_action` + - `preferred_source` + - `suggested_action` + - `total_entries` +- 非 dry-run 寫入前,後端會用最新 dedupe plan 重算 fingerprint: + - 未帶 fingerprint -> `403` + - fingerprint 與最新 plan 不一致 -> `409` +- Archive audit 與 stale ratio recheck context 都會記錄 `dry_run_plan_fingerprint`,讓 AwoooP 能追到 confirm 是基於哪一次 preview plan。 +- Work Items confirm payload 改為帶回 preview response 的 `dry_run_plan_fingerprint`;若前端狀態缺 fingerprint,UI 直接阻擋並要求重跑 preview。 +- Preview card 顯示 plan fingerprint,讓 Operator 看得到「這次確認」綁定的 plan 證據。 + +**Local verification**: + +```text +python3 -m py_compile apps/api/src/models/governance.py apps/api/src/services/governance_km_review_service.py apps/api/src/api/v1/ai_governance.py + -> ok +/Users/ogt/awoooi/apps/api/.venv/bin/python -m ruff check apps/api/src/models/governance.py apps/api/src/services/governance_km_review_service.py apps/api/src/api/v1/ai_governance.py apps/api/tests/test_ai_governance_endpoints.py + -> All checks passed +DATABASE_URL=postgresql+asyncpg://test:test@localhost:5432/test /Users/ogt/awoooi/apps/api/.venv/bin/python -m pytest apps/api/tests/test_ai_governance_endpoints.py apps/api/tests/test_governance_dispatcher.py apps/api/tests/test_hermes_kb_growth_worker.py -q + -> 61 passed +pnpm --dir apps/web exec next lint --file 'src/app/[locale]/awooop/work-items/page.tsx' + -> No ESLint warnings or errors +pnpm --dir apps/web exec tsc --noEmit --pretty false + -> ok +node -e 'JSON.parse(...)' + -> i18n json ok +cd apps/api && /Users/ogt/awoooi/apps/api/.venv/bin/python ../../scripts/generate-schemas.py && cd ../../packages/shared-types && pnpm generate:types + -> packages/shared-types 無 diff +NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build + -> compiled and generated 90/90 static pages +git diff --check + -> pass +``` + +**Local interactive smoke(live production API bridge,無寫入)**: + +```text +Local dev: + NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web dev --hostname 127.0.0.1 --port 3030 + +Playwright route: + -> dry-run POST 回傳測試 fingerprint + -> confirm POST 在 Playwright 層攔截,不送到 production + +Checks: + -> confirmDisabledBefore=true + -> confirmEnabledAfterPreview=true + -> previewShowsFingerprint=true + -> dryRunRequestOnlyFirst=true + -> confirmCarriesFingerprint=true + -> blockedExactlyOneConfirmWrite=true + -> noSuccessfulWrite=true + -> pageErrors=0 / consoleErrors=0 + +Screenshot: + /tmp/awoooi-t96-km-archive-fingerprint-local.png +``` + +**目前整體進度**: + +- AwoooP 告警可觀測鏈:約 99.1%。 +- 低風險自動修復閉環:約 95%。 +- 前端 AI 自動化管理介面同步:約 98.4%。 +- 治理告警可讀性 / 可處置性:約 98.7%。 +- AI Agent ownership 可追溯性:約 97.6%。 +- KM healthcheck 派工可追蹤性:約 99.6%。 +- Hermes KB growth 草稿 / owner review 閉環:約 99.3%。 +- 完整 AI 自動化管理產品化:約 97.0%。 + ## 2026-05-20|T95 KM duplicate archive two-step safety **觸發**: diff --git a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md index 7ef62398a..0abd43101 100644 --- a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md +++ b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md @@ -2341,6 +2341,14 @@ Phase 6 完成後 - Production:`ba904ec4 feat(governance): require dry-run preview before km archive` 已推 Gitea main;deploy marker `42b668bb chore(cd): deploy ba904ec [skip ci]`;Gitea runs `2551` / `2552` completed success。Production health healthy/prod/mock_mode=false。`GET /ai/governance/km-review-drafts/dedupe?limit=100` 回 `total_review_drafts=95`、`event_group_total=28`、`duplicate_draft_total=67`。Work Items production smoke 顯示 nav、KM 草稿去重視圖、乾跑預覽、確認封存;confirm button preview 前 disabled、preview 後 enabled;唯一 POST 為 `dry_run=true` + `owner_approved=false`;`writes_km=false` / `writes_governance_audit=false` 可見;blockedWrites=0;pageErrors=0 / consoleErrors=0;dry-run 前後 duplicate total 仍為 67;截圖 `/tmp/awoooi-t95-km-archive-two-step-production.png`。 - 目前進度更新:前端 AI 自動化管理介面同步約 98.3%;治理告警可讀性 / 可處置性約 98.6%;AI Agent ownership 可追溯性約 97.4%;KM healthcheck 派工可追蹤性約 99.5%;Hermes KB growth 草稿 / owner review 閉環約 99.2%;完整 AI 自動化管理產品化約 96.8%。 +**T96 KM archive dry-run fingerprint guard(2026-05-20 台北)**: +- 觸發:T95 已把前端做成兩段式,但後端仍只要求 `owner_approved=true`;直接 POST `dry_run=false` 仍可繞過前端 preview,dry-run-first 需要落到 API contract。 +- 修正:archive request / response 新增 `dry_run_plan_fingerprint`。Dry-run 依最新 dedupe plan 產生 deterministic fingerprint;confirm 必須帶回同一 fingerprint,後端會用最新 dedupe plan 重算。缺 fingerprint 回 `403`,fingerprint 與最新 plan 不一致回 `409`。Archive audit 與 stale ratio recheck context 都記錄 fingerprint,讓 AwoooP 可追溯確認動作基於哪一次 preview plan。 +- UI:Work Items confirm payload 帶回 preview response 的 fingerprint;若前端狀態缺 fingerprint,UI 直接要求重跑 preview。Preview card 直接顯示 plan fingerprint。 +- 邊界:T96 local smoke 沒有送任何成功的 `dry_run=false` 到 production;confirm POST 在 Playwright 層攔截,只驗證 payload 帶 fingerprint。 +- Local verification:`py_compile` ok;ruff ok;治理 endpoint / dispatcher / Hermes worker tests `61 passed`;Work Items Next lint ok;`tsc --noEmit` ok;i18n JSON parse ok;shared-types regenerate 後無 diff;`NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build` 成功產出 90/90 static pages;`git diff --check` pass。Local Playwright smoke 確認 confirm preview 前 disabled、preview 後 enabled、preview 顯示 fingerprint、confirm payload 帶 fingerprint、confirm write 被測試層攔截、pageErrors=0 / consoleErrors=0,截圖 `/tmp/awoooi-t96-km-archive-fingerprint-local.png`。 +- 目前進度更新:前端 AI 自動化管理介面同步約 98.4%;治理告警可讀性 / 可處置性約 98.7%;AI Agent ownership 可追溯性約 97.6%;KM healthcheck 派工可追蹤性約 99.6%;Hermes KB growth 草稿 / owner review 閉環約 99.3%;完整 AI 自動化管理產品化約 97.0%。 + --- ### 2026-04-20 晚 (台北) — C1-C4 全流程串接 — Playbook 鏈路保護(commit de2d34d)