feat(governance): bind km archive confirm to dry-run fingerprint
All checks were successful
Code Review / ai-code-review (push) Successful in 14s
Type Sync Check / check-type-sync (push) Successful in 31s
CD Pipeline / tests (push) Successful in 4m8s
CD Pipeline / build-and-deploy (push) Successful in 4m48s
CD Pipeline / post-deploy-checks (push) Successful in 2m13s

This commit is contained in:
Your Name
2026-05-20 09:19:32 +08:00
parent 83ca72e989
commit 584d2a77ff
8 changed files with 230 additions and 2 deletions

View File

@@ -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

View File

@@ -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": {

View File

@@ -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

View File

@@ -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}",

View File

@@ -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}",

View File

@@ -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 (
<section className="border border-[#e0ddd4] bg-white">
@@ -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({
<p className="mt-1 text-[#5f5b52]">
{t("archiveActions.previewNext")}
</p>
<p className="mt-1 break-all font-mono text-[11px] text-[#5f5b52]">
{t("archiveActions.planFingerprint", {
fingerprint: previewResult.dry_run_plan_fingerprint ?? "--",
})}
</p>
{previewResult.stale_ratio_snapshot ? (
<p className="mt-1 text-[#5f5b52]">
{t("archiveActions.snapshot", {