process_audit_verdict now calls validate_signer_separation before any state save or ledger write (previously the function had no production caller on this path: it only ran inside collect_failed_checks via decide_pushback, which itself has no production caller). Signer is bound to the authenticated caller -- a body signer differing from the caller is rejected -- and executor/coordinator_approver/qa_tester must be declared and distinct from the caller; missing field, self-audit, or forged signer raises ValueError, mapped by _loop_run to HTTP 400 with no side effects. No new route. Honest limits are in the helper docstring: declared-identity enforcement only (task rows store no authoritative executor / coordinator / QA identity; task executor is a backend, not a person), and audit-fix reaudit verdicts are out of scope (the fix poster is not necessarily the re-auditor, so binding signer to the fix poster would be a mis-binding). (cherry picked from commit 56dce5165f4d08f38fe1730b9eea3b78f90cb97d) Reconcile-port: handler_lifecycle.py docstring hunk redirected into cloud/server.py audit-verdict handler (target keeps 6-mixin split; enforcement itself is in audit_loop_wiring.py and applied cleanly).
1003 lines
45 KiB
Python
1003 lines
45 KiB
Python
"""Server-side wiring for the project-level audit loop (track 2).
|
||
|
||
This module is the ONLY place that connects the track-1 pure driver
|
||
(``cloud/audit_loop_driver.py``, PR #260) to durable server state.
|
||
It changes no driver semantics and it does not re-implement the
|
||
driver, the #247 gate reader, or the #241 hash chain:
|
||
|
||
- Decisions come from the driver, verbatim:
|
||
``decide_audit`` / ``after_fix`` / ``apply_correction`` /
|
||
``confirm_rebuild``. This module never recomputes a pushback
|
||
round, a failure count, or a rebuild budget; every number it
|
||
persists comes from ``Action.next_state``.
|
||
- Merge blocking stays exactly the #247 reader
|
||
(``audit_wiring.pushback_block_for_task``) ANDed with the five
|
||
hard-gate checks in ``server._merge_task``. This module does NOT
|
||
add a second gate reader for merge: the loop-state table below
|
||
is driver state, not a gate. If chain and loop state ever
|
||
diverged (only possible if a caller wrote state without going
|
||
through this module), merge follows the chain, because the
|
||
chain is the auditable record. The normal flow cannot diverge:
|
||
``process_*`` writes the ledger FIRST and only then saves the
|
||
state, so a ledger failure leaves the old state untouched.
|
||
- Ledger writes go through the #241/#247 layer only
|
||
(``audit_wiring.record_*`` over a ``LedgerStore``-shaped store).
|
||
No second write path is invented here.
|
||
- Quota and cost are caller-injected, as the driver requires.
|
||
The quota adapter below follows the existing server quota
|
||
model (``server._quota_status``: a USD-micros budget per user
|
||
per month, ``used >= quota`` blocks, ``None`` means uncapped);
|
||
it does not invent a second quota table. #212's design口径 is
|
||
the server's existing ``user_quotas`` table, reused as-is.
|
||
- ``evaluate_feedback`` / the #238 metrics loop is untouched:
|
||
it stays ``auto_apply: False``. Nothing in this module feeds
|
||
loop outcomes back into the standard documents, and no
|
||
threshold in this module loosens or tightens a standard.
|
||
- ``deploy.py`` is NOT in any task gate here. It deploys the
|
||
platform itself, has no task id, and does not go through
|
||
server HTTP auth; binding it to a task chain would lock
|
||
deployment exactly when a fix or rollback needs deploying.
|
||
The release endpoint below is a gate + authorization for a
|
||
release, not a deployment runner.
|
||
|
||
Module mapping (task -> loop key), stated once and tested:
|
||
|
||
- The loop key is ``(module_id, contract_version)``, exactly as
|
||
the driver counts it.
|
||
- ``module_id``: a task's explicit ``module_id`` field when it
|
||
is a non-empty string, else its ``module`` field, else the
|
||
task id itself. Trade-off, stated not hidden: an explicit
|
||
module field lets several tasks share one loop (one module,
|
||
one counter), which is what a module-level loop means; the
|
||
task-id fallback isolates a task that names no module, so two
|
||
unrelated tasks can never silently share a counter. Callers
|
||
must not mix the two for the same module: once a module is
|
||
named, every verdict/fix for it must carry that name.
|
||
- ``contract_version``: the verdict's (or re-audit verdict's)
|
||
``contract_version`` when given, else the task's
|
||
``contract_version`` field. When neither exists the call
|
||
fails closed (ValueError) instead of inventing a version:
|
||
an invented version would merge counters across versions.
|
||
- Loop state is persisted in table ``audit_loop_state`` keyed
|
||
by ``(module_id, contract_version)`` with the owning
|
||
``task_id`` recorded for traceability. It is NOT memory-only:
|
||
a server restart reloads the same counters, rounds, rebuild
|
||
budget, targeted budget, and pending confirmation from this
|
||
table. The table is created idempotently by
|
||
``ensure_loop_state_table``.
|
||
|
||
Automatic chain (what "automatic" means here, precisely):
|
||
|
||
- "Task completed -> independent audit -> fail auto-derives a
|
||
fix subtask and blocks merge/release (original task stays
|
||
``completed``, it is NOT rolled back to a re-run)" is
|
||
``process_audit_verdict``: the driver decides, the ledger is
|
||
written, the state is saved, and a fix/rebuild subtask
|
||
descriptor is returned. The server handler turns the
|
||
descriptor into a real queued task row; this module itself
|
||
creates no task rows (it has no HTTP/task-row layer).
|
||
- "Fix completed -> automatic full re-audit" is
|
||
``process_fix_completion``: the fix-completion event itself
|
||
advances the loop through the driver's ``after_fix``, which
|
||
requires a FULL re-audit verdict. The automated checks run
|
||
here (hard gate five + directory/contract + demo/runnable,
|
||
regression, security, signer-separation, QA-link re-runs)
|
||
are EVIDENCE for that re-audit, never the verdict: if any
|
||
automated check fails, a claimed pass is forced to fail
|
||
before it reaches the driver; if they all pass, the loop
|
||
still waits for the auditor-submitted verdict, and a
|
||
missing verdict or ``diff_only`` is refused with ValueError
|
||
by the driver. Automated-checks-passing is NEVER reported
|
||
as audit-passing.
|
||
- Subjective audit conclusions still come from the auditor:
|
||
this module accepts verdicts, it does not invent them.
|
||
No verdict is fabricated anywhere in this file.
|
||
|
||
Release gate (``evaluate_release_gate``) follows the #259
|
||
contract: re-read the SAME #247 reader at release time, re-run
|
||
the hard gate on caller-supplied FRESH evidence (a merge-time
|
||
200 is never an input), and require task status ``merged``.
|
||
All three are ANDed. ``blocked_sources`` lists every blocking
|
||
source when several block at once, except a chain-read failure,
|
||
which short-circuits per #259 (no other reading is trustworthy
|
||
then). A 409 from the endpoint writes NO audit record, because
|
||
a check writing to the chain would move the chain head and
|
||
invalidate an anchor taken before the check.
|
||
|
||
Confirmation rule (acceptance-triggered rebuild):
|
||
|
||
- ``process_rebuild_confirmation`` never auto-confirms. It
|
||
requires an explicit ``confirmed_by`` that is a real user id:
|
||
the reserved markers ``"system"`` and ``"coordinator"`` are
|
||
refused with ValueError, and the server endpoint additionally
|
||
requires the request body to carry ``user_confirmed: true``
|
||
AND ``confirmed_by`` equal to the authenticated caller.
|
||
No code path in this module or in the server handlers calls
|
||
confirmation on behalf of a user. A rejection escalates to
|
||
the coordinator and consumes no rebuild budget (driver).
|
||
Pending and confirmed are two chain rows, never an edit.
|
||
Confirmation is NOT G6 acceptance: the user still holds
|
||
final acceptance afterwards, and there is no
|
||
``tick_acceptance`` anywhere in this wiring.
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import time
|
||
|
||
from cloud import audit_loop_driver as _driver
|
||
from cloud import audit_wiring as _wiring
|
||
from cloud.audit_pushback import validate_signer_separation as _validate_signer_separation
|
||
from cloud.merge_gate_hard import evaluate_hard_merge_gate as _hard_gate
|
||
|
||
# ------------------------------------------------------------------
|
||
# Mapping: task / verdict -> loop key
|
||
# ------------------------------------------------------------------
|
||
|
||
_RESERVED_CONFIRMERS = ("system", "coordinator", "")
|
||
|
||
# Automated re-audit evidence: extra checks beyond the hard five.
|
||
# Each must be exactly True; missing or non-True is a failure,
|
||
# never a skip. These names are the driver's FULL_REAUDIT_CRITERIA
|
||
# minus the five hard-gate items, plus the QA re-run check.
|
||
_AUTOMATED_EXTRA_CHECKS = (
|
||
"security_unconditional",
|
||
"regression_exact_artifact",
|
||
"demo_runnable",
|
||
"known_deviation_due_todo",
|
||
"audit_signer_separation",
|
||
"qa_failed_links_rerun",
|
||
)
|
||
|
||
|
||
def resolve_module_id(task, explicit=None):
|
||
"""Task -> module_id. Explicit argument wins, then the task's
|
||
own module fields, then the task id (isolated fallback).
|
||
See the module docstring for the trade-off."""
|
||
if isinstance(explicit, str) and explicit.strip():
|
||
return explicit.strip()
|
||
if isinstance(task, dict):
|
||
for key in ("module_id", "module"):
|
||
value = task.get(key)
|
||
if isinstance(value, str) and value.strip():
|
||
return value.strip()
|
||
task_id = task.get("id")
|
||
if isinstance(task_id, str) and task_id.strip():
|
||
return task_id.strip()
|
||
raise ValueError("cannot resolve module_id: no module field "
|
||
"and no task id")
|
||
|
||
|
||
def resolve_contract_version(task, verdict=None):
|
||
"""Task/verdict -> contract_version. Verdict wins when given
|
||
(a version change is how the driver resets counters), else
|
||
the task's field. Neither -> ValueError, never an invented
|
||
version."""
|
||
if isinstance(verdict, dict):
|
||
version = verdict.get("contract_version")
|
||
if isinstance(version, str) and version.strip():
|
||
return version.strip()
|
||
if isinstance(task, dict):
|
||
version = task.get("contract_version")
|
||
if isinstance(version, str) and version.strip():
|
||
return version.strip()
|
||
raise ValueError("cannot resolve contract_version: neither the "
|
||
"verdict nor the task carries one")
|
||
|
||
|
||
def task_project_id(task):
|
||
"""Project id for ledger rows (empty string when unknown;
|
||
the ledger layer, not this module, decides whether that is
|
||
acceptable for a given row type)."""
|
||
if isinstance(task, dict):
|
||
project_id = task.get("project_id")
|
||
if isinstance(project_id, str):
|
||
return project_id
|
||
return ""
|
||
|
||
|
||
def task_id_of(task):
|
||
if isinstance(task, dict):
|
||
return task.get("id")
|
||
return None
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Loop-state persistence (table audit_loop_state)
|
||
# ------------------------------------------------------------------
|
||
|
||
def ensure_loop_state_table(conn):
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS audit_loop_state("
|
||
"module_id TEXT NOT NULL, "
|
||
"contract_version TEXT NOT NULL, "
|
||
"task_id TEXT, "
|
||
"state_json TEXT NOT NULL, "
|
||
"updated_at REAL, "
|
||
"PRIMARY KEY(module_id, contract_version))")
|
||
try:
|
||
conn.commit()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def state_to_dict(state):
|
||
return {
|
||
"module_id": state.module_id,
|
||
"contract_version": state.contract_version,
|
||
"pushback_round": state.pushback_round,
|
||
"failure_count": state.failure_count,
|
||
"audit_fail_count": state.audit_fail_count,
|
||
"acceptance_fail_count": state.acceptance_fail_count,
|
||
"rebuild_no": state.rebuild_no,
|
||
"targeted_used": state.targeted_used,
|
||
"status": state.status,
|
||
"pending_confirmation": state.pending_confirmation,
|
||
"retained_tests": list(state.retained_tests or []),
|
||
"retained_passed_items": list(state.retained_passed_items or []),
|
||
"previous_artifacts": list(state.previous_artifacts or []),
|
||
"history": list(state.history or []),
|
||
}
|
||
|
||
|
||
def state_from_dict(data):
|
||
if not isinstance(data, dict):
|
||
raise ValueError("loop state must be a dict")
|
||
return _driver.LoopState(
|
||
module_id=data["module_id"],
|
||
contract_version=data["contract_version"],
|
||
pushback_round=int(data.get("pushback_round") or 0),
|
||
failure_count=int(data.get("failure_count") or 0),
|
||
audit_fail_count=int(data.get("audit_fail_count") or 0),
|
||
acceptance_fail_count=int(data.get("acceptance_fail_count") or 0),
|
||
rebuild_no=int(data.get("rebuild_no") or 0),
|
||
targeted_used=bool(data.get("targeted_used")),
|
||
status=data.get("status") or "auditing",
|
||
pending_confirmation=bool(data.get("pending_confirmation")),
|
||
retained_tests=list(data.get("retained_tests") or []),
|
||
retained_passed_items=list(
|
||
data.get("retained_passed_items") or []),
|
||
previous_artifacts=list(data.get("previous_artifacts") or []),
|
||
history=list(data.get("history") or []),
|
||
)
|
||
|
||
|
||
def load_loop_state(conn, module_id, contract_version):
|
||
"""Load persisted state for (module_id, contract_version),
|
||
or None when the loop has never started. Numbers are never
|
||
taken from a caller: they come from this row, or from a
|
||
fresh driver state."""
|
||
ensure_loop_state_table(conn)
|
||
row = conn.execute(
|
||
"SELECT state_json FROM audit_loop_state"
|
||
" WHERE module_id = ? AND contract_version = ?",
|
||
(module_id, contract_version)).fetchone()
|
||
if row is None:
|
||
return None
|
||
raw = row[0]
|
||
return state_from_dict(json.loads(raw))
|
||
|
||
|
||
def save_loop_state(conn, task_id, state):
|
||
"""Upsert by (module_id, contract_version) using a portable
|
||
select-then-write (no dialect-specific upsert syntax, so the
|
||
same code works on the SQLite and Postgres adapters)."""
|
||
ensure_loop_state_table(conn)
|
||
payload = json.dumps(state_to_dict(state), sort_keys=True,
|
||
ensure_ascii=False)
|
||
existing = conn.execute(
|
||
"SELECT module_id FROM audit_loop_state"
|
||
" WHERE module_id = ? AND contract_version = ?",
|
||
(state.module_id, state.contract_version)).fetchone()
|
||
if existing is None:
|
||
conn.execute(
|
||
"INSERT INTO audit_loop_state(module_id,"
|
||
" contract_version, task_id, state_json, updated_at)"
|
||
" VALUES(?, ?, ?, ?, ?)",
|
||
(state.module_id, state.contract_version, task_id,
|
||
payload, time.time()))
|
||
else:
|
||
conn.execute(
|
||
"UPDATE audit_loop_state SET task_id = ?,"
|
||
" state_json = ?, updated_at = ?"
|
||
" WHERE module_id = ? AND contract_version = ?",
|
||
(task_id, payload, time.time(),
|
||
state.module_id, state.contract_version))
|
||
conn.commit()
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Quota / cost injection (server quota model, no new table)
|
||
# ------------------------------------------------------------------
|
||
|
||
def build_quota_check(quota_usd_micros, used_usd_micros):
|
||
"""Server quota -> driver quota_check callable.
|
||
|
||
``quota_usd_micros`` is None for uncapped users (always
|
||
allowed). Otherwise an estimate (USD float, as the driver
|
||
carries it) blocks when used + estimate would exceed the
|
||
budget; with no estimate the existing server rule applies
|
||
(used >= quota blocks). Any lookup problem must surface as
|
||
a raising callable: the driver fail-closes a raising check
|
||
to quota_blocked, it never treats it as allowed.
|
||
"""
|
||
def _check(estimate):
|
||
if quota_usd_micros is None:
|
||
return True
|
||
if estimate is None:
|
||
return used_usd_micros < quota_usd_micros
|
||
try:
|
||
estimate_micros = int(float(estimate) * 1000000)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
return used_usd_micros + estimate_micros <= quota_usd_micros
|
||
return _check
|
||
|
||
|
||
def cost_threshold_from_env(env=None):
|
||
"""User-set rebuild cost-escalation threshold in USD, from
|
||
CLOUD_AUDIT_LOOP_COST_THRESHOLD_USD. Unset / unparsable
|
||
means None: no cost escalation, only quota checks. The
|
||
threshold is never hard-coded here; an operator sets it."""
|
||
raw = (env if env is not None else os.environ).get(
|
||
"CLOUD_AUDIT_LOOP_COST_THRESHOLD_USD", "")
|
||
raw = (raw or "").strip()
|
||
if not raw:
|
||
return None
|
||
try:
|
||
value = float(raw)
|
||
except ValueError:
|
||
return None
|
||
return value if value >= 0 else None
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Automated re-audit evidence (never a verdict by itself)
|
||
# ------------------------------------------------------------------
|
||
|
||
def evaluate_automated_reaudit(hard_gate_kwargs, extra_checks):
|
||
"""Run the automatable part of a full re-audit.
|
||
|
||
``hard_gate_kwargs`` are the kwargs for
|
||
``evaluate_hard_merge_gate`` (fresh evidence, supplied by
|
||
the caller at fix-completion time). ``extra_checks`` maps
|
||
each _AUTOMATED_EXTRA_CHECKS name to a boolean supplied by
|
||
the caller (demo run result, regression artifact comparison,
|
||
and so on). Missing evidence fails, it never passes by
|
||
default. Returns {automated_checks_pass, failed_checks,
|
||
hard_gate}. This result is evidence only: see
|
||
process_fix_completion for how a failed automated check
|
||
forces a claimed pass to fail, and how passing automated
|
||
checks still require an auditor verdict.
|
||
"""
|
||
failed = []
|
||
gate = None
|
||
if not isinstance(hard_gate_kwargs, dict):
|
||
failed.append("hard_gate_evidence_missing")
|
||
else:
|
||
# Vocabulary translation at the boundary: merge bodies
|
||
# carry "branch_protection", the hard-gate function
|
||
# takes "protection". Anything else is passed through
|
||
# untouched (unknown kwargs still fail loudly, which
|
||
# is the honest outcome for malformed evidence).
|
||
kwargs = dict(hard_gate_kwargs)
|
||
if "branch_protection" in kwargs:
|
||
kwargs.setdefault("protection", kwargs.pop("branch_protection"))
|
||
gate = _hard_gate(**kwargs)
|
||
if not gate.get("allowed"):
|
||
failed.extend("hard_gate:%s" % code
|
||
for code in (gate.get("blocks") or []))
|
||
checks = extra_checks if isinstance(extra_checks, dict) else {}
|
||
for name in _AUTOMATED_EXTRA_CHECKS:
|
||
if checks.get(name) is not True:
|
||
failed.append(name)
|
||
return {"automated_checks_pass": not failed,
|
||
"failed_checks": failed, "hard_gate": gate}
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Ledger sink: driver events -> #241/#247 write path, fail-closed
|
||
# ------------------------------------------------------------------
|
||
|
||
def _prevalidate_ledger_events(action, old_impl_ref):
|
||
"""Refuse, BEFORE writing anything, the two driver-event
|
||
shapes the #241 layer cannot store: a rebuild event with
|
||
no old-implementation reference, and a meta-review event
|
||
with no chain entry id. Writing the verdict row first and
|
||
then failing on the rebuild row would leave a half-written
|
||
round on the chain; fail-closed means refusing the whole
|
||
step up front. The old_impl_ref itself is caller-supplied
|
||
(a real commit/tag when known, else a task-scoped archive
|
||
reference); this module never invents a commit hash."""
|
||
for event in (action.ledger_events or []):
|
||
payload = event.get("payload") or {}
|
||
if event.get("event_type") == "audit_rebuild":
|
||
ref = payload.get("old_impl_ref") or old_impl_ref
|
||
if not isinstance(ref, dict) or not any(
|
||
isinstance(ref.get(key), str) and ref[key]
|
||
for key in ("commit", "tag", "archive_ref")):
|
||
raise ValueError(
|
||
"rebuild ledger event needs old_impl_ref "
|
||
"(commit/tag/archive_ref) from the caller; "
|
||
"refusing to write a partial round")
|
||
if event.get("event_type") == "audit_meta_review":
|
||
entry_id = payload.get("original_entry_id")
|
||
if (isinstance(entry_id, bool)
|
||
or not isinstance(entry_id, int)
|
||
or entry_id < 1):
|
||
raise ValueError(
|
||
"meta-review ledger event needs "
|
||
"original_entry_id (chain id >= 1)")
|
||
|
||
|
||
def write_loop_ledger_events(ledger_store, task_id, project_id,
|
||
action, old_impl_ref=None,
|
||
user_id="system"):
|
||
"""Write one Action's ledger events + compat hint through
|
||
the #247 record_* functions, in driver order. Any failure
|
||
propagates (fail-closed): the caller must NOT save the new
|
||
loop state after this raises. Unknown event types raise
|
||
instead of being silently skipped."""
|
||
_prevalidate_ledger_events(action, old_impl_ref)
|
||
written = []
|
||
for event in (action.ledger_events or []):
|
||
event_type = event.get("event_type")
|
||
payload = event.get("payload") or {}
|
||
# The driver's meta-review event carries no module key
|
||
# (its field table does not require one); the loop key
|
||
# for the chain row then comes from next_state, which
|
||
# is the same loop the correction acted on. Other
|
||
# events carry their own key and are left untouched.
|
||
module_id = payload.get("module_id") \
|
||
or action.next_state.module_id
|
||
contract_version = payload.get("contract_version") \
|
||
or action.next_state.contract_version
|
||
if event_type == "audit_verdict":
|
||
written.append(_wiring.record_audit_verdict(
|
||
ledger_store, task_id, project_id, module_id,
|
||
contract_version, payload.get("verdict"),
|
||
payload.get("source"),
|
||
failures=payload.get("failures"),
|
||
user_id=user_id, extra=payload.get("extra")))
|
||
elif event_type == "audit_pushback_round":
|
||
trigger = _wiring._trigger_check_names(
|
||
payload.get("trigger_items"))
|
||
if not trigger:
|
||
# A fail verdict with no itemised failures still
|
||
# happened; the generic code names that fact
|
||
# instead of inventing a specific check.
|
||
trigger = ["audit_verdict_not_pass"]
|
||
if payload.get("round_status") == "re_reviewed":
|
||
written.append(_wiring.record_reaudit(
|
||
ledger_store, task_id, project_id, module_id,
|
||
contract_version, payload.get("round_no"),
|
||
payload.get("re_review_result"),
|
||
package=payload.get("pushback_package"),
|
||
failure_source=payload.get("failure_source"),
|
||
failure_counts=payload.get("failure_counts"),
|
||
user_id=user_id))
|
||
else:
|
||
written.append(_wiring.record_pushback(
|
||
ledger_store, task_id, project_id, module_id,
|
||
contract_version, payload.get("round_no"),
|
||
trigger, payload.get("pushback_package"),
|
||
failure_source=payload.get("failure_source"),
|
||
failure_counts=payload.get("failure_counts"),
|
||
user_id=user_id))
|
||
elif event_type == "audit_rebuild":
|
||
root_cause = payload.get("root_cause")
|
||
# Driver emits "implementation"; the #247 layer maps
|
||
# contract/design to contract_design, so a
|
||
# contract_design payload is handed over as
|
||
# "contract" (the layer's own vocabulary).
|
||
if root_cause == "contract_design":
|
||
root_cause = "contract"
|
||
written.append(_wiring.record_rebuild(
|
||
ledger_store, task_id, project_id, module_id,
|
||
contract_version, payload.get("rebuild_no"),
|
||
payload.get("old_impl_ref") or old_impl_ref,
|
||
root_cause=root_cause or "implementation",
|
||
status=payload.get("status") or "decided",
|
||
retained_assets=payload.get("retained_assets"),
|
||
trigger_source=payload.get("trigger_source"),
|
||
user_id=user_id))
|
||
elif event_type == "audit_third_failure_assessment":
|
||
# Translation at the boundary (driver field vs #247
|
||
# API): the driver emits the #241 determination
|
||
# ("continuation_round" / "early_rebuild"), while
|
||
# #247's record_* takes the triage verdict
|
||
# ("targeted_fix" / "early_rebuild") and maps it to
|
||
# the same determination on the chain. Translating
|
||
# here is this layer's job; the driver is untouched.
|
||
triage_verdict = {
|
||
"continuation_round": "targeted_fix",
|
||
"early_rebuild": "early_rebuild",
|
||
}.get(payload.get("determination"))
|
||
if triage_verdict is None:
|
||
raise ValueError(
|
||
"unknown third-failure determination: %r"
|
||
% (payload.get("determination"),))
|
||
written.append(_wiring.record_third_failure_assessment(
|
||
ledger_store, task_id, project_id, module_id,
|
||
contract_version, triage_verdict,
|
||
payload.get("basis"),
|
||
failure_total=payload.get("failure_total") or 3,
|
||
max_extra_rounds=payload.get("max_extra_rounds"),
|
||
user_id=user_id))
|
||
elif event_type == "audit_test_change":
|
||
changes = payload.get("test_changes") or {}
|
||
written.append(_wiring.record_test_change(
|
||
ledger_store, task_id, project_id, module_id,
|
||
contract_version, changes,
|
||
payload.get("auditor") or user_id,
|
||
bool(payload.get("contract_refrozen")),
|
||
refreeze_ref=payload.get("refreeze_ref"),
|
||
reason=payload.get("reason") or "",
|
||
user_id=user_id))
|
||
elif event_type == "audit_meta_review":
|
||
written.append(_wiring.record_meta_review(
|
||
ledger_store, task_id, project_id, module_id,
|
||
contract_version, payload.get("original_entry_id"),
|
||
payload.get("original_verdict"),
|
||
payload.get("corrected_verdict"),
|
||
bool(payload.get("misjudgment_found")),
|
||
payload.get("reviewer"),
|
||
findings=payload.get("findings"),
|
||
triggered_process=payload.get("triggered_process"),
|
||
user_id=user_id))
|
||
elif event_type == "audit_rebuild_notice":
|
||
written.append(_wiring.record_rebuild_notice(
|
||
ledger_store, task_id, project_id, module_id,
|
||
contract_version, payload.get("rebuild_no"),
|
||
payload.get("notification_content"),
|
||
payload.get("notified_party"),
|
||
payload.get("confirmation_status"),
|
||
confirmed_by=payload.get("confirmed_by"),
|
||
user_id=user_id))
|
||
else:
|
||
raise ValueError(
|
||
"unknown loop ledger event type: %r" % (event_type,))
|
||
hint = getattr(action, "compat_hint", None)
|
||
if hint == "audit_wiring_event:blocked_quota":
|
||
written.append(_wiring.record_escalation(
|
||
ledger_store, task_id, project_id,
|
||
action.next_state.module_id,
|
||
action.next_state.contract_version,
|
||
"blocked_quota", reason=action.reason,
|
||
user_id=user_id))
|
||
elif hint == "audit_wiring_event:escalate":
|
||
written.append(_wiring.record_escalation(
|
||
ledger_store, task_id, project_id,
|
||
action.next_state.module_id,
|
||
action.next_state.contract_version,
|
||
"escalate", target=action.escalation_target,
|
||
reason=action.reason, user_id=user_id))
|
||
elif hint == "audit_wiring_event:escalate_refreeze":
|
||
written.append(_wiring.record_escalation(
|
||
ledger_store, task_id, project_id,
|
||
action.next_state.module_id,
|
||
action.next_state.contract_version,
|
||
"escalate_refreeze",
|
||
target=action.escalation_target,
|
||
reason=action.reason, user_id=user_id))
|
||
return written
|
||
|
||
|
||
def derive_subtask(action, parent_task):
|
||
"""Driver Action -> fix/rebuild subtask descriptor, or None.
|
||
|
||
This is a descriptor only: the server handler turns it into
|
||
a queued task row (same project, created_by = the parent
|
||
task's creator for cost attribution). No subtask is derived
|
||
for pass / escalate / refreeze / pending confirmation /
|
||
quota_blocked: a pending acceptance rebuild must not create
|
||
a rebuild task before the user confirms, and an escalation
|
||
goes to a human, not to an auto-created task.
|
||
A correction to pass is the one exception inside kind
|
||
"pushback": it schedules a re-audit only, so its
|
||
descriptor is kind "reaudit", never a fix task.
|
||
"""
|
||
if getattr(action, "correction_action", None) == "schedule_reaudit":
|
||
return {
|
||
"kind": "reaudit",
|
||
"parent_task_id": task_id_of(parent_task),
|
||
"module_id": action.next_state.module_id,
|
||
"contract_version": action.next_state.contract_version,
|
||
"scope": "reaudit_only",
|
||
"pushback_package": None,
|
||
"requires_reaudit": True,
|
||
}
|
||
if action.kind == "pushback":
|
||
return {
|
||
"kind": "fix",
|
||
"parent_task_id": task_id_of(parent_task),
|
||
"module_id": action.next_state.module_id,
|
||
"contract_version": action.next_state.contract_version,
|
||
"scope": action.scope,
|
||
"targeted_items": action.targeted_items,
|
||
"pushback_package": action.pushback_package,
|
||
"requires_reaudit": True,
|
||
}
|
||
if action.kind == "rebuild":
|
||
return {
|
||
"kind": "rebuild",
|
||
"parent_task_id": task_id_of(parent_task),
|
||
"module_id": action.next_state.module_id,
|
||
"contract_version": action.next_state.contract_version,
|
||
"scope": "rebuild",
|
||
"pushback_package": action.pushback_package,
|
||
"requires_reaudit": True,
|
||
}
|
||
return None
|
||
|
||
|
||
def derive_notification(action):
|
||
"""Notification/escalation descriptor for an Action, or
|
||
None. Escalations carry the FULL round history and the
|
||
caller-supplied cost estimate (never just the last round);
|
||
a pending acceptance rebuild carries the driver's notice
|
||
content (failure reasons, rebuild scope, retained assets,
|
||
first gate)."""
|
||
if action.kind == "pending_user_confirmation":
|
||
content = None
|
||
for event in (action.ledger_events or []):
|
||
if event.get("event_type") == "audit_rebuild_notice":
|
||
content = event.get("payload", {}).get(
|
||
"notification_content")
|
||
return {"party": "user", "content": content,
|
||
"rebuild_no": action.rebuild_no,
|
||
"confirmation_status": "pending"}
|
||
if action.kind == "escalate":
|
||
return {"party": action.escalation_target,
|
||
"reason": action.reason,
|
||
"escalation_history": action.escalation_history,
|
||
"cost_estimate": action.cost_estimate,
|
||
"grants_user_acceptance": False}
|
||
return None
|
||
|
||
|
||
def _finish_process(conn, ledger_store, task, action,
|
||
old_impl_ref=None, user_id="system",
|
||
ledger_task_id=None):
|
||
"""Ledger rows are attributed to ``ledger_task_id`` (the
|
||
loop's root task) when given, else to the task passed in.
|
||
This matters for fix completions: the #247 reader replays
|
||
ONE task's chain, so a passing re-audit written under a
|
||
transient fix-subtask id would never unblock the parent
|
||
task's merge. The server handler therefore passes the root
|
||
task id (following forked_from) for every loop call; the
|
||
loop-state row itself still records the calling task id
|
||
for traceability.
|
||
|
||
Shared tail of the process_* functions: ledger first
|
||
(fail-closed, raises before any state save), then persist,
|
||
then derive the subtask/notification descriptors. A
|
||
quota_blocked action is the exception that proves the
|
||
ledger-first rule: its state is returned unchanged, so it
|
||
is deliberately NOT saved (saving an identical state would
|
||
still be harmless, but not saving is the auditable proof
|
||
that no round and no rebuild was consumed)."""
|
||
ledger_id = ledger_task_id or task_id_of(task)
|
||
if action.kind == "quota_blocked":
|
||
# Only the compat wiring row is written; the loop state
|
||
# is returned exactly as the driver returned it.
|
||
write_loop_ledger_events(ledger_store, ledger_id,
|
||
task_project_id(task), action,
|
||
old_impl_ref=old_impl_ref,
|
||
user_id=user_id)
|
||
return {
|
||
"action_kind": action.kind,
|
||
"reason": action.reason,
|
||
"next_state": action.next_state,
|
||
"merge_blocked": action.merge_blocked,
|
||
"release_blocked": action.release_blocked,
|
||
"subtask": None,
|
||
"notification": None,
|
||
"requires_reaudit": action.requires_reaudit,
|
||
"escalation_grants_user_acceptance": False,
|
||
"state_saved": False,
|
||
}
|
||
write_loop_ledger_events(ledger_store, ledger_id,
|
||
task_project_id(task), action,
|
||
old_impl_ref=old_impl_ref,
|
||
user_id=user_id)
|
||
save_loop_state(conn, task_id_of(task), action.next_state)
|
||
return {
|
||
"action_kind": action.kind,
|
||
"reason": action.reason,
|
||
"next_state": action.next_state,
|
||
"merge_blocked": action.merge_blocked,
|
||
"release_blocked": action.release_blocked,
|
||
"subtask": derive_subtask(action, task),
|
||
"notification": derive_notification(action),
|
||
"requires_reaudit": action.requires_reaudit,
|
||
"escalation_grants_user_acceptance": False,
|
||
"state_saved": True,
|
||
}
|
||
|
||
|
||
def _require_signer_separation(verdict, user_id):
|
||
"""Verdict 提交时的 signer 分离强制(A3,fail-closed)。
|
||
|
||
signer 不取自请求体:它就是认证调用方 ``user_id`` 本人——
|
||
体里自带 signer 若与调用方不一致即拒(否则调用方可随手
|
||
写一个“别人”冒充独立审计,字段不等式检查就成了摆设)。
|
||
executor / coordinator_approver / qa_tester 必须由提交方
|
||
在 verdict 里显式声明,缺字段或任一与调用方相等即
|
||
ValueError(HTTP 层 _loop_run 映射为 400),且校验发生在
|
||
任何循环状态落盘与链写之前,拒掉的提交不留任何痕迹。
|
||
|
||
诚实边界:本校验只保证“提交方 ≠ 提交方自己声明的另三
|
||
方”,不能识破提交方谎报另三方身份——任务行本身不存
|
||
执行人/协调批准人/QA 测试人的权威身份(任务 executor 列
|
||
是 local/docker/cloud 执行后端,不是自然人),服务端无
|
||
从独立来源核对的依据;谎报身份的防范须另立身份溯源,
|
||
不在本项用字段检查冒充。audit-fix 路径的 reaudit_verdict
|
||
不走本校验:那里的 HTTP 提交人是修复提交人,未必是重审
|
||
审计人,把 signer 绑到修复提交人身上是错绑,见交回报告
|
||
的已知残留;该路径仍受自动化检查 + 完整重审结论约束。
|
||
"""
|
||
if not isinstance(verdict, dict):
|
||
raise TypeError("verdict must be a dict")
|
||
claimed = verdict.get("signer")
|
||
if claimed is not None and claimed != user_id:
|
||
raise ValueError(
|
||
"verdict signer must equal the authenticated caller")
|
||
audit = {
|
||
"signer": user_id,
|
||
"executor": verdict.get("executor"),
|
||
"coordinator_approver": verdict.get("coordinator_approver"),
|
||
"qa_tester": verdict.get("qa_tester"),
|
||
}
|
||
valid, reasons = _validate_signer_separation(audit)
|
||
if not valid:
|
||
raise ValueError(
|
||
"audit signer separation failed: %s" % ",".join(reasons))
|
||
|
||
|
||
def process_audit_verdict(conn, ledger_store, task, verdict, *,
|
||
quota_check=None, cost_threshold=None,
|
||
old_impl_ref=None, user_id="system",
|
||
ledger_task_id=None):
|
||
"""One independent audit/acceptance verdict -> loop step.
|
||
|
||
The original task's status is never touched here (the
|
||
server handler likewise does not change it): a fail blocks
|
||
merge/release via the chain and derives a fix subtask, it
|
||
does not roll the completed task back into a re-run.
|
||
|
||
Signer separation (A3) is enforced before anything is
|
||
written: the caller is the signer, and the verdict must
|
||
declare executor / coordinator_approver / qa_tester, all
|
||
distinct from the caller; otherwise ValueError (HTTP 400)
|
||
and no loop state or ledger row is created.
|
||
"""
|
||
if not isinstance(verdict, dict):
|
||
raise TypeError("verdict must be a dict")
|
||
_require_signer_separation(verdict, user_id)
|
||
module_id = resolve_module_id(task)
|
||
contract_version = resolve_contract_version(task, verdict)
|
||
state = load_loop_state(conn, module_id, contract_version)
|
||
if state is None:
|
||
state = _driver.new_loop_state(module_id, contract_version)
|
||
action = _driver.decide_audit(
|
||
state, verdict, quota_check=quota_check,
|
||
cost_threshold=cost_threshold)
|
||
return _finish_process(conn, ledger_store, task, action,
|
||
old_impl_ref=old_impl_ref,
|
||
user_id=user_id,
|
||
ledger_task_id=ledger_task_id)
|
||
|
||
|
||
def process_fix_completion(conn, ledger_store, task, fix_result, *,
|
||
automated=None, quota_check=None,
|
||
cost_threshold=None, old_impl_ref=None,
|
||
user_id="system", ledger_task_id=None):
|
||
"""A fix landing -> automatic FULL re-audit.
|
||
|
||
``automated`` is {"hard_gate": {...}, "extra_checks": {...}}
|
||
or None (no automated evidence supplied: the re-audit then
|
||
rests entirely on the auditor verdict, and the caller must
|
||
say so in its own records -- this function never claims an
|
||
automated pass it did not run). When automated evidence IS
|
||
supplied and any check fails, a claimed pass verdict is
|
||
forced to fail with the automated failures appended, before
|
||
the driver ever sees it. The driver then enforces the rest:
|
||
a missing reaudit verdict or diff_only raises ValueError,
|
||
and only the re-audit verdict counts (the fix itself never
|
||
counts). The loop state must already exist: a fix with no
|
||
prior loop is refused rather than inventing a fresh loop
|
||
that a pass could silently clear.
|
||
"""
|
||
if not isinstance(fix_result, dict):
|
||
raise TypeError("fix_result must be a dict")
|
||
reaudit = (fix_result.get("reaudit_verdict")
|
||
or fix_result.get("reaudit")
|
||
or fix_result.get("verdict"))
|
||
module_id = resolve_module_id(task)
|
||
# Load by the task's own contract version first: that is
|
||
# the loop's current version, and the driver itself applies
|
||
# a version change carried by the re-audit verdict (reset).
|
||
# Loading by the verdict version first would mistake a
|
||
# version-change re-audit for a loop that never started.
|
||
contract_version = None
|
||
state = None
|
||
task_version = None
|
||
if isinstance(task, dict):
|
||
task_version = task.get("contract_version")
|
||
if isinstance(task_version, str) and task_version.strip():
|
||
state = load_loop_state(conn, module_id,
|
||
task_version.strip())
|
||
contract_version = task_version.strip()
|
||
if state is None:
|
||
contract_version = resolve_contract_version(task, reaudit)
|
||
state = load_loop_state(conn, module_id, contract_version)
|
||
if state is None:
|
||
raise ValueError(
|
||
"no audit loop state for module %s@%s: a fix cannot "
|
||
"re-audit a loop that never started" % (
|
||
module_id, contract_version))
|
||
automated_result = None
|
||
if automated is not None:
|
||
automated_result = evaluate_automated_reaudit(
|
||
automated.get("hard_gate"),
|
||
automated.get("extra_checks"))
|
||
if not automated_result["automated_checks_pass"]:
|
||
forced = dict(fix_result)
|
||
verdict = dict(reaudit) if isinstance(reaudit, dict) \
|
||
else {}
|
||
verdict["verdict"] = "fail"
|
||
failures = list(verdict.get("failures") or [])
|
||
failures.extend(
|
||
{"check": check,
|
||
"detail": "automated re-audit check failed"}
|
||
for check in automated_result["failed_checks"])
|
||
verdict["failures"] = failures
|
||
forced["reaudit_verdict"] = verdict
|
||
fix_result = forced
|
||
action = _driver.after_fix(
|
||
state, fix_result, quota_check=quota_check,
|
||
cost_threshold=cost_threshold)
|
||
result = _finish_process(conn, ledger_store, task, action,
|
||
old_impl_ref=old_impl_ref,
|
||
user_id=user_id,
|
||
ledger_task_id=ledger_task_id)
|
||
result["automated_checks"] = automated_result
|
||
return result
|
||
|
||
|
||
def process_correction(conn, ledger_store, task, correction, *,
|
||
user_id="system", ledger_task_id=None):
|
||
"""An audit-of-audit correction. Semantics are the driver's
|
||
(#247, unchanged): corrected-to-fail re-blocks at once and
|
||
counts a failure; corrected-to-pass does NOT unblock (it
|
||
schedules a re-audit, keeps merge/release blocked, zeroes
|
||
the effective failure count). The original chain row is
|
||
never edited: only the new meta-review row is written."""
|
||
if not isinstance(correction, dict):
|
||
raise TypeError("correction must be a dict")
|
||
module_id = resolve_module_id(task)
|
||
contract_version = resolve_contract_version(
|
||
task, {"contract_version": correction.get("contract_version")}
|
||
if correction.get("contract_version") else None)
|
||
state = load_loop_state(conn, module_id, contract_version)
|
||
if state is None:
|
||
state = _driver.new_loop_state(module_id, contract_version)
|
||
action = _driver.apply_correction(state, correction)
|
||
result = _finish_process(conn, ledger_store, task, action,
|
||
user_id=user_id,
|
||
ledger_task_id=ledger_task_id)
|
||
result["correction_action"] = action.correction_action
|
||
return result
|
||
|
||
|
||
def process_rebuild_confirmation(conn, ledger_store, task, *,
|
||
user_confirmed, confirmed_by,
|
||
quota_check=None,
|
||
cost_threshold=None,
|
||
cost_estimate=None,
|
||
old_impl_ref=None,
|
||
user_id="system",
|
||
ledger_task_id=None):
|
||
"""Resolve a pending acceptance-triggered rebuild.
|
||
|
||
``confirmed_by`` must be a real user id: "system" and
|
||
"coordinator" are refused here, and the server endpoint
|
||
additionally requires it to equal the authenticated caller
|
||
with an explicit user_confirmed body flag. Nothing in this
|
||
module calls this on a user's behalf."""
|
||
if (not isinstance(confirmed_by, str)
|
||
or confirmed_by.strip() in _RESERVED_CONFIRMERS):
|
||
raise ValueError(
|
||
"rebuild confirmation requires a real user id as "
|
||
"confirmed_by, not a system/coordinator marker")
|
||
module_id = resolve_module_id(task)
|
||
contract_version = resolve_contract_version(task)
|
||
state = load_loop_state(conn, module_id, contract_version)
|
||
if state is None:
|
||
raise ValueError(
|
||
"no audit loop state for module %s@%s" % (
|
||
module_id, contract_version))
|
||
action = _driver.confirm_rebuild(
|
||
state, user_confirmed=bool(user_confirmed),
|
||
quota_check=quota_check, cost_threshold=cost_threshold,
|
||
cost_estimate=cost_estimate)
|
||
return _finish_process(conn, ledger_store, task, action,
|
||
old_impl_ref=old_impl_ref,
|
||
user_id=user_id,
|
||
ledger_task_id=ledger_task_id)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Release gate (#259 contract, pure evaluator)
|
||
# ------------------------------------------------------------------
|
||
|
||
def evaluate_release_gate(task_id, conn, *, task_status=None,
|
||
hard_gate=None):
|
||
"""Pure release decision. Inputs, in #259 evaluation order:
|
||
|
||
1. Chain read via the SAME #247 reader merge uses. Any
|
||
read failure (other than a never-written chain, which
|
||
the reader reports as unblocked) short-circuits to
|
||
release_blocked_state_unavailable.
|
||
2. Task status: must be "merged" (release_requires_merged).
|
||
3. Fresh hard gate: None means evidence missing
|
||
(release_hard_gate_evidence_missing); allowed=False
|
||
means release_blocked_hard_gate with blocks verbatim.
|
||
4. Audit pushback: reader blocked means
|
||
release_blocked_audit_pushback with failed_checks /
|
||
round / modules verbatim.
|
||
|
||
Returns {allowed, http_status, code, blocked_by,
|
||
blocked_sources, task_status, hard_gate, audit_pushback}.
|
||
``blocked_sources`` lists every blocking source when
|
||
several block (chain failure excepted, see above). This
|
||
evaluator never writes anything: no audit, no chain row,
|
||
no status change. The endpoint's 200/409 handling must
|
||
preserve that read-only property for the check itself;
|
||
any success record is an explicit post-check action, not
|
||
part of this evaluation.
|
||
"""
|
||
try:
|
||
pushback = _wiring.pushback_block_for_task(conn, task_id)
|
||
except Exception:
|
||
return {
|
||
"allowed": False, "http_status": 409,
|
||
"code": "release_blocked_state_unavailable",
|
||
"blocked_by": "audit_pushback_state_unavailable",
|
||
"blocked_sources": ["audit_pushback_state_unavailable"],
|
||
"task_status": task_status, "hard_gate": hard_gate,
|
||
"audit_pushback": None,
|
||
}
|
||
sources = []
|
||
first_code = None
|
||
first_by = None
|
||
|
||
def _add(code, by):
|
||
nonlocal first_code, first_by
|
||
sources.append(by)
|
||
if first_code is None:
|
||
first_code, first_by = code, by
|
||
|
||
if task_status != "merged":
|
||
_add("release_requires_merged", "task_status")
|
||
if hard_gate is None:
|
||
_add("release_hard_gate_evidence_missing", "hard_gate")
|
||
elif not hard_gate.get("allowed"):
|
||
_add("release_blocked_hard_gate", "hard_gate")
|
||
if pushback.get("merge_blocked") or pushback.get("release_blocked"):
|
||
_add("release_blocked_audit_pushback", "audit_pushback")
|
||
if not sources:
|
||
return {
|
||
"allowed": True, "http_status": 200,
|
||
"code": "release_allowed", "blocked_by": None,
|
||
"blocked_sources": [], "task_status": task_status,
|
||
"hard_gate": hard_gate, "audit_pushback": pushback,
|
||
}
|
||
return {
|
||
"allowed": False, "http_status": 409,
|
||
"code": first_code, "blocked_by": first_by,
|
||
"blocked_sources": sources, "task_status": task_status,
|
||
"hard_gate": hard_gate, "audit_pushback": pushback,
|
||
}
|