Single sampling rule; track 3 (#244) is authoritative for WHO is mandatory, track 5 (#238) for HOW the rest are drawn: - Mandatory (1.0): golden-task records + rebuild / refreeze / escalation records + third-failure near-complete still-failed cases (mandatory_triggers, full list per case). Track 3's old deterministic every-10th ordinary rule is removed (it drew zero from batches under 10 ordinary records and was predictable); track 5's old narrow mandatory list is expanded with golden / refreeze / escalate. blocked_quota stays non-mandatory. - Random tiers: normal 0.10, critical-path 0.50, seed required and recorded, ceil so a non-empty group is never rounded to zero. 0.10/0.50 are TEST DEFAULTS, not approved production values; production rates remain a user decision. - One pass, no double counting: mandatory records leave the random pools before drawing; a multi-trigger record (e.g. golden + rebuild) is sampled once with all triggers listed, and its correction follows the (original, corrected) verdict pair, never the trigger list. - cloud/audit_pushback.py::select_audit_sample is now only a record-shaped adapter over cloud/audit_sampling.py::select_sample (seed required, retired reason names critical_path-for-kinds and periodic_sample removed); AUDIT_SAMPLE_EVERY_NTH deleted. - Correction: original never mutated, correction appended, corrector != signer enforced in audit_sampling when both names are supplied; gate effect pinned (correction to pass never unblocks by itself -- schedule/cancel-and-rerun, blocked until a new passing re-audit; correction to fail re-blocks at once). Track 3 / track 5 retrigger vocabularies documented as synonyms. - Skill text (tightening 3 item 2 + correction paragraph) rewritten to the unified rule; new test file pins the boundaries (mandatory union, dedup, ceil incl. the 9-record case, seed reproducibility, golden-set construction under both sample vocabularies, adapter order/non-mutation, correction preservation on both tracks).
1527 lines
68 KiB
Python
1527 lines
68 KiB
Python
"""Independent-audit auto-pushback: decision, package, ledger interface.
|
|
|
|
Pure functions, standard library only. This module does NOT wire itself
|
|
into ``cloud/server.py`` or ``cloud/worker.py`` on purpose: the hard
|
|
merge gate (track 1) owns the server-side blocking check, and the
|
|
tamper-evident ledger (track 4) owns durable storage. What this module
|
|
does own, and what its tests pin down, is the decision itself, so the
|
|
rule "audit fail -> automatic pushback, merge/release blocked" cannot
|
|
drift into "audit fail -> wait for someone to reopen it manually".
|
|
|
|
Decision vocabulary (``decide_pushback`` returns one of these):
|
|
|
|
- ``allow``: every gate check passes, the audit signature is valid,
|
|
the security verdict is unconditional, and no blocking deviation
|
|
exists. Only then may merge/release proceed.
|
|
- ``pushback``: automatic, no human nod required. The original task
|
|
stays ``completed`` (its record and its passed artifacts are NOT
|
|
rewound or overwritten); a fix/optimisation subtask is derived
|
|
automatically and merge/release stay blocked until a full re-audit
|
|
passes. This is pushback form (2) in the skill text; form (1)
|
|
(rewind the original task to a re-runnable state) was rejected
|
|
there because rewinding destroys the completed record, invites
|
|
re-running side effects, and lets a fix silently drop or overwrite
|
|
artifacts that already passed.
|
|
- ``rebuild``: the pushback budget is exhausted (see "Rebuild" below)
|
|
and the root cause is in the implementation, not in the contract
|
|
or design. The module is rebuilt from the frozen contract; this
|
|
is the FIRST escalation branch, not a parallel loop.
|
|
- ``escalate``: no further automatic step applies -- either the
|
|
rebuild budget is also exhausted, or the rebuild cost estimate
|
|
exceeds the user-set cost-escalation threshold. The escalation
|
|
target is the coordinator (or the user, for a cost-threshold
|
|
escalation), with the full round history attached -- never just
|
|
the last round. Escalation does NOT grant user acceptance: G6
|
|
acceptance can only be ticked by the user, and an escalated
|
|
audit never substitutes for it.
|
|
- ``escalate_refreeze``: the root cause is in the contract or
|
|
design itself (see "Root-cause gate" below). No code rebuild is
|
|
started automatically; the G2 re-freeze flow is triggered and
|
|
the case is escalated to the coordinator for human review, who
|
|
must bring any new freeze to the user. A refreeze never ticks
|
|
G6 acceptance either.
|
|
- ``blocked_quota``: the fix itself would exceed quota. The fix is
|
|
interrupted under the normal quota rules, not exempted because it
|
|
is a fix. This consumes no pushback round.
|
|
|
|
``round_no`` counts pushback rounds already used for this task
|
|
(0 = the first audit). A failed audit with ``round_no < 3`` pushes
|
|
back; with ``round_no >= 3`` it escalates. The limit value (3) matches
|
|
the full-auto CI auto-fix limit, but the counters are NOT shared:
|
|
CI auto-fix rounds happen before audit, audit pushback rounds happen
|
|
after audit fails, and one must never consume the other's budget.
|
|
``decide_pushback`` accepts ``ci_auto_fix_rounds`` only as
|
|
informational input and ignores it for the limit -- that is tested.
|
|
|
|
Fail-closed rules, all tested:
|
|
|
|
- A missing gate check is a failure, never a pass.
|
|
- ``CONDITIONAL`` is not a pass: a ``conditional`` audit verdict, or
|
|
a ``conditional`` security verdict, always pushes back.
|
|
- A known deviation only excuses a failure while it has a follow-up
|
|
todo AND a due date that has not expired without follow-up. A
|
|
deviation with no due date, no todo, or an expired due date with
|
|
no follow-up blocks.
|
|
- Regression must run the exact artifact, verified the #211 way:
|
|
manifest hash vs recomputed hash must match, and a fixture
|
|
substitute is reported as "unverified", never as a pass.
|
|
- A demo must be verifiably runnable and must run the app's own
|
|
entry point; a generic file server standing in for the app
|
|
(``runs_app_entry=False``) is not a runnable demo.
|
|
- The audit signer must differ from the executor, the coordinator
|
|
approver, and the QA tester. If the signer equals any of them, the
|
|
audit conclusion itself is invalid and counts as a failure.
|
|
Auditor / QA / coordinator / executor / merge-executor are five
|
|
separate functions; one person (or thread) never holds two of them
|
|
on the same task.
|
|
|
|
Gate interface for track 1 (proposed contract, to be confirmed when
|
|
track 1 lands -- see the skill section): the hard gate reads
|
|
``gate_block()`` output, i.e. ``merge_blocked`` / ``release_blocked``
|
|
plus ``blocked_by="audit_pushback"``, ``round`` and
|
|
``failed_checks``. The gate must AND this with its own checks: an
|
|
audit pass does not unblock a failing track-1 check, and a track-1
|
|
pass does not unblock an open audit pushback. Until track 1 wires
|
|
this in, this module alone does not block any server endpoint --
|
|
that wiring gap is stated in the PR summary, not hidden.
|
|
|
|
Ledger interface for track 4 (proposed contract, to be confirmed
|
|
when track 4 lands): ``build_ledger_entry`` produces one JSON-able
|
|
record per pushback / fix / re-audit / escalation event, chained by
|
|
``prev_hash`` / ``entry_hash`` (sha256 over the canonical JSON of
|
|
the entry without ``entry_hash``). ``validate_ledger_chain``
|
|
recomputes every hash and link. If track 4 lands a different hash
|
|
format, an adapter is required -- the field names here are the
|
|
proposal, not a claim that track 4 already speaks them. The
|
|
timeline rule itself does not wait for track 4: every round's
|
|
entry is appended and never rewritten, so the timeline always
|
|
shows "audit fail -> auto pushback -> fix -> re-audit", never
|
|
only the final passing record.
|
|
|
|
Rebuild (the over-3 escalation branch -- this supersedes the
|
|
earlier, vaguer "exhausted budget -> escalate" wording; the
|
|
escalation after a rebuild is a second, distinct step):
|
|
|
|
Counting is per module AND per contract version. Audit failures
|
|
(independent audit / hard gate) and acceptance failures (G6 user
|
|
acceptance / QA acceptance) are counted separately by source but
|
|
trigger on their SUM for the module. One pass resets the module's
|
|
failure count to zero (the next failure starts counting again);
|
|
a contract-version change (re-freeze) resets the failure count
|
|
AND the rebuild count, because the module is then judged against
|
|
a different contract. A pass does NOT reset the rebuild count
|
|
within the same contract version: otherwise a pass-fail cycle
|
|
could rebuild the same module forever, one rebuild per episode.
|
|
|
|
Threshold, written identically here and in the skill text (tests
|
|
pin the boundary both ways): fix pushbacks cover failures 1-3
|
|
(``round_no`` 0, 1, 2 at decision time). A cumulative failure
|
|
count > 3 -- i.e. the 4th failure, ``round_no >= 3`` at decision
|
|
time -- is no longer patched: it triggers the rebuild branch
|
|
below. "Over three" is ``> 3``, never ``>= 3``; exactly 3 failures
|
|
still push back for a fix.
|
|
|
|
Rebuild meaning: ONLY the failing module is rebuilt. Other
|
|
modules, the frozen contract, the design, and other modules'
|
|
passed artifacts are untouched, and directory isolation is
|
|
unchanged. The module is re-implemented from the frozen contract.
|
|
The old implementation is NOT deleted from history: it is kept
|
|
via its commit / tag / archive reference in git history and in
|
|
the audit record for review, and only the working tree is
|
|
replaced by the new implementation. The previous rounds' audit
|
|
conclusions, failure reasons, and passed-item list travel into
|
|
the new implementation's task package as an avoidance list, so
|
|
the rebuild cannot repeat the same error and still claim a
|
|
fresh start.
|
|
|
|
Root-cause gate (checked BEFORE deciding rebuild vs refreeze):
|
|
if the continuous failures are caused by the contract or design
|
|
itself -- e.g. a self-contradictory contract, an unimplementable
|
|
design, a wrong technology-stack choice -- rebuilding the code
|
|
treats the symptom, not the cause, so no code rebuild is started
|
|
automatically. Instead the G2 re-freeze flow is triggered and
|
|
the case escalates to the coordinator. ``classify_root_cause``
|
|
implements the testable standard: at least two distinct rounds
|
|
point to the same contract/design clause with a contract/design
|
|
root cause, or at least two distinct rounds prove the contract
|
|
cannot be implemented as written. Fewer than two such rounds
|
|
leaves the cause "unknown", and an unknown cause defaults to
|
|
the rebuild branch: refreeze needs positive evidence, and the
|
|
rebuild branch is the reversible first step. A rebuild package
|
|
always carries the avoidance list, so even an unknown-cause
|
|
rebuild does not start blind.
|
|
|
|
Rebuild budget: at most ``MAX_REBUILDS_PER_CONTRACT_VERSION``
|
|
(1) rebuild per module per contract version. Rationale for 1
|
|
rather than 2: a rebuild is the most expensive step (a full
|
|
re-implementation); one rebuild is a genuine second chance, and
|
|
if the new implementation also fails more than 3 times, the
|
|
problem is almost certainly in the contract/design or in
|
|
capability, not in patchable code, so a second automatic rebuild
|
|
would mostly burn cost while masking that. After a rebuild, a
|
|
fresh failure count starts for the new implementation, and if
|
|
it again exceeds 3, the case escalates (coordinator) with no
|
|
further automatic rebuild. Rebuild cost is costed and
|
|
quota-checked like any task: exceeding quota blocks the rebuild
|
|
(``blocked_quota``, consuming no rebuild), and a rebuild cost
|
|
estimate above the user-set cost-escalation threshold escalates
|
|
to the USER before anything is burned -- never silently.
|
|
|
|
Three tightenings (added after the pushback/rebuild rules; they do
|
|
NOT change the counting above, they refine what happens at its
|
|
edges -- the single counting story is restated at the end of this
|
|
section so the two cannot drift apart):
|
|
|
|
Tightening 1 -- third-failure triage (``triage_third_failure``).
|
|
3 is a default, not a truth. At the 3rd failure (cumulative fail
|
|
count == 3, before the rebuild threshold), a root-cause triage
|
|
runs first and its result is written into the audit record:
|
|
(a) structural error -- an item labelled with a structural kind
|
|
(architecture boundary, module responsibility, tech stack, data
|
|
model) or the repeated-clause / contract-design signal from
|
|
``classify_root_cause`` -- rebuilds EARLY, without waiting for
|
|
the 4th failure, and that early rebuild consumes the rebuild
|
|
budget; (b) nearly done -- at most
|
|
``NEAR_COMPLETE_MAX_FAILED_ITEMS`` failed items, none touching
|
|
core structure, no previously passed item broken, and a pass
|
|
rate over the known items -- gets ONE targeted fix round whose
|
|
scope is exactly the failed items plus directly related code,
|
|
never an unrelated refactor; (c) anything else is an ordinary
|
|
3rd pushback. A targeted round's failure counts in the cumulative
|
|
total (it is the 3rd pushback, not a free round): if it fails,
|
|
the total becomes 4 and the ordinary rebuild branch fires. The
|
|
targeted budget is 1 per module per contract version, independent
|
|
of the rebuild budget; a rebuild does not replenish it.
|
|
|
|
Tightening 2 -- rebuild keeps tests and passed assets
|
|
(``build_rebuild_package`` / ``check_rebuild_first_gate`` /
|
|
``validate_test_changes``). A rebuild replaces ONLY the module's
|
|
implementation code. Tests, passed acceptance items, the contract,
|
|
the design, audit conclusions, and the avoidance list are all
|
|
retained and listed in the rebuild package; a package built
|
|
without a retained test list is flagged
|
|
``retained_tests_missing``. The new implementation's FIRST gate
|
|
is the old test set plus the retained acceptance items, all
|
|
passing; missing results fail closed, and a first-gate failure
|
|
counts as an audit failure for the new implementation's episode.
|
|
Deleting a test or changing an assertion requires a contract
|
|
change via the G2 re-freeze flow AND a separate audit record;
|
|
without both it is a violation, not a quiet edit.
|
|
|
|
Tightening 3 -- the audit itself is audited. (i) Independent
|
|
re-runs use exact artifacts (#211 triple-hash), never reports
|
|
alone. (ii) Golden tasks (human-approved only; example/sample
|
|
files are rejected by ``validate_golden_task_set``) and
|
|
rebuild/refreeze/escalation records are always sampled, and the
|
|
rest are drawn by seeded random sampling (``select_audit_sample``,
|
|
which only adapts ``cloud/audit_sampling.py::select_sample`` --
|
|
the unified sampling rule lives there, not here). (iii) G6
|
|
user acceptance is the final human gate and is never ticked by
|
|
automation (``check_g6_guard``). A sampled audit found wrong is
|
|
corrected by appending a correction record that preserves the
|
|
original (``build_audit_correction``): a wrong pass re-triggers
|
|
pushback and re-blocks, a wrong block schedules a re-audit, and
|
|
the corrector must differ from the original signer. (iv) A
|
|
rebuild triggered by acceptance failures needs a user notice
|
|
first (``build_acceptance_rebuild_notice``): reasons, rebuild
|
|
scope, retained assets, and the old-test first gate, with the
|
|
user's confirmation recorded; until confirmed, the rebuild does
|
|
not start (``rebuild_pending_user_confirmation``), and after
|
|
confirmation the user still holds final G6 acceptance.
|
|
|
|
Single counting story (all rules together, in one place):
|
|
failures count per module per contract version, audit and
|
|
acceptance separately recorded but summed for thresholds; a pass
|
|
resets failure counts and pushback rounds but NOT rebuild count
|
|
or targeted budget within the same version; a version change
|
|
resets failures, pushback rounds, rebuild count, and targeted
|
|
budget. Failures 1-3 push back (the 3rd is triaged: structural
|
|
-> early rebuild consuming rebuild budget; nearly done -> one
|
|
targeted 3rd pushback; otherwise ordinary 3rd pushback). A
|
|
targeted round's failure is failure 4. Failure 4 (or an early
|
|
rebuild) rebuilds only if the rebuild budget remains, the root
|
|
cause is not contract/design, the cost threshold is not
|
|
exceeded, and (for acceptance-triggered rebuilds) the user has
|
|
confirmed. A rebuild resets failure counts and pushback rounds
|
|
for the new implementation but keeps rebuild count and targeted
|
|
budget; a first-gate failure counts as an audit failure for the
|
|
new episode. After a rebuild, failures over 3 again escalate
|
|
with no second automatic rebuild.
|
|
|
|
Relation to the three existing loops (also spelled out in the
|
|
skill text, and encoded in ``EXISTING_LOOP_RELATIONS``):
|
|
|
|
- G5 defect loop (defect back to G4, re-regress in G5): REUSED as
|
|
the fix-execution channel. An audit pushback's derived subtask
|
|
is executed the G5 way; no second defect state machine is built.
|
|
The difference from plain G5 is only the automatic trigger and
|
|
the merge/release block, which G5 alone does not impose.
|
|
- G7 hotfix loop: PARALLEL, not reused. Pushback is pre-merge;
|
|
hotfix is post-release emergency repair. A pushback can never
|
|
un-release something, and a hotfix never satisfies a pushback.
|
|
- Full-auto CI auto-fix (limit 3): PARALLEL with a separate
|
|
counter (see above). CI auto-fix runs before audit; audit
|
|
pushback runs after audit fails. A CI fix success does not
|
|
skip audit, and an audit pass does not retroactively excuse
|
|
a CI failure.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import date
|
|
|
|
MAX_PUSHBACK_ROUNDS = 3
|
|
# Rebuilds per module per contract version. See module docstring
|
|
# ("Rebuild budget") for why 1, not 2.
|
|
MAX_REBUILDS_PER_CONTRACT_VERSION = 1
|
|
# Targeted fix rounds per module per contract version (tightening 1).
|
|
# A targeted round is the 3rd pushback with a restricted scope, not
|
|
# an extra round outside the failure count (see "Third-failure
|
|
# triage" in the module docstring).
|
|
MAX_TARGETED_FIX_ROUNDS_PER_CONTRACT_VERSION = 1
|
|
# Near-complete threshold (tightening 1): at most this many failed
|
|
# items, with no core-structure item and no broken passed item,
|
|
# may qualify as "nearly done". More than this is not near-complete.
|
|
NEAR_COMPLETE_MAX_FAILED_ITEMS = 2
|
|
# Audit-of-audit sampling (tightening 3): the unified rule lives in
|
|
# cloud/audit_sampling.py (mandatory = golden tasks + rebuild /
|
|
# refreeze / escalation + third-failure near-complete still failed,
|
|
# all at 1.0; the rest by seeded random draw, normal 0.10 /
|
|
# critical-path 0.50, ceil so a small group is never rounded to
|
|
# zero). The old deterministic "every 10th record" rule is
|
|
# superseded and removed: it drew zero from batches under 10
|
|
# ordinary records. The 0.10 / 0.50 ratios are TEST DEFAULTS, not
|
|
# approved production values; production rates are a user decision.
|
|
|
|
# Gate checks, in the order they are reported. The first nine are the
|
|
# hard trigger list from the audit-hardening brief; the tenth is the
|
|
# signer-separation validity check from the independent-audit role.
|
|
GATE_CHECKS = (
|
|
"ci_green",
|
|
"directory_isolation",
|
|
"contract_version",
|
|
"coordinator_approval",
|
|
"unresolved_review_comments",
|
|
"security_unconditional",
|
|
"regression_exact_artifact",
|
|
"demo_runnable",
|
|
"known_deviation_due_todo",
|
|
"audit_signer_separation",
|
|
)
|
|
|
|
LEDGER_KINDS = ("pushback", "fix", "reaudit", "rebuild", "refreeze",
|
|
"escalate", "blocked_quota", "audit_sample",
|
|
"audit_correction", "rebuild_notice", "user_confirmation")
|
|
|
|
EXISTING_LOOP_RELATIONS = {
|
|
# G5 defect loop: reused as the fix-execution channel.
|
|
"g5_defect_loop": "reuse",
|
|
# G7 hotfix loop: pre-merge pushback vs post-release hotfix.
|
|
"g7_hotfix_loop": "parallel",
|
|
# Full-auto CI auto-fix: separate counter, same limit value.
|
|
"ci_auto_fix": "parallel_separate_counter",
|
|
}
|
|
|
|
|
|
def full_reaudit_criteria():
|
|
"""Full re-audit checklist: every gate check, every time.
|
|
|
|
A re-audit after a fix never re-checks only the items that
|
|
failed last round: previously passing items must be re-verified
|
|
too, because a fix can break them.
|
|
"""
|
|
return list(GATE_CHECKS)
|
|
|
|
|
|
def _is_pass(value):
|
|
return value is True
|
|
|
|
|
|
def validate_signer_separation(audit):
|
|
"""Check auditor independence. Returns (valid, [reasons]).
|
|
|
|
The signer must differ from the executor, the coordinator
|
|
approver, and the QA tester. A missing identity field is also
|
|
invalid: an audit record that does not say who signed, who
|
|
executed, who approved, or who tested cannot be verified, so
|
|
it fails closed instead of passing on an incomplete record.
|
|
"""
|
|
reasons = []
|
|
signer = audit.get("signer")
|
|
for field in ("executor", "coordinator_approver", "qa_tester"):
|
|
other = audit.get(field)
|
|
if not signer or not other:
|
|
reasons.append("missing_%s" % field if not other else "missing_signer")
|
|
elif signer == other:
|
|
reasons.append("signer_equals_%s" % field)
|
|
return (not reasons, reasons)
|
|
|
|
|
|
def deviation_is_blocking(deviation, today=None):
|
|
"""True when a known deviation must block (i.e. push back).
|
|
|
|
Non-blocking only when all three hold: a follow-up todo is
|
|
recorded, a due date is recorded and parses, and the due date
|
|
has not expired without follow-up. ``followed_up=True`` clears
|
|
the block even past the due date, because the follow-up
|
|
actually happened; an expired, un-followed deviation blocks.
|
|
"""
|
|
if today is None:
|
|
today = date.today().isoformat()
|
|
if not deviation.get("todo"):
|
|
return True
|
|
due = deviation.get("due_date")
|
|
if not due:
|
|
return True
|
|
try:
|
|
due_date = date.fromisoformat(str(due))
|
|
today_date = date.fromisoformat(str(today))
|
|
except ValueError:
|
|
return True
|
|
if deviation.get("followed_up"):
|
|
return False
|
|
return due_date < today_date
|
|
|
|
|
|
def evaluate_regression(items):
|
|
"""Check exact-artifact regression evidence. Returns [reasons].
|
|
|
|
Each item: ``path``, ``sha256_manifest``, ``sha256_recomputed``,
|
|
``source`` (``"exact"`` or ``"fixture"``). A fixture item is
|
|
never a pass: it is reported as unverified. A hash mismatch
|
|
stops the regression, the #211 way, instead of running a
|
|
fixture stand-in.
|
|
"""
|
|
reasons = []
|
|
for item in items or []:
|
|
path = item.get("path", "?")
|
|
if item.get("source") == "fixture":
|
|
reasons.append("fixture_not_exact_artifact:%s" % path)
|
|
continue
|
|
if item.get("source") not in (None, "exact"):
|
|
reasons.append("unknown_regression_source:%s" % path)
|
|
continue
|
|
manifest = item.get("sha256_manifest")
|
|
recomputed = item.get("sha256_recomputed")
|
|
if not manifest or not recomputed:
|
|
reasons.append("missing_regression_hash:%s" % path)
|
|
elif manifest != recomputed:
|
|
reasons.append("regression_hash_mismatch:%s" % path)
|
|
return reasons
|
|
|
|
|
|
def evaluate_demo(demo):
|
|
"""True only for a verifiably runnable demo of the app itself."""
|
|
if not demo:
|
|
return False
|
|
return bool(demo.get("verified_runnable")) and bool(demo.get("runs_app_entry"))
|
|
|
|
|
|
def collect_failed_checks(audit, today=None):
|
|
"""All failing gate checks for an audit, in GATE_CHECKS order.
|
|
|
|
Fail-closed: a check absent from ``audit["checks"]`` counts as
|
|
failed. Rule overlays (security, separation, regression, demo,
|
|
deviations, verdict) can only add failures, never remove one
|
|
that the checks dict already reports.
|
|
"""
|
|
checks = audit.get("checks") or {}
|
|
failed = []
|
|
for name in GATE_CHECKS:
|
|
if not _is_pass(checks.get(name)):
|
|
failed.append(name)
|
|
|
|
# Note: a non-"pass" verdict is a trigger even if every individual
|
|
# check was (wrongly) marked pass; decide_pushback reports it via
|
|
# verdict_failed, so it needs no pseudo-check here.
|
|
security = audit.get("security_verdict")
|
|
if security in ("conditional", "fail") or not _is_pass(
|
|
checks.get("security_unconditional")
|
|
):
|
|
if "security_unconditional" not in failed:
|
|
failed.append("security_unconditional")
|
|
valid, _reasons = validate_signer_separation(audit)
|
|
if not valid and "audit_signer_separation" not in failed:
|
|
failed.append("audit_signer_separation")
|
|
if evaluate_regression((audit.get("regression") or {}).get("items")):
|
|
if "regression_exact_artifact" not in failed:
|
|
failed.append("regression_exact_artifact")
|
|
if not evaluate_demo(audit.get("demo")):
|
|
if "demo_runnable" not in failed:
|
|
failed.append("demo_runnable")
|
|
for deviation in audit.get("deviations") or []:
|
|
if deviation_is_blocking(deviation, today=today):
|
|
if "known_deviation_due_todo" not in failed:
|
|
failed.append("known_deviation_due_todo")
|
|
break
|
|
order = {name: i for i, name in enumerate(GATE_CHECKS)}
|
|
return sorted(set(failed), key=lambda name: order[name])
|
|
|
|
|
|
def quota_allows(quota_used, fix_cost_estimate, quota_limit):
|
|
"""True when a fix fits in quota. None limit means unmetered."""
|
|
if quota_limit is None:
|
|
return True
|
|
if quota_used is None:
|
|
quota_used = 0
|
|
if fix_cost_estimate is None:
|
|
return quota_used < quota_limit
|
|
return quota_used + fix_cost_estimate <= quota_limit
|
|
|
|
|
|
def gate_block(action, failed_checks, round_no):
|
|
"""Track-1 gate interface: what the hard gate must read."""
|
|
blocked = action in ("pushback", "rebuild", "escalate",
|
|
"escalate_refreeze", "blocked_quota",
|
|
"rebuild_pending_user_confirmation")
|
|
return {
|
|
"merge_blocked": blocked,
|
|
"release_blocked": blocked,
|
|
"blocked_by": "audit_pushback" if blocked else None,
|
|
"round": round_no,
|
|
"failed_checks": list(failed_checks),
|
|
}
|
|
|
|
|
|
def _rebuild_branch(round_no, root_cause, rebuilds_used, max_rebuilds,
|
|
rebuild_cost_estimate, cost_escalation_threshold,
|
|
acceptance_triggered, user_confirmed, early):
|
|
"""Shared rebuild decision: over-limit AND early (triage) rebuilds.
|
|
|
|
Gate order: contract/design root cause -> refreeze; rebuild
|
|
budget exhausted -> escalate (coordinator); acceptance-triggered
|
|
rebuild without user confirmation -> pending (no rebuild starts);
|
|
rebuild cost above the user threshold -> escalate (user); else
|
|
rebuild. ``early`` only changes the reason text.
|
|
"""
|
|
if root_cause in ("contract", "design"):
|
|
return ("escalate_refreeze",
|
|
"root cause is in the %s itself; rebuilding code "
|
|
"would treat the symptom, so no code rebuild is started"
|
|
% root_cause, "coordinator", None, "G2", round_no)
|
|
if rebuilds_used >= max_rebuilds:
|
|
return ("escalate",
|
|
"rebuild budget exhausted (%d rebuild(s) per module "
|
|
"per contract version) and failures still exceed 3"
|
|
% max_rebuilds, "coordinator", None, None, round_no)
|
|
if acceptance_triggered and not user_confirmed:
|
|
return ("rebuild_pending_user_confirmation",
|
|
"rebuild triggered by acceptance failures: user notice "
|
|
"and confirmation required before the rebuild starts",
|
|
"user", None, None, round_no)
|
|
if cost_escalation_threshold is not None \
|
|
and rebuild_cost_estimate is not None \
|
|
and rebuild_cost_estimate > cost_escalation_threshold:
|
|
return ("escalate",
|
|
"estimated rebuild cost exceeds the user-set "
|
|
"cost-escalation threshold; escalating before burning it",
|
|
"user", None, None, round_no)
|
|
reason = ("structural triage at the 3rd failure: rebuild early, "
|
|
"without waiting for a 4th failure"
|
|
if early else
|
|
"cumulative failures exceed 3; patching stops, "
|
|
"the module is rebuilt from the frozen contract")
|
|
return ("rebuild", reason, None, rebuilds_used + 1, None, round_no)
|
|
|
|
|
|
def decide_pushback(audit, round_no, max_rounds=MAX_PUSHBACK_ROUNDS,
|
|
today=None, quota_used=None, quota_limit=None,
|
|
fix_cost_estimate=None, ci_auto_fix_rounds=None,
|
|
history=None, root_cause=None,
|
|
rebuilds_used=0,
|
|
max_rebuilds=MAX_REBUILDS_PER_CONTRACT_VERSION,
|
|
rebuild_cost_estimate=None,
|
|
cost_escalation_threshold=None,
|
|
triage_verdict=None, triage_scope=None,
|
|
acceptance_triggered=False,
|
|
user_confirmed_rebuild=False):
|
|
"""Audit conclusion -> allow / pushback / rebuild / escalate.
|
|
|
|
``ci_auto_fix_rounds`` is informational only and is never
|
|
counted against the pushback budget (separate counter, same
|
|
limit value -- see module docstring). ``history`` is the list
|
|
of previous round summaries; on rebuild / escalation the full
|
|
history (previous rounds plus this one) is attached, never
|
|
just the last round.
|
|
|
|
Over-limit order (``round_no >= max_rounds``, i.e. the 4th
|
|
failure -- cumulative failures > 3): see ``_rebuild_branch``.
|
|
``root_cause`` should come from ``classify_root_cause`` over
|
|
the round history; when it is None (unclassified) the branch
|
|
defaults to rebuild, because refreeze needs positive
|
|
contract/design evidence. A rebuild cost estimate above
|
|
``cost_escalation_threshold`` escalates to the USER before
|
|
anything is burned; quota is checked before that and blocks
|
|
outright (``blocked_quota``).
|
|
|
|
Third-failure triage (tightening 1): when ``round_no`` is the
|
|
last pushback round (``max_rounds - 1``, the 3rd failure) and
|
|
``triage_verdict`` is ``"early_rebuild"``, the rebuild branch
|
|
runs early instead of a 3rd patch; when it is
|
|
``"early_refreeze"``, the refreeze branch runs early (the
|
|
effective root cause is forced to contract/design for that
|
|
branch, since triage already established it); when it is
|
|
``"targeted_fix"``, the pushback is scope-limited to
|
|
``triage_scope`` (the failed items plus directly related
|
|
code). Any other triage verdict is an ordinary pushback.
|
|
"""
|
|
failed = collect_failed_checks(audit, today=today)
|
|
verdict = audit.get("verdict")
|
|
verdict_failed = verdict != "pass"
|
|
at_rebuild = round_no >= max_rounds
|
|
cost_for_this_step = rebuild_cost_estimate \
|
|
if (at_rebuild or triage_verdict == "early_rebuild") \
|
|
else fix_cost_estimate
|
|
rebuild_no = None
|
|
refreeze_flow = None
|
|
pushback_scope = "normal"
|
|
targeted_items = None
|
|
if quota_limit is not None and not quota_allows(
|
|
quota_used, cost_for_this_step, quota_limit
|
|
):
|
|
action = "blocked_quota"
|
|
reason = "fix/rebuild exceeds quota: a fix or rebuild cost " \
|
|
"is not exempt from quota"
|
|
target = None
|
|
next_round = round_no
|
|
elif not failed and not verdict_failed:
|
|
action = "allow"
|
|
reason = "all gate checks pass with an unconditional audit"
|
|
target = None
|
|
next_round = round_no
|
|
elif at_rebuild or (round_no == max_rounds - 1
|
|
and triage_verdict in ("early_rebuild",
|
|
"early_refreeze")):
|
|
effective_root = root_cause
|
|
if triage_verdict == "early_refreeze" \
|
|
and effective_root not in ("contract", "design"):
|
|
effective_root = "contract"
|
|
action, reason, target, rebuild_no, refreeze_flow, next_round = \
|
|
_rebuild_branch(round_no, effective_root, rebuilds_used,
|
|
max_rebuilds, rebuild_cost_estimate,
|
|
cost_escalation_threshold,
|
|
acceptance_triggered,
|
|
user_confirmed_rebuild,
|
|
early=not at_rebuild)
|
|
elif round_no < max_rounds:
|
|
action = "pushback"
|
|
reason = "audit failed: %s" % ", ".join(failed) if failed else \
|
|
"audit verdict is %s, not an unconditional pass" % verdict
|
|
target = None
|
|
next_round = round_no + 1
|
|
if triage_verdict == "targeted_fix":
|
|
pushback_scope = "targeted"
|
|
targeted_items = list(triage_scope) if triage_scope is not None \
|
|
else list(failed)
|
|
reason = "targeted fix round: scope limited to the failed " \
|
|
"items and directly related code; no unrelated refactor"
|
|
else: # pragma: no cover - round_no cannot be both >= max and < max
|
|
action = "escalate"
|
|
reason = "unreachable pushback state"
|
|
target = "coordinator"
|
|
next_round = round_no
|
|
current_summary = {
|
|
"round": round_no,
|
|
"action": action,
|
|
"failed_checks": list(failed),
|
|
"verdict": verdict,
|
|
"root_cause": root_cause,
|
|
"rebuild_no": rebuild_no,
|
|
"pushback_scope": pushback_scope,
|
|
}
|
|
full_history = list(history or []) + [current_summary]
|
|
attach_history = action in ("rebuild", "escalate", "escalate_refreeze",
|
|
"rebuild_pending_user_confirmation")
|
|
return {
|
|
"action": action,
|
|
"merge_blocked": action != "allow",
|
|
"release_blocked": action != "allow",
|
|
"round": round_no,
|
|
"next_round": next_round,
|
|
"failed_checks": failed,
|
|
"verdict_failed": verdict_failed,
|
|
"reason": reason,
|
|
"pushback_form": "derived_fix_subtask" if action == "pushback" else None,
|
|
"pushback_scope": pushback_scope,
|
|
"targeted_items": targeted_items,
|
|
"rebuild_no": rebuild_no,
|
|
"refreeze_flow": refreeze_flow,
|
|
"escalation_target": target,
|
|
# Escalation never grants user acceptance; that stays G6-only.
|
|
"escalation_grants_user_acceptance": False,
|
|
"history": full_history if attach_history else list(history or []),
|
|
"counters": {
|
|
"pushback_rounds_used": round_no,
|
|
"pushback_limit": max_rounds,
|
|
"rebuilds_used": rebuilds_used,
|
|
"rebuild_limit": max_rebuilds,
|
|
"ci_auto_fix_rounds_shared": False,
|
|
"ci_auto_fix_rounds_seen": ci_auto_fix_rounds,
|
|
},
|
|
"gate": gate_block(action, failed, round_no),
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Per-module failure counting (audit + acceptance, per contract
|
|
# version) and the root-cause gate for the rebuild branch.
|
|
# ------------------------------------------------------------------
|
|
|
|
def new_module_failure_state(module_id, contract_version):
|
|
"""Fresh per-module counting state (JSON-able dict)."""
|
|
return {
|
|
"module_id": module_id,
|
|
"contract_version": contract_version,
|
|
"audit_fail_count": 0,
|
|
"acceptance_fail_count": 0,
|
|
"total_fail_count": 0,
|
|
"pushback_rounds_used": 0,
|
|
"rebuilds_used": 0,
|
|
"rebuild_history": [],
|
|
# Tightening 1: one targeted fix round per module per
|
|
# contract version; a pass does not replenish it.
|
|
"targeted_fix_used": False,
|
|
"last_outcome": None,
|
|
}
|
|
|
|
|
|
def record_module_failure(state, source, contract_version=None):
|
|
"""Record one failure for a module; returns a NEW state dict.
|
|
|
|
``source`` is ``"audit"`` (independent audit / hard gate) or
|
|
``"acceptance"`` (G6 user acceptance / QA acceptance). Sources
|
|
are counted separately but trigger on their sum. A contract
|
|
version different from the state's resets failure AND rebuild
|
|
counts before this failure is counted (the module is judged
|
|
against a different contract, so the old count must not carry
|
|
over); the failure itself then counts as 1 under the new
|
|
version.
|
|
"""
|
|
if source not in ("audit", "acceptance"):
|
|
raise ValueError("unknown failure source: %r" % (source,))
|
|
new = dict(state)
|
|
new["rebuild_history"] = list(state.get("rebuild_history") or [])
|
|
if contract_version is not None \
|
|
and contract_version != state.get("contract_version"):
|
|
# Version change: reset first, then count this failure as 1
|
|
# under the new version (see docstring).
|
|
new["contract_version"] = contract_version
|
|
new["audit_fail_count"] = 0
|
|
new["acceptance_fail_count"] = 0
|
|
new["total_fail_count"] = 0
|
|
new["pushback_rounds_used"] = 0
|
|
new["rebuilds_used"] = 0
|
|
new["rebuild_history"] = []
|
|
new["targeted_fix_used"] = False
|
|
if source == "audit":
|
|
new["audit_fail_count"] = new.get("audit_fail_count", 0) + 1
|
|
else:
|
|
new["acceptance_fail_count"] = new.get("acceptance_fail_count", 0) + 1
|
|
new["total_fail_count"] = new["audit_fail_count"] \
|
|
+ new["acceptance_fail_count"]
|
|
new["last_outcome"] = "fail:%s" % source
|
|
return new
|
|
|
|
|
|
def record_module_pass(state, contract_version=None):
|
|
"""Record a pass: failure counts reset, rebuild count kept.
|
|
|
|
A pass clears the failure count (and the pushback rounds for
|
|
the new episode) so the next failure starts counting again,
|
|
but it does NOT reset ``rebuilds_used`` within the same
|
|
contract version -- otherwise pass-fail cycles could rebuild
|
|
forever. A version change resets both (see
|
|
``record_module_failure``).
|
|
"""
|
|
new = dict(state)
|
|
new["rebuild_history"] = list(state.get("rebuild_history") or [])
|
|
if contract_version is not None \
|
|
and contract_version != state.get("contract_version"):
|
|
new["contract_version"] = contract_version
|
|
new["rebuilds_used"] = 0
|
|
new["rebuild_history"] = []
|
|
new["targeted_fix_used"] = False
|
|
new["audit_fail_count"] = 0
|
|
new["acceptance_fail_count"] = 0
|
|
new["total_fail_count"] = 0
|
|
new["pushback_rounds_used"] = 0
|
|
# A pass does NOT replenish rebuild count or targeted budget
|
|
# within the same contract version (see module docstring).
|
|
new["last_outcome"] = "pass"
|
|
return new
|
|
|
|
|
|
def record_module_rebuild(state, rebuild_no):
|
|
"""Record that a rebuild happened; returns a NEW state dict.
|
|
|
|
The new implementation starts a fresh episode: failure counts
|
|
and pushback rounds reset to 0. Rebuild count and targeted
|
|
budget are NOT reset within the same contract version, so a
|
|
rebuilt module gets fresh failures but no second rebuild and
|
|
no replenished targeted round.
|
|
"""
|
|
new = dict(state)
|
|
new["rebuild_history"] = list(state.get("rebuild_history") or [])
|
|
new["rebuild_history"].append(rebuild_no)
|
|
new["rebuilds_used"] = len(new["rebuild_history"])
|
|
new["audit_fail_count"] = 0
|
|
new["acceptance_fail_count"] = 0
|
|
new["total_fail_count"] = 0
|
|
new["pushback_rounds_used"] = 0
|
|
new["last_outcome"] = "rebuild:%d" % rebuild_no
|
|
return new
|
|
|
|
|
|
def record_module_targeted_fix(state):
|
|
"""Record that the targeted fix round was used (tightening 1)."""
|
|
new = dict(state)
|
|
new["rebuild_history"] = list(state.get("rebuild_history") or [])
|
|
new["targeted_fix_used"] = True
|
|
new["last_outcome"] = "targeted_fix"
|
|
return new
|
|
|
|
|
|
def classify_root_cause(failed_items_by_round):
|
|
"""Classify a continuous failure: implementation/contract/design.
|
|
|
|
Input: one list per round, each a list of failed-item dicts
|
|
with optional ``root_cause`` (``"implementation"`` /
|
|
``"contract"`` / ``"design"``), ``evidence.contract_clause``,
|
|
and ``proved_unimplementable`` (the fix side proved the
|
|
contract cannot be implemented as written).
|
|
|
|
Returns ``(cause, evidence)``. ``"contract"`` / ``"design"``
|
|
require at least two distinct rounds: either the same
|
|
contract clause recurs across >= 2 rounds with a
|
|
contract/design root cause, or >= 2 rounds prove
|
|
unimplementability. Anything weaker is ``"unknown"`` (the
|
|
caller defaults an unknown cause to the rebuild branch), and
|
|
>= 2 rounds labelled implementation with no contract/design
|
|
signal is ``"implementation"``.
|
|
"""
|
|
contract_rounds = set()
|
|
design_rounds = set()
|
|
implementation_rounds = set()
|
|
clause_rounds = {}
|
|
proof_rounds = set()
|
|
proof_cause = None
|
|
for round_index, items in enumerate(failed_items_by_round or []):
|
|
for item in items or []:
|
|
cause = item.get("root_cause")
|
|
evidence = item.get("evidence") or {}
|
|
clause = evidence.get("contract_clause")
|
|
if cause == "contract":
|
|
contract_rounds.add(round_index)
|
|
elif cause == "design":
|
|
design_rounds.add(round_index)
|
|
elif cause == "implementation":
|
|
implementation_rounds.add(round_index)
|
|
if clause and cause in ("contract", "design"):
|
|
clause_rounds.setdefault(clause, set()).add(round_index)
|
|
if item.get("proved_unimplementable") and cause in ("contract", "design"):
|
|
proof_rounds.add(round_index)
|
|
proof_cause = cause
|
|
evidence = {
|
|
"contract_rounds": sorted(contract_rounds),
|
|
"design_rounds": sorted(design_rounds),
|
|
"repeated_clauses": {clause: sorted(rounds)
|
|
for clause, rounds in clause_rounds.items()
|
|
if len(rounds) >= 2},
|
|
"proof_rounds": sorted(proof_rounds),
|
|
}
|
|
repeated = any(len(rounds) >= 2 for rounds in clause_rounds.values())
|
|
if len(proof_rounds) >= 2 and proof_cause:
|
|
return proof_cause, evidence
|
|
if repeated:
|
|
# The repeated clause's cause wins; contract beats design
|
|
# only on a tie of first appearance, which the evidence
|
|
# dict makes auditable either way.
|
|
for clause, rounds in clause_rounds.items():
|
|
if len(rounds) >= 2:
|
|
for items in failed_items_by_round or []:
|
|
for item in items or []:
|
|
if (item.get("evidence") or {}).get("contract_clause") == clause \
|
|
and item.get("root_cause") in ("contract", "design"):
|
|
return item["root_cause"], evidence
|
|
if len(contract_rounds) >= 2:
|
|
return "contract", evidence
|
|
if len(design_rounds) >= 2:
|
|
return "design", evidence
|
|
if len(implementation_rounds) >= 2:
|
|
return "implementation", evidence
|
|
return "unknown", evidence
|
|
|
|
|
|
STRUCTURAL_KINDS = (
|
|
"architecture_boundary",
|
|
"module_responsibility",
|
|
"tech_stack",
|
|
"data_model",
|
|
)
|
|
|
|
|
|
def triage_third_failure(audit, failed_items_by_round, passed_items,
|
|
broken_passed_items, targeted_already_used=False):
|
|
"""Third-failure triage (tightening 1). Returns a verdict dict.
|
|
|
|
Call this when the cumulative failure count reaches 3, before
|
|
deciding the 3rd pushback. Inputs are evidence, not opinions:
|
|
|
|
- ``audit``: the current (3rd-failure) audit; its
|
|
``failed_items`` carry optional ``structural_kind``,
|
|
``touches_core_structure``, ``root_cause`` and
|
|
``evidence`` (test name, file path, line, contract clause,
|
|
diff range).
|
|
- ``failed_items_by_round``: per-round failed items, for the
|
|
repeated-clause check via ``classify_root_cause``.
|
|
- ``passed_items``: items that passed before this round.
|
|
- ``broken_passed_items``: previously passed items that this
|
|
round broke; must be supplied (an absent list is treated as
|
|
unknown, which disqualifies "nearly done" -- a near-complete
|
|
claim needs positive evidence that nothing passed broke).
|
|
|
|
Verdicts: ``"early_rebuild"`` (implementation-structural),
|
|
``"early_refreeze"`` (contract/design-structural: the existing
|
|
root-cause gate, applied early instead of waiting for the 4th
|
|
failure -- contract/design priority wins when both signals are
|
|
present, because a contract fix may change the architecture),
|
|
``"targeted_fix"`` (nearly done: at most
|
|
NEAR_COMPLETE_MAX_FAILED_ITEMS failed items, none structural
|
|
or core-touching, no broken passed item, and passed items
|
|
outnumber failed items), or ``"normal_pushback"`` (everything
|
|
else, including a nearly-done case whose targeted budget is
|
|
already used). This split keeps tightening 1 consistent with
|
|
the earlier root-cause rule: a repeated contract/design clause
|
|
is structural, but it is NOT a code rebuild -- rebuilding code
|
|
against a broken contract treats the symptom. The returned
|
|
``basis`` copies the deciding evidence verbatim so a later
|
|
sample can re-check the triage.
|
|
"""
|
|
failed = list(audit.get("failed_items") or [])
|
|
passed = list(passed_items or [])
|
|
broken = broken_passed_items # None means unknown, not empty.
|
|
root_cause, root_evidence = classify_root_cause(
|
|
failed_items_by_round)
|
|
impl_structural = []
|
|
contract_structural = []
|
|
for item in failed:
|
|
kind = item.get("structural_kind")
|
|
if kind in STRUCTURAL_KINDS:
|
|
impl_structural.append("structural_kind:%s" % kind)
|
|
if item.get("touches_core_structure"):
|
|
impl_structural.append("core_structure_touched")
|
|
if item.get("root_cause") in ("contract", "design") \
|
|
and (item.get("evidence") or {}).get("contract_clause") \
|
|
in root_evidence.get("repeated_clauses", {}):
|
|
contract_structural.append("repeated_contract_design_clause")
|
|
if root_cause in ("contract", "design") or contract_structural:
|
|
verdict = "early_refreeze"
|
|
reasons = contract_structural or ["root_cause:%s" % root_cause]
|
|
elif impl_structural:
|
|
verdict = "early_rebuild"
|
|
reasons = impl_structural
|
|
elif len(failed) <= NEAR_COMPLETE_MAX_FAILED_ITEMS \
|
|
and broken == [] \
|
|
and len(passed) > len(failed):
|
|
if targeted_already_used:
|
|
verdict = "normal_pushback"
|
|
reasons = ["targeted budget already used (1 per version)"]
|
|
else:
|
|
verdict = "targeted_fix"
|
|
reasons = ["failed_items=%d (<= %d), no structural item, "
|
|
"no broken passed item, passed=%d > failed=%d"
|
|
% (len(failed), NEAR_COMPLETE_MAX_FAILED_ITEMS,
|
|
len(passed), len(failed))]
|
|
else:
|
|
verdict = "normal_pushback"
|
|
reasons = []
|
|
if len(failed) > NEAR_COMPLETE_MAX_FAILED_ITEMS:
|
|
reasons.append("failed_items=%d exceeds near-complete cap %d"
|
|
% (len(failed), NEAR_COMPLETE_MAX_FAILED_ITEMS))
|
|
if broken != []:
|
|
reasons.append("broken passed items present or unknown")
|
|
if len(passed) <= len(failed):
|
|
reasons.append("passed items do not outnumber failed items")
|
|
return {
|
|
"verdict": verdict,
|
|
"reasons": reasons,
|
|
"targeted_scope": [item.get("check") for item in failed]
|
|
if verdict == "targeted_fix" else None,
|
|
"root_cause": root_cause,
|
|
"basis": {
|
|
"audit_verdict_text": audit.get("verdict_text"),
|
|
"failed_items": failed,
|
|
"passed_items": passed,
|
|
"broken_passed_items": broken,
|
|
"failed_items_by_round": failed_items_by_round,
|
|
"root_cause_evidence": root_evidence,
|
|
},
|
|
}
|
|
|
|
|
|
def build_rebuild_package(module_id, decision, audit_history,
|
|
previous_artifacts, old_implementation_ref=None,
|
|
other_module_ids=None, contract_version=None,
|
|
passed_items=None, rebuild_cost_estimate=None,
|
|
actual_cost_note=None, retained_tests=None):
|
|
"""Build the rebuild task package for a ``rebuild`` decision.
|
|
|
|
The package is explicitly labelled a rebuild (never disguised
|
|
as an ordinary new task), scopes the rebuild to this module
|
|
only, keeps the old implementation by reference in git history
|
|
and in the audit record (never deleted from history), and
|
|
carries the avoidance list: every previous audit conclusion,
|
|
failure reason, and passed item, so the new implementation
|
|
does not repeat the same error and claim a fresh start.
|
|
|
|
Tightening 2: only the implementation code is rebuilt. The
|
|
package's ``retained_assets`` list the old test set, the
|
|
retained passed acceptance items, the unchanged contract and
|
|
design, and the audit conclusions -- none of these is reset
|
|
with the implementation. ``retained_tests=None`` (not
|
|
supplied) is flagged ``retained_tests_missing``; an explicitly
|
|
empty list means the module genuinely has no tests, which the
|
|
first gate then treats as a vacuous pass only if the caller
|
|
also supplies no retained tests to check (see
|
|
``check_rebuild_first_gate``).
|
|
"""
|
|
avoidance = []
|
|
for round_audit in audit_history or []:
|
|
for item in round_audit.get("failed_items") or []:
|
|
avoidance.append({
|
|
"kind": "prior_failure",
|
|
"check": item.get("check"),
|
|
"detail": item.get("detail"),
|
|
"audit_verdict_text": round_audit.get("verdict_text"),
|
|
})
|
|
for item in passed_items or []:
|
|
avoidance.append({
|
|
"kind": "previously_passed_must_not_regress",
|
|
"check": item.get("check"),
|
|
"detail": item.get("detail"),
|
|
})
|
|
return {
|
|
"kind": "rebuild",
|
|
"task_label": "推倒重做第 %s 次:模块 %s,上一轮失败原因见避坑清单"
|
|
% (decision.get("rebuild_no"), module_id),
|
|
"module_id": module_id,
|
|
"contract_version": contract_version,
|
|
"rebuild_no": decision.get("rebuild_no"),
|
|
"scope": {
|
|
"rebuild_only": module_id,
|
|
"untouched_modules": list(other_module_ids or []),
|
|
"frozen_contract_unchanged": True,
|
|
"design_unchanged": True,
|
|
"other_modules_passed_artifacts_untouched": True,
|
|
"directory_isolation_unchanged": True,
|
|
},
|
|
"old_implementation": {
|
|
"retention": "git_history_and_audit_record",
|
|
"ref": old_implementation_ref,
|
|
"archive_ref_required_before_replace": old_implementation_ref is None,
|
|
"workspace_action": "replace_with_new_implementation",
|
|
"deleted_from_history": False,
|
|
},
|
|
"implementation_basis": "frozen_contract",
|
|
"avoidance_list": avoidance,
|
|
# Tightening 2: retained assets. Only implementation code
|
|
# is replaced; everything listed here survives the rebuild.
|
|
"retained_assets": {
|
|
"tests": list(retained_tests) if retained_tests is not None else [],
|
|
"retained_tests_missing": retained_tests is None,
|
|
"passed_acceptance_items": list(passed_items or []),
|
|
"contract_unchanged": True,
|
|
"design_unchanged": True,
|
|
"audit_conclusions": [round_audit.get("verdict_text")
|
|
for round_audit in audit_history or []],
|
|
},
|
|
"first_gate": "old tests plus retained acceptance items must "
|
|
"all pass before any new full audit (see "
|
|
"check_rebuild_first_gate); a first-gate failure counts "
|
|
"as an audit failure for the new implementation",
|
|
"reaudit_criteria": full_reaudit_criteria(),
|
|
"previous_artifacts": [
|
|
{"path": artifact.get("path"),
|
|
"sha256": artifact.get("sha256"),
|
|
"bytes": artifact.get("bytes")}
|
|
for artifact in previous_artifacts or []
|
|
],
|
|
"merge_blocked_until_reaudit_pass": True,
|
|
"release_blocked_until_reaudit_pass": True,
|
|
"cost": {
|
|
"estimated_rebuild_cost": rebuild_cost_estimate,
|
|
"actual_cost_note": actual_cost_note
|
|
or "record estimated cost here before starting, and "
|
|
"actual cost plus token usage here when the rebuild "
|
|
"finishes; never report an unpriced rebuild as 0",
|
|
"quota_note": "rebuild is costed and quota-checked like any "
|
|
"other task; being a rebuild grants no exemption",
|
|
},
|
|
}
|
|
|
|
|
|
def check_rebuild_first_gate(retained_tests, retained_passed_items,
|
|
test_results, acceptance_results):
|
|
"""First gate of a rebuild (tightening 2): old tests must pass.
|
|
|
|
``test_results`` / ``acceptance_results`` map id -> bool (or a
|
|
list of ``{"id": ..., "passed": bool}`` dicts). A retained test
|
|
or retained acceptance item with no result fails closed
|
|
(missing is not a pass), and any failed item fails the gate.
|
|
A gate failure counts as an audit failure for the new
|
|
implementation's episode (record it with
|
|
``record_module_failure(state, "audit")``); it is never waved
|
|
through because the new implementation "has new features".
|
|
Returns ``{"gate_pass": bool, "missing": [...],
|
|
"failed": [...]}``.
|
|
"""
|
|
def _as_map(values):
|
|
if isinstance(values, dict):
|
|
return values
|
|
out = {}
|
|
for entry in values or []:
|
|
out[entry.get("id")] = bool(entry.get("passed"))
|
|
return out
|
|
|
|
test_map = _as_map(test_results)
|
|
acceptance_map = _as_map(acceptance_results)
|
|
missing = []
|
|
failed = []
|
|
for test_id in retained_tests or []:
|
|
if test_id not in test_map:
|
|
missing.append("test:%s" % test_id)
|
|
elif not test_map[test_id]:
|
|
failed.append("test:%s" % test_id)
|
|
for item in retained_passed_items or []:
|
|
item_id = item.get("id") or item.get("check")
|
|
if item_id not in acceptance_map:
|
|
missing.append("acceptance:%s" % item_id)
|
|
elif not acceptance_map[item_id]:
|
|
failed.append("acceptance:%s" % item_id)
|
|
return {"gate_pass": not missing and not failed,
|
|
"missing": missing, "failed": failed}
|
|
|
|
|
|
def validate_test_changes(changes, refreeze_approved=False,
|
|
separate_audit_ids=None):
|
|
"""Validate test deletions / assertion changes (tightening 2).
|
|
|
|
Each change: ``{"action": "delete" | "modify_assertion" |
|
|
"add", "test_id": ..., "separate_audit_id": ...}``. Deleting
|
|
a test or changing an assertion is allowed only when the
|
|
contract changed via the G2 re-freeze flow AND the change has
|
|
its own separate audit record (its id in
|
|
``separate_audit_ids`` or on the change itself). Adding a test
|
|
never needs refreeze, but an addition must not silently
|
|
replace a retained test -- that is a delete, not an add.
|
|
Returns a list of violations (empty means allowed).
|
|
"""
|
|
audits = set(separate_audit_ids or [])
|
|
violations = []
|
|
for change in changes or []:
|
|
action = change.get("action")
|
|
test_id = change.get("test_id")
|
|
if action in ("delete", "modify_assertion"):
|
|
audit_id = change.get("separate_audit_id")
|
|
if not refreeze_approved:
|
|
violations.append(
|
|
"%s:%s without contract re-freeze" % (action, test_id))
|
|
if audit_id not in audits and audit_id is None:
|
|
violations.append(
|
|
"%s:%s without separate audit" % (action, test_id))
|
|
elif audit_id is not None and audit_id not in audits:
|
|
violations.append(
|
|
"%s:%s separate audit not recorded" % (action, test_id))
|
|
elif action == "add":
|
|
continue
|
|
else:
|
|
violations.append("unknown test change action:%s" % action)
|
|
return violations
|
|
|
|
|
|
def validate_golden_task_set(tasks):
|
|
"""Validate a golden-task set (tightening 3).
|
|
|
|
Each task needs a human approver (``human_approved_by``) and
|
|
the set must contain no example/sample files
|
|
(``is_example``): a sample file is not a human-approved golden
|
|
task, however realistic it looks. Returns (valid, [reasons]).
|
|
"""
|
|
reasons = []
|
|
for task in tasks or []:
|
|
task_id = task.get("id", "?")
|
|
if task.get("is_example"):
|
|
reasons.append("example file is not a golden task:%s" % task_id)
|
|
if not task.get("human_approved_by"):
|
|
reasons.append("golden task lacks human approver:%s" % task_id)
|
|
return (not reasons, reasons)
|
|
|
|
|
|
def select_audit_sample(records, golden_task_ids, seed=None,
|
|
normal_ratio=None, critical_ratio=None):
|
|
"""Select audit records for audit-of-audit sampling (tightening 3).
|
|
|
|
ADAPTER, not a second rule: this function translates records
|
|
into ``cloud/audit_sampling.py`` cases, runs the unified
|
|
``select_sample`` there, and translates the plan back into
|
|
record order. The unified rule is: mandatory (1.0) = golden
|
|
tasks + rebuild / refreeze / escalation records + third
|
|
failure root-caused near-complete whose continuation round
|
|
still failed; the rest by seeded random draw (normal default
|
|
0.10, critical-path default 0.50, ceil so a non-empty group
|
|
is never rounded to zero). ``seed`` is REQUIRED -- a missing
|
|
seed raises ValueError, because an unseeded draw cannot be
|
|
reproduced on review. Selection is a single pass: a mandatory
|
|
record is removed from the random pools before drawing, so it
|
|
consumes no random quota, and a multi-trigger record is
|
|
returned ONCE with all its triggers in ``reasons`` (``reason``
|
|
is the first trigger, in golden / rebuild / third-failure /
|
|
refreeze / escalate order).
|
|
|
|
Retired reason names: track 3's old ``critical_path`` label
|
|
for mandatory kinds and ``periodic_sample`` label for every
|
|
10th record no longer exist -- mandatory kinds carry their own
|
|
trigger names, and random draws carry ``random_critical_path``
|
|
/ ``random_normal``. Input records are never mutated.
|
|
|
|
Returns a list of ``{"record": ..., "reason": ...,
|
|
"reasons": [...]}`` in original record order.
|
|
"""
|
|
from cloud.audit_sampling import select_sample as _select_sample
|
|
|
|
records = list(records or [])
|
|
cases = []
|
|
for index, record in enumerate(records):
|
|
case = dict(record)
|
|
# Unique per-record id for the plan; the golden check uses
|
|
# task_id, so sharing a task_id across records is fine.
|
|
case["id"] = "record-%d" % index
|
|
cases.append(case)
|
|
kwargs = {}
|
|
if normal_ratio is not None:
|
|
kwargs["normal_ratio"] = normal_ratio
|
|
if critical_ratio is not None:
|
|
kwargs["critical_ratio"] = critical_ratio
|
|
plan = _select_sample(cases, seed, golden_task_ids=golden_task_ids,
|
|
**kwargs)
|
|
reasons_by_id = {}
|
|
for entry in plan["mandatory"]:
|
|
reasons_by_id[entry["case_id"]] = list(entry["triggers"])
|
|
for case_id in plan["random_critical_path"]:
|
|
reasons_by_id[case_id] = ["random_critical_path"]
|
|
for case_id in plan["random_normal"]:
|
|
reasons_by_id[case_id] = ["random_normal"]
|
|
selected = []
|
|
for index, record in enumerate(records):
|
|
reasons = reasons_by_id.get("record-%d" % index)
|
|
if reasons:
|
|
selected.append({"record": record, "reason": reasons[0],
|
|
"reasons": list(reasons)})
|
|
return selected
|
|
|
|
|
|
def build_audit_correction(original_entry, corrected_verdict, corrector,
|
|
reason):
|
|
"""Correct a wrong audit conclusion without rewriting it.
|
|
|
|
Returns ``(correction, errors)``. The original entry is never
|
|
mutated: the correction appends a new record that references
|
|
the original by its ``entry_hash`` (or task/round when no hash
|
|
exists). The corrector must differ from the original signer --
|
|
the audit of the audit cannot be signed by the same person.
|
|
Restored action is derived, not chosen freely: a wrong pass
|
|
(should have blocked) re-triggers pushback and re-blocks;
|
|
a wrong block (should have passed) schedules a re-audit --
|
|
it does not jump straight to merge, because the correction
|
|
itself is a review, not a full audit. This is the SAME pair
|
|
rule as ``cloud/audit_sampling.py::handle_sampling_result``,
|
|
only the vocabulary differs (there: wrong pass ->
|
|
``reopen_audit_and_pushback``, wrong block ->
|
|
``cancel_pushback_and_rerun_audit`` where "cancel" means
|
|
cancel-and-rerun, never cancel-and-merge); the gate effect is
|
|
identical and is pinned by the wiring track (#247): a
|
|
correction to ``pass`` never unblocks by itself.
|
|
"""
|
|
errors = []
|
|
signer = original_entry.get("signer")
|
|
if not corrector or (signer and corrector == signer):
|
|
errors.append("corrector_equals_original_signer")
|
|
original_verdict = original_entry.get("verdict")
|
|
if corrected_verdict == original_verdict:
|
|
errors.append("correction_matches_original_verdict")
|
|
if corrected_verdict == "fail" and original_verdict == "pass":
|
|
restored = "retrigger_pushback_and_reblock"
|
|
elif corrected_verdict == "pass" and original_verdict == "fail":
|
|
restored = "schedule_reaudit"
|
|
else:
|
|
restored = "review_required"
|
|
correction = {
|
|
"kind": "audit_correction",
|
|
"original_entry_hash": original_entry.get("entry_hash"),
|
|
"original_task_id": original_entry.get("task_id"),
|
|
"original_round": original_entry.get("round"),
|
|
"original_verdict": original_verdict,
|
|
"corrected_verdict": corrected_verdict,
|
|
"corrector": corrector,
|
|
"reason": reason,
|
|
"restored_action": restored,
|
|
"valid": not errors,
|
|
"errors": errors,
|
|
}
|
|
return correction, errors
|
|
|
|
|
|
def check_g6_guard(record):
|
|
"""G6 guard (tightening 3): only the user ticks acceptance.
|
|
|
|
``record`` may carry ``g6_acceptance`` and ``g6_accepted_by``.
|
|
Acceptance ticked by anyone other than the user is a
|
|
violation; an absent acceptance field is not a violation (it
|
|
simply means acceptance has not happened). Returns violations.
|
|
"""
|
|
if record.get("g6_acceptance") and record.get("g6_accepted_by") != "user":
|
|
return ["g6 acceptance ticked by non-user:%s"
|
|
% record.get("g6_accepted_by")]
|
|
return []
|
|
|
|
|
|
def build_acceptance_rebuild_notice(module_id, decision, package,
|
|
failed_reasons):
|
|
"""User notice required before an acceptance-triggered rebuild.
|
|
|
|
Contents: failure reasons, rebuild scope, retained assets, and
|
|
the old-test first gate. The notice itself does not start the
|
|
rebuild: ``confirmed`` starts False and only a recorded user
|
|
confirmation (see ``confirm_acceptance_rebuild``) unblocks it.
|
|
"""
|
|
return {
|
|
"kind": "rebuild_notice",
|
|
"to": "user",
|
|
"module_id": module_id,
|
|
"failure_reasons": list(failed_reasons or []),
|
|
"rebuild_scope": (package or {}).get("scope"),
|
|
"retained_assets": (package or {}).get("retained_assets"),
|
|
"first_gate": (package or {}).get("first_gate"),
|
|
"rebuild_no": decision.get("rebuild_no"),
|
|
"requires_user_confirmation": True,
|
|
"confirmed": False,
|
|
}
|
|
|
|
|
|
def confirm_acceptance_rebuild(notice, user_confirmed):
|
|
"""Apply the user's confirmation to a rebuild notice.
|
|
|
|
Unconfirmed: the rebuild must not start silently -- the
|
|
returned notice stays unconfirmed and the caller must keep
|
|
the ``rebuild_pending_user_confirmation`` decision. Confirmed:
|
|
the notice records the confirmation and the rebuild may
|
|
proceed; the user still holds final G6 acceptance afterwards.
|
|
"""
|
|
updated = dict(notice)
|
|
updated["confirmed"] = bool(user_confirmed)
|
|
updated["g6_acceptance_retained_by_user"] = True
|
|
return updated
|
|
|
|
|
|
def build_pushback_package(audit, decision, previous_artifacts,
|
|
fix_diff_paths=None, qa_failed_links=None):
|
|
"""Build the executable pushback package for a pushback/escalate.
|
|
|
|
Carries the audit conclusion verbatim, one entry per failed
|
|
item with its evidence (test name, file path, line number,
|
|
contract clause, diff range), reproduction steps, the full
|
|
re-audit criteria (not just the failed items), and the previous
|
|
round's artifact manifest with hashes, so a fix can be diffed
|
|
against it and cannot silently drop or overwrite a passed
|
|
artifact (see ``verify_previous_artifacts``).
|
|
|
|
QA full-chain supplement: ``qa_failed_links`` (from the QA
|
|
matrix, see ``cloud/qa_matrix.py``) lists the failed business
|
|
links and cases (link name, case name, failure, evidence,
|
|
reproduction). The re-audit must re-run exactly those links,
|
|
not just unit tests; they are carried separately in
|
|
``qa_failed_links`` and ``reaudit_must_rerun_links`` so the
|
|
re-audit checklist cannot drop them.
|
|
"""
|
|
failed_items = []
|
|
for item in audit.get("failed_items") or []:
|
|
evidence = item.get("evidence") or {}
|
|
failed_items.append({
|
|
"check": item.get("check"),
|
|
"detail": item.get("detail"),
|
|
"evidence": {
|
|
"test_name": evidence.get("test_name"),
|
|
"file_path": evidence.get("file_path"),
|
|
"line_no": evidence.get("line_no"),
|
|
"contract_clause": evidence.get("contract_clause"),
|
|
"diff_range": evidence.get("diff_range"),
|
|
},
|
|
"reproduction_steps": item.get("reproduction_steps") or [],
|
|
"reaudit_criterion": item.get("reaudit_criterion")
|
|
or "re-run the full re-audit checklist for this check",
|
|
})
|
|
for check in decision.get("failed_checks") or []:
|
|
if not any(entry["check"] == check for entry in failed_items):
|
|
failed_items.append({
|
|
"check": check,
|
|
"detail": "gate check failed (no per-item audit detail supplied)",
|
|
"evidence": {
|
|
"test_name": None, "file_path": None, "line_no": None,
|
|
"contract_clause": None, "diff_range": None,
|
|
},
|
|
"reproduction_steps": ["re-run the audit checks"],
|
|
"reaudit_criterion": "re-run the full re-audit checklist",
|
|
})
|
|
reproduction = []
|
|
for entry in failed_items:
|
|
reproduction.extend(entry["reproduction_steps"])
|
|
if not reproduction:
|
|
reproduction = ["re-run the audit checks listed in failed_checks"]
|
|
return {
|
|
"task_id": audit.get("task_id"),
|
|
"round": decision.get("round"),
|
|
"next_round": decision.get("next_round"),
|
|
"decision_action": decision.get("action"),
|
|
"audit_verdict_text": audit.get("verdict_text"),
|
|
"failed_items": failed_items,
|
|
"failed_checks": list(decision.get("failed_checks") or []),
|
|
"reproduction_steps": reproduction,
|
|
# Full re-audit, not failed-items-only.
|
|
"reaudit_criteria": full_reaudit_criteria(),
|
|
"previous_artifacts": [
|
|
{
|
|
"path": artifact.get("path"),
|
|
"sha256": artifact.get("sha256"),
|
|
"bytes": artifact.get("bytes"),
|
|
}
|
|
for artifact in previous_artifacts or []
|
|
],
|
|
"derived_subtask": {
|
|
"kind": "audit_fix",
|
|
"parent_task_id": audit.get("task_id"),
|
|
"pushback_round": decision.get("round"),
|
|
"merge_blocked_until_reaudit_pass": True,
|
|
"release_blocked_until_reaudit_pass": True,
|
|
"cost_and_quota_note": "the fix task is costed and quota-checked "
|
|
"like any other task; being a fix grants no exemption",
|
|
},
|
|
"fix_diff_paths": list(fix_diff_paths or []),
|
|
"qa_failed_links": list(
|
|
qa_failed_links if qa_failed_links is not None
|
|
else audit.get("qa_failed_links") or []),
|
|
"reaudit_must_rerun_links": list(
|
|
qa_failed_links if qa_failed_links is not None
|
|
else audit.get("qa_failed_links") or []),
|
|
}
|
|
|
|
|
|
def verify_previous_artifacts(previous_artifacts, new_manifest,
|
|
fix_diff_paths=None):
|
|
"""Check a fix did not silently drop/overwrite passed artifacts.
|
|
|
|
Returns ``{"ok": bool, "violations": [...],
|
|
"changed_and_reauditable": [...]}``. A previously passed artifact
|
|
missing from the new manifest is a violation ("dropped"); one
|
|
whose hash changed without appearing in the fix diff is a
|
|
violation ("silently_changed"); one whose hash changed AND is
|
|
listed in the fix diff is allowed but must go through full
|
|
re-audit, so it is reported separately, not as a pass.
|
|
"""
|
|
fix_diff = set(fix_diff_paths or [])
|
|
new_by_path = {item.get("path"): item for item in new_manifest or []}
|
|
violations = []
|
|
changed = []
|
|
for artifact in previous_artifacts or []:
|
|
path = artifact.get("path")
|
|
new = new_by_path.get(path)
|
|
if new is None:
|
|
violations.append("dropped:%s" % path)
|
|
elif new.get("sha256") != artifact.get("sha256"):
|
|
if path in fix_diff:
|
|
changed.append(path)
|
|
else:
|
|
violations.append("silently_changed:%s" % path)
|
|
return {
|
|
"ok": not violations,
|
|
"violations": violations,
|
|
"changed_and_reauditable": changed,
|
|
}
|
|
|
|
|
|
def _canonical_hash(entry_without_hash):
|
|
payload = json.dumps(entry_without_hash, sort_keys=True,
|
|
separators=(",", ":"), ensure_ascii=False)
|
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def build_ledger_entry(kind, task_id, round_no, trigger_checks=None,
|
|
package_ref=None, fix_diff=None,
|
|
reaudit_result=None, cost=None, tokens=None,
|
|
prev_hash="", module_id=None, contract_version=None,
|
|
fail_counts=None, rebuild_no=None, root_cause=None,
|
|
old_implementation_ref=None):
|
|
"""One append-only audit-ledger record (track-4 interface proposal).
|
|
|
|
Every pushback, fix, re-audit, rebuild, refreeze escalation,
|
|
and escalation appends one entry; entries are never rewritten,
|
|
so the timeline keeps failed rounds. Module fields record the
|
|
per-module failure counts, the rebuild number, the root-cause
|
|
verdict, and the old implementation's archive reference, so a
|
|
rebuild's old/new correspondence stays auditable.
|
|
"""
|
|
if kind not in LEDGER_KINDS:
|
|
raise ValueError("unknown ledger kind: %r" % (kind,))
|
|
entry = {
|
|
"kind": kind,
|
|
"task_id": task_id,
|
|
"round": round_no,
|
|
"trigger_checks": list(trigger_checks or []),
|
|
"package_ref": package_ref,
|
|
"fix_diff": list(fix_diff or []),
|
|
"reaudit_result": reaudit_result,
|
|
"cost": cost,
|
|
"tokens": tokens,
|
|
"prev_hash": prev_hash,
|
|
"module_id": module_id,
|
|
"contract_version": contract_version,
|
|
"fail_counts": fail_counts,
|
|
"rebuild_no": rebuild_no,
|
|
"root_cause": root_cause,
|
|
"old_implementation_ref": old_implementation_ref,
|
|
}
|
|
entry["entry_hash"] = _canonical_hash(entry)
|
|
return entry
|
|
|
|
|
|
def validate_ledger_chain(entries):
|
|
"""Validate a ledger chain. Returns (valid, [errors]).
|
|
|
|
Checks every entry hash, every prev_hash link, the kind
|
|
vocabulary, and non-decreasing rounds per task. It does NOT
|
|
claim a chain is complete merely because it validates: a
|
|
chain that kept only the final passing entry would validate
|
|
structurally while violating the timeline rule, so callers
|
|
must also retain (and show) every round's entry.
|
|
"""
|
|
errors = []
|
|
prev_hash = ""
|
|
last_round = {}
|
|
for index, entry in enumerate(entries or []):
|
|
claimed = entry.get("entry_hash")
|
|
body = {key: value for key, value in entry.items()
|
|
if key != "entry_hash"}
|
|
if claimed != _canonical_hash(body):
|
|
errors.append("entry %d: hash mismatch" % index)
|
|
if entry.get("prev_hash", "") != prev_hash:
|
|
errors.append("entry %d: prev_hash link broken" % index)
|
|
if entry.get("kind") not in LEDGER_KINDS:
|
|
errors.append("entry %d: unknown kind" % index)
|
|
task_id = entry.get("task_id")
|
|
round_no = entry.get("round")
|
|
if task_id in last_round and round_no < last_round[task_id]:
|
|
errors.append("entry %d: round goes backwards" % index)
|
|
if isinstance(round_no, int):
|
|
last_round[task_id] = round_no
|
|
prev_hash = claimed or ""
|
|
return (not errors, errors)
|