diff --git a/cloud/audit_wiring.py b/cloud/audit_wiring.py new file mode 100644 index 0000000..42017fb --- /dev/null +++ b/cloud/audit_wiring.py @@ -0,0 +1,746 @@ +"""Wiring between three already-landed tracks (no new semantics here): + +- track 1 (``cloud/merge_gate_hard.py``, PR #240): the hard merge + gate, five checks + branch protection, machine-readable ``blocks`` + codes on its 409 response; +- track 3 (``cloud/audit_pushback.py``, PR #244): the independent + audit + auto-pushback decision, the pushback package, and the QA + re-run rule; +- track 4 (``cloud/ledger.py``, PR #241): the append-only hash-chain + ledger, the only durable store in this wiring. + +What this module owns, and nothing else: + +1. Translation at the boundary. Track-3 field names and enums are + authoritative for callers; track-4 storage field names are + authoritative on the chain. This module translates, it does not + invent a second ledger schema: + + ============================ =================================== + track-3 event chain record (track 4 action) + ============================ =================================== + audit verdict ``audit_verdict`` + pushback round (pushed) ``audit_pushback_round`` (pushed) + fix (landed, re-audit open) ``audit_pushback_round`` with + ``fix_diff`` + package marker + ``wiring_event="fix"`` (evidence + row only; see the gate reader) + fix + re-audit ``audit_pushback_round`` + (re_reviewed, ``fix_diff`` + + ``re_review_result``) + rebuild decision / done ``audit_rebuild`` + third-failure triage ``audit_third_failure_assessment`` + test-change single audit ``audit_test_change`` + audit-of-audit correction ``audit_meta_review`` + rebuild notice / confirmation ``audit_rebuild_notice`` + escalation / blocked quota / ``audit_wiring_event`` (the one + audit sample compat action added to track 4 + by this wiring; see below) + ============================ =================================== + +2. CONDITIONAL mapping, done here and only here. Track 4 stores + verdicts as ``pass`` / ``fail`` only, and track 3 says + CONDITIONAL is not a pass. Every verdict (audit verdict and + re-audit verdict) that arrives as ``"conditional"`` -- or as + anything that is not exactly ``"pass"`` -- is stored as + ``"fail"``, with the original value preserved in + ``extra.original_verdict`` (verdict rows) or in + ``re_review_result.original_verdict`` (pushback rows). The + original conclusion is never overwritten: the chain keeps the + mapped value AND the original, and corrections are separate + ``audit_meta_review`` rows, never edits. + +3. The gate reader (``pushback_block_for_task``). It replays a + task's chain rows in id order and returns a ``gate_block()``- + shaped dict (``merge_blocked`` / ``release_blocked`` / + ``blocked_by="audit_pushback"`` / ``round`` / ``failed_checks``, + plus a ``modules`` detail map). ``cloud/server.py`` ANDs this + with the hard gate's own five checks: either side blocks and + the merge returns 409 with the task status unchanged. The + pushback block applies in every gate mode -- an open audit + pushback is not something a ``record``-mode caller can confirm + away, and ``merge_gate_confirm`` never touches it. + +4. The gate-blocks-to-package bridge + (``pushback_package_from_gate``). A hard-gate 409's ``blocks`` + codes are machine input to the pushback package: each code maps + to a track-3 gate check (``ci_*`` -> ``ci_green``, + ``directory_*`` -> ``directory_isolation``, + ``contract_*`` -> ``contract_version``, + ``coordinator_*`` -> ``coordinator_approval``, + ``review_*`` / ``unresolved_review_comments`` -> + ``unresolved_review_comments``). Branch-protection codes have + no track-3 gate-check member, so they travel as the extra check + name ``"branch_protection"`` in the package's failed items -- + they are never dropped, and they are never disguised as one of + the ten checks. Callers must take the codes from the 409 + ``merge_gate`` object, never by parsing audit detail text. + +Known translation losses (stated, not hidden): + +- Track 3 distinguishes root causes ``"contract"`` vs ``"design"``; + track 4 stores only ``"implementation"`` / ``"contract_design"``. + Both contract and design map to ``"contract_design"`` on rebuild + rows (track 3's rebuild branch treats them identically: refreeze, + no code rebuild). The finer distinction survives on escalation + rows, which carry the original ``root_cause`` verbatim. +- Track 3's third-failure triage verdict ``"early_refreeze"`` has + no track-4 assessment determination (only ``early_rebuild`` / + ``continuation_round`` exist there). It is recorded as an + ``audit_wiring_event`` of kind ``escalate_refreeze``, never + forced into an assessment row with the wrong determination. +- Track 3's ``"unknown"`` root cause has no track-4 value and is + refused (``ValueError``) rather than guessed: a rebuild row must + say which side the root cause is on. +- A fix-only row is counted by track 4's timeline summary inside + ``pushback_rounds`` (that summary counts rows, and a round + already spans two rows there). The gate reader skips fix rows + for state transitions, so the count wart cannot unblock or block + a merge by itself. + +What this module does NOT do: + +- It does not change any track-1 / track-3 / track-4 semantic: + hard stays fail-closed by default, protection unknown / + unprotected still block in hard mode, confirmation still never + covers a directory violation, the chain stays fail-closed and + append-only, and the cost dual-column rule is untouched. +- It does not create pushback subtasks, send notices, or run + fixes. ``record_*`` only appends chain rows; deriving a fix + subtask from a package is the caller's job (track 3's + ``build_pushback_package`` already describes its shape). +- It does not gate ``deploy.py``. There is no server-side release + endpoint in this codebase: the merge endpoint is where + ``merge_blocked`` is enforced, and ``release_blocked`` is + returned alongside it so a future release endpoint can enforce + it with this same reader. Deploy remains an operator action + outside this wiring. +""" + +from cloud.audit_pushback import GATE_CHECKS +from cloud.audit_pushback import build_pushback_package as _build_package +from cloud.audit_pushback import gate_block as _gate_block +from cloud.ledger import WIRING_EVENT_ACTION +from cloud.ledger import audit_timeline as _audit_timeline +from cloud.merge_gate_hard import block_descriptions as _block_descriptions +from cloud.qa_matrix import ( + check_reaudit_reran_links as _check_reran_links, +) + +# Hard-gate block code -> track-3 gate check. Branch-protection +# codes have no track-3 member; see BRANCH_PROTECTION_CHECK. +_GATE_BLOCK_TO_CHECK = {} +for _code in ( + "ci_evidence_missing", "ci_not_green", "ci_check_failed", + "ci_required_checks_unconfigured", "ci_required_check_missing"): + _GATE_BLOCK_TO_CHECK[_code] = "ci_green" +for _code in ( + "directory_evidence_missing", "directory_owned_dir_unknown", + "directory_isolation_violation"): + _GATE_BLOCK_TO_CHECK[_code] = "directory_isolation" +for _code in ( + "contract_version_missing", "contract_not_frozen", + "contract_version_mismatch"): + _GATE_BLOCK_TO_CHECK[_code] = "contract_version" +for _code in ( + "coordinator_approval_missing", "coordinator_not_approved"): + _GATE_BLOCK_TO_CHECK[_code] = "coordinator_approval" +for _code in ("review_evidence_missing", "unresolved_review_comments"): + _GATE_BLOCK_TO_CHECK[_code] = "unresolved_review_comments" +# Branch-protection codes (hard-mode codes from track 1's #240 +# layer plus record-mode codes from the #209 layer). +for _code in ("branch_protection_unknown", + "branch_protection_unprotected", + "branch_protection_enforce", + "branch_protection_risk_unconfirmed"): + _GATE_BLOCK_TO_CHECK[_code] = "branch_protection" + +BRANCH_PROTECTION_CHECK = "branch_protection" + +# Track-3 decision actions that block merge/release (mirrors +# track 3's gate_block() membership; kept here so the package +# builder does not have to re-derive it). +BLOCKING_ACTIONS = ( + "pushback", "rebuild", "escalate", "escalate_refreeze", + "blocked_quota", "rebuild_pending_user_confirmation", +) + +# Wiring-event kinds that block (compat action rows). +_BLOCKING_WIRING_KINDS = ("escalate", "escalate_refreeze", + "blocked_quota") + +# Triage verdict -> track-4 assessment determination. "early_refreeze" +# is deliberately absent: it goes to record_escalation instead. +_TRIAGE_TO_DETERMINATION = { + "early_rebuild": "early_rebuild", + "targeted_fix": "continuation_round", +} + +# Track-3 root cause -> track-4 root cause. "unknown" is absent +# on purpose (refused, not guessed). +_ROOT_CAUSE_MAP = { + "implementation": "implementation", + "contract": "contract_design", + "design": "contract_design", +} + + +def normalize_verdict(verdict): + """Track-3 verdict -> (track-4 verdict, original). + + Exactly ``"pass"`` stays a pass; anything else -- including + ``"conditional"`` and missing/garbage values -- maps to + ``"fail"`` (fail-closed), with the original preserved for the + chain. The caller stores the original alongside; this function + never drops it silently. + """ + if verdict == "pass": + return "pass", "pass" + original = verdict if isinstance(verdict, str) else None + return "fail", original + + +def normalize_re_review_result(result): + """Copy of a re-review result with its verdict normalized. + + ``CONDITIONAL`` re-audits do not pass (track 3); the stored + verdict becomes ``"fail"`` and the original is kept under + ``original_verdict``. A result without a verdict dict is + returned unchanged (the ledger validates the shape). + """ + if not isinstance(result, dict): + return result + copied = dict(result) + normalized, original = normalize_verdict(result.get("verdict")) + copied["verdict"] = normalized + if normalized == "fail" and original != "fail": + copied["original_verdict"] = original + return copied + + +def gate_failed_checks(gate): + """Hard-gate 409 -> (track-3 checks, extra checks), in order. + + Track-3 checks come first in GATE_CHECKS order, then the + branch-protection extra (if any). Unknown codes pass through + as extras rather than being dropped: a code the wiring does + not recognise must still show up in the package. + """ + checks = [] + extras = [] + for code in (gate or {}).get("blocks") or []: + mapped = _GATE_BLOCK_TO_CHECK.get(code) + if mapped == BRANCH_PROTECTION_CHECK: + if BRANCH_PROTECTION_CHECK not in extras: + extras.append(BRANCH_PROTECTION_CHECK) + elif mapped is not None: + if mapped not in checks: + checks.append(mapped) + elif code not in extras: + extras.append(code) + order = {name: index for index, name in enumerate(GATE_CHECKS)} + checks.sort(key=lambda name: order.get(name, len(order))) + return checks, extras + + +def gate_block_items(gate): + """One pushback-package failed item per hard-gate block code. + + The package builder also appends decision ``failed_checks`` + that are not already items, so the item ``check`` values here + use the mapped track-3 names (branch protection keeps its + extra name) to avoid duplicate generic entries. + """ + descriptions = _block_descriptions((gate or {}).get("blocks") or []) + items = [] + for code, description in zip((gate or {}).get("blocks") or [], + descriptions): + check = _GATE_BLOCK_TO_CHECK.get(code, code) + items.append({ + "check": check, + "detail": description, + "evidence": { + "test_name": None, + "file_path": None, + "line_no": None, + "contract_clause": None, + "diff_range": None, + }, + "reproduction_steps": [ + "re-run the merge gate with full evidence; " + "the failing code is %s" % code, + ], + "reaudit_criterion": "re-run the full re-audit checklist " + "for this check", + }) + return items + + +def pushback_package_from_gate(gate, task_id, round_no, + decision_action="pushback", + previous_artifacts=None, + qa_failed_links=None): + """Build a track-3 pushback package from a hard-gate 409. + + ``gate`` is the ``merge_gate`` object from the 409 response + (not audit text). ``round_no`` is the track-3 decision round + (0 for the first audit); the ledger round number is derived + by the caller (see ``record_pushback``'s docstring). The + package carries the QA failed links verbatim when given, so + the re-audit must re-run exactly those links (see + ``record_reaudit``'s QA guard). + """ + if decision_action not in BLOCKING_ACTIONS and \ + decision_action != "allow": + raise ValueError("unknown decision action: %r" % (decision_action,)) + checks, extras = gate_failed_checks(gate) + audit = { + "task_id": task_id, + "verdict": "fail", + "verdict_text": "merge gate blocked: %s" % ( + ", ".join((gate or {}).get("blocks") or ["unknown"])), + "failed_items": gate_block_items(gate), + } + decision = { + "round": round_no, + "next_round": round_no + 1, + "action": decision_action, + "failed_checks": checks + extras, + } + return _build_package(audit, decision, previous_artifacts, + qa_failed_links=qa_failed_links) + + +def _require_module_contract(module_id, contract_version): + """Wiring strictness: pushback rows must be attributable. + + Track 4 allows module/contract to be None on pushback rows, + but unattributed rows cannot be scoped by the gate reader, so + this wiring refuses them instead of writing rows the gate + cannot reason about. Verdict/rebuild rows already require + both in track 4 itself. + """ + if not isinstance(module_id, str) or not module_id: + raise ValueError("module_id is required for wiring records") + if not isinstance(contract_version, str) or not contract_version: + raise ValueError("contract_version is required for wiring records") + + +def record_audit_verdict(store, task_id, project_id, module_id, + contract_version, verdict, source, + failures=None, user_id="system", extra=None): + """Append one audit/acceptance verdict to the chain. + + ``verdict`` uses track-3 vocabulary (``pass`` / ``fail`` / + ``conditional``); ``"conditional"`` is stored as ``"fail"`` + with the original in ``extra.original_verdict``. + """ + normalized, original = normalize_verdict(verdict) + merged_extra = dict(extra or {}) + if normalized == "fail" and original != "fail": + merged_extra.setdefault("original_verdict", original) + return store.append_audit_verdict( + task_id, project_id, module_id, contract_version, + normalized, source, failures=failures, user_id=user_id, + extra=merged_extra) + + +def record_pushback(store, task_id, project_id, module_id, + contract_version, round_no, trigger_items, + package, failure_source=None, failure_counts=None, + user_id="system"): + """Append one pushed pushback round to the chain. + + ``round_no`` is the LEDGER round number, starting at 1 for + the first pushback. Track 3's ``decide_pushback`` counts + rounds already used (0 for the first audit) and returns + ``next_round``; for a pushback action that value IS the + ledger round number (first failure: decision round 0, + next_round 1, ledger round_no 1). Trigger items are the + package's failed checks/items (machine codes, not prose). + """ + _require_module_contract(module_id, contract_version) + return store.append_pushback_round( + task_id, project_id, round_no, trigger_items, package, + user_id=user_id, module_id=module_id, + contract_version=contract_version, + failure_source=failure_source, failure_counts=failure_counts) + + +def record_fix(store, task_id, project_id, module_id, + contract_version, round_no, fix_diff, package, + user_id="system"): + """Append fix evidence for a round whose re-audit is still open. + + The row carries ``fix_diff`` (summary + sha, never the full + diff) and a package copy marked ``wiring_event="fix"``. The + gate reader skips such rows for state transitions: landing a + fix does not unblock anything -- only a passing re-audit does. + When fix and re-audit arrive together, use ``record_reaudit`` + with ``fix_diff`` instead of writing both rows. + """ + _require_module_contract(module_id, contract_version) + marked = dict(package or {}) + marked["wiring_event"] = "fix" + return store.append_pushback_round( + task_id, project_id, round_no, _package_trigger_items(package), + marked, fix_diff=fix_diff, user_id=user_id, + module_id=module_id, contract_version=contract_version) + + +def _package_trigger_items(package): + """Trigger items for a fix row: the package's failed checks.""" + if isinstance(package, dict): + checks = package.get("failed_checks") + if isinstance(checks, list) and checks: + return list(checks) + return ["audit_fix"] + + +def check_reaudit_qa_guard(package, re_review_result, rerun_results): + """Fail-closed QA guard for a claimed passing re-audit. + + If the package lists ``reaudit_must_rerun_links`` and the + re-audit claims a pass, every listed link must have a passing + re-run result; a missing or failed re-run raises ValueError + instead of letting a false pass reach the chain. A failing + re-audit needs no guard (it stays blocked either way). + Returns the normalized re-review result. + """ + normalized_result = normalize_re_review_result(re_review_result) + if normalized_result.get("verdict") != "pass": + return normalized_result + required = (package or {}).get("reaudit_must_rerun_links") or [] + if not required: + return normalized_result + checked = _check_reran_links(package, rerun_results or {}) + if not checked["reran_all_failed_links"]: + raise ValueError( + "re-audit claims pass but QA failed links were not " + "all re-run passing: missing=%s failed=%s" + % (checked["missing"], checked["failed"])) + return normalized_result + + +def record_reaudit(store, task_id, project_id, module_id, + contract_version, round_no, re_review_result, + package=None, fix_diff=None, rerun_results=None, + failure_source=None, failure_counts=None, + user_id="system"): + """Append one re-audited pushback round to the chain. + + This is the row that can unblock: a stored ``pass`` verdict + here clears the module's block in the gate reader, a stored + ``fail`` (including a mapped ``conditional``) keeps it + blocked. The QA guard in ``check_reaudit_qa_guard`` runs + before anything is written, so a claimed pass with un-rerun + or failed QA links never reaches the chain. + """ + _require_module_contract(module_id, contract_version) + guarded = check_reaudit_qa_guard(package, re_review_result, + rerun_results) + return store.append_pushback_round( + task_id, project_id, round_no, + _package_trigger_items(package), package or {}, + fix_diff=fix_diff, re_review_result=guarded, + user_id=user_id, module_id=module_id, + contract_version=contract_version, + failure_source=failure_source, failure_counts=failure_counts) + + +def record_rebuild(store, task_id, project_id, module_id, + contract_version, rebuild_no, old_impl_ref, + new_impl_ref=None, root_cause="implementation", + status="decided", prior_failure_total=None, + retained_assets=None, trigger_source=None, + assessment_entry_id=None, user_id="system"): + """Append one rebuild decision / completion to the chain. + + ``root_cause`` uses track-3 vocabulary; ``"contract"`` and + ``"design"`` both store as track-4 ``"contract_design"`` + (see the module docstring). ``"unknown"`` is refused. + A ``rebuilt`` row does NOT unblock the gate on its own: the + new implementation still needs a passing re-audit. + """ + mapped = _ROOT_CAUSE_MAP.get(root_cause) + if mapped is None: + raise ValueError( + "root_cause must be implementation|contract|design, " + "not %r (unknown is refused, not guessed)" % (root_cause,)) + return store.append_rebuild_decision( + task_id, project_id, module_id, contract_version, + rebuild_no, old_impl_ref, new_impl_ref=new_impl_ref, + root_cause=mapped, status=status, + prior_failure_total=prior_failure_total, + user_id=user_id, retained_assets=retained_assets, + trigger_source=trigger_source, + assessment_entry_id=assessment_entry_id) + + +def record_third_failure_assessment(store, task_id, project_id, + module_id, contract_version, + triage_verdict, basis, + failure_total=3, + max_extra_rounds=None, + user_id="system"): + """Append one third-failure triage determination to the chain. + + Track-3 triage verdicts map as ``early_rebuild`` -> + ``early_rebuild`` and ``targeted_fix`` -> ``continuation_round``. + ``early_refreeze`` has no assessment determination in track 4 + and is refused here: record it with ``record_escalation`` + (kind ``escalate_refreeze``) instead. + """ + determination = _TRIAGE_TO_DETERMINATION.get(triage_verdict) + if determination is None: + raise ValueError( + "triage_verdict %r has no assessment determination; " + "use record_escalation(kind='escalate_refreeze') for " + "early_refreeze" % (triage_verdict,)) + return store.append_third_failure_assessment( + task_id, project_id, module_id, contract_version, + determination, basis, failure_total=failure_total, + max_extra_rounds=max_extra_rounds, user_id=user_id) + + +def record_test_change(store, task_id, project_id, module_id, + contract_version, test_changes, auditor, + contract_refrozen, refreeze_ref=None, + reason="", user_id="system"): + """Append one test-change single audit (track-4 native).""" + return store.append_test_change_audit( + task_id, project_id, module_id, contract_version, + test_changes, auditor, contract_refrozen, + refreeze_ref=refreeze_ref, reason=reason, user_id=user_id) + + +def record_meta_review(store, task_id, project_id, module_id, + contract_version, original_entry_id, + original_verdict, corrected_verdict, + misjudgment_found, reviewer, findings=None, + triggered_process=None, user_id="system"): + """Append one audit-of-audit correction (history is kept). + + The correction never rewrites the original row. How the gate + reader treats it is a resolved semantic tension, stated here + rather than buried (see ``pushback_block_for_task``): a + correction to ``fail`` (a wrong pass) re-blocks immediately, + but a correction to ``pass`` (a wrong block) does NOT unblock + by itself -- track 3's restored action for that case is + "schedule a re-audit", not "release the gate", and a + meta-review is a review of an audit, not a full re-audit. + Unblocking still needs a fresh passing verdict or a passing + re-reviewed pushback row. Track 4's failure counts are + unaffected by this choice: counts use effective (corrected) + verdicts to decide rebuild triggers, while the gate asks a + different question ("has a passing re-audit been recorded + since the block?"), so the two may legitimately disagree + while a scheduled re-audit is still pending. + """ + return store.append_audit_of_audit( + task_id, project_id, module_id, contract_version, + original_entry_id, original_verdict, corrected_verdict, + misjudgment_found, reviewer, findings=findings, + triggered_process=triggered_process, user_id=user_id) + + +def record_rebuild_notice(store, task_id, project_id, module_id, + contract_version, rebuild_no, + notification_content, notified_party, + confirmation_status, confirmed_by=None, + user_id="system"): + """Append one rebuild-notice row (pending -> confirmed are two rows).""" + return store.append_rebuild_notice( + task_id, project_id, module_id, contract_version, + rebuild_no, notification_content, notified_party, + confirmation_status, confirmed_by=confirmed_by, + user_id=user_id) + + +def record_escalation(store, task_id, project_id, module_id, + contract_version, kind, target=None, reason="", + round_no=None, root_cause=None, + failed_checks=None, user_id="system"): + """Append one escalation / quota / audit-sample wiring event. + + ``kind`` is track-3 vocabulary: ``escalate``, + ``escalate_refreeze``, ``blocked_quota``, ``audit_sample``. + Escalation kinds block the gate; ``audit_sample`` (a sampling + selection, not a verdict) does not change gate state. The + original ``root_cause`` (including the contract/design + distinction) is stored verbatim here. + """ + _require_module_contract(module_id, contract_version) + return store.append_wiring_event( + task_id, project_id, module_id, contract_version, kind, + target=target, reason=reason, round_no=round_no, + root_cause=root_cause, failed_checks=failed_checks, + user_id=user_id) + + +def _trigger_check_names(trigger_items): + """Trigger items (str or dict) -> check-name strings.""" + names = [] + for item in trigger_items or []: + if isinstance(item, str): + names.append(item) + elif isinstance(item, dict) and item.get("check"): + names.append(item["check"]) + else: + names.append(str(item)) + return names + + +def _module_key(parsed): + """(module_id, contract_version) or None for unattributed rows.""" + module_id = parsed.get("module_id") if isinstance(parsed, dict) else None + contract_version = parsed.get("contract_version") \ + if isinstance(parsed, dict) else None + if module_id and contract_version: + return (module_id, contract_version) + return None + + +def pushback_block_for_task(conn, task_id): + """Replay a task's chain into a gate_block()-shaped verdict. + + State machine, in chain-id order, per (module, contract + version) plus one global slot for unattributed rows: + + - verdict row: its stored verdict sets blocked (pass clears, + fail blocks). + - meta-review row: a correction to ``fail`` blocks at once + (a wrong pass is re-blocked); a correction to ``pass`` + changes nothing by itself -- the wrong block stays blocked + until a fresh passing verdict or re-reviewed row arrives + (track 3's "schedule a re-audit", not "release the gate"). + This is deliberately NOT track 4's effective-verdict + counting: counts and gate state answer different + questions, and conflating them would let a review of an + audit substitute for a re-audit. + - pushback pushed: blocked. pushback re_reviewed: blocked + unless the stored re-audit verdict is a pass (a stored + ``conditional`` was already mapped to fail at write time; + anything non-pass here still blocks, fail-closed). + - pushback fix-evidence row (package ``wiring_event="fix"``): + no state change. + - rebuild row (decided or rebuilt): blocked -- a rebuilt + implementation still needs a passing re-audit. + - wiring event escalate / escalate_refreeze / blocked_quota: + blocked. audit_sample: no change. + - rebuild notice pending: blocked. confirmed / rejected: no + change (confirmation alone never unblocks). + - assessment / test-change / meta rows: no direct change + (meta rows act only through corrections). + + A task is blocked if the global slot or any module slot is + blocked. A missing ``audit_ledger`` table means no chain has + ever been written for this database, which is unblocked, not + an error; any other read failure propagates so the server can + fail closed (see server.py). + """ + try: + timeline = _audit_timeline(conn, task_id=task_id, limit=10000) + except Exception as exc: + if "no such table" in str(exc): + return _gate_block("allow", [], 0) + raise + entries = timeline.get("entries") or [] + modules = {} + global_state = {"blocked": False, "round": None, "checks": []} + last_blocked_round = None + + def slot_for(parsed): + key = _module_key(parsed) + if key is None: + return None, global_state + if key not in modules: + modules[key] = {"blocked": False, "round": None, + "checks": []} + return key, modules[key] + + for entry in entries: + parsed = entry.get("parsed") + if not isinstance(parsed, dict): + continue + action = entry.get("action") + _key, slot = slot_for(parsed) + if action == "audit_verdict": + blocked = parsed.get("verdict") != "pass" + slot["blocked"] = blocked + slot["checks"] = _trigger_check_names( + parsed.get("failures")) if blocked else [] + elif action == "audit_meta_review": + # Re-block on a corrected fail; a corrected pass + # schedules a re-audit and does not unblock (see the + # module docstring: correction != re-audit). + if parsed.get("misjudgment_found") and \ + parsed.get("corrected_verdict") == "fail": + slot["blocked"] = True + if not slot["checks"]: + slot["checks"] = ["audit_meta_review"] + elif action == "audit_pushback_round": + package = parsed.get("pushback_package") or {} + if package.get("wiring_event") == "fix": + continue + round_no = parsed.get("round_no") + if parsed.get("round_status") == "pushed": + slot["blocked"] = True + slot["round"] = round_no + slot["checks"] = _trigger_check_names( + parsed.get("trigger_items")) + last_blocked_round = round_no + elif parsed.get("round_status") == "re_reviewed": + result = parsed.get("re_review_result") or {} + blocked = result.get("verdict") != "pass" + slot["blocked"] = blocked + slot["round"] = round_no + slot["checks"] = _trigger_check_names( + parsed.get("trigger_items")) if blocked else [] + if blocked: + last_blocked_round = round_no + elif action == "audit_rebuild": + slot["blocked"] = True + if not slot["checks"]: + slot["checks"] = ["audit_rebuild"] + last_blocked_round = slot["round"] + elif action == WIRING_EVENT_ACTION: + if parsed.get("wiring_kind") in _BLOCKING_WIRING_KINDS: + slot["blocked"] = True + checks = parsed.get("failed_checks") + if checks: + slot["checks"] = list(checks) + if parsed.get("round_no") is not None: + slot["round"] = parsed.get("round_no") + last_blocked_round = parsed.get("round_no") + elif action == "audit_rebuild_notice": + if parsed.get("confirmation_status") == "pending": + slot["blocked"] = True + blocked = global_state["blocked"] or any( + slot["blocked"] for slot in modules.values()) + checks = [] + if global_state["blocked"]: + checks.extend(global_state["checks"]) + for slot in modules.values(): + if slot["blocked"]: + for check in slot["checks"]: + if check not in checks: + checks.append(check) + block = _gate_block( + "pushback" if blocked else "allow", checks, + last_blocked_round if last_blocked_round is not None else 0) + block["modules"] = { + "%s@%s" % key: { + "blocked": slot["blocked"], + "round": slot["round"], + "failed_checks": list(slot["checks"]), + } + for key, slot in modules.items() + } + if global_state["blocked"]: + block["modules"][""] = { + "blocked": True, + "round": global_state["round"], + "failed_checks": list(global_state["checks"]), + } + return block diff --git a/cloud/ledger.py b/cloud/ledger.py index 3afec4e..88cad3e 100644 --- a/cloud/ledger.py +++ b/cloud/ledger.py @@ -153,9 +153,21 @@ ASSESSMENT_ACTION = "audit_third_failure_assessment" TEST_CHANGE_ACTION = "audit_test_change" META_REVIEW_ACTION = "audit_meta_review" REBUILD_NOTICE_ACTION = "audit_rebuild_notice" +# Wiring compat action (audit-gap-1 wiring, PR #244 -> #241): track 3 +# event kinds with no native track-4 action -- escalation outcomes, +# blocked-quota outcomes, and audit-sample selections. These are +# annotations on the timeline, not verdicts: they never feed +# module_failure_counts, and only the blocking kinds move the +# wiring gate (see cloud/audit_wiring.py). The action name is +# intentionally generic so no second ledger vocabulary grows here: +# one compat action, with the track-3 kind inside the detail. +WIRING_EVENT_ACTION = "audit_wiring_event" +WIRING_KINDS = ("escalate", "escalate_refreeze", "blocked_quota", + "audit_sample") TIMELINE_ACTIONS = (VERDICT_ACTION, PUSHBACK_ACTION, REBUILD_ACTION, ASSESSMENT_ACTION, TEST_CHANGE_ACTION, - META_REVIEW_ACTION, REBUILD_NOTICE_ACTION) + META_REVIEW_ACTION, REBUILD_NOTICE_ACTION, + WIRING_EVENT_ACTION) _VERDICT_VALUES = ("pass", "fail") _FAILURE_SOURCES = ("audit", "acceptance") @@ -1049,6 +1061,8 @@ def audit_timeline(conn, task_id=None, module_id=None, 1 for e in entries if e["action"] == META_REVIEW_ACTION), "rebuild_notices": sum( 1 for e in entries if e["action"] == REBUILD_NOTICE_ACTION), + "wiring_events": sum( + 1 for e in entries if e["action"] == WIRING_EVENT_ACTION), "counts_by_contract": counts_by_contract, "anomalies": anomalies, } @@ -1291,6 +1305,55 @@ def append_rebuild_notice(conn, task_id, project_id, module_id, task_id=task_id, created_at=created_at) +def append_wiring_event(conn, task_id, project_id, module_id, + contract_version, wiring_kind, target=None, + reason="", round_no=None, root_cause=None, + failed_checks=None, user_id="system", + created_at=None): + """记一次接线兼容事件(审计加固第 1 项接线缺口新增)。 + + - wiring_kind: escalate | escalate_refreeze | blocked_quota | + audit_sample(第 3 轨词汇,原文进链;其余第 3 轨事件 + 各有原生动作,不走这里,见 cloud/audit_wiring.py)。 + - 本动作不参与 module_failure_counts(不是结论),时间线 + 只把它计入 wiring_events;门禁是否阻塞由接线层按 kind + 判定(escalate / escalate_refreeze / blocked_quota 阻塞, + audit_sample 不动状态)。 + - root_cause 原文保存(contract 与 design 的区分只在 + 这里保留,audit_rebuild 行只存 contract_design)。 + 校验失败抛 ValueError,绝不写半条记录。 + """ + _require_non_empty_str(task_id, "task_id") + _require_non_empty_str(module_id, "module_id") + _require_non_empty_str(contract_version, "contract_version") + if wiring_kind not in WIRING_KINDS: + raise ValueError( + "wiring_kind must be one of %s" % (WIRING_KINDS,)) + if round_no is not None: + _require_int_ge(round_no, "round_no", 0) + if failed_checks is not None and not isinstance(failed_checks, list): + raise ValueError("failed_checks must be a list or None") + if target is not None: + _require_non_empty_str(target, "target") + if root_cause is not None: + _require_non_empty_str(root_cause, "root_cause") + if not isinstance(reason, str): + raise ValueError("reason must be a string") + detail = json.dumps({ + "module_id": module_id, + "contract_version": contract_version, + "wiring_kind": wiring_kind, + "target": target, + "reason": reason, + "round_no": round_no, + "root_cause": root_cause, + "failed_checks": failed_checks or [], + }, sort_keys=True, ensure_ascii=False) + return append_entry(conn, user_id, project_id, + WIRING_EVENT_ACTION, detail=detail, + task_id=task_id, created_at=created_at) + + class LedgerStore: """db_path 版账本存储(测试与脚本用;服务端走连接版函数)。 @@ -1391,3 +1454,8 @@ class LedgerStore: entry = append_rebuild_notice(self.conn, *args, **kwargs) self.conn.commit() return entry + + def append_wiring_event(self, *args, **kwargs): + entry = append_wiring_event(self.conn, *args, **kwargs) + self.conn.commit() + return entry diff --git a/cloud/server.py b/cloud/server.py index f9c24fc..8880e36 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -89,6 +89,7 @@ from cloud.peer_messages import row_get as _row_get from cloud.marker_tools import parse_spawn_defaults as _parse_spawn_defaults from cloud.merge_gate import evaluate_merge_gate as _merge_gate_evaluate from cloud.merge_gate import protection_from_env as _merge_gate_protection +from cloud.audit_wiring import pushback_block_for_task as _pushback_block from cloud.merge_gate_hard import blocked_audit_detail as _gate_blocked_detail from cloud.merge_gate_hard import evaluate_hard_merge_gate as _merge_gate_hard from cloud.merge_gate_hard import hard_mode_from_env as _merge_gate_hard_mode @@ -8087,6 +8088,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): 门禁阻断(任一模式)都写 merge_gate_blocked 审计后才回 409, 这是第 3 轨自动推回的阻塞依据:推回包取响应 merge_gate 的 blocks 码,不要解析审计文本;本接口不建推回任务、不改状态机。 + 审计推回接线(cloud/audit_wiring,第 1 项接线缺口):五项 + 检查之外再与链上推回阻塞做“与”:链上任一模块仍阻塞 + (merge_blocked)时,即使五项全过也 409、状态不变,并在 + merge_gate.blocks 里追加 audit_pushback_blocked 码、响应 + 里附 audit_pushback 对象(merge_blocked / release_blocked + / blocked_by="audit_pushback" / round / failed_checks)。 + 推回阻塞与门禁模式无关:record 模式的显式确认只覆盖 + 保护风险,绝不覆盖推回阻塞;链读不到(audit_ledger 表 + 不存在)视为无推回状态,其它读链失败一律 fail-closed + 409,不放行。注意:本接口没有独立的发布端点, + release_blocked 随响应返回、由未来的发布端点用同一 + reader 强制;deploy.py 是运维脚本,不在本接口门禁内。 本服务没有可信渠道直连 GitHub 核实 CI/保护:门禁强制的是 “调用方提交的证据齐全且全绿”,谎报证据由审计与 PR/Checks 只读核对覆盖,不在本接口的验证范围内。 @@ -8147,6 +8160,46 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): approved_exceptions=body.get("approved_exceptions") or (), protection=protection, mode=mode, explicit_confirm=bool(body.get("merge_gate_confirm"))) + # 审计推回接线:链上阻塞与门禁五项为“与”关系,且与模式 + # 无关。读链失败(表不存在除外,见 audit_wiring)时 + # fail-closed:不证明无阻塞就不放行。 + pushback = None + try: + conn = self._db() + try: + pushback = _pushback_block(conn, task_id) + finally: + conn.close() + except Exception: + pushback = None + if pushback is None: + try: + with _auth_store(self.server.db_path) as store: + store.audit( + user_id, task["project_id"], + "merge_gate_blocked", + "%s mode=%s blocks=audit_pushback_state_unavailable" + % (task_id, gate.get("mode", "hard"))) + except Exception: + pass + self._send_json(409, { + "error": "audit pushback state unavailable", + "merge_gate": gate, + "audit_pushback": { + "merge_blocked": True, + "release_blocked": True, + "blocked_by": "audit_pushback_state_unavailable", + "round": None, + "failed_checks": [], + }, + }) + return + gate["audit_pushback"] = pushback + if pushback["merge_blocked"]: + gate["allowed"] = False + blocks = gate.setdefault("blocks", []) + if "audit_pushback_blocked" not in blocks: + blocks.append("audit_pushback_blocked") if not gate["allowed"]: with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], diff --git a/cloud/skills/software-engineering/SKILL.md b/cloud/skills/software-engineering/SKILL.md index e061966..a06e66a 100644 --- a/cloud/skills/software-engineering/SKILL.md +++ b/cloud/skills/software-engineering/SKILL.md @@ -696,9 +696,13 @@ tests/ # QA 线程专属,含跨端一致性用例 `release_blocked`、`blocked_by="audit_pushback"`、轮次、 失败检查清单),硬门禁自己的检查与推回阻塞是“与”关系: 审计通过不解硬门禁的锁,硬门禁通过也不解推回的锁,两边都 - 通过才能合并/发布。代码侧该接口目前是提案(第 1 轨落地时 - 确认接线,在此之前本模块本身不拦任何服务端点,这一点要 - 如实写进 PR 摘要,不冒充已接线)。 + 通过才能合并/发布。接线已落地(`cloud/audit_wiring.py`): + 服务端 POST /tasks/{id}/merge 在五项检查之外再读链上推回 + 阻塞,任一阻塞即 409、状态不变;推回阻塞与门禁模式无关, + 显式确认绝不覆盖它;每轮推回/修复/重审/推倒/升级都写进 + 第 4 轨哈希链账本(字段以本轨为准,第 4 轨加了 + `audit_wiring_event` 兼容动作,见该模块)。CONDITIONAL + 在接线处映射成 fail 再记,原结论保留在链上不抹历史。 - 推回包必须可执行,缺一项就不算推回完成:审计结论原文; 失败项逐条(含测试名、文件路径、行号、合同条款、diff 范围); 复现步骤;修复验收标准(即重新审计时要过的同一批检查, @@ -723,10 +727,12 @@ tests/ # QA 线程专属,含跨端一致性用例 不因“是修复”就绕过配额。 - 状态与账本:每轮推回、修复、重审都要写审计记录(轮次、触发项、 推回包、修复 diff、重审结果),账本走第 4 轨的防篡改机制; - 本轨先按“审计记录字段 + 校验接口”定义(代码里 - `build_ledger_entry` / `validate_ledger_chain` 的 - `prev_hash` / `entry_hash` 链式格式是提案,第 4 轨落地后接线, - 接口不一致处在 PR 摘要写清)。任务时间线要能看到 + 接线已落地(`cloud/audit_wiring.py`):推回/修复/重审/ + 推倒/升级按该模块的映射表写进第 4 轨哈希链,链式校验走 + 第 4 轨的 `verify_chain` / 导出头哈希;本轨的 + `build_ledger_entry` / `validate_ledger_chain` 仍是纯函数 + 提案格式(供离线校验),链上权威格式以第 4 轨为准。 + 任务时间线要能看到 “审计 fail → 自动推回 → 修复 → 重审”的完整链路,不能只有 最终通过一条记录把失败轮次抹掉。 - 与已有回路的关系,不重复造轮子:G5 缺陷回路(缺陷回 G4、 @@ -837,10 +843,10 @@ tests/ # QA 线程专属,含跨端一致性用例 - 模块失败计数(审核/验收分来源、合计)、推回轮次、推倒次数、 重做新旧实现对应关系、根因判定结果都要写审计记录,走第 4 轨 - 的防篡改机制(本轨先按字段 + 校验接口定义:账本条目含 - `module_id`、`contract_version`、`fail_counts`、`rebuild_no`、 - `root_cause`、`old_implementation_ref`,链式校验见代码 - `build_ledger_entry` / `validate_ledger_chain`,第 4 轨落地后接线)。 + 的防篡改机制(接线已落地,见 `cloud/audit_wiring.py` 的 + 映射表:账本条目含 `module_id`、`contract_version`、 + `fail_counts`、`rebuild_no`、`root_cause`、 + `old_implementation_ref`,链式校验走第 4 轨)。 - 任务时间线要能看到完整链路:“审计 fail → 自动推回修复 (1–3 轮)→ 超过 3 次 → 推倒重做 → 重新审计”,不能只有最终 通过一条记录;推倒记录、重新冻结升级记录同样留痕,不得抹掉。 diff --git a/cloud/tests/test_audit_wiring.py b/cloud/tests/test_audit_wiring.py new file mode 100644 index 0000000..2eefd92 --- /dev/null +++ b/cloud/tests/test_audit_wiring.py @@ -0,0 +1,627 @@ +"""Audit-gap-1 wiring: track 3 (pushback) <-> track 1 (hard gate) +<-> track 4 (hash-chain ledger), through cloud/audit_wiring.py. + +What is pinned here (and nowhere else): + +- CONDITIONAL maps to fail at the wiring boundary, original kept. +- Hard-gate 409 blocks codes become pushback-package failed + checks (branch protection travels as its own extra, never + dropped, never disguised as one of the ten gate checks). +- Every pushback / fix / re-audit / rebuild / escalation step + lands on the hash chain, and the chain still verifies. +- The server ANDs the chain's pushback block with the hard + gate's five checks: audit fail -> merge 409, status + unchanged, chain intact -> fix -> re-audit pass -> merge 200. + This chain runs against the real HTTP endpoint, not just + pure functions. +- The QA full chain is covered separately: a package carrying + QA failed links cannot be re-audited to a pass until exactly + those links have been re-run passing. +- The resolved meta-review tension: a correction to fail + re-blocks at once, but a correction to pass does NOT unblock + by itself (track 3: schedule a re-audit, not release the + gate); only a fresh passing verdict / re-audit unblocks. + Track 4's failure counts still use effective verdicts, so + the two views may disagree while a re-audit is pending -- + that disagreement is asserted, not papered over. +""" + +import http.client +import json +import os +import sqlite3 +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +try: + from cloud.audit_pushback import GATE_CHECKS + from cloud.audit_pushback import decide_pushback + from cloud.audit_wiring import gate_failed_checks + from cloud.audit_wiring import normalize_re_review_result + from cloud.audit_wiring import normalize_verdict + from cloud.audit_wiring import pushback_block_for_task + from cloud.audit_wiring import pushback_package_from_gate + from cloud.audit_wiring import record_audit_verdict + from cloud.audit_wiring import record_escalation + from cloud.audit_wiring import record_fix + from cloud.audit_wiring import record_meta_review + from cloud.audit_wiring import record_pushback + from cloud.audit_wiring import record_reaudit + from cloud.audit_wiring import record_rebuild + from cloud.audit_wiring import record_third_failure_assessment + from cloud.auth import AuthStore + from cloud.ledger import LedgerStore + from cloud.ledger import audit_timeline + from cloud.merge_gate_hard import evaluate_hard_merge_gate + from cloud.server import create_server +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.audit_pushback import GATE_CHECKS + from cloud.audit_pushback import decide_pushback + from cloud.audit_wiring import gate_failed_checks + from cloud.audit_wiring import normalize_re_review_result + from cloud.audit_wiring import normalize_verdict + from cloud.audit_wiring import pushback_block_for_task + from cloud.audit_wiring import pushback_package_from_gate + from cloud.audit_wiring import record_audit_verdict + from cloud.audit_wiring import record_escalation + from cloud.audit_wiring import record_fix + from cloud.audit_wiring import record_meta_review + from cloud.audit_wiring import record_pushback + from cloud.audit_wiring import record_reaudit + from cloud.audit_wiring import record_rebuild + from cloud.audit_wiring import record_third_failure_assessment + from cloud.auth import AuthStore + from cloud.ledger import LedgerStore + from cloud.ledger import audit_timeline + from cloud.merge_gate_hard import evaluate_hard_merge_gate + from cloud.server import create_server + +MODULE = "apps/web" +CONTRACT = "v2" + + +def _failing_gate(): + return evaluate_hard_merge_gate( + files_changed=["apps/web/a.py"], owned_dir="apps/web", + protection="protected", ci_status="pending", + ci_checks=[{"name": "ci", "status": "pending"}], + required_checks=["ci"], contract_version="v2", + contract_frozen=True, coordinator_approved=True, + unresolved_review_comments=0) + + +def _full_gate(): + return evaluate_hard_merge_gate( + files_changed=["apps/web/a.py"], owned_dir="apps/web", + protection="protected", ci_status="success", + ci_checks=[{"name": "ci", "status": "success"}], + required_checks=["ci"], contract_version="v2", + contract_frozen=True, coordinator_approved=True, + unresolved_review_comments=0) + + +def _failing_audit(): + checks = {name: True for name in GATE_CHECKS} + checks["ci_green"] = False + return { + "task_id": "task-1", + "verdict": "fail", + "verdict_text": "audit fail: ci not green", + "signer": "auditor-a", + "executor": "executor-e", + "coordinator_approver": "coordinator-c", + "qa_tester": "qa-q", + "security_verdict": "pass", + "checks": checks, + "failed_items": [], + "deviations": [], + "regression": {"items": [ + {"path": "apps/web/app.py", "sha256_manifest": "a" * 64, + "sha256_recomputed": "a" * 64, "source": "exact"}, + ]}, + "demo": {"verified_runnable": True, "runs_app_entry": True, + "command": "python3 apps/web/app.py"}, + } + + +class VerdictNormalizationTestCase(unittest.TestCase): + def test_conditional_maps_to_fail_and_keeps_original(self): + normalized, original = normalize_verdict("conditional") + self.assertEqual(normalized, "fail") + self.assertEqual(original, "conditional") + normalized, original = normalize_verdict("pass") + self.assertEqual((normalized, original), ("pass", "pass")) + normalized, original = normalize_verdict(None) + self.assertEqual(normalized, "fail") + self.assertIsNone(original) + + def test_re_review_conditional_maps_to_fail(self): + result = normalize_re_review_result({"verdict": "conditional"}) + self.assertEqual(result["verdict"], "fail") + self.assertEqual(result["original_verdict"], "conditional") + result = normalize_re_review_result({"verdict": "pass"}) + self.assertEqual(result["verdict"], "pass") + self.assertNotIn("original_verdict", result) + + +class GatePackageTestCase(unittest.TestCase): + def test_all_block_families_map_and_branch_protection_survives(self): + gate = evaluate_hard_merge_gate( + files_changed=["apps/other/x.py"], owned_dir="apps/web", + protection="unknown", ci_status="pending", + ci_checks=[{"name": "ci", "status": "failure"}], + required_checks=["ci"], contract_version=None, + contract_frozen=False, coordinator_approved=None, + unresolved_review_comments=["c1"]) + self.assertFalse(gate["allowed"]) + checks, extras = gate_failed_checks(gate) + for name in ("ci_green", "directory_isolation", + "contract_version", "coordinator_approval", + "unresolved_review_comments"): + self.assertIn(name, checks) + self.assertIn("branch_protection", extras) + package = pushback_package_from_gate(gate, "task-1", 0) + self.assertIn("branch_protection", package["failed_checks"]) + self.assertIn("ci_green", package["failed_checks"]) + # Full re-audit criteria, not failed-items-only. + self.assertEqual(package["reaudit_criteria"], + list(GATE_CHECKS)) + # No block code is lost in translation. + self.assertTrue(package["failed_items"]) + + def test_qa_links_carried_onto_package(self): + links = [{"link": "checkout-flow", "case": "pay", + "failure": "500", "evidence": "log", + "reproduction": ["run checkout"]}] + package = pushback_package_from_gate( + _failing_gate(), "task-1", 0, qa_failed_links=links) + self.assertEqual(package["qa_failed_links"], links) + self.assertEqual(package["reaudit_must_rerun_links"], links) + + +class LedgerRecordingTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + self.store = LedgerStore(self.db_path) + self.task = "task-1" + self.project = "proj-1" + + def tearDown(self): + try: + self.store.close() + except Exception: + pass + self.tmp.cleanup() + + def _block(self): + conn = sqlite3.connect(self.db_path) + try: + return pushback_block_for_task(conn, self.task) + finally: + conn.close() + + def _chain_ok(self): + result = self.store.verify() + self.assertTrue(result["ok"], result) + return result + + def test_conditional_verdict_stored_as_fail_with_original(self): + entry = record_audit_verdict( + self.store, self.task, self.project, MODULE, CONTRACT, + "conditional", "audit", failures=["ci_green"]) + timeline = audit_timeline(self.store.conn, task_id=self.task) + verdict = [e for e in timeline["entries"] + if e["action"] == "audit_verdict"][0] + self.assertEqual(verdict["parsed"]["verdict"], "fail") + self.assertEqual(verdict["parsed"]["extra"]["original_verdict"], + "conditional") + self.assertEqual(entry["id"], verdict["id"]) + self._chain_ok() + + def test_pushback_fix_reaudit_cycle_moves_gate(self): + record_audit_verdict(self.store, self.task, self.project, + MODULE, CONTRACT, "fail", "audit", + failures=["ci_green"]) + package = pushback_package_from_gate(_failing_gate(), + self.task, 0) + record_pushback(self.store, self.task, self.project, MODULE, + CONTRACT, 1, package["failed_checks"], package, + failure_source="audit") + block = self._block() + self.assertTrue(block["merge_blocked"]) + self.assertTrue(block["release_blocked"]) + self.assertEqual(block["blocked_by"], "audit_pushback") + self.assertEqual(block["round"], 1) + self.assertIn("ci_green", block["failed_checks"]) + # A landed fix alone does not unblock. + record_fix(self.store, self.task, self.project, MODULE, + CONTRACT, 1, + {"summary": "fix ci", "diff_sha256": "b" * 64, + "files_changed": ["apps/web/a.py"]}, package) + self.assertTrue(self._block()["merge_blocked"]) + # A passing re-audit unblocks; chain still verifies. + record_reaudit(self.store, self.task, self.project, MODULE, + CONTRACT, 1, {"verdict": "pass"}, + package=package) + self.assertFalse(self._block()["merge_blocked"]) + self._chain_ok() + + def test_qa_guard_refuses_unrerun_or_failed_link_pass(self): + links = [{"link": "checkout-flow", "case": "pay", + "failure": "500", "evidence": "log", + "reproduction": ["run checkout"]}] + package = pushback_package_from_gate( + _failing_gate(), self.task, 0, qa_failed_links=links) + record_audit_verdict(self.store, self.task, self.project, + MODULE, CONTRACT, "fail", "audit", + failures=["ci_green"]) + record_pushback(self.store, self.task, self.project, MODULE, + CONTRACT, 1, package["failed_checks"], package, + failure_source="audit") + before = self.store.verify()["count"] + with self.assertRaises(ValueError): + record_reaudit(self.store, self.task, self.project, + MODULE, CONTRACT, 1, {"verdict": "pass"}, + package=package, rerun_results={}) + with self.assertRaises(ValueError): + record_reaudit(self.store, self.task, self.project, + MODULE, CONTRACT, 1, {"verdict": "pass"}, + package=package, + rerun_results={"checkout-flow": False}) + self.assertEqual(self.store.verify()["count"], before) + self.assertTrue(self._block()["merge_blocked"]) + record_reaudit(self.store, self.task, self.project, MODULE, + CONTRACT, 1, {"verdict": "pass"}, + package=package, + rerun_results={"checkout-flow": True}) + self.assertFalse(self._block()["merge_blocked"]) + + def test_rebuild_alone_does_not_unblock(self): + record_audit_verdict(self.store, self.task, self.project, + MODULE, CONTRACT, "fail", "audit", + failures=["ci_green"]) + record_rebuild(self.store, self.task, self.project, MODULE, + CONTRACT, 1, {"commit": "old123"}, + root_cause="implementation", status="decided") + self.assertTrue(self._block()["merge_blocked"]) + record_rebuild(self.store, self.task, self.project, MODULE, + CONTRACT, 1, {"commit": "old123"}, + new_impl_ref={"commit": "new456"}, + root_cause="implementation", status="rebuilt", + retained_assets={"old_tests_status": "pass"}) + self.assertTrue(self._block()["merge_blocked"]) + record_audit_verdict(self.store, self.task, self.project, + MODULE, CONTRACT, "pass", "audit") + self.assertFalse(self._block()["merge_blocked"]) + self._chain_ok() + + def test_escalation_blocks_sample_does_not(self): + record_escalation(self.store, self.task, self.project, + MODULE, CONTRACT, "blocked_quota", + target="coordinator", + reason="fix exceeds quota", round_no=2, + failed_checks=["ci_green"]) + self.assertTrue(self._block()["merge_blocked"]) + record_escalation(self.store, self.task, self.project, + MODULE, CONTRACT, "audit_sample", + reason="every 10th record") + self.assertTrue(self._block()["merge_blocked"]) + record_audit_verdict(self.store, self.task, self.project, + MODULE, CONTRACT, "pass", "audit") + self.assertFalse(self._block()["merge_blocked"]) + + def test_meta_review_tension_is_pinned(self): + fail_entry = record_audit_verdict( + self.store, self.task, self.project, MODULE, CONTRACT, + "fail", "audit", failures=["ci_green"]) + self.assertTrue(self._block()["merge_blocked"]) + # Wrong block corrected to pass: gate STAYS blocked until + # a fresh passing re-audit (track 3 restored action). + record_meta_review(self.store, self.task, self.project, + MODULE, CONTRACT, fail_entry["id"], + "fail", "pass", True, "reviewer-r", + findings=["wrong call"], + triggered_process="re-review") + self.assertTrue(self._block()["merge_blocked"]) + # ...while track 4 counts use the effective verdict. + counts = self.store.module_failure_counts(MODULE, CONTRACT, + task_id=self.task) + self.assertEqual(counts["total_fails"], 0) + record_audit_verdict(self.store, self.task, self.project, + MODULE, CONTRACT, "pass", "audit") + self.assertFalse(self._block()["merge_blocked"]) + # Wrong pass corrected to fail: re-blocks at once. + pass_entry = record_audit_verdict( + self.store, self.task, self.project, MODULE, CONTRACT, + "pass", "audit") + self.assertFalse(self._block()["merge_blocked"]) + record_meta_review(self.store, self.task, self.project, + MODULE, CONTRACT, pass_entry["id"], + "pass", "fail", True, "reviewer-r", + findings=["missed ci"], + triggered_process="re-review") + self.assertTrue(self._block()["merge_blocked"]) + self._chain_ok() + + def test_modules_are_scoped_not_global(self): + record_audit_verdict(self.store, self.task, self.project, + "apps/a", CONTRACT, "fail", "audit", + failures=["ci_green"]) + record_audit_verdict(self.store, self.task, self.project, + "apps/b", CONTRACT, "pass", "audit") + self.assertTrue(self._block()["merge_blocked"]) + record_audit_verdict(self.store, self.task, self.project, + "apps/a", CONTRACT, "pass", "audit") + self.assertFalse(self._block()["merge_blocked"]) + + def test_triage_and_root_cause_mapping(self): + record_third_failure_assessment( + self.store, self.task, self.project, MODULE, CONTRACT, + "targeted_fix", + {"audit_conclusion": "nearly done", + "failure_items": ["ci_green"], + "passed_items": ["directory_isolation"]}) + timeline = audit_timeline(self.store.conn, task_id=self.task) + assessment = [e for e in timeline["entries"] + if e["action"] == "audit_third_failure_assessment"][0] + self.assertEqual(assessment["parsed"]["determination"], + "continuation_round") + with self.assertRaises(ValueError): + record_third_failure_assessment( + self.store, self.task, self.project, MODULE, CONTRACT, + "early_refreeze", {"audit_conclusion": "contract"}) + with self.assertRaises(ValueError): + record_rebuild(self.store, self.task, self.project, + MODULE, CONTRACT, 1, {"commit": "old"}, + root_cause="unknown") + # Design root cause stores as contract_design. + record_rebuild(self.store, self.task, self.project, MODULE, + CONTRACT, 1, {"commit": "old"}, + root_cause="design", status="decided") + timeline = audit_timeline(self.store.conn, task_id=self.task) + rebuild = [e for e in timeline["entries"] + if e["action"] == "audit_rebuild"][0] + self.assertEqual(rebuild["parsed"]["root_cause"], + "contract_design") + self._chain_ok() + + def test_missing_ledger_table_is_unblocked_not_error(self): + fresh = os.path.join(self.tmp.name, "empty.db") + conn = sqlite3.connect(fresh) + try: + block = pushback_block_for_task(conn, "nope") + finally: + conn.close() + self.assertFalse(block["merge_blocked"]) + self.assertIsNone(block["blocked_by"]) + + def test_decide_pushback_round_feeds_ledger_round(self): + decision = decide_pushback(_failing_audit(), 0) + self.assertEqual(decision["action"], "pushback") + self.assertEqual(decision["next_round"], 1) + record_audit_verdict(self.store, self.task, self.project, + MODULE, CONTRACT, "fail", "audit", + failures=decision["failed_checks"]) + package = pushback_package_from_gate( + _failing_gate(), self.task, decision["round"]) + record_pushback(self.store, self.task, self.project, MODULE, + CONTRACT, decision["next_round"], + package["failed_checks"], package, + failure_source="audit") + block = self._block() + self.assertTrue(block["merge_blocked"]) + self.assertEqual(block["round"], 1) + + +class EndpointChainTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + self.auth = AuthStore(self.db_path) + self.owner_id, self.owner_key = self.auth.create_user("owner") + self.server = create_server(self.db_path, "127.0.0.1", 0) + self.port = self.server.server_address[1] + self.thread = threading.Thread( + target=self.server.serve_forever, daemon=True) + self.thread.start() + status, self.project = self._req( + "POST", "/projects", {"name": "demo"}, self.owner_key) + self.assertEqual(status, 201) + self._saved_env = {k: os.environ.get(k) for k in + ("CLOUD_MERGE_GATE_MODE", + "CLOUD_BRANCH_PROTECTION")} + os.environ.pop("CLOUD_MERGE_GATE_MODE", None) + os.environ.pop("CLOUD_BRANCH_PROTECTION", None) + self.ledger = LedgerStore(self.db_path) + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + for key, value in self._saved_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + try: + self.ledger.close() + except Exception: + pass + try: + self.auth.conn.close() + except Exception: + pass + self.tmp.cleanup() + + def _req(self, method, path, body=None, key=None): + conn = http.client.HTTPConnection( + "127.0.0.1", self.port, timeout=10) + hdrs = {} + payload = None + if body is not None: + payload = json.dumps(body).encode("utf-8") + hdrs["Content-Type"] = "application/json" + if key is not None: + hdrs["X-API-Key"] = key + try: + conn.request(method, path, body=payload, headers=hdrs) + resp = conn.getresponse() + data = resp.read() + finally: + conn.close() + try: + parsed = json.loads(data.decode("utf-8")) if data else None + except ValueError: + parsed = None + return resp.status, parsed + + def _completed_task(self): + status, task = self._req( + "POST", "/tasks", + {"project_id": self.project["id"], "prompt": "x"}, + self.owner_key) + self.assertEqual(status, 201) + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "UPDATE tasks SET status = 'completed' WHERE id = ?", + (task["id"],)) + conn.commit() + finally: + conn.close() + return task + + def _hard_body(self, **overrides): + body = { + "files_changed": ["apps/web/a.py"], + "owned_dir": "apps/web", + "branch_protection": "protected", + "ci_status": "success", + "ci_checks": [{"name": "ci", "status": "success"}], + "required_checks": ["ci"], + "contract_version": "v2", + "contract_frozen": True, + "coordinator_approved": True, + "unresolved_review_comments": 0, + } + body.update(overrides) + return body + + def _status_of(self, task_id): + _s, got = self._req("GET", "/tasks/%s" % task_id, + key=self.owner_key) + return got["status"] + + def _block_task(self, task, package=None): + if package is None: + package = pushback_package_from_gate(_failing_gate(), + task["id"], 0) + record_audit_verdict(self.ledger, task["id"], + self.project["id"], MODULE, CONTRACT, + "fail", "audit", + failures=package["failed_checks"]) + record_pushback(self.ledger, task["id"], self.project["id"], + MODULE, CONTRACT, 1, package["failed_checks"], + package, failure_source="audit") + return package + + def test_audit_fail_blocks_merge_fix_reaudit_releases(self): + task = self._completed_task() + package = self._block_task(task) + status, blocked = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(), self.owner_key) + self.assertEqual(status, 409, blocked) + self.assertIn("audit_pushback_blocked", + blocked["merge_gate"]["blocks"]) + self.assertTrue( + blocked["merge_gate"]["audit_pushback"]["merge_blocked"]) + self.assertTrue( + blocked["merge_gate"]["audit_pushback"]["release_blocked"]) + self.assertEqual( + blocked["merge_gate"]["audit_pushback"]["blocked_by"], + "audit_pushback") + self.assertEqual(self._status_of(task["id"]), "completed") + # Fix + passing re-audit (no QA links on this package). + record_fix(self.ledger, task["id"], self.project["id"], + MODULE, CONTRACT, 1, + {"summary": "fix ci", "diff_sha256": "b" * 64, + "files_changed": ["apps/web/a.py"]}, package) + status, still = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(), self.owner_key) + self.assertEqual(status, 409) + record_reaudit(self.ledger, task["id"], self.project["id"], + MODULE, CONTRACT, 1, {"verdict": "pass"}, + package=package) + status, merged = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(), self.owner_key) + self.assertEqual(status, 200, merged) + self.assertEqual(self._status_of(task["id"]), "merged") + self.assertFalse( + merged["merge_gate"]["audit_pushback"]["merge_blocked"]) + chain = self.ledger.verify() + self.assertTrue(chain["ok"], chain) + + def test_qa_failed_links_gate_the_release(self): + task = self._completed_task() + links = [{"link": "checkout-flow", "case": "pay", + "failure": "500", "evidence": "log", + "reproduction": ["run checkout"]}] + package = pushback_package_from_gate( + _failing_gate(), task["id"], 0, qa_failed_links=links) + self._block_task(task, package=package) + status, _blocked = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(), self.owner_key) + self.assertEqual(status, 409) + # Claimed pass without a passing re-run of the failed + # link must not reach the chain, and must not unblock. + with self.assertRaises(ValueError): + record_reaudit(self.ledger, task["id"], + self.project["id"], MODULE, CONTRACT, 1, + {"verdict": "pass"}, package=package, + rerun_results={"checkout-flow": False}) + status, _still = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(), self.owner_key) + self.assertEqual(status, 409) + record_reaudit(self.ledger, task["id"], self.project["id"], + MODULE, CONTRACT, 1, {"verdict": "pass"}, + package=package, + rerun_results={"checkout-flow": True}) + status, merged = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(), self.owner_key) + self.assertEqual(status, 200, merged) + + def test_pushback_blocks_even_in_record_mode(self): + task = self._completed_task() + self._block_task(task) + os.environ["CLOUD_MERGE_GATE_MODE"] = "record" + status, blocked = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(), self.owner_key) + self.assertEqual(status, 409, blocked) + self.assertIn("audit_pushback_blocked", + blocked["merge_gate"]["blocks"]) + self.assertEqual(self._status_of(task["id"]), "completed") + + def test_hard_gate_failure_without_pushback_has_no_pushback_code(self): + task = self._completed_task() + status, blocked = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(ci_status="pending"), self.owner_key) + self.assertEqual(status, 409, blocked) + self.assertIn("ci_not_green", blocked["merge_gate"]["blocks"]) + self.assertNotIn("audit_pushback_blocked", + blocked["merge_gate"]["blocks"]) + self.assertFalse( + blocked["merge_gate"]["audit_pushback"]["merge_blocked"]) + self.assertEqual(self._status_of(task["id"]), "completed")