"""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.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 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.""" if not isinstance(verdict, dict): raise TypeError("verdict must be a dict") 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, }