audit: wire pushback gate to hard merge gate and hash-chain ledger (gap 1)
Track 3 decisions (audit_pushback) now really block the server merge
endpoint and really land on the track 4 hash chain, via the new
cloud/audit_wiring.py translation layer:
- POST /tasks/{id}/merge ANDs the chain pushback block
(merge_blocked/release_blocked/blocked_by=audit_pushback/round/
failed_checks, replayed per module+contract version) with the
hard gate's five checks in every gate mode; either side blocks
-> 409, status unchanged, audit_pushback_blocked appended to
merge_gate.blocks. Ledger reads that fail (other than a missing
audit_ledger table) fail closed to 409.
- Every pushback/fix/re-audit/rebuild/escalation step is written
to the ledger with track 3 fields authoritative and track 4
storage authoritative; CONDITIONAL maps to fail at the boundary
with the original preserved, history never rewritten.
- Hard-gate 409 blocks codes feed pushback packages machine-first
(branch protection survives as its own extra check).
- Re-audit QA guard: a claimed pass with un-rerun or failed QA
links never reaches the chain.
- Track 4 compat: one audit_wiring_event action for escalation /
blocked-quota / audit-sample kinds, counted in the timeline.
- Resolved tension (documented in audit_wiring.py): a meta-review
correction to fail re-blocks at once, but a correction to pass
does NOT unblock by itself -- track 3 schedules a re-audit,
it does not release the gate; track 4 failure counts still use
effective verdicts, so the two views may disagree while a
re-audit is pending.
Tests: cloud/tests/test_audit_wiring.py (18 items), incl. a real
HTTP end-to-end chain (fail -> 409 -> fix -> re-audit pass ->
200) and a separate QA-link chain test.
This commit is contained in:
1 parent
eeb10118d6
commit
382ec6d62e
5 files changed
+1512
-12
No files matched your search
@@ -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"]["<global>"] = {
|
||||
"blocked": True,
|
||||
"round": global_state["round"],
|
||||
"failed_checks": list(global_state["checks"]),
|
||||
}
|
||||
return block
|
||||
+69
-1
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 次 → 推倒重做 → 重新审计”,不能只有最终
|
||||
通过一条记录;推倒记录、重新冻结升级记录同样留痕,不得抹掉。
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user