diff --git a/core/application/health.py b/core/application/health.py index 45c697f..c3281b7 100644 --- a/core/application/health.py +++ b/core/application/health.py @@ -20,7 +20,7 @@ FAIL = "FAIL" SKIPPED = "SKIPPED" -# Every table created by initialize_database() (core/persistence/schema.py, schema version 5). +# Every table created by initialize_database() (core/persistence/schema.py, schema version 6). # tests/unit/test_health.py checks this constant against the real DDL. EXPECTED_TABLES: frozenset[str] = frozenset( { @@ -41,6 +41,8 @@ "evidence_validity_transitions", "evidence_supersessions", "audit_events", + # core/persistence/schema.py, schema 6 (P6b) + "promotion_decisions", } ) diff --git a/core/domain/invariant.py b/core/domain/invariant.py index b687533..99f9b2b 100644 --- a/core/domain/invariant.py +++ b/core/domain/invariant.py @@ -384,7 +384,12 @@ def for_baseline( @classmethod def carry_forward( - cls, parent_ref: InvariantRef, candidate: object, impact: object + cls, + parent_ref: InvariantRef, + candidate: object, + impact: object, + *, + extra_evidence_ids: Iterable[str] = (), ) -> InvariantProof: """The proof of an invariant that the candidate leaves as the current state has it (origin CARRIED_FORWARD). Status, evidence and verification time are copied unchanged, so a @@ -396,7 +401,11 @@ def carry_forward( affected, uncertain or unmentioned invariant, or a report that is for another candidate or parent, is for another version, or no longer recomputes its hash, is refused: a regression cannot hide behind the parent's old evidence. Whether the policy permits carrying forward at - all is decided by the Promotion Controller (C-22, C-159).""" + all is decided by the Promotion Controller (C-22, C-159). + + ``extra_evidence_ids`` (P6b, C-164) adds evidence ids to the copied ones: the reference of + the next state also carries the id of the promotion binding record, so the next + carry-forward can find it. Nothing else about the proof changes.""" from core.domain.impact import ImpactReport from core.domain.state import CandidateState @@ -432,11 +441,15 @@ def carry_forward( f"C-40: the PROTECTED reference to {parent_ref.invariant_id} was invalidated " "and cannot be carried forward" ) + if isinstance(extra_evidence_ids, str) or not isinstance(extra_evidence_ids, Iterable): + raise DomainValidationError( + "InvariantProof.carry_forward: extra_evidence_ids must be a list of UUIDs" + ) return cls( invariant_id=parent_ref.invariant_id, invariant_version=parent_ref.invariant_version, status=parent_ref.status, - evidence_ids=parent_ref.evidence_ids, + evidence_ids=(*parent_ref.evidence_ids, *extra_evidence_ids), verified_at=parent_ref.last_verified_at, origin=ProofOrigin.CARRIED_FORWARD, candidate_id=candidate.candidate_id, diff --git a/core/domain/lifecycle.py b/core/domain/lifecycle.py index 9145c1e..f1d9f09 100644 --- a/core/domain/lifecycle.py +++ b/core/domain/lifecycle.py @@ -30,8 +30,9 @@ _C.BUILDING: frozenset({_C.READY, _C.FAILED}), _C.READY: frozenset({_C.ANALYZING}), _C.ANALYZING: frozenset({_C.REJECTED, _C.RETRY_REQUIRED, _C.ESCALATED, _C.PROMOTABLE}), - # PROMOTED only via mark_promoted; REJECTED only via reject_stale_candidate (C-29). - _C.PROMOTABLE: frozenset({_C.PROMOTED, _C.REJECTED}), + # PROMOTED only via mark_promoted; REJECTED only via reject_stale_candidate (C-29); + # ESCALATED when the commit-time revalidation fails (C-38, P6b). + _C.PROMOTABLE: frozenset({_C.PROMOTED, _C.REJECTED, _C.ESCALATED}), _C.FAILED: frozenset(), _C.REJECTED: frozenset(), _C.RETRY_REQUIRED: frozenset(), diff --git a/core/domain/promotion.py b/core/domain/promotion.py index 77024af..45dcbfa 100644 --- a/core/domain/promotion.py +++ b/core/domain/promotion.py @@ -15,14 +15,19 @@ from __future__ import annotations +import dataclasses from collections.abc import Iterable, Mapping -from dataclasses import dataclass, field +from dataclasses import InitVar, dataclass, field from datetime import datetime from enum import StrEnum from typing import Any, Final from core.domain.enums import DecisionType, VerificationResult -from core.domain.errors import DomainValidationError, HashMismatchError +from core.domain.errors import ( + DomainValidationError, + HashMismatchError, + UnauthorizedConstructionError, +) from core.domain.hashing import content_hash from core.domain.ids import require_uuid from core.domain.invariant import Invariant @@ -52,6 +57,16 @@ MAX_ATTEMPTS_LIMIT: Final = 10 +# Only ``committed`` and ``with_new_state`` (and ``from_dict``, which rebuilds what they stored) may +# put a state id into a decision or its evidence (DATA-INT-004): the commit of P6b is the one place +# that does. The token is private to this module (R9). +_COMMIT_TOKEN: Final = object() + + +class ConflictingDuplicateError(DomainValidationError): + """A ``decision_id`` was submitted again with different content (C-163, Doc 09 §38).""" + + class PromotionInputError(DomainValidationError): """The arguments of ``decide`` are malformed (wrong types, another lineage, ...). A finding of the decision itself is never an error: it is a REJECT, RETRY or ESCALATE (C-157).""" @@ -273,8 +288,9 @@ class PromotionDecision: created_at: datetime committed_state_id: str | None content_hash: str + _token: InitVar[object | None] = None - def __post_init__(self) -> None: + def __post_init__(self, _token: object | None) -> None: name = "PromotionDecision" require_uuid(self.decision_id, f"{name}.decision_id") require_uuid(self.candidate_id, f"{name}.candidate_id") @@ -284,9 +300,17 @@ def __post_init__(self) -> None: require_text(self.policy_version, f"{name}.policy_version") require_int(self.attempt_number, f"{name}.attempt_number", 1) if self.committed_state_id is not None: - raise DomainValidationError( - f"{name}.committed_state_id: null until the commit of P6b (DATA-INT-004)" - ) + if _token is not _COMMIT_TOKEN: + raise UnauthorizedConstructionError( + f"{name}.committed_state_id: only PromotionDecision.committed sets it, at the " + "commit (DATA-INT-004)" + ) + if self.decision is not DecisionType.PROMOTE: + raise DomainValidationError( + f"{name}.committed_state_id: only a committed PROMOTE names a state " + "(DATA-INT-004)" + ) + require_uuid(self.committed_state_id, f"{name}.committed_state_id") object.__setattr__(self, "created_at", utc(self.created_at, f"{name}.created_at")) checks = _checks(self.required_checks) conditions = _objects(self.blocking_conditions, BlockingCondition, "blocking_conditions") @@ -338,6 +362,20 @@ def _payload(self) -> dict[str, Any]: def compute_hash(self) -> str: return content_hash(self._payload()) + def committed(self, state_id: str) -> PromotionDecision: + """The same PROMOTE once it is committed to ``state_id`` (DATA-INT-004). Returned as a new + object with its hash recomputed; allowed once, and only for a PROMOTE.""" + if self.decision is not DecisionType.PROMOTE or self.committed_state_id is not None: + raise DomainValidationError( + "PromotionDecision.committed: only a PROMOTE that is not yet committed can be " + "committed (DATA-INT-004)" + ) + require_uuid(state_id, "PromotionDecision.committed.state_id") + digest = content_hash({**self._payload(), "committed_state_id": state_id}) + return dataclasses.replace( + self, committed_state_id=state_id, content_hash=digest, _token=_COMMIT_TOKEN + ) + @classmethod def create( cls, @@ -423,6 +461,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> PromotionDecision: created_at=parse_utc(fields["created_at"], "PromotionDecision.created_at"), committed_state_id=fields["committed_state_id"], content_hash=fields["content_hash"], + _token=_COMMIT_TOKEN, ) @@ -460,8 +499,9 @@ class PromotionEvidence: reason: DecisionReason new_state_id: str | None content_hash: str + _token: InitVar[object | None] = None - def __post_init__(self) -> None: + def __post_init__(self, _token: object | None) -> None: name = "PromotionEvidence" require_uuid(self.decision_id, f"{name}.decision_id") require_uuid(self.candidate_id, f"{name}.candidate_id") @@ -474,7 +514,16 @@ def __post_init__(self) -> None: f"{name}.evidence_sufficiency: must be one of {SUFFICIENCY_VALUES}" ) if self.new_state_id is not None: - raise DomainValidationError(f"{name}.new_state_id: null until the commit of P6b") + if _token is not _COMMIT_TOKEN: + raise UnauthorizedConstructionError( + f"{name}.new_state_id: only PromotionEvidence.with_new_state sets it, at the " + "commit (DATA-INT-004)" + ) + if self.decision is not DecisionType.PROMOTE: + raise DomainValidationError( + f"{name}.new_state_id: only a PROMOTE names a new state (DATA-INT-004)" + ) + require_uuid(self.new_state_id, f"{name}.new_state_id") checks = _checks(self.required_checks) refs = _uuids(self.verification_refs, f"{name}.verification_refs") if self.decision is DecisionType.PROMOTE and ( @@ -501,6 +550,19 @@ def _payload(self) -> dict[str, Any]: def compute_hash(self) -> str: return content_hash(self._payload()) + def with_new_state(self, state_id: str) -> PromotionEvidence: + """The same evidence once the state exists (DATA-INT-004); allowed once, for a PROMOTE.""" + if self.decision is not DecisionType.PROMOTE or self.new_state_id is not None: + raise DomainValidationError( + "PromotionEvidence.with_new_state: only the evidence of a PROMOTE that names no " + "state yet can name one (DATA-INT-004)" + ) + require_uuid(state_id, "PromotionEvidence.with_new_state.state_id") + digest = content_hash({**self._payload(), "new_state_id": state_id}) + return dataclasses.replace( + self, new_state_id=state_id, content_hash=digest, _token=_COMMIT_TOKEN + ) + @classmethod def create( cls, @@ -573,6 +635,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> PromotionEvidence: reason=parse_enum(fields["reason"], DecisionReason, "reason"), new_state_id=fields["new_state_id"], content_hash=fields["content_hash"], + _token=_COMMIT_TOKEN, ) diff --git a/core/domain/repositories.py b/core/domain/repositories.py index 963a393..7a744a4 100644 --- a/core/domain/repositories.py +++ b/core/domain/repositories.py @@ -36,6 +36,7 @@ ) from core.domain.invariant import Invariant, InvariantRef from core.domain.patch import Patch +from core.domain.promotion import PromotionDecision from core.domain.state import CandidateState, TrustedState @@ -182,6 +183,32 @@ def list_for_correlation(self, correlation_id: str) -> tuple[AuditEvent, ...]: def list_for_candidate(self, candidate_id: str) -> tuple[AuditEvent, ...]: ... +class DecisionRepository(Protocol): + """Promotion decisions (Doc 05 §15, §22; P6b, C-163, C-165). There is no ``update`` and no + ``delete``: a decision is stored uncommitted, and ``commit`` is the one change, from no state to + the state it created (DATA-INT-004).""" + + def save(self, decision: PromotionDecision) -> None: + """Store an uncommitted decision. The same ``decision_id`` with identical content (also once + it is committed) changes nothing; with any other content it raises + ``ConflictingDuplicateError``. DATA-INT-003: the decision's parent is its candidate's + parent.""" + ... + + def get(self, decision_id: str) -> PromotionDecision | None: + """The decision, rebuilt with its hash recomputed (DATA-INT-010), or ``None``.""" + ... + + def for_candidate(self, candidate_id: str) -> tuple[PromotionDecision, ...]: + """The candidate's decisions in the order they were stored.""" + ... + + def commit(self, decision_id: str, state_id: str) -> PromotionDecision: + """Record that the PROMOTE decision created ``state_id``: ``committed_state_id`` goes from + null to the state, once (DATA-INT-004). Returns the committed decision.""" + ... + + class UnitOfWork(Protocol): """One transaction over every repository (Doc 05 §32). @@ -207,6 +234,9 @@ def evidence(self) -> EvidenceRepository: ... @property def audit(self) -> AuditRepository: ... + @property + def decisions(self) -> DecisionRepository: ... + def __enter__(self) -> UnitOfWork: ... def __exit__( diff --git a/core/domain/verification.py b/core/domain/verification.py index 1b842f0..d510188 100644 --- a/core/domain/verification.py +++ b/core/domain/verification.py @@ -574,6 +574,11 @@ def __post_init__(self) -> None: raise DomainValidationError(f"{name}.results: must be sorted by target type and id") if len(set(keys)) != len(keys): raise DomainValidationError(f"{name}.results: one result per target") + target_ids = [item.target_id for item in results] + if len(set(target_ids)) != len(target_ids): + raise DomainValidationError( + f"{name}.results: a target id has at most one result, whatever its type (C-162)" + ) ids = [item.verification_id for item in results] if len(set(ids)) != len(ids): raise DomainValidationError(f"{name}.results: duplicate verification_id") diff --git a/core/persistence/schema.py b/core/persistence/schema.py index 1f59d9f..f21ebe8 100644 --- a/core/persistence/schema.py +++ b/core/persistence/schema.py @@ -1,6 +1,6 @@ -"""SQLite schema version 5: tables, constraints and triggers (Doc 05 §16, §20-§23, §32, §36; -Doc 06 §5, §6, §20, §22, §30; Doc 11; C-42, C-44, C-46, C-47, C-51 to C-60; P1b step 4, -P2-fix step 3). +"""SQLite schema version 6: tables, constraints and triggers (Doc 05 §15, §16, §20-§23, §32, §36; +Doc 06 §5, §6, §20, §22, §30; Doc 11; C-38, C-42, C-44, C-46, C-47, C-51 to C-60, C-163, C-165, +C-168; P1b step 4, P2-fix step 3, P6b step 4). The database is the second line of defense. P1a enforces the SM rules in the domain; here the database refuses the same illegal moves when application code is wrong (Doc 05 §36: "Promotion @@ -40,6 +40,12 @@ append-only (no UPDATE, DELETE or REPLACE, including from a plain connection), and the transition rules of C-52 and C-53 are triggers as well as domain functions. +Schema 6 (P6b) appends the ``promotion_decisions`` table, its triggers (DATA-INT-003 and 004) and +two unique partial indexes after the 92 schema-5 statements, which stay byte-identical. The +schema-5 candidate-status trigger lacks PROMOTABLE -> ESCALATED (C-38), so it is dropped and +created again in the appended statements. No trigger demands a decision row for a promoted state +(C-168, decision (b)): that direction is enforced by ``commit_promotion``. + There is no migration tooling before P8a (C-44): a database with any other schema version is refused with instructions to delete the local development database. """ @@ -60,8 +66,12 @@ EvidenceValidity, ) from core.domain.lifecycle import CANDIDATE_TRANSITIONS, TRUSTED_REF_STATUSES +from core.domain.promotion import DecisionReason + +SCHEMA_VERSION = "6" -SCHEMA_VERSION = "5" +# The candidate transition that schema 6 adds to the schema-5 trigger (C-38). +C38_PAIR = ("PROMOTABLE", "ESCALATED") # The evidence and audit tables of schema 5 (P2-fix; C-59). EVIDENCE_TABLES = ( @@ -84,6 +94,7 @@ "invariants", "invariant_refs", *EVIDENCE_TABLES, + "promotion_decisions", ) # The schema-3 names and the five tables of the interim evidence store (retired by P2-fix). LEGACY_TABLES = ( @@ -153,10 +164,15 @@ def candidate_status_pairs() -> tuple[tuple[str, str], ...]: ) -def _pair_condition() -> str: - return " OR ".join( - f"(OLD.status = '{old}' AND NEW.status = '{new}')" for old, new in candidate_status_pairs() - ) +def _pair_condition(pairs: tuple[tuple[str, str], ...] | None = None) -> str: + chosen = candidate_status_pairs() if pairs is None else pairs + return " OR ".join(f"(OLD.status = '{old}' AND NEW.status = '{new}')" for old, new in chosen) + + +def _schema5_pairs() -> tuple[tuple[str, str], ...]: + """The pairs of the schema-5 trigger: every domain pair but the one schema 6 adds (C-38), so + that the schema-5 statements stay byte-identical.""" + return tuple(pair for pair in candidate_status_pairs() if pair != C38_PAIR) def _trigger(name: str, timing_event: str, table: str, message: str, violation: str) -> str: @@ -368,7 +384,7 @@ def _triggers() -> tuple[str, ...]: "BEFORE UPDATE", "candidates", "Doc 06 §5, SM-002: illegal candidate status transition", - f"NOT ({_pair_condition()})", + f"NOT ({_pair_condition(_schema5_pairs())})", ) ) triggers.append( @@ -832,11 +848,125 @@ def _evidence_triggers() -> tuple[str, ...]: return tuple(triggers) +# --- Schema 6: promotion decisions (P6b step 4; Doc 05 §15, §22, §32; DATA-INT-003 and 004; +# C-38, C-163, C-165, C-168). Appended after the 92 schema-5 statements, which stay byte-identical +# (the golden hash of ``DDL[:92]`` is pinned by a test). + +_DECISION_TYPES = _quoted(["PROMOTE", "REJECT", "RETRY", "ESCALATE"]) +_DECISION_REASONS = _quoted([reason.value for reason in DecisionReason]) +_DECISION_COLUMNS = ( + "decision_id", + "candidate_id", + "parent_state_id", + "decision", + "reason", + "policy_version", + "attempt_number", + "required_checks_json", + "blocking_conditions_json", + "supporting_results_json", + "content_hash", + "created_at", +) + + +def _schema6() -> tuple[str, ...]: + changed = " OR ".join(f"NEW.{column} IS NOT OLD.{column}" for column in _DECISION_COLUMNS) + return ( + f"""CREATE TABLE IF NOT EXISTS promotion_decisions ( + decision_id TEXT NOT NULL PRIMARY KEY {_uuid("decision_id")}, + candidate_id TEXT NOT NULL {_uuid("candidate_id")} + REFERENCES candidates(candidate_id), + parent_state_id TEXT NOT NULL {_uuid("parent_state_id")} + REFERENCES trusted_states(state_id), + decision TEXT NOT NULL CHECK (decision IN ({_DECISION_TYPES})), + reason TEXT NOT NULL CHECK (reason IN ({_DECISION_REASONS})), + policy_version TEXT NOT NULL CHECK (length(policy_version) > 0), + attempt_number INTEGER NOT NULL CHECK (attempt_number >= 1), + required_checks_json TEXT NOT NULL CHECK (json_valid(required_checks_json)), + blocking_conditions_json TEXT NOT NULL CHECK (json_valid(blocking_conditions_json)), + supporting_results_json TEXT NOT NULL CHECK (json_valid(supporting_results_json)), + content_hash TEXT NOT NULL {_sha256("content_hash")}, + created_at TEXT NOT NULL, + committed_state_id TEXT UNIQUE {_uuid("committed_state_id", null=True)} + REFERENCES trusted_states(state_id), + CHECK (committed_state_id IS NULL OR decision = 'PROMOTE') +)""", + "CREATE INDEX IF NOT EXISTS ix_promotion_decisions_candidate " + "ON promotion_decisions(candidate_id)", + # C-163: at most one committed decision per candidate, and one commit decision per state. + "CREATE UNIQUE INDEX IF NOT EXISTS uq_promotion_decisions_committed_candidate " + "ON promotion_decisions(candidate_id) WHERE committed_state_id IS NOT NULL", + "CREATE UNIQUE INDEX IF NOT EXISTS uq_trusted_states_commit_decision " + "ON trusted_states(commit_decision_id) WHERE commit_decision_id IS NOT NULL", + _trigger( + "promotion_decisions_no_delete", + "BEFORE DELETE", + "promotion_decisions", + "DATA-INT-004: a promotion decision is never deleted (DATA-INT-006, Doc 09 §40)", + "1", + ), + _trigger( + "promotion_decisions_no_replace", + "BEFORE INSERT", + "promotion_decisions", + "DATA-INT-006: promotion_decisions row already exists; REPLACE would rewrite history", + "EXISTS (SELECT 1 FROM promotion_decisions WHERE decision_id = NEW.decision_id)", + ), + _trigger( + "promotion_decisions_insert_rules", + "BEFORE INSERT", + "promotion_decisions", + "DATA-INT-003: a decision's parent_state_id is its candidate's parent_state_id", + "NOT EXISTS (SELECT 1 FROM candidates c WHERE c.candidate_id = NEW.candidate_id " + "AND c.parent_state_id = NEW.parent_state_id)", + ), + _trigger( + "promotion_decisions_insert_uncommitted", + "BEFORE INSERT", + "promotion_decisions", + "DATA-INT-004: a decision is stored uncommitted; only the commit sets " + "committed_state_id", + "NEW.committed_state_id IS NOT NULL", + ), + _trigger( + "promotion_decisions_update_identity", + "BEFORE UPDATE", + "promotion_decisions", + "DATA-INT-004: only committed_state_id of a decision changes (DATA-INT-006)", + changed, + ), + _trigger( + "promotion_decisions_update_commit", + "BEFORE UPDATE", + "promotion_decisions", + "DATA-INT-004: committed_state_id is set once, from null, for a PROMOTE, to the state " + "that names this decision as its commit decision and has the decision's parent", + "NEW.committed_state_id IS NOT OLD.committed_state_id AND " + "NOT (OLD.committed_state_id IS NULL AND NEW.committed_state_id IS NOT NULL " + "AND NEW.decision = 'PROMOTE' AND EXISTS (SELECT 1 FROM trusted_states s " + "WHERE s.state_id = NEW.committed_state_id AND s.commit_decision_id = NEW.decision_id " + "AND s.parent_state_id = NEW.parent_state_id))", + ), + # C-38: the schema-5 trigger lacks PROMOTABLE -> ESCALATED. The schema-5 statements stay + # as they were, so it is dropped here and created again with every pair of the domain. + "DROP TRIGGER IF EXISTS candidates_update_status_pairs", + _trigger( + "candidates_update_status_pairs", + "BEFORE UPDATE", + "candidates", + "Doc 06 §5, SM-002: illegal candidate status transition", + f"NOT ({_pair_condition()})", + ), + ) + + DDL: tuple[str, ...] = ( *_TABLES, *_triggers(), *_EVIDENCE_TABLE_DDL, *_evidence_triggers(), + *_schema6(), ) @@ -868,14 +998,14 @@ def _refuse_other_versions(conn: sqlite3.Connection, path: Path) -> None: def initialize_database(path: str | Path) -> None: - """Create the schema-5 tables and triggers and record the version. Idempotent; creates the - parent directory. Refuses a database whose schema version is not 5 and changes nothing then.""" + """Create the schema-6 tables and triggers and record the version. Idempotent; creates the + parent directory. Refuses a database whose schema version is not 6 and changes nothing then.""" db_path = Path(path) db_path.parent.mkdir(parents=True, exist_ok=True) try: with closing(open_connection(db_path)) as conn: if conn.execute("SELECT json_valid('[]')").fetchone()[0] != 1: - raise PersistenceError("this SQLite build has no JSON1; schema 5 needs it") + raise PersistenceError("this SQLite build has no JSON1; schema 6 needs it") conn.execute("BEGIN IMMEDIATE") try: _refuse_other_versions(conn, db_path) diff --git a/core/persistence/sqlite.py b/core/persistence/sqlite.py index e516a08..de7241d 100644 --- a/core/persistence/sqlite.py +++ b/core/persistence/sqlite.py @@ -36,10 +36,14 @@ domain constructors and cross-checks the columns (A4). A validity transition is checked by ``check_transition`` (C-52) and appends its EVIDENCE_VALIDITY_CHANGED event in the same step. -``checkpoint`` is a test-only fault-injection seam. If given, it is called with a name at four -points of a state write and of the commit: ``"after_trusted_state_insert"``, -``"after_invariant_refs_insert"``, ``"after_lineage_head_update"`` and ``"before_commit"``. -Production code passes ``None``. +``checkpoint`` is a test-only fault-injection seam. If given, it is called with a name at seven +points of a promotion: ``"after_trusted_state_insert"``, ``"after_invariant_refs_insert"``, +``"after_lineage_head_update"``, ``"before_commit"``, and (P6b) ``"after_decision_insert"``, +``"after_candidate_update"`` and ``"after_decision_commit"``. Production code passes ``None``. + +Promotion decisions (P6b, schema 6) are stored uncommitted; ``commit`` is their one change, from no +state to the state they created (DATA-INT-004), and a repeat of a stored decision with identical +content changes nothing (C-163). """ from __future__ import annotations @@ -92,10 +96,12 @@ validity_changed_payload, ) from core.domain.hashing import canonical_json +from core.domain.ids import require_uuid from core.domain.invariant import Invariant, InvariantRef from core.domain.jsonvalue import iso_utc, parse_enum, parse_utc from core.domain.lifecycle import check_candidate_transition from core.domain.patch import Patch +from core.domain.promotion import ConflictingDuplicateError, PromotionDecision from core.domain.repositories import UnitOfWork from core.domain.resource import Resource from core.domain.state import CandidateState, TrustedState @@ -107,6 +113,13 @@ "after_lineage_head_update", "before_commit", ) +# P6b: the points of the decision repository and of a candidate update (next to the four above) +DECISION_CHECKPOINTS = ( + "after_decision_insert", + "after_candidate_update", + "after_decision_commit", +) +ALL_CHECKPOINTS = (*CHECKPOINTS, *DECISION_CHECKPOINTS) _CANDIDATE_IDENTITY = ( "candidate_id", @@ -644,6 +657,7 @@ def save_transition(self, new: CandidateState) -> None: raise PersistenceError( f"save_transition: candidate {new.candidate_id} is no longer {old.status}" ) + self._s.hit("after_candidate_update") if building_to_ready: for resource in new.resources: _store_resource(conn, resource) @@ -687,6 +701,146 @@ def _check_lineage_rules(self, old: CandidateState, new: CandidateState) -> None ) +# --- promotion decisions ----------------------------------------------------------------------- + +_DECISION_COLUMNS = ( + "decision_id, candidate_id, parent_state_id, decision, reason, policy_version, attempt_number, " + "required_checks_json, blocking_conditions_json, supporting_results_json, content_hash, " + "created_at, committed_state_id" +) + + +def _decision_from_row(row: sqlite3.Row) -> PromotionDecision: + """The column ``content_hash`` is the hash of the decision as it was stored, uncommitted; the + commit changes only ``committed_state_id``, so a committed decision is rebuilt from the stored + one and then committed again in the domain, which recomputes its own hash.""" + what = f"promotion decision {row['decision_id']}" + data = { + "decision_id": row["decision_id"], + "candidate_id": row["candidate_id"], + "parent_state_id": row["parent_state_id"], + "decision": row["decision"], + "reason": row["reason"], + "policy_version": row["policy_version"], + "attempt_number": row["attempt_number"], + "required_checks": _loads(row["required_checks_json"], what), + "blocking_conditions": _loads(row["blocking_conditions_json"], what), + "supporting_results": _loads(row["supporting_results_json"], what), + "created_at": row["created_at"], + "committed_state_id": None, + "content_hash": row["content_hash"], + } + decision = _rebuild(what, PromotionDecision.from_dict, data) + if row["committed_state_id"] is not None: + stored = decision + decision = _rebuild(what, lambda _: stored.committed(row["committed_state_id"]), None) + _require_equal( + what, + dict(row), + { + "candidate_id": decision.candidate_id, + "parent_state_id": decision.parent_state_id, + "decision": decision.decision.value, + "reason": decision.reason.value, + "policy_version": decision.policy_version, + "attempt_number": decision.attempt_number, + "committed_state_id": decision.committed_state_id, + }, + ) + return decision + + +def _same_decision(stored: PromotionDecision, incoming: PromotionDecision) -> bool: + """Identical in content (the hash excludes the id and the clock): an uncommitted repeat of a + decision that has since been committed is the same decision (C-163).""" + if stored.content_hash == incoming.content_hash: + return True + return ( + stored.committed_state_id is not None + and incoming.committed_state_id is None + and incoming.committed(stored.committed_state_id).content_hash == stored.content_hash + ) + + +class _Decisions: + def __init__(self, session: _Session) -> None: + self._s = session + + def save(self, decision: PromotionDecision) -> None: + if not isinstance(decision, PromotionDecision): + raise DomainValidationError("save: expected a PromotionDecision") + if decision.committed_state_id is not None: + raise DomainValidationError( + "save: a decision is stored uncommitted; only commit sets its state (DATA-INT-004)" + ) + with _guard("save promotion decision"), self._s.atomic(): + existing = self.get(decision.decision_id) + if existing is not None: + if _same_decision(existing, decision): + return + raise ConflictingDuplicateError( + f"decision {decision.decision_id} was already stored with different content " + "(C-163, Doc 09 §38)" + ) + self._s.conn.execute( + f"INSERT INTO promotion_decisions ({_DECISION_COLUMNS}) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)", + ( + decision.decision_id, + decision.candidate_id, + decision.parent_state_id, + decision.decision.value, + decision.reason.value, + decision.policy_version, + decision.attempt_number, + canonical_json([c.to_dict() for c in decision.required_checks]), + canonical_json([c.to_dict() for c in decision.blocking_conditions]), + canonical_json(list(decision.supporting_results)), + decision.content_hash, + iso_utc(decision.created_at), + ), + ) + self._s.hit("after_decision_insert") + + def get(self, decision_id: str) -> PromotionDecision | None: + with _guard("read promotion decision"): + row = self._s.conn.execute( + f"SELECT {_DECISION_COLUMNS} FROM promotion_decisions WHERE decision_id = ?", + (decision_id,), + ).fetchone() + return None if row is None else _decision_from_row(row) + + def for_candidate(self, candidate_id: str) -> tuple[PromotionDecision, ...]: + with _guard("list promotion decisions"): + rows = self._s.conn.execute( + f"SELECT {_DECISION_COLUMNS} FROM promotion_decisions WHERE candidate_id = ? " + "ORDER BY rowid", + (candidate_id,), + ).fetchall() + return tuple(_decision_from_row(row) for row in rows) + + def commit(self, decision_id: str, state_id: str) -> PromotionDecision: + require_uuid(decision_id, "commit.decision_id") + require_uuid(state_id, "commit.state_id") + with _guard("commit promotion decision"), self._s.atomic(): + stored = self.get(decision_id) + if stored is None: + raise PersistenceError(f"commit: decision {decision_id} not found") + expected = stored.committed(state_id) # DomainValidationError: not a fresh PROMOTE + moved = self._s.conn.execute( + "UPDATE promotion_decisions SET committed_state_id = ? " + "WHERE decision_id = ? AND committed_state_id IS NULL", + (state_id, decision_id), + ) + if moved.rowcount != 1: + raise PersistenceError(f"commit: decision {decision_id} is no longer uncommitted") + self._s.hit("after_decision_commit") + done = self.get(decision_id) + if done != expected: + raise PersistenceError(f"commit: decision {decision_id} reads back differently") + return done + + # --- patches and invariants -------------------------------------------------------------------- @@ -1339,6 +1493,7 @@ def __init__( self._invariants: _Invariants | None = None self._evidence: _Evidence | None = None self._audit: _Audit | None = None + self._decisions: _Decisions | None = None def __enter__(self) -> SqliteUnitOfWork: if self._session is not None: @@ -1376,6 +1531,7 @@ def __enter__(self) -> SqliteUnitOfWork: audit = _Audit(session) self._audit = audit self._evidence = _Evidence(session, audit) + self._decisions = _Decisions(session) return self def __exit__( @@ -1409,6 +1565,7 @@ def __exit__( self._invariants = None self._evidence = None self._audit = None + self._decisions = None @staticmethod def _rollback(conn: sqlite3.Connection) -> None: @@ -1444,6 +1601,10 @@ def evidence(self) -> _Evidence: def audit(self) -> _Audit: return self._open(self._audit) + @property + def decisions(self) -> _Decisions: + return self._open(self._decisions) + def _as_port(uow: SqliteUnitOfWork) -> UnitOfWork: """Type-checked proof that the adapter satisfies the domain's ``UnitOfWork`` port.""" diff --git a/core/promotion/__init__.py b/core/promotion/__init__.py index e9c4f7c..dc5c751 100644 --- a/core/promotion/__init__.py +++ b/core/promotion/__init__.py @@ -7,8 +7,15 @@ P6a is the decision half: ``decide`` is a pure function of its arguments that returns PROMOTE, REJECT, RETRY or ESCALATE with every blocking reason; ``lifecycle.candidate_after`` applies the -decision to the candidate; ``policy`` builds the DAIL and Stateful-Full policies. Nothing here -commits anything or calls ``TrustedState.promote`` (P6b does). +decision to the candidate; ``policy`` builds the DAIL and Stateful-Full policies. None of these +commits anything or calls ``TrustedState.promote``. + +P6b is the commit half: ``commit.record_decision`` makes every decision durable and moves the +candidate; ``commit.commit_promotion`` turns a PROMOTE into the next Trusted State, once, from the +current parent only, in one transaction (``commit`` is the only module that calls +``TrustedState.promote`` and ``mark_promoted``, R8). ``binding`` checks that the evidence of a proof +binds to the new state (C-164), ``proofs`` and ``newstate`` build what ``promote`` is given, and +``ids`` derives the state, evidence and event ids from the decision id (C-163). Import restrictions (tests/contract/test_architecture_boundaries.py): standard library and ``core.domain.*`` only (R18, C-157). In particular it never imports ``core.verification``: it diff --git a/core/promotion/binding.py b/core/promotion/binding.py new file mode 100644 index 0000000..cf2b426 --- /dev/null +++ b/core/promotion/binding.py @@ -0,0 +1,284 @@ +"""The evidence binding of a promotion (P6b; Doc 11 §8, §9, §44; Doc 09 §27; C-52, C-60, C-68, +C-164). + +C-60 only checks that the evidence a trusted state links to exists. Whether it may serve as proof +for that state is decided here, at the commit, with ``evaluate_proof`` (conditions P1 to P6): + +* a VERIFIED reference rests on VERIFICATION evidence produced for the candidate: bound to it, with + its state hash, whose subject (the invariant, its version, the verifier and the result) is the one + the proof plan names; +* a CARRIED_FORWARD reference rests on the parent state: for a baseline, its evidence is bound to + the baseline; for a promoted parent, the promotion binding record of that parent (a STATE-kind + record bound to it, event name ``promotion.committed``) is, and it names the invariant. + +So the chain never needs evidence to be valid in a context it was not produced for: the commit +writes one new binding record bound to the new state (``binding_payload``), and the next promotion +carries from that record. Every check fails closed: a missing record, a record that does not +resolve, a tampered payload, a wrong hash or a wrong subject is a problem, and a problem stops the +commit. Standard library and ``core.domain`` only (R18). +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any, Final + +from core.domain import hashing +from core.domain.enums import VerificationResult +from core.domain.errors import DomainValidationError +from core.domain.evidence import ( + ArtifactRef, + BrokenReferenceError, + EvidenceContext, + EvidenceEvent, + EvidenceKind, + IntegrityStatus, + evaluate_proof, +) +from core.domain.invariant import Invariant, InvariantRef +from core.domain.promotion import PromotionDecision, PromotionEvidence, ProofPlanEntry, ProofSource +from core.domain.repositories import EvidenceRepository +from core.domain.state import CandidateState, TrustedState +from core.domain.verification import VerificationResultRecord +from core.promotion import ids + +BINDING_EVENT_NAME: Final = "promotion.committed" + + +def integrity_of(evidence: EvidenceRepository, event: EvidenceEvent) -> IntegrityStatus: + """Doc 11 §45: does the payload the record names resolve, and is it the text that was hashed? + The same answer as ``EvidenceService.verify_integrity`` (a test shows they agree), computed from + the repository port so that this package never imports ``evidence/``.""" + try: + payload = evidence.resolve(ArtifactRef.parse(event.payload_ref)) + except BrokenReferenceError: + return IntegrityStatus.MISSING_PAYLOAD + except DomainValidationError: # the stored text is not valid JSON, or not canonical + return IntegrityStatus.TAMPERED + if hashing.content_hash(payload) != event.content_hash: + return IntegrityStatus.TAMPERED + return IntegrityStatus.VALID + + +def _fetch( + evidence: EvidenceRepository, evidence_id: str, label: str +) -> tuple[EvidenceEvent | None, list[str]]: + """The record, or why it cannot be read: a record whose stored columns disagree with its + content (DATA-INT-010) is a binding problem, not an infrastructure failure.""" + try: + return evidence.get(evidence_id), [] + except DomainValidationError as unreadable: + return None, [f"{label}: the evidence record cannot be read ({unreadable})"] + + +def _payload(evidence: EvidenceRepository, event: EvidenceEvent) -> Mapping[str, Any] | None: + try: + value = evidence.resolve(ArtifactRef.parse(event.payload_ref)) + except DomainValidationError: + return None + return value if isinstance(value, Mapping) else None + + +def _proof_problems( + evidence: EvidenceRepository, + event: EvidenceEvent | None, + context: EvidenceContext, + state_hash: str, + label: str, +) -> list[str]: + """The reasons ``event`` may not serve as proof in ``context`` (none if it may).""" + if event is None: + return [f"{label}: the evidence record does not exist"] + check = evaluate_proof( + event, + context, + evidence.transitions(event.evidence_id), + integrity_of(evidence, event), + state_hash, + ) + return [] if check.usable else [f"{label}: {reason}" for reason in check.reasons] + + +def verified_problems( + evidence: EvidenceRepository, + entry: ProofPlanEntry, + candidate: CandidateState, + definition: Invariant | None, + result: VerificationResultRecord | None, + evidence_id: str | None, +) -> list[str]: + """Why the evidence of a VERIFIED entry is not proof for this candidate (C-164, C-68).""" + label = f"{entry.target_id} (VERIFIED)" + if candidate.state_hash is None: + return [f"{label}: the candidate has no state_hash"] + if result is None or evidence_id is None: + return [f"{label}: no verification result or no evidence record for it"] + if definition is None or definition.version != entry.invariant_version: + return [f"{label}: no definition of that version to check the verifier against"] + problems: list[str] = [] + if ( + result.result is not VerificationResult.PASS + or result.candidate_hash != candidate.state_hash + ): + problems.append(f"{label}: the result is not a PASS of this candidate's hash") + event, problems_read = _fetch(evidence, evidence_id, label) + problems += problems_read + if event is None: + return problems or [f"{label}: the evidence record does not exist"] + problems += _proof_problems( + evidence, + event, + EvidenceContext.candidate(candidate.candidate_id), + candidate.state_hash, + label, + ) + if event.kind is not EvidenceKind.VERIFICATION or event.candidate_id != candidate.candidate_id: + problems.append(f"{label}: the record is not VERIFICATION evidence bound to the candidate") + payload = _payload(evidence, event) + if payload is not None: + subject = { + "verification_id": entry.verification_id, + "invariant_id": entry.invariant_id, + "invariant_version": entry.invariant_version, + "verifier_id": definition.verifier_id, + "verifier_version": definition.verifier_version, + "candidate_id": candidate.candidate_id, + "candidate_hash": candidate.state_hash, + "result": VerificationResult.PASS.value, + } + for key, expected in subject.items(): + if payload.get(key) != expected: + problems.append(f"{label}: the record's {key} is not {expected!r} (C-68)") + return problems + + +def carried_problems( + evidence: EvidenceRepository, + entry: ProofPlanEntry, + parent: TrustedState, + reference: InvariantRef | None, +) -> list[str]: + """Why the parent's evidence is not proof for a CARRIED_FORWARD entry (C-164).""" + label = f"{entry.target_id} (CARRIED_FORWARD)" + if reference is None: + return [f"{label}: the parent state has no reference to it"] + if set(entry.evidence_ids) != set(reference.evidence_ids) or not reference.evidence_ids: + return [f"{label}: the plan's evidence is not the parent reference's evidence"] + context = EvidenceContext.state(parent.state_id) + if parent.version == 0: + problems: list[str] = [] + for evidence_id in reference.evidence_ids: + found, unread = _fetch(evidence, evidence_id, label) + problems += unread or _proof_problems( + evidence, found, context, parent.state_hash, label + ) + return problems + assert parent.commit_decision_id is not None # a promoted state names its decision + binding_id = ids.evidence_id_for(parent.commit_decision_id) + if binding_id not in reference.evidence_ids: + return [f"{label}: the parent reference does not carry the parent's binding record"] + event, problems = _fetch(evidence, binding_id, label) + if event is None: + return problems or [f"{label}: the parent's binding record does not exist"] + problems += _proof_problems(evidence, event, context, parent.state_hash, label) + if event.kind is not EvidenceKind.STATE or event.event_name != BINDING_EVENT_NAME: + problems.append( + f"{label}: the parent's binding record is not a {BINDING_EVENT_NAME} record" + ) + payload = _payload(evidence, event) + if payload is not None: + problems += _binding_payload_problems(payload, entry, parent, label) + return problems + + +def _binding_payload_problems( + payload: Mapping[str, Any], entry: ProofPlanEntry, parent: TrustedState, label: str +) -> list[str]: + decision = payload.get("decision") + promoted = payload.get("promotion_evidence") + named = payload.get("invariants") + problems: list[str] = [] + if ( + not isinstance(decision, Mapping) + or decision.get("decision_id") != parent.commit_decision_id + ): + problems.append(f"{label}: the binding record names another decision") + if not isinstance(promoted, Mapping) or promoted.get("new_state_id") != parent.state_id: + problems.append(f"{label}: the binding record names another state") + found = isinstance(named, list) and any( + isinstance(item, Mapping) + and item.get("invariant_id") == entry.invariant_id + and item.get("invariant_version") == entry.invariant_version + for item in named + ) + if not found: + problems.append(f"{label}: the binding record does not name this invariant") + return problems + + +def binding_problems( + evidence: EvidenceRepository, + plan: Sequence[ProofPlanEntry], + candidate: CandidateState, + parent: TrustedState, + definitions: Mapping[str, Invariant], + results: Mapping[str, VerificationResultRecord], + evidence_ids: Mapping[str, str], +) -> list[str]: + """Every reason the evidence of ``plan`` does not bind to the new state (empty: it does).""" + references = {r.invariant_id: r for r in parent.invariant_refs} + problems: list[str] = [] + for entry in plan: + if entry.source is ProofSource.VERIFIED: + key = entry.verification_id or "" + problems += verified_problems( + evidence, + entry, + candidate, + definitions.get(entry.invariant_id), + results.get(key), + evidence_ids.get(key), + ) + else: + problems += carried_problems( + evidence, entry, parent, references.get(entry.invariant_id) + ) + return problems + + +def binding_payload( + decision: PromotionDecision, + promotion_evidence: PromotionEvidence, + plan: Sequence[ProofPlanEntry], + new_state: TrustedState, + evidence_ids: Mapping[str, str], +) -> dict[str, Any]: + """The payload of the binding record: the committed decision, its evidence naming the new + state, the state, and for every invariant its source (C-164).""" + invariants: list[dict[str, Any]] = [] + for entry in sorted(plan, key=lambda e: e.invariant_id): + verified = entry.source is ProofSource.VERIFIED + invariants.append( + { + "invariant_id": entry.invariant_id, + "invariant_version": entry.invariant_version, + "source": entry.source.value, + "source_evidence_ids": ( + [evidence_ids[entry.verification_id or ""]] + if verified + else sorted(entry.evidence_ids) + ), + "source_state_id": entry.source_state_id, + } + ) + return { + "decision": decision.to_dict(), + "promotion_evidence": promotion_evidence.to_dict(), + "state": { + "state_id": new_state.state_id, + "version": new_state.version, + "parent_state_id": new_state.parent_state_id, + "state_hash": new_state.state_hash, + }, + "invariants": invariants, + } diff --git a/core/promotion/commit.py b/core/promotion/commit.py new file mode 100644 index 0000000..9bb2c8e --- /dev/null +++ b/core/promotion/commit.py @@ -0,0 +1,758 @@ +"""The promotion commit: the one place where a decision becomes Trusted State (P6b; Doc 09 §26, §27, +§38-§40, Appendix B; Doc 06 §20, §21, §27; Doc 05 §15, §22, §32; Doc 11 §26, §43; C-38, C-163 to +C-168). + +``record_decision`` makes every decision durable (Doc 09 §40): it stores the decision, moves the +candidate along the exit of Doc 06 §5 that the decision names and appends the audit events. For a +PROMOTE the candidate becomes PROMOTABLE and nothing else changes: no trusted state is touched. + +``commit_promotion`` turns a PROMOTE into the next Trusted State, once, from the current parent +only. In one unit of work (``BEGIN IMMEDIATE``, so the write lock is taken first, C-166): + + 1. an existing decision with this id is a duplicate (C-163) or a conflict; + 2. the candidate and the current state are read from the database; a candidate whose parent is + no longer the current state lost the race (C-38 case 1); + 3. ``decide`` is run again with the same inputs and must give the same decision and the same + proof plan (C-167, Appendix B ``validate_decision``); + 4. the evidence of the plan is checked to bind to the new state (C-164, ``binding``); + 5. the proofs, the new state, the binding record and the committed decision are built; nothing + is written yet, so a refusal here leaves no trace; + 6. writes, in this order, each a seam for a fault (``core.persistence.sqlite`` checkpoints): + the decision (``after_decision_insert``), the binding record, the state and its references + and the lineage head (``after_trusted_state_insert``, ``after_invariant_refs_insert``, + ``after_lineage_head_update``), the candidate PROMOTED (``after_candidate_update``), the + decision committed to the state (``after_decision_commit``), the STATE_PROMOTED event, + ``before_commit``. + +A refusal in 3 to 5 rolls everything back and escalates the candidate in a transaction of its own +(C-38 case 2); a lost race stores a REJECT decision and rejects the candidate in its own +transaction (C-38 case 1, C-166); any other failure rolls back, leaves the candidate PROMOTABLE and +raises ``PromotionCommitError`` after a best-effort PROMOTION_FAILED event (C-38 case 3). The +exception is never swallowed inside the transaction. This module is the only caller of +``TrustedState.promote`` and ``mark_promoted`` (R8). Standard library and ``core.domain`` only +(R18). +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from core.domain.audit import ActorType, AuditEventType, AuditSubmission +from core.domain.enums import ( + CandidateStatus, + DecisionType, + ProvenanceSourceKind, +) +from core.domain.errors import DomainValidationError, PersistenceError, StaleParentError +from core.domain.evidence import EvidenceKind, EvidenceSubmission +from core.domain.ids import require_uuid +from core.domain.impact import ImpactReport +from core.domain.invariant import Invariant +from core.domain.jsonvalue import optional_text, utc +from core.domain.promotion import ( + BlockingCondition, + ConflictingDuplicateError, + DecisionOutcome, + DecisionReason, + OracleVerdict, + PromotionDecision, + PromotionInputError, + PromotionPolicy, + ProofPlanEntry, + ProofSource, +) +from core.domain.repositories import UnitOfWork +from core.domain.state import ( + CandidateState, + TrustedState, + mark_promoted, + transition_candidate, +) +from core.domain.values import Provenance +from core.domain.verification import VerificationReport +from core.promotion import ids +from core.promotion.binding import BINDING_EVENT_NAME, binding_payload, binding_problems +from core.promotion.decide import decide +from core.promotion.lifecycle import candidate_after +from core.promotion.newstate import promotion_arguments +from core.promotion.proofs import build_proofs, evaluate_from_results + +D = DecisionType +R = DecisionReason + +_COMPONENT = "core.promotion.commit" +_VERSION = "1" +_REASON_LIMIT = 500 + + +class PromotionCommitError(PersistenceError): + """The commit failed for a reason that is not a decision (a database error, an I/O error, an + injected fault). Everything was rolled back; the candidate is still PROMOTABLE and the same + commit may be retried (C-38 case 3, C-163).""" + + +@dataclass(frozen=True) +class PromotionContext: + """Who is committing: the ids that tie the evidence and audit records to one workflow.""" + + run_id: str + correlation_id: str + operation_id: str + attempt_id: str + actor_id: str | None = None + + def __post_init__(self) -> None: + for name in ("run_id", "correlation_id", "operation_id", "attempt_id"): + require_uuid(getattr(self, name), f"PromotionContext.{name}") + optional_text(self.actor_id, "PromotionContext.actor_id") + + +@dataclass(frozen=True) +class CommitResult: + """What ``commit_promotion`` returns. ``state`` is the new Trusted State, or ``None`` when the + commit did not promote. ``duplicate``: the decision had been committed before and nothing was + written. ``stale``: the parent was no longer current, ``decision`` is the stored REJECT. + ``escalated``: the decision no longer held, the candidate is ESCALATED.""" + + decision: PromotionDecision + state: TrustedState | None = None + duplicate: bool = False + stale: bool = False + escalated: bool = False + + +class _Lost(Exception): + """The candidate's parent is not the current state.""" + + +class _Invalid(Exception): + """The decision no longer holds, or its evidence does not bind (revalidation failure).""" + + +# --- audit events --------------------------------------------------------------------------------- + + +def _event( + event_id: str, + event_type: AuditEventType, + context: PromotionContext, + when: datetime, + payload: Mapping[str, Any], + *, + state_id: str | None = None, + candidate_id: str | None = None, + decision_id: str | None = None, +) -> AuditSubmission: + return AuditSubmission.create( + event_id=event_id, + event_type=event_type, + correlation_id=context.correlation_id, + operation_id=context.operation_id, + timestamp=when, + payload=dict(payload), + actor_type=ActorType.SYSTEM, + actor_id=context.actor_id, + state_id=state_id, + candidate_id=candidate_id, + decision_id=decision_id, + ) + + +def _append_once(uow: UnitOfWork, submission: AuditSubmission) -> None: + """Append unless the event is there: a retry must not trip over its own earlier event.""" + if uow.audit.get(submission.event_id) is None: + uow.audit.append(submission) + + +def _decided_events( + uow: UnitOfWork, decision: PromotionDecision, context: PromotionContext +) -> None: + """PROMOTION_DECIDED, and for a non-PROMOTE the event Doc 11 §12 names.""" + base = { + "candidate_id": decision.candidate_id, + "decision_id": decision.decision_id, + } + _append_once( + uow, + _event( + ids.decided_event_id_for(decision.decision_id), + AuditEventType.PROMOTION_DECIDED, + context, + decision.created_at, + { + "parent_state_id": decision.parent_state_id, + "decision": decision.decision.value, + "reason": decision.reason.value, + "policy_version": decision.policy_version, + "attempt_number": decision.attempt_number, + }, + **base, + ), + ) + outcome_id = ids.outcome_event_id_for(decision.decision_id) + conditions = [c.to_dict() for c in decision.blocking_conditions] + if decision.decision is D.REJECT: + event = _event( + outcome_id, + AuditEventType.CANDIDATE_REJECTED, + context, + decision.created_at, + { + "parent_state_id": decision.parent_state_id, + "reason": decision.reason.value, + "blocking_conditions": conditions, + }, + **base, + ) + elif decision.decision is D.RETRY: + event = _event( + outcome_id, + AuditEventType.RETRY_SCHEDULED, + context, + decision.created_at, + { + "attempt_id": context.attempt_id, + "parent_state_id": decision.parent_state_id, + "reason": decision.reason.value, + "blocking_conditions": conditions, + }, + **base, + ) + elif decision.decision is D.ESCALATE: + event = _event( + outcome_id, + AuditEventType.ESCALATED, + context, + decision.created_at, + {"reason": decision.reason.value, "unresolved_conditions": conditions}, + **base, + ) + else: + return + _append_once(uow, event) + + +# --- record_decision ------------------------------------------------------------------------------ + + +def record_decision( + uow: UnitOfWork, + outcome: DecisionOutcome, + candidate: CandidateState, + context: PromotionContext, + *, + now: datetime, +) -> CandidateState: + """Store the decision and move the candidate along its exit (Doc 09 §40; C-38). + + Every decision is stored with all its blocking conditions and every one moves the candidate: + PROMOTE to PROMOTABLE, REJECT to REJECTED, RETRY to RETRY_REQUIRED, ESCALATE to ESCALATED. The + trusted state is read and never written (SM-001, SM-003, SM-004). Recording the same decision + again changes nothing; the same id with another content raises ``ConflictingDuplicateError``. + ``uow`` is open; the caller's ``with`` commits. Returns the candidate as stored afterwards.""" + if not isinstance(outcome, DecisionOutcome): + raise PromotionInputError("record_decision: outcome must be a DecisionOutcome") + if not isinstance(candidate, CandidateState): + raise PromotionInputError("record_decision: candidate must be a CandidateState") + if not isinstance(context, PromotionContext): + raise PromotionInputError("record_decision: context must be a PromotionContext") + utc(now, "record_decision.now") + decision = outcome.decision + if decision.committed_state_id is not None: + raise PromotionInputError("record_decision: a committed decision cannot be recorded again") + if decision.candidate_id != candidate.candidate_id: + raise PromotionInputError("record_decision: the decision is about another candidate") + stored = uow.candidates.get(candidate.candidate_id) + if stored is None: + raise PromotionInputError("record_decision: the candidate is not stored") + existing = uow.decisions.get(decision.decision_id) + if existing is not None: + if existing.content_hash != decision.content_hash and ( + existing.committed_state_id is None + or existing.content_hash != decision.committed(existing.committed_state_id).content_hash + ): + raise ConflictingDuplicateError( + f"decision {decision.decision_id} was already stored with different content " + "(C-163, Doc 09 §38)" + ) + return stored + trusted = uow.trusted_states.get_current(stored.lineage_id) + if trusted is None: + raise PromotionInputError("record_decision: the candidate's lineage has no current state") + moved = candidate_after(decision, stored, trusted) + uow.decisions.save(decision) + uow.candidates.save_transition(moved) + _decided_events(uow, decision, context) + return moved + + +# --- commit_promotion ----------------------------------------------------------------------------- + + +def _check_arguments( + factory: object, + outcome: object, + candidate: object, + impact: object, + verification: object, + policy: object, + evidence_ids: object, + context: object, +) -> None: + for value, kind, name in ( + (outcome, DecisionOutcome, "outcome"), + (candidate, CandidateState, "candidate"), + (impact, ImpactReport, "impact"), + (verification, VerificationReport, "verification"), + (policy, PromotionPolicy, "policy"), + (context, PromotionContext, "context"), + ): + if not isinstance(value, kind): + raise PromotionInputError(f"commit_promotion: {name} must be a {kind.__name__}") + if not callable(factory): + raise PromotionInputError("commit_promotion: factory must open a unit of work") + if not isinstance(evidence_ids, Mapping): + raise PromotionInputError("commit_promotion: evidence_ids must be a mapping") + assert isinstance(outcome, DecisionOutcome) and isinstance(candidate, CandidateState) + if outcome.decision.decision is not D.PROMOTE: + raise PromotionInputError( + f"commit_promotion: only a PROMOTE can be committed, not {outcome.decision.decision}" + ) + if outcome.decision.committed_state_id is not None: + raise PromotionInputError("commit_promotion: the outcome's decision is already committed") + if outcome.decision.candidate_id != candidate.candidate_id: + raise PromotionInputError("commit_promotion: the decision is about another candidate") + + +def commit_promotion( + factory: Callable[[], UnitOfWork], + outcome: DecisionOutcome, + candidate: CandidateState, + impact: ImpactReport, + verification: VerificationReport, + policy: PromotionPolicy, + evidence_ids: Mapping[str, str], + context: PromotionContext, + *, + now: datetime, + attempt_number: int | None = None, + oracle_verdicts: Iterable[OracleVerdict] = (), +) -> CommitResult: + """Commit a PROMOTE decision as the next Trusted State, once (see the module docstring). + + ``factory`` opens a unit of work on the database; it is called once for the commit and once + more for each outcome that is not a promotion. ``evidence_ids`` maps each ``verification_id`` + of the report to the id of the evidence record that stores that result. ``impact``, + ``verification`` and ``policy`` are the inputs of the decision: ``decide`` is run on them again + at the commit. Raises ``PromotionInputError`` for malformed arguments, + ``ConflictingDuplicateError`` for a decision id or a candidate that is already used + differently, and ``PromotionCommitError`` for an infrastructure failure.""" + _check_arguments( + factory, outcome, candidate, impact, verification, policy, evidence_ids, context + ) + moment = utc(now, "commit_promotion.now") + attempt = outcome.decision.attempt_number if attempt_number is None else attempt_number + verdicts = tuple(oracle_verdicts) + try: + try: + with factory() as uow: + return _attempt( + uow, + outcome, + impact, + verification, + policy, + evidence_ids, + context, + moment, + attempt, + verdicts, + ) + except _Lost as lost: + return _lose(factory, outcome, context, moment, str(lost)) + except _Invalid as invalid: + return _escalate(factory, outcome, context, moment, str(invalid)) + except (ConflictingDuplicateError, PromotionInputError): + raise + except Exception as failure: + _record_failure(factory, outcome, context, moment, failure) + raise PromotionCommitError( + f"the promotion of decision {outcome.decision.decision_id} could not be committed " + f"({type(failure).__name__}); everything was rolled back, the candidate is still " + "PROMOTABLE and the same commit can be retried (C-38, C-163)" + ) from failure + + +def _existing( + stored: PromotionDecision, decision: PromotionDecision, uow: UnitOfWork +) -> CommitResult | None: + """A decision with this id is stored: a committed twin returns its state (C-163), another + content is a conflict, an uncommitted twin lets the commit go on.""" + if stored.committed_state_id is None: + if stored.content_hash != decision.content_hash: + raise ConflictingDuplicateError( + f"decision {decision.decision_id} was already stored with different content " + "(C-163, Doc 09 §38)" + ) + return None + if stored.content_hash != decision.committed(stored.committed_state_id).content_hash: + raise ConflictingDuplicateError( + f"decision {decision.decision_id} was already committed with different content " + "(C-163, Doc 09 §38)" + ) + state = uow.trusted_states.get(stored.committed_state_id) + if state is None: + raise PersistenceError( + f"decision {stored.decision_id} names state {stored.committed_state_id}, which " + "is not stored" + ) + return CommitResult(stored, state, duplicate=True) + + +def _attempt( + uow: UnitOfWork, + outcome: DecisionOutcome, + impact: ImpactReport, + verification: VerificationReport, + policy: PromotionPolicy, + evidence_ids: Mapping[str, str], + context: PromotionContext, + now: datetime, + attempt_number: int, + oracle_verdicts: tuple[OracleVerdict, ...], +) -> CommitResult: + decision = outcome.decision + stored = uow.decisions.get(decision.decision_id) + if stored is not None: + found = _existing(stored, decision, uow) + if found is not None: + return found + else: + rejection = uow.decisions.get(ids.rejection_decision_id_for(decision.decision_id)) + if rejection is not None: + return CommitResult(rejection, stale=True) + others = [ + d + for d in uow.decisions.for_candidate(decision.candidate_id) + if d.committed_state_id is not None + ] + if others: + raise ConflictingDuplicateError( + f"candidate {decision.candidate_id} was already promoted by decision " + f"{others[0].decision_id} (C-163)" + ) + candidate = uow.candidates.get(decision.candidate_id) + if candidate is None: + raise _Invalid("the candidate is not stored") + current = uow.trusted_states.get_current(candidate.lineage_id) + if current is None: + raise _Invalid("the candidate's lineage has no current state") + if candidate.status is not CandidateStatus.PROMOTABLE: + raise _Invalid(f"the candidate is {candidate.status.value}, not PROMOTABLE") + if current.state_id != candidate.parent_state_id: + raise _Lost( + f"the candidate's parent {candidate.parent_state_id} is not the current state " + f"{current.state_id}" + ) + plan = _revalidate( + outcome, candidate, current, impact, verification, policy, attempt_number, oracle_verdicts + ) + definitions = _definitions(uow, plan) + results = {r.verification_id: r for r in verification.results} + problems = binding_problems( + uow.evidence, plan, candidate, current, definitions, results, evidence_ids + ) + if problems: + raise _Invalid("the evidence does not bind to the new state: " + "; ".join(problems)) + try: + built = _build( + outcome, + candidate, + current, + impact, + policy, + plan, + definitions, + results, + evidence_ids, + context, + now, + ) + except DomainValidationError as refusal: + raise _Invalid(f"the new state cannot be built: {refusal}") from refusal + state, committed, promoted, binding, marked = built + if stored is None: + uow.decisions.save(decision) + _decided_events(uow, decision, context) + uow.evidence.append(binding) + try: + uow.trusted_states.save_promoted(state) + except StaleParentError as moved: + raise _Lost(str(moved)) from moved + uow.candidates.save_transition(marked) + done = uow.decisions.commit(decision.decision_id, state.state_id) + if done != committed: + raise PersistenceError(f"decision {decision.decision_id} reads back differently") + _append_once( + uow, + _event( + ids.event_id_for(decision.decision_id), + AuditEventType.STATE_PROMOTED, + context, + now, + { + "parent_state_id": current.state_id, + "new_state_id": state.state_id, + "version": state.version, + "state_hash": state.state_hash, + "binding_evidence_id": binding.event.evidence_id, + "evidence_sufficiency": promoted.evidence_sufficiency, + }, + state_id=state.state_id, + candidate_id=candidate.candidate_id, + decision_id=decision.decision_id, + ), + ) + return CommitResult(done, state) + + +def _revalidate( + outcome: DecisionOutcome, + candidate: CandidateState, + current: TrustedState, + impact: ImpactReport, + verification: VerificationReport, + policy: PromotionPolicy, + attempt_number: int, + oracle_verdicts: tuple[OracleVerdict, ...], +) -> tuple[ProofPlanEntry, ...]: + """C-167: ``decide`` again, on the candidate and the state as the database holds them.""" + try: + fresh = decide( + candidate, + current, + impact, + verification, + policy, + decision_id=outcome.decision.decision_id, + now=outcome.decision.created_at, + attempt_number=attempt_number, + oracle_verdicts=oracle_verdicts, + ) + except DomainValidationError as refusal: + raise _Invalid(f"decide refuses its inputs now: {refusal}") from refusal + if ( + fresh.decision.content_hash != outcome.decision.content_hash + or fresh.proof_plan != outcome.proof_plan + or fresh.evidence.content_hash != outcome.evidence.content_hash + ): + raise _Invalid( + f"the decision no longer holds: deciding again gives {fresh.decision.decision.value} " + f"({fresh.decision.reason.value}) instead of the stored PROMOTE" + ) + return fresh.proof_plan + + +def _definitions(uow: UnitOfWork, plan: Iterable[ProofPlanEntry]) -> dict[str, Invariant]: + found: dict[str, Invariant] = {} + for entry in plan: + if entry.source is ProofSource.VERIFIED: + definition = uow.invariants.get_definition(entry.invariant_id, entry.invariant_version) + if definition is not None: + found[entry.invariant_id] = definition + return found + + +def _build( + outcome: DecisionOutcome, + candidate: CandidateState, + current: TrustedState, + impact: ImpactReport, + policy: PromotionPolicy, + plan: tuple[ProofPlanEntry, ...], + definitions: Mapping[str, Invariant], + results: Mapping[str, Any], + evidence_ids: Mapping[str, str], + context: PromotionContext, + now: datetime, +) -> tuple[TrustedState, PromotionDecision, Any, EvidenceSubmission, CandidateState]: + """Everything the commit will write, built before anything is (so a refusal leaves no trace).""" + decision = outcome.decision + binding_id = ids.evidence_id_for(decision.decision_id) + evaluations = evaluate_from_results( + plan, candidate, current, definitions, results, evidence_ids, binding_id + ) + proofs = build_proofs( + plan, candidate, current, impact, evaluations, binding_evidence_id=binding_id + ) + arguments = promotion_arguments( + candidate, + current, + decision.decision_id, + proofs, + registry_version=policy.invariant_registry_version, + now=now, + ) + state = TrustedState.promote(**arguments) + committed = decision.committed(state.state_id) + promoted = outcome.evidence.with_new_state(state.state_id) + binding = EvidenceSubmission.create( + payload=binding_payload(committed, promoted, plan, state, evidence_ids), + evidence_id=binding_id, + run_id=context.run_id, + attempt_id=context.attempt_id, + correlation_id=context.correlation_id, + operation_id=context.operation_id, + kind=EvidenceKind.STATE, + event_name=BINDING_EVENT_NAME, + provenance=Provenance( + source_kind=ProvenanceSourceKind.DERIVED, + source_component=_COMPONENT, + source_reference=decision.decision_id, + observed_at=now, + tool_name="promotion-controller", + tool_version=_VERSION, + algorithm_version=_VERSION, + ), + created_at=now, + state_id=state.state_id, + parent_state_id=current.state_id, + state_hash=state.state_hash, + ) + return state, committed, promoted, binding, mark_promoted(candidate, state) + + +# --- the outcomes that are not a promotion -------------------------------------------------------- + + +def _clip(text: str) -> str: + return text if len(text) <= _REASON_LIMIT else text[: _REASON_LIMIT - 3] + "..." + + +def _lose( + factory: Callable[[], UnitOfWork], + outcome: DecisionOutcome, + context: PromotionContext, + now: datetime, + detail: str, +) -> CommitResult: + """C-38 case 1, C-166: the commit lost the race. Store a REJECT decision (STALE_PARENT) and + reject the candidate; no state. The same loser again returns the stored REJECT.""" + decision = outcome.decision + rejection_id = ids.rejection_decision_id_for(decision.decision_id) + with factory() as uow: + existing = uow.decisions.get(rejection_id) + if existing is not None: + return CommitResult(existing, stale=True) + candidate = uow.candidates.get(decision.candidate_id) + current = ( + None if candidate is None else uow.trusted_states.get_current(candidate.lineage_id) + ) + if candidate is None or current is None: + raise PersistenceError("the stale candidate or its lineage is not stored") + rejection = PromotionDecision.create( + decision_id=rejection_id, + candidate_id=decision.candidate_id, + parent_state_id=decision.parent_state_id, + decision=D.REJECT, + reason=R.STALE_PARENT, + policy_version=decision.policy_version, + attempt_number=decision.attempt_number, + required_checks=(), + blocking_conditions=[BlockingCondition(R.STALE_PARENT, D.REJECT, "STATE", detail)], + supporting_results=(), + created_at=now, + ) + moved = candidate_after(rejection, candidate, current) + uow.decisions.save(rejection) + uow.candidates.save_transition(moved) + _decided_events(uow, rejection, context) + return CommitResult(rejection, stale=True) + + +def _escalate( + factory: Callable[[], UnitOfWork], + outcome: DecisionOutcome, + context: PromotionContext, + now: datetime, + detail: str, +) -> CommitResult: + """C-38 case 2: the decision no longer holds. The commit is rolled back; in a transaction of + its own the candidate is ESCALATED and PROMOTION_FAILED is appended. The PROMOTE stays as the + uncommitted record of what was decided.""" + decision = outcome.decision + reason = _clip(f"REVALIDATION: {detail}") + with factory() as uow: + candidate = uow.candidates.get(decision.candidate_id) + if candidate is None: + raise PersistenceError("the candidate is not stored") + if uow.decisions.get(decision.decision_id) is None: + uow.decisions.save(decision) + _decided_events(uow, decision, context) + if candidate.status in (CandidateStatus.ANALYZING, CandidateStatus.PROMOTABLE): + uow.candidates.save_transition( + transition_candidate(candidate, CandidateStatus.ESCALATED) + ) + _append_once( + uow, + _event( + ids.failure_event_id_for(decision.decision_id, "REVALIDATION"), + AuditEventType.PROMOTION_FAILED, + context, + now, + { + "parent_state_id": decision.parent_state_id, + "reason": reason, + "decision_id": decision.decision_id, + }, + candidate_id=decision.candidate_id, + ), + ) + _append_once( + uow, + _event( + ids.outcome_event_id_for(decision.decision_id), + AuditEventType.ESCALATED, + context, + now, + {"reason": reason, "unresolved_conditions": [reason]}, + candidate_id=decision.candidate_id, + ), + ) + return CommitResult(decision, escalated=True) + + +def _record_failure( + factory: Callable[[], UnitOfWork], + outcome: DecisionOutcome, + context: PromotionContext, + now: datetime, + failure: BaseException, +) -> None: + """C-38 case 3, best effort: a PROMOTION_FAILED event in a separate transaction. If that fails + too, nothing is recorded and nothing is claimed; the caller still gets the original error.""" + decision = outcome.decision + try: + with factory() as uow: + _append_once( + uow, + _event( + ids.failure_event_id_for(decision.decision_id, "INFRASTRUCTURE"), + AuditEventType.PROMOTION_FAILED, + context, + now, + { + "parent_state_id": decision.parent_state_id, + "reason": _clip( + f"INFRASTRUCTURE: {type(failure).__name__}: the commit was rolled back" + ), + "decision_id": decision.decision_id, + }, + candidate_id=decision.candidate_id, + ), + ) + except Exception: # best effort by C-38 case 3: the original failure is what the caller gets + return diff --git a/core/promotion/decide.py b/core/promotion/decide.py index 09f4e13..f8b44df 100644 --- a/core/promotion/decide.py +++ b/core/promotion/decide.py @@ -54,6 +54,7 @@ EVIDENCE_LEVELS, VerificationReport, VerificationResultRecord, + VerificationTargetType, fresh, sufficient, ) @@ -86,6 +87,13 @@ VerificationResult.VERIFIER_ERROR: R.VERIFIER_ERROR, } _DECIDABLE: Final = (CandidateStatus.ANALYZING, CandidateStatus.PROMOTABLE) +# the target type a result must have for each kind of required item (C-162) +_TYPE_OF_KIND: Final = { + "GATE": VerificationTargetType.STRUCTURAL, + "INVARIANT": VerificationTargetType.INVARIANT, + "UNDEFINED": VerificationTargetType.INVARIANT, + "BASELINE": VerificationTargetType.RESOURCE, +} def fold(conditions: Sequence[BlockingCondition]) -> tuple[DecisionType, DecisionReason]: @@ -315,7 +323,7 @@ def _carry_forward_refusals( def _assess( item: _Item, - records: Mapping[str, VerificationResultRecord], + by_id: Mapping[str, Sequence[VerificationResultRecord]], candidate: CandidateState, trusted: TrustedState, impact: ImpactReport, @@ -327,7 +335,13 @@ def _assess( verdict = _Verdict(RequiredCheck(item.target_id, _kind(item), None, None)) if item.kind == "UNDEFINED": return verdict # the policy condition already says what is wrong - record = records.get(item.target_id) + # C-162: the result is looked up by the type the kind requires, and every result that carries + # the target id is examined, so a FAIL under another type cannot be hidden + matches = by_id.get(item.target_id, ()) + right = [r for r in matches if r.target_type is _TYPE_OF_KIND[item.kind]] + if len(right) != len(matches) or len(right) > 1: + return _inconsistent(verdict, item, matches, right) + record = right[0] if right else None invariant = item.invariant verifier_id, verifier_version = item.verifier or ( (record.verifier_id, record.verifier_version) if record is not None else ("", "") @@ -376,6 +390,41 @@ def _assess( return verdict +def _inconsistent( + verdict: _Verdict, + item: _Item, + matches: Sequence[VerificationResultRecord], + right: Sequence[VerificationResultRecord], +) -> _Verdict: + """The report holds a second result for a required target id, or one of the wrong type: it is + inconsistent (REJECT), no result proves the item, and a FAIL among them stays blocking.""" + kinds = sorted({r.target_type.value for r in matches}) + verdict.conditions.append( + _condition( + R.INCONSISTENT_REPORTS, + D.REJECT, + item.target_id, + f"{len(matches)} results carry this target id (types {', '.join(kinds)}); " + f"{_TYPE_OF_KIND[item.kind].value} is the type it needs (C-162)", + ) + ) + verdict.conditions.extend( + _condition(_failure_code(item), D.REJECT, item.target_id, _described(r)) + for r in matches + if r.result is VerificationResult.FAIL + ) + if not right: + verdict.conditions.append( + _condition( + R.INSUFFICIENT_EVIDENCE, + D.REJECT, + item.target_id, + f"no result of type {_TYPE_OF_KIND[item.kind].value}", + ) + ) + return verdict + + def _kind(item: _Item) -> str: return "INVARIANT" if item.kind == "UNDEFINED" else item.kind @@ -548,9 +597,11 @@ def decide( return _build(candidate, policy, decision_id, moment, attempt_number, [], [stale], (), ()) items, conditions = _required_items(trusted, impact, policy) conditions += _binding_conditions(candidate, impact, verification) - records = {r.target_id: r for r in verification.results} + by_id: dict[str, list[VerificationResultRecord]] = {} + for result in verification.results: + by_id.setdefault(result.target_id, []).append(result) assessed = { - item.target_id: _assess(item, records, candidate, trusted, impact, policy) for item in items + item.target_id: _assess(item, by_id, candidate, trusted, impact, policy) for item in items } for verdict in assessed.values(): conditions += verdict.conditions diff --git a/core/promotion/ids.py b/core/promotion/ids.py new file mode 100644 index 0000000..d63f88e --- /dev/null +++ b/core/promotion/ids.py @@ -0,0 +1,69 @@ +"""Identifiers derived from a decision id (P6b; Doc 09 §38; C-163). + +The key of a promotion is its ``decision_id``. The new state id, the promotion-evidence id and the +STATE_PROMOTED audit event id are derived from it with ``uuid5`` under one fixed namespace, so the +same decision always names the same state, record and event, and a duplicate submission is +detected by the database as well as by code. Nothing here draws a random number or reads a clock +(R10); standard library and ``core.domain`` only (R18). + +Two further ids are derived the same way for the C-38 outcomes: the REJECT decision that a stale +loser stores (it needs an id of its own, because the PROMOTE decision keeps its) and the +PROMOTION_FAILED audit events. +""" + +from __future__ import annotations + +import uuid +from typing import Final + +from core.domain.errors import DomainValidationError +from core.domain.ids import require_uuid + +# A fixed constant: changing it would change every derived id (a golden value is pinned by a test). +PROMOTION_NAMESPACE: Final = uuid.UUID("c3b0e0d2-6f1a-4d8e-9a57-2b7c4e1f8a90") +FAILURE_KINDS: Final = ("REVALIDATION", "INFRASTRUCTURE") + + +def _derived(decision_id: str, prefix: str) -> str: + require_uuid(decision_id, "decision_id") + return str(uuid.uuid5(PROMOTION_NAMESPACE, prefix + decision_id)) + + +def state_id_for(decision_id: str) -> str: + """The id of the trusted state that the PROMOTE decision ``decision_id`` creates.""" + return _derived(decision_id, "state:") + + +def evidence_id_for(decision_id: str) -> str: + """The id of the promotion binding record (C-164) of that decision.""" + return _derived(decision_id, "promotion-evidence:") + + +def event_id_for(decision_id: str) -> str: + """The id of the STATE_PROMOTED audit event of that decision.""" + return _derived(decision_id, "state-promoted:") + + +def decided_event_id_for(decision_id: str) -> str: + """The id of the PROMOTION_DECIDED audit event of that decision.""" + return _derived(decision_id, "promotion-decided:") + + +def outcome_event_id_for(decision_id: str) -> str: + """The id of the audit event that follows a decision other than PROMOTE (CANDIDATE_REJECTED, + RETRY_SCHEDULED or ESCALATED, Doc 11 §12).""" + return _derived(decision_id, "decision-outcome:") + + +def rejection_decision_id_for(decision_id: str) -> str: + """The id of the REJECT decision (STALE_PARENT) stored when a commit of ``decision_id`` loses + the race (C-166).""" + return _derived(decision_id, "stale-rejection:") + + +def failure_event_id_for(decision_id: str, kind: str) -> str: + """The id of the PROMOTION_FAILED audit event for a failed commit of ``decision_id`` + (C-38 case 2 is REVALIDATION, case 3 is INFRASTRUCTURE).""" + if kind not in FAILURE_KINDS: + raise DomainValidationError(f"failure kind must be one of {FAILURE_KINDS}, not {kind!r}") + return _derived(decision_id, f"promotion-failed:{kind}:") diff --git a/core/promotion/newstate.py b/core/promotion/newstate.py new file mode 100644 index 0000000..d081576 --- /dev/null +++ b/core/promotion/newstate.py @@ -0,0 +1,55 @@ +"""The arguments of the new trusted state (P6b; Doc 09 §26, §27, Appendix B; C-41, C-163, C-164). + +``TrustedState.promote`` is called in exactly one place, ``core.promotion.commit`` (R8; the +contract test pins it). This module checks everything that can be checked before that call and +returns the keyword arguments for it: the derived state id (C-163), the proofs, the evidence +references (the union of the proofs' evidence, which includes the binding record), the policy's +registry version (non-decreasing across promotions, C-41) and the time. Standard library and +``core.domain`` only (R18). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime +from typing import Any + +from core.domain.enums import CandidateStatus +from core.domain.errors import DomainValidationError +from core.domain.invariant import InvariantProof +from core.domain.jsonvalue import require_int, utc +from core.domain.state import CandidateState, TrustedState +from core.promotion.ids import state_id_for + + +def promotion_arguments( + candidate: CandidateState, + trusted: TrustedState, + decision_id: str, + proofs: Sequence[InvariantProof], + *, + registry_version: int, + now: datetime, +) -> dict[str, Any]: + """The keyword arguments of ``TrustedState.promote`` for this decision.""" + if candidate.status is not CandidateStatus.PROMOTABLE: + raise DomainValidationError( + f"a {candidate.status.value} candidate cannot become a trusted state; only a " + "PROMOTABLE one can (SM-002)" + ) + require_int(registry_version, "registry_version", 1) + if registry_version < trusted.invariant_registry_version: + raise DomainValidationError( + f"C-41: the registry version {registry_version} is lower than the current state's " + f"{trusted.invariant_registry_version}" + ) + return { + "candidate": candidate, + "current": trusted, + "decision_id": decision_id, + "invariant_proofs": list(proofs), + "evidence_refs": sorted({e for proof in proofs for e in proof.evidence_ids}), + "invariant_registry_version": registry_version, + "now": utc(now, "now"), + "state_id": state_id_for(decision_id), + } diff --git a/core/promotion/proofs.py b/core/promotion/proofs.py new file mode 100644 index 0000000..25b5687 --- /dev/null +++ b/core/promotion/proofs.py @@ -0,0 +1,160 @@ +"""Proofs for the new trusted state, built from the proof plan of a decision (P6b; Doc 06 §14, +§21; Doc 09 §26, §27; C-40, C-45, C-164). + +``decide`` says which source proves each required invariant (``ProofPlanEntry``). This module turns +that plan into the ``InvariantProof`` objects that ``TrustedState.promote`` accepts: + +* VERIFIED: an ``InvariantEvaluation`` driven by the fresh PASS the plan names + (``evaluate_from_results``: affect or reopen the parent's reference, or register a new invariant, + then apply the result with the evidence ids), then ``to_proof`` (origin VERIFIED, bound to the + candidate); +* CARRIED_FORWARD: ``InvariantProof.carry_forward(parent_ref, candidate, impact)``, which asks the + impact report again (C-45) even though ``decide`` already did. + +Every proof also carries the id of the promotion binding record (C-164). A plan entry without an +evaluation or an evidence id, a result that is not a PASS, a version that differs from the parent's +reference (the lifecycle has no path for a version change) and a plan that leaves out an invariant +of the parent are refused: fail closed. Standard library and ``core.domain`` only (R18). +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from core.domain.enums import VerificationResult +from core.domain.errors import DomainValidationError +from core.domain.ids import require_uuid +from core.domain.impact import ImpactReport +from core.domain.invariant import Invariant, InvariantEvaluation, InvariantProof, InvariantRef +from core.domain.promotion import ProofPlanEntry, ProofSource +from core.domain.state import CandidateState, TrustedState +from core.domain.verification import VerificationResultRecord + +# The lifecycle call that takes a VIOLATED or UNCERTAIN reference back into verification. It is +# bound to a name here because the purity test of P6a looks for the text of an I/O call here. +_REVISIT = InvariantEvaluation.reopen + + +def _reference(trusted: TrustedState, invariant_id: str) -> InvariantRef | None: + return next((r for r in trusted.invariant_refs if r.invariant_id == invariant_id), None) + + +def _unique(plan: Sequence[ProofPlanEntry]) -> None: + seen = [entry.invariant_id for entry in plan] + if len(set(seen)) != len(seen): + raise DomainValidationError("proof plan: two entries for one invariant") + + +def evaluate_from_results( + plan: Sequence[ProofPlanEntry], + candidate: CandidateState, + trusted: TrustedState, + definitions: Mapping[str, Invariant], + results: Mapping[str, VerificationResultRecord], + evidence_ids: Mapping[str, str], + binding_evidence_id: str, +) -> dict[str, InvariantEvaluation]: + """The evaluation of every VERIFIED entry of ``plan``, by invariant id. + + ``results`` holds the verification results by ``verification_id`` and ``evidence_ids`` the id + of the evidence record that stores each of them. Each evaluation is the one the lifecycle + allows for the parent's reference (a PROTECTED one is affected and re-verified, a VIOLATED or + UNCERTAIN one is reopened, a missing one is registered from ``definitions``) with the PASS of + the entry applied; its evidence is the source record plus the binding record.""" + require_uuid(binding_evidence_id, "binding_evidence_id") + _unique(plan) + found: dict[str, InvariantEvaluation] = {} + for entry in plan: + if entry.source is not ProofSource.VERIFIED: + continue + assert entry.verification_id is not None # guaranteed by ProofPlanEntry + record = results.get(entry.verification_id) + source = evidence_ids.get(entry.verification_id) + if record is None or source is None: + raise DomainValidationError( + f"proof plan: {entry.target_id} names result {entry.verification_id}, which has no " + "stored result or no evidence record" + ) + if record.result is not VerificationResult.PASS: + raise DomainValidationError( + f"proof plan: {entry.target_id} is VERIFIED by a {record.result.value} result" + ) + reference = _reference(trusted, entry.invariant_id) + if reference is None: + definition = definitions.get(entry.invariant_id) + if definition is None or definition.version != entry.invariant_version: + raise DomainValidationError( + f"proof plan: {entry.target_id} has no reference on the parent state and no " + "matching definition to register" + ) + started = InvariantEvaluation.register( + candidate, definition, trusted + ).start_verification() + else: + if reference.invariant_version != entry.invariant_version: + raise DomainValidationError( + f"proof plan: {entry.target_id} changes the version of the parent's reference " + f"({reference.invariant_version}); the lifecycle has no path for that" + ) + if reference.status.value == "PROTECTED": + started = InvariantEvaluation.affect( + reference, candidate, "AFFECTED_BY_CANDIDATE" + ).start_reverification() + else: + started = _REVISIT(reference, candidate, "REOPENED_FOR_CANDIDATE") + found[entry.invariant_id] = started.apply_result( + record.result, [source, binding_evidence_id], record.completed_at + ) + return found + + +def build_proofs( + plan: Sequence[ProofPlanEntry], + candidate: CandidateState, + trusted: TrustedState, + impact: ImpactReport, + evaluations: Mapping[str, InvariantEvaluation], + *, + binding_evidence_id: str, +) -> list[InvariantProof]: + """The proofs for ``TrustedState.promote``, sorted by invariant id (C-40, C-45, C-164).""" + require_uuid(binding_evidence_id, "binding_evidence_id") + _unique(plan) + covered = {entry.invariant_id for entry in plan} + missing = sorted({r.invariant_id for r in trusted.invariant_refs} - covered) + if missing: + raise DomainValidationError( + f"proof plan: no proof for {', '.join(missing)}; an invariant of the current state " + "cannot be dropped at promotion (C-41)" + ) + proofs: list[InvariantProof] = [] + for entry in sorted(plan, key=lambda e: e.invariant_id): + if entry.source is ProofSource.VERIFIED: + evaluation = evaluations.get(entry.invariant_id) + if evaluation is None: + raise DomainValidationError( + f"proof plan: {entry.target_id} is VERIFIED but has no evaluation" + ) + if ( + evaluation.candidate_id != candidate.candidate_id + or evaluation.invariant_version != entry.invariant_version + or binding_evidence_id not in evaluation.evidence_ids + ): + raise DomainValidationError( + f"proof plan: the evaluation of {entry.target_id} is not this candidate's, is " + "of another version, or lacks the binding record" + ) + proofs.append(evaluation.to_proof()) + continue + reference = _reference(trusted, entry.invariant_id) + if reference is None: + raise DomainValidationError( + f"proof plan: {entry.target_id} is CARRIED_FORWARD but the current state has no " + "reference to it" + ) + proofs.append( + InvariantProof.carry_forward( + reference, candidate, impact, extra_evidence_ids=(binding_evidence_id,) + ) + ) + return proofs diff --git a/docs/DECISIONS_REGISTER.md b/docs/DECISIONS_REGISTER.md index d075283..11d5eff 100644 --- a/docs/DECISIONS_REGISTER.md +++ b/docs/DECISIONS_REGISTER.md @@ -48,13 +48,13 @@ This register records every conflict found between the 15 `CloudSpartanX_*` spec | C-35 | Interim storage in P1a | `core/domain/storage.py` is adapted to the new shapes: schema version 3, the same four table names, every immutability trigger kept, older schema versions refused. It is replaced behind repository ports with the Doc 05 §20 tables in P1b. | Accepted 1 Oct 2026 (P1a) | | C-36 | Enum scope | `ObligationCategory` (not in Doc 05 §4.1) is removed; obligation kinds come from Docs 08/09 in P4 if needed. `EvidenceKind` is not defined in the domain; P2-fix defines the single evidence taxonomy (C-06). | Accepted 1 Oct 2026 (P1a) | | C-37 | When `InvariantRef` invalidation fields are set (Doc 06 §14) | P1a keeps both fields, validated both-set-or-both-null, and `can_satisfy_proof()` is False when they are set. Under C-23, a rejection never touches vN's refs, so it is undefined which event sets them. Decide before P6a, against Doc 09. **Decided 29 Oct 2026 (P6a prompt).** *The invalidation fields of an `InvariantRef` are never set by any P6 event.* Trusted states are immutable (DATA-INT-006) and a rejection never touches vN's references (C-23), so no event can write them to a stored reference; the references of vN+1 are created fresh from VERIFIED or CARRIED_FORWARD proofs, so there is nothing to invalidate at creation. "Invalidation" is represented by (a) the candidate-scoped AFFECTED records of the impact report and (b) evidence-validity transitions (C-52). The two fields stay in the model, always null on references written by baseline, promotion or carry-forward; a reference that has them set can never satisfy proof (`can_satisfy_proof()` unchanged). Tests: promotion-side code never sets them; `carry_forward` refuses a PROTECTED reference with them set (already true; pinned). | Decided 29 Oct 2026 (P6a prompt); implemented in P6a | -| C-38 | A PROMOTABLE candidate whose promotion commit fails for a reason other than a stale parent | Doc 06 §27 requires "no new current Trusted State" but gives no candidate status. P1a allows no exit other than PROMOTED or stale rejection. Decide before P6b. | **Open** | +| C-38 | A PROMOTABLE candidate whose promotion commit fails for a reason other than a stale parent | Doc 06 §27 requires "no new current Trusted State" but gives no candidate status. P1a allows no exit other than PROMOTED or stale rejection. Decide before P6b. **Decided 29 Oct 2026 (P6b prompt).** *A PROMOTABLE candidate whose commit fails.* Three cases, kept apart: (1) **Stale parent** (the current state is not the candidate's parent): the existing exit PROMOTABLE to REJECTED through `reject_stale_candidate`, and a REJECT decision (reason `STALE_PARENT`) is stored; no state. (2) **Revalidation failure** (Appendix B `validate_decision` finds that the decision no longer holds at commit time: the re-run `decide` gives a different decision, an evidence binding fails, a hash does not recompute): the transaction rolls back, nothing about vN+1 exists, the candidate takes the **new legal exit PROMOTABLE to ESCALATED** (rule id `C-38`, trigger "commit-time revalidation failed"; it is added to the domain lifecycle table and, because the database encodes transitions, to the schema-6 triggers), and a `PROMOTION_FAILED` audit event is appended in its own transaction. ESCALATED is where the automated system cannot decide safely (Doc 09 §24). (3) **Infrastructure failure** (any other exception: database error, injected fault, I/O): everything rolls back, the candidate **stays PROMOTABLE** and unchanged, the exception is raised to the caller as `PromotionCommitError`, and a best-effort `PROMOTION_FAILED` event is appended in a separate transaction (if that also fails, nothing is recorded and nothing is claimed). The same commit may be retried; it is idempotent (C-163). Retry timing and limits are outside the core (Doc 09 §23). No state that was not committed is ever reported as promoted. | Decided 29 Oct 2026 (P6b prompt); implemented in P6b | | C-39 | Evidence record identifiers | Record IDs in evidence/ (evidence, operation, attempt, request, transition, event) become canonical UUIDs per Doc 05 §3. Implemented in P2-fix, which also restores the P2 gate test's link to real evidence IDs. | Accepted 1 Oct 2026 (review). Implemented in P2-fix | | C-40 | Proof binding and coverage | `InvariantProof` carries `origin` (`ProofOrigin`: BASELINE, VERIFIED, CARRIED_FORWARD) and is created only by `InvariantProof.for_baseline` (origin BASELINE), `InvariantEvaluation.to_proof` (origin VERIFIED, bound to the evaluation's `candidate_id`) and `InvariantProof.carry_forward(parent_ref, candidate)` (origin CARRIED_FORWARD; `parent_ref.state_id` must equal `candidate.parent_state_id`; status and evidence copied unchanged; a PROTECTED ref must pass `can_satisfy_proof()`). `establish_baseline` accepts only BASELINE proofs. `promote` accepts only VERIFIED proofs bound to this candidate and CARRIED_FORWARD proofs from the current state. Every `invariant_id` on the current state must have a proof, with an `invariant_version` not lower than its current one. A trusted state holds at most one reference per `invariant_id`. `InvariantRef` records `origin`; `origin` is not part of `state_hash` (C-33 unchanged). Whether carry-forward is permitted for a given invariant (impact, C-22) remains P6 policy. | Accepted 1 Oct 2026 (review) | | C-41 | Retiring an invariant from a lineage, and whether `invariant_registry_version` must be monotonic across promotions | P1a forbids dropping an invariant at promotion (C-40). Decide before P6a. **Decided 29 Oct 2026 (P6a prompt).** *In this prototype an invariant is never retired from a lineage.* (1) Dropping an invariant at promotion stays forbidden (C-40). A registry change that removes an invariant requires a **new lineage** with its own baseline. (2) `invariant_registry_version` is **non-decreasing across promotions** within a lineage: a policy whose registry version is lower than the current state's is not a PROMOTE (decision ESCALATE, reason `POLICY_REGISTRY_REGRESSION`). (3) Invariant versions per id are non-decreasing (already enforced). (4) An invariant that is **new** in the registry (not on the current state) is required by the policy's extra-required list and, having no parent reference, can only be proven by a fresh VERIFIED PASS. (5) An invariant whose version rose cannot be carried forward (Doc 09 §28); it needs a fresh VERIFIED PASS at the new version. | Decided 29 Oct 2026 (P6a prompt); implemented in P6a | | C-42 | P1 table subset | P1b creates `patches`, `trusted_states`, `candidates`, `resources`, `invariants` and `invariant_refs` (Doc 05 §20), plus three tables not named in §20: `state_resources` and `candidate_resources` (a resource record is shared by a candidate and the state promoted from it, so ownership is a link), and `lineage_heads` (the Doc 06 §6 current pointer). The other §20 tables arrive with their owning packages: evidence_events P2-fix; identity/dependency/impact/verification reports P3–P5; promotion_decisions P6; llm_* P7; experiments/runs/attempts/oracle_results P8. | Accepted 2 Oct 2026 (P1b) | | C-43 | Repository ports and adapter | Protocols in `core/domain/repositories.py`. The SQLite adapter lives in `core/persistence/`. The Doc 05 §26 set is extended with a `PatchRepository` (Doc 05 §9, §33) and a `UnitOfWork` that implements the §32 transaction. EvidenceRepository comes in P2-fix; AnalysisRepository in P3–P6. `sqlite3` may be imported only in `core/persistence/`, `evidence/` and `core/application/health.py` (rule R11). | Accepted 2 Oct 2026 (P1b) | -| C-44 | Schema migrations | No migration tooling until the first persistent research data exists. Any database with a schema version other than 5 is refused (P2-fix, C-59) with instructions to delete the local development database. The tooling decision is made before P8a. | Accepted 2 Oct 2026 (P1b) | +| C-44 | Schema migrations | No migration tooling until the first persistent research data exists. Any database with a schema version other than 5 is refused (P2-fix, C-59) with instructions to delete the local development database. The tooling decision is made before P8a. Schema 6 (P6b, C-165): the refusal now reads "other than 6". | Accepted 2 Oct 2026 (P1b) | | C-45 | Carrying forward an affected invariant (P1a L3) | At `31595fc`, `promote` accepts a CARRIED_FORWARD proof for an invariant that the same candidate AFFECTED and then VIOLATED. That hides a regression behind the parent's old evidence (the canonical DAIL failure). To be made structurally impossible: `InvariantProof.carry_forward` will require the P4 impact result's UNAFFECTED verdict for that candidate and invariant as an input. **P6 gate blocker.** | Proposed answer in C-141 (P4); enforcement in P6; P6 gate blocker unchanged | | C-46 | Candidate sequence | `UNIQUE(lineage_id, candidate_sequence)` | Accepted 2 Oct 2026 (P1b) | | C-47 | DATA-INT coverage after P1b | 001, 002, 005, 006, 009, 010 are enforced in the domain and the database. 003 and 004 come with promotion_decisions (P6); 007 with UUID evidence IDs (P2-fix, C-39); 008 with verification reports (P5). | Accepted 2 Oct 2026 (P1b). Implemented in P2-fix: DATA-INT-007 is enforced in the adapter and by triggers (C-60) | @@ -173,3 +173,11 @@ This register records every conflict found between the 15 `CloudSpartanX_*` spec | C-159 | P6a: required set and proof source (this closes C-45) | **Required set** = every invariant on the current state (its id, at the version in the policy's registry) together with `policy.extra_required` (C-41(4)), plus the gate items STRUCTURAL and CONSISTENCY and the RESOURCE baseline items of the impact work set. A required invariant missing from the policy's registry, or at a lower version than the current reference, is an `ESCALATE` (`POLICY_REGISTRY_REGRESSION`). Each required invariant is proven by exactly one of: **VERIFIED**, a fresh (`fresh(...)`), sufficient (`sufficient(...)`) PASS in the verification report for target `INVARIANT::` with the definition's verifier id and version, and `evidence_level` not below `policy.min_evidence_level` ("L1"; the same level rule holds for a baseline item, while the two gate items are L0 evidence by construction in the verification engine and need only a `sufficient` PASS: found by running the real pipeline, where a uniform L1 rule rejected every gate item); or **CARRIED_FORWARD**, only if **all** hold: `policy.carry_forward_enabled`; `impact.carry_forward_ok(id, version, candidate_id=..., parent_state_id=...)` is True (the C-45 query); the parent reference exists with the **same** invariant version, status **PROTECTED** and `can_satisfy_proof()`; the invariant is **not** in the impact report's affected set and no uncertainty record names it; the definition names the same verifier as the parent's evidence (definitions are immutable, so equal versions imply it: verifier configuration changes are not modelled, an assumption and a spec gap against Doc 06 §17). **Never carried forward:** an affected invariant (C-45); a non-PROTECTED parent reference (VIOLATED or UNCERTAIN; a violated invariant must be re-verified to PASS, which is what the canonical SSH repair does); a version change; any invariant when `carry_forward_enabled` is False (the Stateful-Full condition, C-22). If a fresh result exists for the target it decides: FAIL rejects, a non-success follows the table, a sufficient PASS is VERIFIED; carry-forward is tried only when no fresh result exists. If neither source exists the decision is REJECT `INSUFFICIENT_EVIDENCE` listing the invariants without proof. A PASS that is not `sufficient` or not `fresh` is not proof (`EVIDENCE_NOT_SUFFICIENT`, `EVIDENCE_STALE`, both under INSUFFICIENT_EVIDENCE). `decide` never runs verification; the orchestrator of P6b applies the `VerificationScopePolicy` before it. A PASS marked not applicable (C-148, open) is a PASS here, as in `aggregate`. | Proposed (P6a) | | C-160 | P6a: oracle evidence (Doc 09 §16, §17) | `OracleVerdict(invariant_id, invariant_version, result, oracle_version)` is optional input. Oracle evidence is never proof (ADR-010, C-54): `PromotionPolicy` refuses to be constructed with `accept_oracle_for_unknown=True`; the field exists so that the refusal is explicit and testable. Primary PASS (or a carried-forward proof) with oracle FAIL: a conflict is recorded, the check is not treated as proven, and the decision is `policy.conflict_decision` (REJECT or ESCALATE, default ESCALATE, never PROMOTE) with reason `EVIDENCE_CONFLICT`. Primary FAIL with oracle PASS: the FAIL stays blocking (REJECT). Primary UNKNOWN with oracle PASS: the UNKNOWN stays non-success; no oracle acceptance. Agreement changes nothing. The conflict is listed in `blocking_conditions`. An oracle FAIL for a carried-forward invariant is treated as a conflict too (the oracle sees a violation that nobody re-verified). | Proposed (P6a) | | C-161 | P6a: emergency switch and decision completeness | `policy.automatic_enabled` False turns any would-be PROMOTE into **ESCALATE** with reason `AUTOMATIC_PROMOTION_DISABLED`; non-PROMOTE decisions are unchanged (Doc 14 §37). The audit record keeps what the decision would have been (`would_have_been` in `blocking_conditions`). Before a decision is returned, **decision completeness** (Doc 11 §43) is evaluated on the finished decision: candidate binding valid; parent binding valid (`parent_state_id` equals `candidate.parent_state_id`, DATA-INT-003); every `supporting_results` id exists in the verification report; the report's `candidate_hash` equals the candidate's `state_hash` and its content hash recomputes; the impact report's content hash recomputes; `policy_version` recorded. A PROMOTE that fails completeness becomes ESCALATE `DECISION_INCOMPLETE`; a non-PROMOTE keeps its decision and records the incompleteness. A PROMOTE lists in `supporting_results` **every** verification result id used, and in `PromotionEvidence.verification_refs` the same ids (Doc 11 §26). | Proposed (P6a) | +| C-162 | P6b: a target id has at most one result, whatever its type (the P6a blocking condition) | (1) `VerificationReport` rejects two results with the same `target_id` even when their target types differ. (2) `decide` looks up each required item by `(target_type, target_id)` with the type its kind requires (INVARIANT for an invariant, RESOURCE for a baseline item, STRUCTURAL for a gate) and examines every result whose `target_id` equals a required id. (3) A report holding a second result for a required target id, or a result of the wrong type for it, is an `INCONSISTENT_REPORTS` REJECT condition, no result proves that item, and a FAIL among them stays blocking. Reason: a STRUCTURAL-typed PASS with an INVARIANT target id hid an invariant FAIL (reproduced in review on INV-SEC-001, INV-FUNC-001 and a `RESOURCE:` item: PROMOTE). Existing tests change only where they built such a report on purpose: none did (the full suite passed unchanged, 3739 tests with 15 new). Implemented in P6b step 1. | Proposed (P6b) | +| C-163 | P6b: identifiers, idempotency key and duplicate detection | The key of a promotion is `decision_id`. The new state id, the promotion-evidence id and the STATE_PROMOTED audit event id are **derived** from it with `uuid.uuid5(PROMOTION_NAMESPACE, ...)` (a fixed namespace constant in `core/promotion/ids.py`; stdlib only; no randomness, R10): `state_id = uuid5(ns, "state:" + decision_id)`, `evidence_id = uuid5(ns, "promotion-evidence:" + decision_id)`, `event_id = uuid5(ns, "state-promoted:" + decision_id)`. Duplicate detection is by the database as well as by code: `promotion_decisions.decision_id` PRIMARY KEY; at most one **committed** PROMOTE decision per candidate (UNIQUE partial index); `committed_state_id` UNIQUE; the state's `commit_decision_id` UNIQUE among promoted states. A repeat of a committed decision returns the existing state (`CommitResult(duplicate=True)`), writes nothing (audit redelivery is a duplicate by event id, Doc 11 §38), and a request with the same `decision_id` but different content raises `ConflictingDuplicateError`. **Recorded limits (P6b):** the PROMOTION_DECIDED event and the event of a non-PROMOTE outcome are appended once, when the decision is first stored; the PROMOTION_FAILED events have ids derived from the decision id and the failure kind (`ids.failure_event_id_for`), so a second failure of the same kind of one decision adds no event (C-169, Open). | Proposed (P6b) | +| C-164 | P6b: evidence valid for vN+1, the promotion binding record | C-52 makes evidence UNCERTAIN in every context it was not produced for, and evidence produced for a candidate cannot be moved into the state it becomes. So validity for vN+1 is **established by one new STATE-kind evidence record bound to vN+1** (kind decided by the user, see the end of this row), written in the commit transaction: `state_id` = vN+1, no `candidate_id`, `parent_state_id` = vN, `state_hash` = vN+1's `state_hash`, event name `promotion.committed`, creation validity VALID, id `uuid5` of C-163, payload = the `PromotionEvidence` of the decision (with `new_state_id`) plus, per invariant, `{invariant_id, invariant_version, source: VERIFIED or CARRIED_FORWARD, source_evidence_ids, source_state_id}`. Every `InvariantRef` of vN+1 has `evidence_ids` = its source evidence ids **plus** the binding record id; `evidence_refs` of vN+1 = the sorted union plus the binding record id. The binding check that C-60 leaves to P6 has two parts, both fail-closed, both computed with `evaluate_proof`: **VERIFIED source:** each source evidence id is VERIFICATION evidence, bound to this candidate, with the invariant's id and version and the verifier id and version of the definition as its subject (C-68), `state_hash` = the candidate's `state_hash`, and `evaluate_proof(..., expected = CANDIDATE context)` passes. **CARRIED_FORWARD source:** the parent reference's evidence includes a promotion binding record (event name `promotion.committed`) bound to vN (or, for the baseline, the baseline's evidence bound to vN), and `evaluate_proof(..., expected = STATE vN context)` passes for it. The chain therefore never needs evidence to be valid in a context it was not produced for. Consequence: carry-forward at vN+1 to vN+2 uses vN+1's binding record, not the original verification evidence. `vN+1.state_hash` does not depend on evidence ids (C-33). **Implementation (user decision, 29 Oct 2026):** `InvariantProof.carry_forward` takes an optional keyword `extra_evidence_ids` (default none; additive; `core/domain/invariant.py`), so that a CARRIED_FORWARD reference of vN+1 also carries the binding record id; a VERIFIED reference gets it through `apply_result`. The C-45 check in `carry_forward` is unchanged. **Why STATE and not PROMOTION (found while building step 5; decided by the user, 29 Oct 2026):** the evidence trigger `evidence_events_insert_candidate_hash` (T2, Doc 11 §44) refuses a candidate-bound record whose `state_hash` is not the candidate's, and C-165 leaves T1 to T5 untouched. vN+1's hash differs from the candidate's, so a PROMOTION record bound to the candidate and to vN+1 cannot be inserted. The STATE-kind record is a proof kind, is bound to vN+1 only, and its payload still names the candidate, the decision and every source. **Unreadable evidence (P6b):** a record that the adapter refuses to rebuild (its stored columns disagree with its content, DATA-INT-010) is a binding problem and so a revalidation failure (C-38 case 2), not an infrastructure failure. `binding.integrity_of` is the short duplicate of `EvidenceService.verify_integrity` that the prompt asks for (it stays in `core.promotion`, which never imports `evidence/`); a test shows both give VALID, TAMPERED and MISSING_PAYLOAD alike. | Proposed (P6b) | +| C-165 | P6b: schema 6 | `SCHEMA_VERSION = "6"`. Appended after the schema-5 statements, which stay **byte-identical** (a golden SHA-256 of the schema-5 prefix and one of the full schema 6, like C-59). New: table `promotion_decisions` (`decision_id` PK; `candidate_id` FK; `parent_state_id` FK; `decision` in the four values; `reason`; `policy_version`; `attempt_number`; `required_checks_json`; `blocking_conditions_json`; `supporting_results_json`; `content_hash`; `created_at`; `committed_state_id` NULL, UNIQUE, FK to `trusted_states`), and triggers: **DATA-INT-003** (`parent_state_id` equals the candidate's parent), **DATA-INT-004** (`committed_state_id` may be set only once, from NULL, only for `decision = 'PROMOTE'`, only when that state's `commit_decision_id` equals the decision id and its parent equals the decision's parent; every other column immutable; no DELETE), and the unique partial index of C-163. The reverse trigger (a trusted state with `version > 0` needs a PROMOTE decision row of its `commit_decision_id`) is **not** added: C-168, decided (b). The refusal message and C-44 now read "other than 6"; every test that asserts version 5 changes only that literal. The candidate transition PROMOTABLE to ESCALATED (C-38) is generated into the candidate-status trigger from the domain table; because the schema-5 statements stay byte-identical, the schema-5 trigger is dropped and created again with the longer pair list in the appended statements. The evidence triggers T1 to T5 are not touched. | Proposed (P6b) | +| C-166 | P6b: concurrency | The commit's unit of work takes the write lock at begin (SQLite `BEGIN IMMEDIATE`; `SqliteUnitOfWork.__enter__` already does, and a busy timeout is set by `open_connection`), and the compare-and-swap of `save_promoted` stays the last line of defence. Two commits racing from one parent: exactly one commits; the other finds the head moved, rolls back, stores a REJECT decision (`STALE_PARENT`, `committed_state_id` null), moves its candidate to REJECTED, and returns `CommitResult(stale=True)`; it never raises an unhandled `sqlite3` error. No test relies on timing: a `threading.Barrier` and the `checkpoint` seam force each interleaving. This is the MVP's single promotion worker (Doc 09 §39) on one SQLite file; nothing is claimed beyond what the tests show. **Loser records (P6b):** the loser's own PROMOTE decision is not stored by the commit (it never committed); the stored REJECT has the derived id `ids.rejection_decision_id_for(decision_id)`. If the PROMOTE had been recorded before with `record_decision`, it stays as an uncommitted row beside the REJECT. Because the write lock makes the stale check and the swap one step, the compare-and-swap of `save_promoted` is tested apart from the lock, with a unit of work that believes the head has not moved. | Proposed (P6b) | +| C-167 | P6b: commit-time revalidation (Appendix B `validate_decision`) | `commit_promotion` receives the stored decision's `DecisionOutcome`, the candidate, the impact and verification reports, the policy and the evidence-id map; inside the transaction it (1) re-reads the current state and candidate from the database, (2) runs `decide` again with those and the same `decision_id`, `now` and `attempt_number`, and (3) requires the new decision's `content_hash` to equal the stored one and its `proof_plan` to be identical. Any difference is a revalidation failure (C-38 case 2). It also checks decision completeness (Doc 11 §43) against what the database holds (the decision row, the candidate, the evidence). Reports are not persisted in P6b (they arrive as arguments); that is a recorded limit for P8. Implementation choice (P6b): `commit_promotion` takes a factory that opens a unit of work, not one open unit, because the C-38 outcomes after a rollback (the REJECT decision of a stale loser, the ESCALATED candidate, the PROMOTION_FAILED event) are written in a separate transaction. **API as built (deviations from the signatures in the P6b prompt):** `commit_promotion(factory, outcome, candidate, impact, verification, policy, evidence_ids, context, *, now, attempt_number=None, oracle_verdicts=())` takes the factory (above), a `PromotionContext` (the run, correlation, operation and attempt ids that the evidence and audit records carry) and no `evaluations`: it builds them from the verification results and `evidence_ids` with `evaluate_from_results`. `record_decision(uow, outcome, candidate, context, *, now)` also takes the context and the clock. A revalidation failure also appends the ESCALATED event of Doc 11 §12 and stores the PROMOTE decision uncommitted if it was not stored. The comparison covers the proof plan as well as the decision hash, because the hash does not cover the evidence ids of a carried reference (a test pins it). | Proposed (P6b) | +| C-168 | P6b: the "a promoted state needs its decision" trigger of C-165 against the existing persistence tests | C-165 asks for a trigger that refuses a trusted state with `version > 0` unless a PROMOTE decision row of its `commit_decision_id` for its parent exists. About 25 direct calls of `save_promoted` in the existing tests (`test_persistence_repositories.py`, `test_persistence_lifecycle.py`, `test_data_int_007.py`, `test_p2_gate.py` and `tests/persistence_builders.py`) promote states with arbitrary decision ids and no decision row, and would be refused. Section 10 R3 of the P6b prompt says that any change to an existing test other than the schema literal is a defect. The two statements cannot both hold. Options: (a) keep the trigger and make the existing tests insert a PROMOTE decision row before `save_promoted`, through the shared builder where possible; (b) keep DATA-INT-004 (the decision row points at the state, one way, once) and the unique indexes, and leave out the reverse trigger, enforcing "no promoted state without its decision" in `commit_promotion` and in the adapter's `save_promoted`; (c) keep the trigger but let it apply only to states promoted through the commit. **Decision (the user, 29 Oct 2026): (b).** DATA-INT-004 and the unique indexes stay in the database; "no promoted state without its PROMOTE decision" is enforced in `commit_promotion` (it writes the decision first and refuses a state without it) and tested there, not by a trigger. No existing test changes for this. Can be tightened with migration tooling before P8. | Decided 29 Oct 2026 (user); implemented in P6b | +| C-169 | P6b: repeated failures of one commit leave one PROMOTION_FAILED event | The audit event ids of C-163 are derived, so that a redelivered event is a duplicate (Doc 11 §38). The PROMOTION_FAILED event of a failed commit (C-38 cases 2 and 3) is derived from the decision id and the failure kind, so a second failure of the same kind of the same decision adds no event: the first is kept and the later ones are only raised to the caller. Doc 11 §12 and §38 do not say whether every failed attempt is an event or whether an attempt number belongs in the id. Choice made here: one event per decision and kind, so that a retry storm cannot grow the log and nothing is lost for a retry that then succeeds. For the authors: if every attempt must be recorded, the id needs the attempt id (it is already on the event's context); the change is one line in `core/promotion/ids.py` and the tests that pin the id. | Open (P6b) | diff --git a/docs/PHASE_GATES.md b/docs/PHASE_GATES.md index 83351b0..3d4d1a9 100644 --- a/docs/PHASE_GATES.md +++ b/docs/PHASE_GATES.md @@ -169,7 +169,22 @@ clean up later. C-45 enforcement in P6 (blocker). C-144 to C-148, C-151 to C-156 C-149, C-150, C-155 Open. ``` -## P6a–P10 — NOT STARTED +## P6a — Promotion decision + +```text +P6a mini-gate: PASSED WITH ONE BLOCKING CONDITION (external review, 29 Oct 2026; PR #24 at +a7ca8af, merged before the verdict, C-28 process lapse recorded a seventh time). Suite passes on +Python 3.12 and 3.13 (3724 tests); black, ruff and mypy clean. 16 of 20 reviewer mutations of +decide() killed; the 3 survivors are redundant checks and one is an equivalent mutant. Limits: the +fixture table and the 37 author mutations were not independently re-run in full. +Blocking condition (MEDIUM, fix in P6b Step 1, before the P6 gate): a second result for the same +target id under another target type hides a FAIL (PROMOTE reproduced). Carry-forward: C-162; +ambiguous_identity_uncertain carries FUNC (P4 question to the authors); severity order, retry +budget in the core and violated-baseline INSUFFICIENT_EVIDENCE for the authors. C-157 to C-161 +Proposed; C-37 and C-41 Decided. C-45 enforced in carry_forward and in decide. +``` + +## P6b–P10 — NOT STARTED ## Temporary exceptions diff --git a/tests/contract/test_architecture_boundaries.py b/tests/contract/test_architecture_boundaries.py index 3300d18..0d6f548 100644 --- a/tests/contract/test_architecture_boundaries.py +++ b/tests/contract/test_architecture_boundaries.py @@ -288,6 +288,24 @@ def check_repository(roots: tuple[str, ...] | None = CHECKED_ROOTS) -> list[Viol } +# P6b (C-157): inside the repository the sole callers of TrustedState.promote and mark_promoted are +# the state module (which defines them) and the commit of the Promotion Controller. +R8_SOLE_CALLERS = ("core.domain.state", "core.promotion.commit") + + +def sole_caller_violations(module: str, source: str) -> list[Violation]: + """R8 for the repository itself: a call of ``TrustedState.promote`` or a reference to + ``mark_promoted`` outside ``R8_SOLE_CALLERS`` (the source is read as a module of no allowed + package, so every such use is found).""" + if module in R8_SOLE_CALLERS: + return [] + return [ + Violation("R8", module, v.imported) + for v in check_source("core.impact.not_allowed", source) + if v.rule == "R8" + ] + + def _import_aliases(tree: ast.AST) -> dict[str, str]: """Local name -> the name it was imported as, so ``T.promote`` and ``dt.now`` are seen through ``import ... as``/``from ... import ... as``.""" @@ -1061,6 +1079,35 @@ def test_no_oracle_imports_anywhere_in_the_repository(self) -> None: violations = [v for v in check_repository(None) if v.rule == "ORACLE"] self.assertEqual(violations, [], "\n".join(str(v) for v in violations)) + def test_only_the_commit_module_calls_promote_and_mark_promoted(self) -> None: + """C-157, P6b: R8 allows the state module and the promotion package; in the repository + itself the only module that calls ``TrustedState.promote`` or ``mark_promoted`` is + ``core.promotion.commit`` (and ``core.domain.state``, which defines them).""" + offenders = [] + for path in iter_python_files(CHECKED_ROOTS): + module, _ = module_name(path) + source = path.read_text(encoding="utf-8") + offenders.extend(str(v) for v in sole_caller_violations(module, source)) + self.assertEqual(offenders, [], "\n".join(offenders)) + + def test_the_sole_caller_check_can_fail(self) -> None: + call = "from core.domain.state import TrustedState\nTrustedState.promote(candidate=c)\n" + mark = "from core.domain.state import mark_promoted\nmark_promoted(c, s)\n" + for module in ( + "core.promotion.newstate", + "core.promotion.decide", + "core.promotion.lifecycle", + "core.promotion", + "core.application.bad", + ): + for source in (call, mark): + with self.subTest(module=module, source=source.splitlines()[-1]): + self.assertTrue(sole_caller_violations(module, source)) + for module in ("core.promotion.commit", "core.domain.state"): + for source in (call, mark): + with self.subTest(module=module, allowed=True): + self.assertEqual(sole_caller_violations(module, source), []) + def test_scripts_import_only_stdlib_and_first_party(self) -> None: """A14: no third-party imports in scripts/ (core/ and evidence/ are covered by R7).""" offenders = [] diff --git a/tests/promotion_builders.py b/tests/promotion_builders.py index e924466..273a952 100644 --- a/tests/promotion_builders.py +++ b/tests/promotion_builders.py @@ -183,7 +183,22 @@ def record( clauses=None, observations={}, assumptions=(), - evidence={"predicate": "p", "inputs": {}, "observations": {}, "result": result.value}, + evidence={ + # the keys the verification engine's evidence carries (core.verification.engine) + "verification_id": uid(7000 + n), + "invariant_id": invariant_id, + "invariant_version": version, + "candidate_id": candidate.candidate_id, + "candidate_hash": candidate_hash or candidate.state_hash, + "target_id": target_id, + "verifier_id": chosen[0], + "verifier_version": chosen[1], + "evidence_level": level or ("L0" if target_id in GATES else "L1"), + "predicate": "p", + "inputs": {}, + "observations": {}, + "result": result.value, + }, error=error, started_at=at(10), completed_at=at(11), diff --git a/tests/promotion_commit_builders.py b/tests/promotion_commit_builders.py new file mode 100644 index 0000000..db929a2 --- /dev/null +++ b/tests/promotion_commit_builders.py @@ -0,0 +1,421 @@ +"""Builders for the commit tests (P6b steps 5 and 6). Not a test module. + +``CommitCase`` stores the canonical world of ``tests/promotion_builders.py`` in a real SQLite file: +the four registry definitions, the baseline v0 (with evidence bound to it), and the candidate walked +to ANALYZING or PROMOTABLE. It also stores the evidence of every verification result the way the +verification engine's ``to_evidence_submission`` does (candidate-bound, with the candidate's hash), +which P6c's orchestrator will do in production, and gives the commit a factory that opens units of +work on the file (with an optional fault-injection checkpoint). +""" + +from __future__ import annotations + +import dataclasses +import sqlite3 +import threading +from collections import Counter +from collections.abc import Callable +from contextlib import closing +from pathlib import Path +from typing import Any + +from core.domain.enums import CandidateStatus, VerificationResult +from core.domain.repositories import UnitOfWork +from core.domain.state import CandidateState, TrustedState, transition_candidate +from core.promotion import ids +from core.promotion.commit import CommitResult, PromotionContext, commit_promotion +from core.verification.engine import to_evidence_submission +from tests.domain_builders import at, patch_for, uid +from tests.persistence_builders import RepoCase, seed_evidence, stages +from tests.promotion_builders import ( + BASELINE_ITEM, + FUNC1, + PASS, + SEC1, + SEC2, + SEC3, + World, + impact_for, + make_world, + record, + report_of, + run, + target, +) + +CONTEXT = PromotionContext( + run_id=uid(0x7A01), + correlation_id=uid(0x7A02), + operation_id=uid(0x7A03), + attempt_id=uid(0x7A04), + actor_id=None, +) +NOW = at(30) + + +class CommitCase(RepoCase): + """A database holding a baseline and the PROMOTABLE candidate of ``make_world()``.""" + + stored_status = CandidateStatus.PROMOTABLE + + def setUp(self) -> None: + super().setUp() + self.world: World = make_world(candidate_status=self.stored_status) + self.install(self.world) + self.evidence_ids: dict[str, str] = self.store_results(self.world) + + # --- installing a world -------------------------------------------------------------------- + + def install(self, world: World, *, n: int = 1) -> None: + """Register the definitions, store the baseline and walk the candidate.""" + with self.uow() as u: + for definition in world.definitions.values(): + u.invariants.register_definition(definition) + self.seeded_evidence += seed_evidence(u, world.trusted, bound=True) + u.trusted_states.save_baseline(world.trusted) + self.store_candidate(world, n=n) + + def store_candidate(self, world: World, *, n: int = 1, sequence: int = 1) -> CandidateState: + upto = 4 if world.candidate.status is CandidateStatus.PROMOTABLE else 3 + steps = stages(world.trusted, list(world.candidate.resources), n=n, sequence=sequence) + with self.uow() as u: + u.patches.save(patch_for(world.trusted, n=n)) + u.candidates.create(steps[0]) + for step in steps[1 : upto + 1]: + u.candidates.save_transition(step) + assert steps[upto].candidate_id == world.candidate.candidate_id + assert steps[upto].state_hash == world.candidate.state_hash + return steps[upto] + + def store_results(self, world: World, *, first: int = 0) -> dict[str, str]: + """The evidence record of every verification result, by verification id.""" + found: dict[str, str] = {} + with self.uow() as u: + for index, result in enumerate(world.verification.results, start=first): + evidence_id = uid(8800 + index) + u.evidence.append( + to_evidence_submission( + result, + evidence_id=evidence_id, + run_id=CONTEXT.run_id, + attempt_id=CONTEXT.attempt_id, + correlation_id=CONTEXT.correlation_id, + operation_id=CONTEXT.operation_id, + parent_state_id=world.trusted.state_id, + ) + ) + found[result.verification_id] = evidence_id + return found + + # --- committing ---------------------------------------------------------------------------- + + def factory(self, checkpoint: Callable[[str], None] | None = None) -> Callable[[], UnitOfWork]: + return lambda: self.uow(checkpoint=checkpoint) + + def commit( + self, + world: World | None = None, + *, + outcome: Any = None, + checkpoint: Callable[[str], None] | None = None, + evidence_ids: dict[str, str] | None = None, + opener: Callable[[], UnitOfWork] | None = None, + **options: Any, + ) -> CommitResult: + chosen = world or self.world + return commit_promotion( + opener or self.factory(checkpoint), + outcome or run(chosen), + chosen.candidate, + chosen.impact, + chosen.verification, + chosen.policy, + self.evidence_ids if evidence_ids is None else evidence_ids, + CONTEXT, + now=NOW, + **options, + ) + + # --- reading ------------------------------------------------------------------------------- + + def current(self) -> TrustedState: + with self.uow() as u: + state = u.trusted_states.get_current(self.world.trusted.lineage_id) + assert state is not None + return state + + def candidate_status(self, candidate_id: str | None = None) -> str: + return self.scalar( + "SELECT status FROM candidates WHERE candidate_id = ?", + (candidate_id or self.world.candidate.candidate_id,), + ) + + def audit_types(self) -> list[str]: + with self.uow() as u: + events = u.audit.list_for_candidate(self.world.candidate.candidate_id) + return [e.event_type.value for e in events] + + def next_world( + self, + state: TrustedState, + *, + sequence: int = 2, + n: int = 2, + func_result: VerificationResult = PASS, + ) -> World: + """A second candidate from ``state`` (a promoted state): INV-FUNC-001 is affected, the other + three are unaffected with eligible evidence.""" + from tests.domain_builders import analyzing_candidate, canonical_resources + + candidate = analyzing_candidate( + state, + canonical_resources(ssh_open=False, db_path=True), + sequence=sequence, + n=n, + ) + candidate = dataclasses.replace(candidate) if False else candidate + impact = impact_for(state, candidate, affected=(FUNC1,), carry=(SEC1, SEC2, SEC3)) + definition = self.world.definitions[FUNC1] + records = [ + record(candidate, "STRUCTURAL", n=1), + record(candidate, "CONSISTENCY", n=2), + record(candidate, target(FUNC1), func_result, definition=definition, n=3), + record(candidate, BASELINE_ITEM, n=4), + ] + return dataclasses.replace( + self.world, + trusted=state, + candidate=candidate, + impact=impact, + verification=report_of(candidate, records), + records={r.target_id: r for r in records}, + ) + + def rival( + self, *, sequence: int = 2, n: int = 2, first: int = 40 + ) -> tuple[World, dict[str, str]]: + """A second PROMOTABLE candidate of the same parent, stored beside the first, with its + verification evidence; returns its world and its own ``verification_id -> evidence_id`` + map (kept apart from ``self.evidence_ids``, because two candidates' results can share a + verification id).""" + from tests.domain_builders import analyzing_candidate, canonical_resources + + trusted = self.world.trusted + analyzing = analyzing_candidate( + trusted, canonical_resources(ssh_open=False, db_path=True), sequence=sequence, n=n + ) + promotable = transition_candidate(analyzing, CandidateStatus.PROMOTABLE) + definitions = self.world.definitions + records = [ + record(promotable, "STRUCTURAL", n=1), + record(promotable, "CONSISTENCY", n=2), + record(promotable, target(SEC1), definition=definitions[SEC1], n=3), + record(promotable, target(FUNC1), definition=definitions[FUNC1], n=4), + record(promotable, BASELINE_ITEM, n=5), + ] + world = dataclasses.replace( + self.world, + candidate=promotable, + impact=impact_for(trusted, promotable, affected=(SEC1, FUNC1), carry=(SEC2, SEC3)), + verification=report_of(promotable, records), + records={r.target_id: r for r in records}, + ) + self.store_candidate(world, n=n, sequence=sequence) + return world, self.store_results(world, first=first) + + +# --- threads that meet at a checkpoint ---------------------------------------------------------- + + +class InjectedFault(RuntimeError): + """A failure put into a commit on purpose.""" + + +class Gate: + """A checkpoint hook that stops its thread the first time it reaches one named checkpoint, + until released, and then either goes on or raises ``InjectedFault``. It acts once: later + transactions of the same commit (the failure event) pass through. Nothing sleeps: the thread + waits on an event.""" + + def __init__(self, point: str | None, *, fail: bool = False, wait: float = 60.0) -> None: + self.point = point + self.fail = fail + self.wait = wait + self.fired = False + self.reached = threading.Event() + self.release = threading.Event() + + def __call__(self, name: str) -> None: + if name != self.point or self.fired: + return + self.fired = True + self.reached.set() + if not self.release.wait(self.wait): + raise TimeoutError(f"the gate at {name} was never released") + if self.fail: + raise InjectedFault(name) + + +class Runner(threading.Thread): + """Runs one callable on its own thread and keeps its result or its exception.""" + + def __init__(self, work: Callable[[], Any]) -> None: + super().__init__(daemon=True) + self.work = work + self.started_running = threading.Event() + self.result: Any = None + self.error: BaseException | None = None + + def run(self) -> None: + self.started_running.set() + try: + self.result = self.work() + except BaseException as failure: # kept for the test to inspect + self.error = failure + + def wait_for(self, gate: Gate) -> bool: + """True once ``gate`` was reached, False if the thread ended without reaching it.""" + while not gate.reached.wait(0.02): + if not self.is_alive(): + return gate.reached.is_set() + return True + + +# --- the rules every database of promotions must satisfy ----------------------------------------- + + +def check_database(path: str | Path) -> list[str]: + """The structural rules of promoted history, read from the file with plain SQL: every problem + found (an empty list means none). Used by the concurrency, fault and property tests; each rule + is shown to fire on a database that breaks it + (``test_promotion_properties6.TestTheCheckerFires``).""" + problems: list[str] = [] + with closing(sqlite3.connect(path)) as conn: + states = { + r[0]: r + for r in conn.execute( + "SELECT state_id, lineage_id, version, parent_state_id, commit_decision_id " + "FROM trusted_states" + ) + } + for state_id, lineage, version, parent, decision in states.values(): + if version == 0: + if parent is not None or decision is not None: + problems.append(f"{state_id}: a baseline with a parent or a decision") + continue + if parent not in states: + problems.append(f"{state_id}: its parent {parent} is not stored") + elif states[parent][2] != version - 1 or states[parent][1] != lineage: + problems.append(f"{state_id}: version {version} is not its parent's plus one") + for state, count in Counter(r[3] for r in states.values() if r[3] is not None).items(): + if count > 1: + problems.append(f"{state}: {count} children (one parent, two futures)") + used = Counter(r[4] for r in states.values() if r[4] is not None) + problems += [f"decision {d}: commits {n} states" for d, n in used.items() if n > 1] + for lineage, head in conn.execute("SELECT lineage_id, current_state_id FROM lineage_heads"): + top = max(r[2] for r in states.values() if r[1] == lineage) + if head not in states or states[head][2] != top: + problems.append(f"lineage {lineage}: the head is not its newest state") + committed = { + r[0]: r[1] + for r in conn.execute( + "SELECT decision_id, committed_state_id FROM promotion_decisions " + "WHERE committed_state_id IS NOT NULL" + ) + } + for state_id, (_, _, version, _, decision) in states.items(): + if version > 0 and committed.get(decision) != state_id: + problems.append(f"{state_id}: no committed decision {decision} names it") + for decision, state_id in committed.items(): + if state_id not in states or states[state_id][4] != decision: + problems.append(f"decision {decision}: committed to a state that is not its own") + owners = conn.execute( + "SELECT s.state_id, s.version FROM trusted_states s WHERE s.version > 0" + ).fetchall() + for state_id, _ in owners: + decision = states[state_id][4] + if decision is None: + problems.append(f"{state_id}: a promoted state without a decision") + continue + binding = ids.evidence_id_for(decision) + rows = conn.execute( + "SELECT state_id, event_name FROM evidence_events WHERE evidence_id = ?", + (binding,), + ).fetchall() + if rows != [(state_id, "promotion.committed")]: + problems.append(f"{state_id}: its binding record is {rows}") + for (evidence_ids_json,) in conn.execute( + "SELECT evidence_ids FROM invariant_refs WHERE state_id = ?", (state_id,) + ): + if binding not in evidence_ids_json: + problems.append(f"{state_id}: a reference without the binding record") + events = conn.execute( + "SELECT COUNT(*) FROM audit_events WHERE event_type = 'STATE_PROMOTED' " + "AND state_id = ?", + (state_id,), + ).fetchone()[0] + if events != 1: + problems.append(f"{state_id}: {events} STATE_PROMOTED events") + parents = { + r[0]: r[1] + for r in conn.execute( + "SELECT invariant_id, invariant_version FROM invariant_refs WHERE state_id = ?", + (states[state_id][3],), + ) + } + mine = { + r[0]: r[1] + for r in conn.execute( + "SELECT invariant_id, invariant_version FROM invariant_refs WHERE state_id = ?", + (state_id,), + ) + } + for invariant, version in parents.items(): + if mine.get(invariant, 0) < version: + problems.append(f"{state_id}: {invariant} dropped or lowered") + for table in ("invariant_refs", "state_resources"): + orphans = conn.execute( + f"SELECT COUNT(*) FROM {table} WHERE state_id NOT IN " + "(SELECT state_id FROM trusted_states)" + ).fetchone()[0] + if orphans: + problems.append(f"{orphans} orphan rows in {table}") + for table, column in ( + ("evidence_events", "state_id"), + ("audit_events", "state_id"), + ): + orphans = conn.execute( + f"SELECT COUNT(*) FROM {table} WHERE {column} IS NOT NULL AND {column} NOT IN " + "(SELECT state_id FROM trusted_states)" + ).fetchone()[0] + if orphans: + problems.append(f"{orphans} orphan rows in {table}") + promoted = { + r[0] + for r in conn.execute("SELECT candidate_id FROM candidates WHERE status = 'PROMOTED'") + } + has_commit = { + r[0] + for r in conn.execute( + "SELECT candidate_id FROM promotion_decisions WHERE committed_state_id IS NOT NULL" + ) + } + if promoted != has_commit: + problems.append("a candidate is PROMOTED without a committed decision, or the reverse") + return problems + + +def dump(path: str | Path) -> dict[str, list[tuple[Any, ...]]]: + """Every row of every table of the file, in insertion order: two dumps are equal exactly when + nothing was written in between.""" + with closing(sqlite3.connect(path)) as conn: + tables = [ + r[0] + for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name" + ) + ] + return { + name: [tuple(row) for row in conn.execute(f"SELECT * FROM {name} ORDER BY rowid")] + for name in tables + } diff --git a/tests/promotion_pipeline.py b/tests/promotion_pipeline.py index 1c10594..b8a3890 100644 --- a/tests/promotion_pipeline.py +++ b/tests/promotion_pipeline.py @@ -95,6 +95,7 @@ def decide( *, trusted: TrustedState | None = None, attempt_number: int = 1, + decision_id: str = uid(9300), ) -> DecisionOutcome: return decide( self.candidate, @@ -102,7 +103,7 @@ def decide( self.impact, self.verification, policy or self.policy(), - decision_id=uid(9300), + decision_id=decision_id, now=NOW, attempt_number=attempt_number, ) @@ -146,8 +147,17 @@ def pipeline( *, stateful_full: bool = False, verifiers: VerifierTable = DEFAULT_VERIFIERS, + candidate_id: str = CANDIDATE_STATE, + parent_trusted: TrustedState | None = None, + sequence: int = 1, + patch_n: int = 7, ) -> PipelineRun: - """Everything up to the verification report for one fixture that has a parent.""" + """Everything up to the verification report for one fixture that has a parent. + + The extra keywords are P6b's (they let a test run a second candidate beside the first, or from + a state that a commit made): ``candidate_id``, ``sequence`` and ``patch_n`` name the candidate + and its patch, and ``parent_trusted`` replaces the state that is otherwise built from the + parent directory. With none of them the behaviour is that of P6a.""" data = fixture.data parent = data["parent"] assert parent is not None, data["fixture_id"] @@ -158,20 +168,22 @@ def pipeline( assert after.resource_set_hash is not None definitions = list(registry(source, destination).values()) semantic = [SemanticBinding("INV-FUNC-001", source, destination, "TCP", 5432)] - trusted = trusted_from(parent, before.resources, definitions) + trusted = parent_trusted or trusted_from(parent, before.resources, definitions) + parent_id = trusted.state_id + before_resources = trusted.resources if parent_trusted else before.resources identity = compare( - before.resources, + before_resources, after.resources, list(after.moves), - parent_state_id=PARENT_STATE, - candidate_id=CANDIDATE_STATE, + parent_state_id=parent_id, + candidate_id=candidate_id, now=NOW, ) trusted_graph = build_graph( - before.resources, definitions, semantic, state_id=PARENT_STATE, now=NOW + before_resources, definitions, semantic, state_id=parent_id, now=NOW ) candidate_graph = build_graph( - after.resources, definitions, semantic, state_id=CANDIDATE_STATE, now=NOW + after.resources, definitions, semantic, state_id=candidate_id, now=NOW ) priors = [ PriorProof( @@ -184,24 +196,24 @@ def pipeline( for ref in trusted.invariant_refs ] impact = compute_impact( - before.resources, + before_resources, after.resources, identity, trusted_graph, candidate_graph, definitions, priors, - parent_state_id=PARENT_STATE, - candidate_id=CANDIDATE_STATE, + parent_state_id=parent_id, + candidate_id=candidate_id, now=NOW, ) inputs = VerificationInputs( candidate_resources=after.resources, - parent_resources=before.resources, + parent_resources=before_resources, candidate_state_hash=after.resource_set_hash, normalization_version=NORMALIZATION_VERSION, - candidate_id=CANDIDATE_STATE, - parent_state_id=PARENT_STATE, + candidate_id=candidate_id, + parent_state_id=parent_id, identity=identity, impact=impact, candidate_graph=candidate_graph, @@ -211,10 +223,10 @@ def pipeline( ) candidate = CandidateState.create( parent=trusted, - patch=patch_for(trusted, n=7), - candidate_sequence=1, + patch=patch_for(trusted, n=patch_n), + candidate_sequence=sequence, now=NOW, - candidate_id=CANDIDATE_STATE, + candidate_id=candidate_id, ) candidate = finish_building(start_building(candidate), after.resources) candidate = transition_candidate(candidate, CandidateStatus.ANALYZING) diff --git a/tests/promotion_pipeline_commit.py b/tests/promotion_pipeline_commit.py new file mode 100644 index 0000000..e9827ed --- /dev/null +++ b/tests/promotion_pipeline_commit.py @@ -0,0 +1,131 @@ +"""The real pipeline's runs, stored in a database and committed (P6b step 6). Not a test module. + +``tests/promotion_pipeline.py`` (P6a) runs normalisation, identity, graphs, impact, the scope +policy, the verification engine and ``decide`` on a fixture. ``PipelineCommitCase`` puts such a +run in a SQLite file the way P6c's orchestrator will: the registry definitions, the trusted state +with its evidence, the candidate walked to ANALYZING, and the evidence record of every +verification result (this is the helper the P6b prompt allows in place of a pipeline that records +evidence; nothing here is production code). ``decide`` is the real one; nothing computes an +expected decision. +""" + +from __future__ import annotations + +from typing import Any + +from core.domain.enums import CandidateStatus +from core.domain.state import ( + CandidateState, + TrustedState, + finish_building, + start_building, + transition_candidate, +) +from core.promotion.commit import CommitResult, commit_promotion, record_decision +from core.verification.engine import to_evidence_submission +from tests.domain_builders import patch_for, uid +from tests.identity_builders import NOW as PIPELINE_NOW +from tests.persistence_builders import RepoCase, seed_evidence +from tests.promotion_commit_builders import CONTEXT, NOW +from tests.promotion_pipeline import PipelineRun + + +class PipelineCommitCase(RepoCase): + def install(self, run: PipelineRun) -> None: + """The definitions and the trusted state of ``run`` (with the evidence it links to).""" + with self.uow() as u: + for definition in run.definitions: + u.invariants.register_definition(definition) + self.seeded_evidence += seed_evidence(u, run.trusted, bound=True) + u.trusted_states.save_baseline(run.trusted) + + def store(self, run: PipelineRun, *, sequence: int = 1, patch_n: int = 7) -> CandidateState: + """The candidate of ``run``, walked CREATED to ANALYZING exactly as the pipeline did.""" + patch = patch_for(run.trusted, n=patch_n) + created = CandidateState.create( + parent=run.trusted, + patch=patch, + candidate_sequence=sequence, + now=PIPELINE_NOW, + candidate_id=run.candidate.candidate_id, + ) + building = start_building(created) + ready = finish_building(building, run.candidate.resources) + analyzing = transition_candidate(ready, CandidateStatus.ANALYZING) + assert analyzing == run.candidate + with self.uow() as u: + u.patches.save(patch) + u.candidates.create(created) + for step in (building, ready, analyzing): + u.candidates.save_transition(step) + return analyzing + + def store_results(self, run: PipelineRun, *, first: int) -> dict[str, str]: + """The evidence record of every verification result of ``run``, by verification id.""" + found: dict[str, str] = {} + with self.uow() as u: + for index, result in enumerate(run.verification.results, start=first): + evidence_id = uid(8800 + index) + u.evidence.append( + to_evidence_submission( + result, + evidence_id=evidence_id, + run_id=CONTEXT.run_id, + attempt_id=CONTEXT.attempt_id, + correlation_id=CONTEXT.correlation_id, + operation_id=CONTEXT.operation_id, + parent_state_id=run.trusted.state_id, + ) + ) + found[result.verification_id] = evidence_id + return found + + def prepare( + self, run: PipelineRun, *, sequence: int = 1, patch_n: int = 7, first: int = 0 + ) -> dict[str, str]: + """Store the candidate and the evidence of its results; return the evidence-id map.""" + self.store(run, sequence=sequence, patch_n=patch_n) + return self.store_results(run, first=first) + + def promote( + self, + run: PipelineRun, + evidence_ids: dict[str, str], + *, + decision_id: str, + stateful_full: bool = False, + record: bool = True, + **options: Any, + ) -> tuple[Any, CommitResult | None]: + """``decide``, ``record_decision``, and, for a PROMOTE, ``commit_promotion``.""" + policy = run.policy(stateful_full=stateful_full) + outcome = run.decide(policy, decision_id=decision_id) + if record: + with self.uow() as u: + record_decision(u, outcome, run.candidate, CONTEXT, now=NOW) + if outcome.decision.decision.value != "PROMOTE": + return outcome, None + result = commit_promotion( + lambda: self.uow(), + outcome, + run.candidate, + run.impact, + run.verification, + policy, + evidence_ids, + CONTEXT, + now=NOW, + **options, + ) + return outcome, result + + def current(self, run: PipelineRun) -> TrustedState: + with self.uow() as u: + state = u.trusted_states.get_current(run.trusted.lineage_id) + assert state is not None + return state + + def status(self, candidate_id: str) -> str: + return str( + self.scalar("SELECT status FROM candidates WHERE candidate_id = ?", (candidate_id,)) + ) diff --git a/tests/unit/test_lifecycle.py b/tests/unit/test_lifecycle.py index a24a9d6..d4e3386 100644 --- a/tests/unit/test_lifecycle.py +++ b/tests/unit/test_lifecycle.py @@ -54,7 +54,7 @@ I = InvariantStatus # noqa: E741 R = VerificationResult -# Doc 06 §5 as amended by C-29. +# Doc 06 §5 as amended by C-29 and C-38. LEGAL_CANDIDATE: set[tuple[C, C]] = { (C.CREATED, C.BUILDING), (C.BUILDING, C.READY), @@ -66,6 +66,7 @@ (C.ANALYZING, C.PROMOTABLE), (C.PROMOTABLE, C.PROMOTED), (C.PROMOTABLE, C.REJECTED), + (C.PROMOTABLE, C.ESCALATED), # C-38 (P6b) } # Doc 06 §9, plain (result-free) transitions only. diff --git a/tests/unit/test_persistence_repositories.py b/tests/unit/test_persistence_repositories.py index 5cdee6b..d91e6a7 100644 --- a/tests/unit/test_persistence_repositories.py +++ b/tests/unit/test_persistence_repositories.py @@ -161,7 +161,7 @@ def test_a_database_with_another_schema_version_is_refused(self) -> None: ) with self.assertRaises(PersistenceError) as ctx, SqliteUnitOfWork(other): pass - self.assertIn("schema version 5", str(ctx.exception)) + self.assertIn("schema version 6", str(ctx.exception)) def test_constructing_a_unit_of_work_does_not_create_the_file(self) -> None: missing = Path(self._tmp.name) / "later.db" @@ -193,7 +193,8 @@ def test_the_four_named_checkpoints_are_called_in_order_during_a_promotion(self) seed_evidence(u, new_state) u.trusted_states.save_promoted(new_state) u.candidates.save_transition(mark_promoted(promotable, new_state)) - self.assertEqual(seen, list(CHECKPOINTS)) + # P6b: saving the candidate's new status is itself a checkpoint, before the commit + self.assertEqual(seen, [*CHECKPOINTS[:3], "after_candidate_update", "before_commit"]) self.assertEqual( CHECKPOINTS, ( diff --git a/tests/unit/test_persistence_schema.py b/tests/unit/test_persistence_schema.py index b6993d0..1f81da2 100644 --- a/tests/unit/test_persistence_schema.py +++ b/tests/unit/test_persistence_schema.py @@ -55,7 +55,7 @@ "BUILDING": {"READY", "FAILED"}, "READY": {"ANALYZING"}, "ANALYZING": {"REJECTED", "RETRY_REQUIRED", "ESCALATED", "PROMOTABLE"}, - "PROMOTABLE": {"PROMOTED", "REJECTED"}, + "PROMOTABLE": {"PROMOTED", "REJECTED", "ESCALATED"}, # ESCALATED: C-38 (P6b) "FAILED": set(), "REJECTED": set(), "RETRY_REQUIRED": set(), @@ -162,6 +162,18 @@ "invariant_refs_insert_evidence_ids", } SCHEMA_5_INDEXES = 8 +# Schema 6 (P6b, C-165): the decision table, three indexes, six triggers, and the candidate trigger +# dropped and created again (C-38). +SCHEMA_6_TABLES = {"promotion_decisions"} +SCHEMA_6_TRIGGERS = { + "promotion_decisions_no_delete", + "promotion_decisions_no_replace", + "promotion_decisions_insert_rules", + "promotion_decisions_insert_uncommitted", + "promotion_decisions_update_identity", + "promotion_decisions_update_commit", +} +SCHEMA_6_STATEMENTS = 12 class DbCase(unittest.TestCase): @@ -347,8 +359,8 @@ def test_the_file_exists_only_after_initialization(self) -> None: self.assertFalse(self.path.exists()) initialize_database(self.path) self.assertTrue(self.path.exists()) - self.assertEqual(schema_version(self.path), "5") - self.assertEqual(SCHEMA_VERSION, "5") + self.assertEqual(schema_version(self.path), "6") + self.assertEqual(SCHEMA_VERSION, "6") def test_initialization_is_idempotent(self) -> None: initialize_database(self.path) @@ -359,7 +371,7 @@ def test_initialization_is_idempotent(self) -> None: after = conn.execute("SELECT type, name, sql FROM sqlite_master ORDER BY 2").fetchall() versions = conn.execute("SELECT * FROM schema_meta").fetchall() self.assertEqual(before, after) - self.assertEqual(versions, [("schema_version", "5")]) + self.assertEqual(versions, [("schema_version", "6")]) def test_the_parent_directory_is_created(self) -> None: nested = Path(self._tmp.name) / "nested" / "dirs" / "t.db" @@ -371,9 +383,12 @@ def test_exactly_the_step_4_tables_are_created(self) -> None: # evidence and audit tables; AUTOINCREMENT adds sqlite_sequence. initialize_database(self.path) self.assertLessEqual(EXPECTED_TABLES, self.tables()) - self.assertEqual(self.tables(), EXPECTED_TABLES | SCHEMA_5_TABLES | {"sqlite_sequence"}) + self.assertEqual( + self.tables(), + EXPECTED_TABLES | SCHEMA_5_TABLES | SCHEMA_6_TABLES | {"sqlite_sequence"}, + ) self.assertLessEqual(EXPECTED_TABLES, set(TABLE_NAMES)) - self.assertEqual(set(TABLE_NAMES), EXPECTED_TABLES | SCHEMA_5_TABLES) + self.assertEqual(set(TABLE_NAMES), EXPECTED_TABLES | SCHEMA_5_TABLES | SCHEMA_6_TABLES) def test_exactly_the_expected_triggers_are_created(self) -> None: initialize_database(self.path) @@ -383,13 +398,17 @@ def test_exactly_the_expected_triggers_are_created(self) -> None: for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'trigger'") } self.assertLessEqual(EXPECTED_TRIGGERS, names) # every schema-4 trigger is kept - self.assertEqual(names, EXPECTED_TRIGGERS | SCHEMA_5_TRIGGERS) + self.assertEqual(names, EXPECTED_TRIGGERS | SCHEMA_5_TRIGGERS | SCHEMA_6_TRIGGERS) self.assertEqual( len(DDL[:SCHEMA_4_STATEMENTS]), len(EXPECTED_TABLES) + len(EXPECTED_TRIGGERS) ) self.assertEqual( len(DDL), - SCHEMA_4_STATEMENTS + len(SCHEMA_5_TABLES) + SCHEMA_5_INDEXES + len(SCHEMA_5_TRIGGERS), + SCHEMA_4_STATEMENTS + + len(SCHEMA_5_TABLES) + + SCHEMA_5_INDEXES + + len(SCHEMA_5_TRIGGERS) + + SCHEMA_6_STATEMENTS, ) def test_connections_have_foreign_keys_on(self) -> None: @@ -408,14 +427,14 @@ def test_a_schema_3_database_is_refused_and_left_unchanged(self) -> None: initialize_database(self.path) message = str(ctx.exception) self.assertIn("schema version 3", message) - self.assertIn("needs 5", message) + self.assertIn("needs 6", message) self.assertIn("C-44", message) self.assertIn("delete", message) self.assertEqual(self.tables(), {"schema_meta", "trusted_state"}) self.assertEqual(schema_version(self.path), "3") def test_any_other_schema_version_is_refused(self) -> None: - for version in ("1", "2", "4", "6", "unknown"): + for version in ("1", "2", "4", "5", "unknown"): with self.subTest(version=version): path = Path(self._tmp.name) / f"v{version}.db" with closing(sqlite3.connect(path)) as conn: @@ -495,7 +514,7 @@ def test_the_trigger_pair_list_equals_the_domain_table(self) -> None: def test_the_trigger_pair_list_equals_doc_06_section_5(self) -> None: by_hand = {(old, new) for old, news in LEGAL.items() for new in news} - self.assertEqual(len(by_hand), 10) + self.assertEqual(len(by_hand), 11) # ten of Doc 06 §5 and PROMOTABLE -> ESCALATED (C-38) self.assertEqual(self.trigger_pairs(), by_hand) def test_every_candidate_status_has_a_check_constraint_value(self) -> None: diff --git a/tests/unit/test_promotion_binding.py b/tests/unit/test_promotion_binding.py new file mode 100644 index 0000000..d91124a --- /dev/null +++ b/tests/unit/test_promotion_binding.py @@ -0,0 +1,483 @@ +"""The evidence binding of a promotion, one condition at a time (P6b step 5; Doc 11 §8, §9, §44, +§45; C-52, C-60, C-68, C-164). + +HAND EXPECTATIONS, written before the code (the canonical world of ``tests/promotion_builders.py``): + +* ``integrity_of`` answers VALID, TAMPERED or MISSING_PAYLOAD exactly as + ``EvidenceService.verify_integrity`` does for the same stored record (the two are written apart + because ``core.promotion`` never imports ``evidence/``). +* ``binding_problems`` of a sound plan is empty. A VERIFIED entry is a problem when its result or + its evidence record is missing, when the record is not VERIFICATION evidence produced for this + candidate with its hash, when its subject (invariant, version, verifier, result) is another one + (C-68), or when the definition it is checked against is missing. A CARRIED_FORWARD entry is a + problem when the plan's evidence is not the parent reference's, when the parent has no such + reference, when a baseline's evidence is not bound to the baseline, or when a promoted parent's + binding record is not carried, not a ``promotion.committed`` STATE record, not usable as proof for + that parent, or does not name the decision, the state and the invariant. +* A record that cannot be read at all (its stored columns disagree with its content, DATA-INT-010) + is a problem, not an exception. +""" + +from __future__ import annotations + +import dataclasses +from typing import Any + +from core.domain.evidence import ArtifactRef, EvidenceKind, EvidenceSubmission, IntegrityStatus +from core.domain.hashing import canonical_json +from core.domain.promotion import ProofSource +from core.domain.state import TrustedState +from core.promotion import ids +from core.promotion.binding import ( + BINDING_EVENT_NAME, + binding_payload, + binding_problems, + integrity_of, +) +from evidence.service import EvidenceService +from tests.domain_builders import uid +from tests.promotion_builders import FAIL, FUNC1, SEC1, SEC2, SEC3, record, run, target +from tests.promotion_commit_builders import CommitCase + +DECISION = uid(9100) + + +class BindingCase(CommitCase): + def setUp(self) -> None: + super().setUp() + self.outcome = run(self.world) + self.plan = self.outcome.proof_plan + self.results = {r.verification_id: r for r in self.world.verification.results} + + def problems(self, **changes: Any) -> list[str]: + arguments: dict[str, Any] = { + "plan": self.plan, + "candidate": self.world.candidate, + "parent": self.world.trusted, + "definitions": self.world.definitions, + "results": self.results, + "evidence_ids": self.evidence_ids, + } + arguments.update(changes) + with self.uow() as u: + return binding_problems( + u.evidence, + arguments["plan"], + arguments["candidate"], + arguments["parent"], + arguments["definitions"], + arguments["results"], + arguments["evidence_ids"], + ) + + def entry(self, invariant_id: str) -> Any: + return next(e for e in self.plan if e.invariant_id == invariant_id) + + def evidence_of(self, invariant_id: str) -> str: + return self.evidence_ids[self.entry(invariant_id).verification_id or ""] + + def store_variant(self, which: str, **payload_changes: Any) -> str: + """A second VERIFICATION record for the same result, with some payload keys changed.""" + return self.forged_copy( + self.evidence_of(which), uid(8950 + len(payload_changes)), **payload_changes + ) + + def forged_copy( + self, + original: str, + new_id: str, + *, + state_hash: str | None = None, + **payload_changes: Any, + ) -> str: + """A readable copy of a stored record under a new id, with some payload keys and perhaps + its ``state_hash`` changed. It is built whole (the columns agree with the content), so the + adapter reads it; the triggers that would refuse it are dropped first, the way an attacker + with file access would.""" + self.tamper("SELECT 1") + with self.uow() as u: + event = u.evidence.get(original) + assert event is not None + payload = dict(u.evidence.resolve(ArtifactRef.parse(event.payload_ref))) + variant = EvidenceSubmission.create( + payload={**payload, **payload_changes}, + evidence_id=new_id, + run_id=event.run_id, + attempt_id=event.attempt_id, + correlation_id=event.correlation_id, + operation_id=event.operation_id, + kind=event.kind, + event_name=event.event_name, + provenance=event.provenance, + created_at=event.created_at, + state_id=event.state_id, + candidate_id=event.candidate_id, + parent_state_id=event.parent_state_id, + state_hash=state_hash or event.state_hash, + ) + u.evidence.append(variant) + return variant.event.evidence_id + + def with_variant(self, which: str, **payload_changes: Any) -> list[str]: + mapped = { + **self.evidence_ids, + self.entry(which).verification_id or "": self.store_variant(which, **payload_changes), + } + return self.problems(evidence_ids=mapped) + + +class TestIntegrityAgrees(CommitCase): + """``integrity_of`` and ``EvidenceService.verify_integrity`` on the same stored record.""" + + def both(self, evidence_id: str) -> tuple[IntegrityStatus, IntegrityStatus]: + with self.uow() as u: + event = u.evidence.get(evidence_id) + assert event is not None + mine = integrity_of(u.evidence, event) + return mine, EvidenceService().verify_integrity(u, evidence_id) + + def a_record(self) -> tuple[str, str]: + evidence_id = next(iter(self.evidence_ids.values())) + with self.uow() as u: + event = u.evidence.get(evidence_id) + assert event is not None + return evidence_id, event.content_hash + + def test_a_sound_record_is_valid_for_both(self) -> None: + evidence_id, _ = self.a_record() + self.assertEqual(self.both(evidence_id), (IntegrityStatus.VALID, IntegrityStatus.VALID)) + + def test_an_edited_payload_is_tampered_for_both(self) -> None: + evidence_id, digest = self.a_record() + self.tamper( + "UPDATE evidence_artifacts SET payload_json = ? WHERE content_hash = ?", + ('{"result":"PASS"}', digest), + ) + self.assertEqual( + self.both(evidence_id), (IntegrityStatus.TAMPERED, IntegrityStatus.TAMPERED) + ) + + def test_an_edit_that_keeps_the_value_is_tampered_for_both(self) -> None: + evidence_id, digest = self.a_record() + with self.uow() as u: + event = u.evidence.get(evidence_id) + assert event is not None + text = u.evidence.resolve(ArtifactRef.parse(event.payload_ref)) + import json + + spaced = json.dumps(text, indent=2, sort_keys=True) + self.tamper( + "UPDATE evidence_artifacts SET payload_json = ? WHERE content_hash = ?", + (spaced, digest), + ) + self.assertEqual( + self.both(evidence_id), (IntegrityStatus.TAMPERED, IntegrityStatus.TAMPERED) + ) + + def test_a_deleted_payload_is_missing_for_both(self) -> None: + evidence_id, digest = self.a_record() + self.tamper("DELETE FROM evidence_artifacts WHERE content_hash = ?", (digest,)) + self.assertEqual( + self.both(evidence_id), + (IntegrityStatus.MISSING_PAYLOAD, IntegrityStatus.MISSING_PAYLOAD), + ) + + +class TestSoundPlan(BindingCase): + def test_a_sound_plan_has_no_problem(self) -> None: + self.assertEqual(self.problems(), []) + + def test_the_plan_has_both_kinds_of_source(self) -> None: + sources = {e.invariant_id: e.source for e in self.plan} + self.assertEqual(sources[SEC1], ProofSource.VERIFIED) + self.assertEqual(sources[SEC2], ProofSource.CARRIED_FORWARD) + + +class TestVerifiedSources(BindingCase): + def test_a_result_that_is_not_in_the_report(self) -> None: + found = self.problems(results={}) + self.assertTrue(any("no verification result" in p for p in found), found) + + def test_an_evidence_record_that_is_not_in_the_map(self) -> None: + found = self.problems(evidence_ids={}) + self.assertTrue(any("no verification result or no evidence" in p for p in found), found) + + def test_an_evidence_id_that_names_nothing(self) -> None: + mapped = {**self.evidence_ids, self.entry(SEC1).verification_id or "": uid(4242)} + found = self.problems(evidence_ids=mapped) + self.assertTrue(any("does not exist" in p for p in found), found) + + def test_a_record_produced_for_the_baseline_and_not_for_the_candidate(self) -> None: + baseline_evidence = self.world.trusted.invariant_refs[0].evidence_ids[0] + mapped = {**self.evidence_ids, self.entry(SEC1).verification_id or "": baseline_evidence} + found = self.problems(evidence_ids=mapped) + self.assertTrue(any("P3" in p for p in found), found) + self.assertTrue(any("not VERIFICATION evidence bound to the candidate" in p for p in found)) + + def test_a_record_of_another_invariant(self) -> None: + found = self.with_variant(SEC1, invariant_id=SEC2) + self.assertTrue(any("invariant_id" in p and "(C-68)" in p for p in found), found) + + def test_a_record_of_another_invariant_version(self) -> None: + found = self.with_variant(SEC1, invariant_version=2) + self.assertTrue(any("invariant_version" in p for p in found), found) + + def test_a_record_of_another_verifier_version(self) -> None: + found = self.with_variant(SEC1, verifier_version="99") + self.assertTrue(any("verifier_version" in p for p in found), found) + + def test_a_record_whose_payload_says_fail(self) -> None: + found = self.with_variant(FUNC1, result="FAIL") + self.assertTrue(any("'result'" in p or "result is not" in p for p in found), found) + + def test_a_readable_record_with_another_state_hash_is_not_proof(self) -> None: + # P6 of evaluate_proof: the record is whole and readable; only its hash is not the + # candidate's + original = self.evidence_of(SEC1) + forged = self.forged_copy(original, uid(8960), state_hash="7" * 64) + mapped = {**self.evidence_ids, self.entry(SEC1).verification_id or "": forged} + found = self.problems(evidence_ids=mapped) + self.assertTrue(any("P6" in p for p in found), found) + + def test_a_record_of_another_candidate_hash(self) -> None: + found = self.with_variant(SEC1, candidate_hash="0" * 64) + self.assertTrue(any("candidate_hash" in p for p in found), found) + + def test_a_result_that_is_not_a_pass(self) -> None: + failing = record( + self.world.candidate, + target(SEC1), + FAIL, + definition=self.world.definitions[SEC1], + n=3, + ) + failing = dataclasses.replace( + failing, verification_id=self.entry(SEC1).verification_id or "" + ) + found = self.problems(results={**self.results, failing.verification_id: failing}) + self.assertTrue(any("not a PASS" in p for p in found), found) + + def test_a_definition_that_is_missing(self) -> None: + found = self.problems(definitions={}) + self.assertTrue(any("no definition" in p for p in found), found) + + def test_a_definition_of_another_version(self) -> None: + other = dataclasses.replace(self.world.definitions[SEC1], version=2) + found = self.problems(definitions={**self.world.definitions, SEC1: other}) + self.assertTrue(any("no definition" in p for p in found), found) + + def test_an_unreadable_record_is_a_problem_and_not_an_exception(self) -> None: + evidence_id = self.evidence_of(SEC1) + self.tamper( + "UPDATE evidence_events SET state_hash = ? WHERE evidence_id = ?", + ("1" * 64, evidence_id), + ) + found = self.problems() + self.assertTrue(any("cannot be read" in p for p in found), found) + + +class TestCarriedSources(BindingCase): + def test_a_plan_whose_evidence_is_not_the_parents(self) -> None: + carried = self.entry(SEC2) + forged = dataclasses.replace(carried, evidence_ids=(uid(4243),)) + plan = tuple(forged if e is carried else e for e in self.plan) + found = self.problems(plan=plan) + self.assertTrue(any("not the parent reference's evidence" in p for p in found), found) + + def test_an_invariant_the_parent_does_not_hold(self) -> None: + carried = self.entry(SEC2) + stranger = dataclasses.replace(carried, invariant_id="INV-SEC-099") + plan = tuple(stranger if e is carried else e for e in self.plan) + found = self.problems(plan=plan) + self.assertTrue(any("no reference to it" in p for p in found), found) + + def test_a_baseline_record_that_is_not_bound_to_the_baseline(self) -> None: + evidence_id = self.entry(SEC2).evidence_ids[0] + self.tamper( + "UPDATE evidence_events SET state_id = NULL, state_hash = NULL, parent_state_id = NULL " + "WHERE evidence_id = ?", + (evidence_id,), + ) + found = self.problems() + self.assertGreaterEqual(len(found), 1) + + def test_a_readable_baseline_record_with_another_state_hash_is_not_proof(self) -> None: + carried = self.entry(SEC2) + forged = self.forged_copy(carried.evidence_ids[0], uid(8961), state_hash="7" * 64) + data = self.world.trusted.to_dict() + for ref in data["invariant_refs"]: + if ref["invariant_id"] == SEC2: + ref["evidence_ids"] = [forged] + data["evidence_refs"] = sorted({*data["evidence_refs"], forged}) + parent = TrustedState.from_dict(data) + swapped = dataclasses.replace(carried, evidence_ids=(forged,)) + plan = tuple(swapped if e is carried else e for e in self.plan) + found = self.problems(plan=plan, parent=parent) + self.assertTrue(any("P6" in p for p in found), found) + + def test_a_baseline_record_with_another_state_hash(self) -> None: + evidence_id = self.entry(SEC3).evidence_ids[0] + self.tamper( + "UPDATE evidence_events SET state_hash = ? WHERE evidence_id = ?", + ("4" * 64, evidence_id), + ) + found = self.problems() + self.assertTrue(any("cannot be read" in p or "P6" in p for p in found), found) + + +class TestPromotedParent(BindingCase): + """The parent is v1, promoted by DECISION; its carried references rest on its binding record.""" + + def setUp(self) -> None: + super().setUp() + first = self.commit(outcome=self.outcome) + assert first.state is not None + self.v1 = first.state + self.world2 = self.next_world(self.v1) + self.outcome2 = run(self.world2, decision_id=uid(9300)) + self.plan2 = self.outcome2.proof_plan + self.results2 = {r.verification_id: r for r in self.world2.verification.results} + self.store_candidate(self.world2, n=2, sequence=2) + self.evidence_ids2 = self.store_results(self.world2, first=60) + + def problems2(self, **changes: Any) -> list[str]: + arguments: dict[str, Any] = { + "plan": self.plan2, + "candidate": self.world2.candidate, + "parent": self.v1, + "definitions": self.world2.definitions, + "results": self.results2, + "evidence_ids": self.evidence_ids2, + } + arguments.update(changes) + with self.uow() as u: + return binding_problems(u.evidence, **arguments) + + def test_the_chain_is_sound(self) -> None: + self.assertEqual(self.problems2(), []) + self.assertIn( + ProofSource.CARRIED_FORWARD, {e.source for e in self.plan2 if e.invariant_id == SEC1} + ) + + def test_a_reference_that_does_not_carry_the_parents_binding_record(self) -> None: + binding_id = ids.evidence_id_for(DECISION) + carried = next(e for e in self.plan2 if e.invariant_id == SEC1) + thinned = dataclasses.replace( + carried, evidence_ids=tuple(e for e in carried.evidence_ids if e != binding_id) + ) + data = self.v1.to_dict() + for ref in data["invariant_refs"]: + if ref["invariant_id"] == SEC1: + ref["evidence_ids"] = list(thinned.evidence_ids) + parent = TrustedState.from_dict(data) + plan = tuple(thinned if e is carried else e for e in self.plan2) + found = self.problems2(parent=parent, plan=plan) + wanted = "does not carry the parent's binding record" + self.assertTrue(any(wanted in p for p in found), found) + + def test_the_binding_record_is_a_state_record_named_promotion_committed(self) -> None: + with self.uow() as u: + event = u.evidence.get(ids.evidence_id_for(DECISION)) + assert event is not None + self.assertIs(event.kind, EvidenceKind.STATE) + self.assertEqual(event.event_name, BINDING_EVENT_NAME) + self.assertEqual(BINDING_EVENT_NAME, "promotion.committed") + + def test_a_binding_record_that_does_not_name_the_invariant(self) -> None: + with self.uow() as u: + event = u.evidence.get(ids.evidence_id_for(DECISION)) + assert event is not None + payload = u.evidence.resolve(ArtifactRef.parse(event.payload_ref)) + trimmed = { + **payload, + "invariants": [i for i in payload["invariants"] if i["invariant_id"] != SEC1], + } + self.replace_binding(trimmed) + found = self.problems2() + self.assertTrue(any("does not name this invariant" in p for p in found), found) + + def test_a_binding_record_of_another_decision(self) -> None: + with self.uow() as u: + event = u.evidence.get(ids.evidence_id_for(DECISION)) + assert event is not None + payload = u.evidence.resolve(ArtifactRef.parse(event.payload_ref)) + other = {**payload, "decision": {**payload["decision"], "decision_id": uid(1)}} + self.replace_binding(other) + found = self.problems2() + self.assertTrue(any("another decision" in p for p in found), found) + + def test_a_binding_record_of_another_state(self) -> None: + with self.uow() as u: + event = u.evidence.get(ids.evidence_id_for(DECISION)) + assert event is not None + payload = u.evidence.resolve(ArtifactRef.parse(event.payload_ref)) + other = { + **payload, + "promotion_evidence": {**payload["promotion_evidence"], "new_state_id": uid(1)}, + } + self.replace_binding(other) + found = self.problems2() + self.assertTrue(any("another state" in p for p in found), found) + + def replace_binding(self, payload: dict[str, Any]) -> None: + """Rewrite the binding record of v1 as a record whose payload is ``payload`` and whose + columns are otherwise the same (an attacker with file access who also fixes the hash).""" + binding_id = ids.evidence_id_for(DECISION) + with self.uow() as u: + event = u.evidence.get(binding_id) + assert event is not None + forged = EvidenceSubmission.create( + payload=payload, + evidence_id=binding_id, + run_id=event.run_id, + attempt_id=event.attempt_id, + correlation_id=event.correlation_id, + operation_id=event.operation_id, + kind=event.kind, + event_name=event.event_name, + provenance=event.provenance, + created_at=event.created_at, + state_id=event.state_id, + parent_state_id=event.parent_state_id, + state_hash=event.state_hash, + ) + self.tamper( + "UPDATE evidence_events SET content_hash = ?, payload_ref = ?, content_json = ? " + "WHERE evidence_id = ?", + ( + forged.event.content_hash, + forged.event.payload_ref, + canonical_json(forged.event.to_dict()), + binding_id, + ), + ) + self.tamper( + "INSERT INTO evidence_artifacts (content_hash, payload_json) VALUES (?, ?)", + (forged.event.content_hash, forged.payload_json), + ) + + +class TestBindingPayload(CommitCase): + def test_the_payload_names_every_invariant_once_with_its_source(self) -> None: + outcome = run(self.world) + state = self.commit(outcome=outcome).state + assert state is not None + payload = binding_payload( + outcome.decision.committed(state.state_id), + outcome.evidence.with_new_state(state.state_id), + outcome.proof_plan, + state, + self.evidence_ids, + ) + named = [i["invariant_id"] for i in payload["invariants"]] + self.assertEqual(named, sorted({SEC1, SEC2, SEC3, FUNC1})) + self.assertEqual(payload["state"]["state_hash"], state.state_hash) + self.assertEqual(payload["state"]["version"], 1) + self.assertEqual(payload["decision"]["committed_state_id"], state.state_id) + self.assertEqual(payload["promotion_evidence"]["new_state_id"], state.state_id) + + +if __name__ == "__main__": + import unittest + + unittest.main() diff --git a/tests/unit/test_promotion_c162.py b/tests/unit/test_promotion_c162.py new file mode 100644 index 0000000..7d999fd --- /dev/null +++ b/tests/unit/test_promotion_c162.py @@ -0,0 +1,289 @@ +"""C-162, the blocking condition of the P6a verdict: a target id has at most one result, whatever +its type (P6b step 1; Doc 09 §18, §30, Doc 12 §17). + +HAND EXPECTATIONS, written before the fix and before any run of ``decide`` on these reports: + +(1) ``VerificationReport`` refuses two results with the same ``target_id`` even when their target + types differ. +(2) ``decide`` looks each required item up by the type its kind requires (INVARIANT for an + invariant, RESOURCE for a baseline item, STRUCTURAL for a gate) and examines every result whose + target id equals a required id. +(3) A report that holds a second result for a required target id, or a result of the wrong type for + it, is an INCONSISTENT_REPORTS REJECT condition, and a FAIL among those results stays blocking. + +So in the canonical world (``tests/promotion_builders.py``): an INVARIANT-typed FAIL for +INV-SEC-001, +INV-FUNC-001 or a ``RESOURCE:`` baseline item, beside a STRUCTURAL-typed PASS with the same target +id, is REJECT with INCONSISTENT_REPORTS *and* REQUIRED_CHECK_FAILED among the blocking conditions +(never PROMOTE). A genuine PASS beside a decoy FAIL is REJECT too. A wrong-type result alone is not +proof: the item has no proof (INSUFFICIENT_EVIDENCE) and the report is inconsistent. + +Such a report cannot be built through the domain once (1) holds, so the tests inject the extra +result into a valid report and recompute its hash (which is what a corrupted store could give). +""" + +from __future__ import annotations + +import dataclasses +import random +import unittest +from collections.abc import Iterable + +from core.domain.enums import DecisionType, VerificationResult +from core.domain.errors import DomainValidationError +from core.domain.promotion import DecisionReason +from core.domain.verification import ( + VerificationReport, + VerificationResultRecord, + VerificationTargetType, + _summary, +) +from tests.domain_builders import uid +from tests.promotion_builders import ( + ALL, + BASELINE_ITEM, + ERROR, + FAIL, + FUNC1, + GATES, + PASS, + SEC1, + UNKNOWN, + UNSUPPORTED, + World, + make_world, + record, + report_of, + run, + target, +) + +D, R, T = DecisionType, DecisionReason, VerificationTargetType + + +def with_extra( + world: World, + extras: Iterable[VerificationResultRecord] = (), + drop: Iterable[tuple[VerificationTargetType, str]] = (), +) -> World: + """The world with ``extras`` added to the report and the ``drop`` pairs removed, without going + through the domain checks (the hash is recomputed so that it is not what stops the decision).""" + gone = set(drop) + kept = [r for r in world.verification.results if (r.target_type, r.target_id) not in gone] + report = report_of(world.candidate, kept) + ordered = tuple(sorted([*kept, *extras], key=VerificationResultRecord.sort_key)) + object.__setattr__(report, "results", ordered) + object.__setattr__(report, "content_hash", report.compute_hash()) + return dataclasses.replace(world, verification=report) + + +def decoy( + world: World, + target_id: str, + result: VerificationResult = PASS, + as_type: VerificationTargetType = T.STRUCTURAL, + n: int = 0, +) -> VerificationResultRecord: + """A result for ``target_id`` under another target type (a STRUCTURAL-typed PASS by default).""" + base = world.records[target_id] + return dataclasses.replace( + base, + target_type=as_type, + verification_id=uid(7500 + n), + result=result, + reason=None if result is PASS else record(world.candidate, "STRUCTURAL", FAIL).reason, + evidence={**base.evidence, "result": result.value}, + ) + + +def codes(world: World) -> list[DecisionReason]: + return [c.code for c in run(world).decision.blocking_conditions] + + +class TestTheDomainRefusesTwoResultsForOneTargetId(unittest.TestCase): + """C-162 (1).""" + + def test_two_results_for_one_target_id_under_two_types_are_refused(self) -> None: + world = make_world() + good = world.records[target(FUNC1)] + other = decoy(world, target(FUNC1)) + self.assertNotEqual(good.target_type, other.target_type) + self.assertEqual(good.target_id, other.target_id) + results = [*world.records.values(), other] + with self.assertRaises(DomainValidationError) as caught: + VerificationReport.create( + candidate_id=world.candidate.candidate_id, + candidate_hash=world.candidate.state_hash or "", + results=results, + verifier_invocations=len(results), + verifier_set_version="dail-verify-1", + ) + self.assertIn("target", str(caught.exception)) + + def test_the_gate_and_the_baseline_item_are_protected_too(self) -> None: + world = make_world() + for tid in ("STRUCTURAL", BASELINE_ITEM): + other = decoy(world, tid, as_type=T.SECURITY_PREDICATE) + results = [*world.records.values(), other] + with self.subTest(target=tid), self.assertRaises(DomainValidationError): + VerificationReport.create( + candidate_id=world.candidate.candidate_id, + candidate_hash=world.candidate.state_hash or "", + results=results, + verifier_invocations=len(results), + verifier_set_version="dail-verify-1", + ) + + def test_a_stored_report_with_two_results_for_one_target_id_is_refused_on_reading(self) -> None: + world = with_extra(make_world(), [decoy(make_world(), target(FUNC1))]) + report = world.verification + data = report.to_dict() + # a store that was corrupted consistently: the summary and the hash match the results + data["summary"] = _summary(report.results, report.verifier_invocations) + self.assertEqual(data["content_hash"], report.compute_hash()) + with self.assertRaises(DomainValidationError) as caught: + VerificationReport.from_dict(data) + self.assertIn("target", str(caught.exception)) + + def test_distinct_target_ids_are_still_accepted(self) -> None: + world = make_world() + self.assertEqual(len(world.verification.results), len(world.records)) + + +class TestTheReviewersReproductions(unittest.TestCase): + """C-162 (2) and (3): the decoy STRUCTURAL-typed PASS beside an INVARIANT FAIL (or a RESOURCE + FAIL) hid the FAIL and gave PROMOTE.""" + + def check(self, tid: str) -> None: + world = make_world(results={tid: FAIL}) + world = with_extra(world, [decoy(world, tid, PASS)]) + found = codes(world) + out = run(world) + self.assertEqual(out.decision.decision, D.REJECT) + self.assertIn(R.INCONSISTENT_REPORTS, found) + self.assertIn(R.REQUIRED_CHECK_FAILED, found) # the FAIL stays blocking + self.assertEqual(out.decision.reason, R.INCONSISTENT_REPORTS) + + def test_inv_sec_001(self) -> None: + self.check(target(SEC1)) + + def test_inv_func_001(self) -> None: + self.check(target(FUNC1)) + + def test_a_resource_baseline_item(self) -> None: + self.check(BASELINE_ITEM) + + +class TestEveryMatchingResultIsExamined(unittest.TestCase): + def test_a_decoy_fail_beside_a_genuine_pass_is_blocking(self) -> None: + world = make_world() + world = with_extra(world, [decoy(world, target(FUNC1), FAIL)]) + out = run(world) + self.assertEqual(out.decision.decision, D.REJECT) + self.assertIn(R.INCONSISTENT_REPORTS, [c.code for c in out.decision.blocking_conditions]) + self.assertIn(R.REQUIRED_CHECK_FAILED, [c.code for c in out.decision.blocking_conditions]) + + def test_a_duplicate_pass_alone_is_still_an_inconsistent_report(self) -> None: + world = make_world() + world = with_extra(world, [decoy(world, target(SEC1), PASS)]) + out = run(world) + self.assertEqual( + (out.decision.decision, out.decision.reason), (D.REJECT, R.INCONSISTENT_REPORTS) + ) + # the item that has two results is not proven by either + self.assertNotIn(SEC1, {e.invariant_id for e in out.proof_plan}) + + def test_a_duplicate_gate_result_is_inconsistent_and_its_fail_is_blocking(self) -> None: + world = make_world() + world = with_extra(world, [decoy(world, "STRUCTURAL", FAIL, as_type=T.SECURITY_PREDICATE)]) + found = codes(world) + self.assertIn(R.INCONSISTENT_REPORTS, found) + self.assertIn(R.STRUCTURAL_FAILURE, found) + self.assertEqual(run(world).decision.decision, D.REJECT) + + def test_a_result_of_the_wrong_type_alone_is_not_proof(self) -> None: + world = make_world() + world = with_extra( + world, + [decoy(world, target(FUNC1), PASS)], + drop=[(T.INVARIANT, target(FUNC1))], + ) + out = run(world) + found = [c.code for c in out.decision.blocking_conditions] + self.assertEqual(out.decision.decision, D.REJECT) + self.assertIn(R.INCONSISTENT_REPORTS, found) + self.assertIn(R.INSUFFICIENT_EVIDENCE, found) + self.assertNotIn(FUNC1, {e.invariant_id for e in out.proof_plan}) + + def test_a_decoy_for_an_id_nobody_requires_is_ignored(self) -> None: + world = make_world() + stray = dataclasses.replace( + world.records["STRUCTURAL"], + target_type=T.SECURITY_PREDICATE, + target_id="SOMETHING-ELSE", + verification_id=uid(7600), + ) + out = run(with_extra(world, [stray])) + self.assertEqual(out.decision.decision, D.PROMOTE) + + def test_a_clean_report_is_unchanged(self) -> None: + self.assertEqual(run(make_world()).decision.decision, D.PROMOTE) + + +class TestAFailNeverPromotesWhateverTheDecoys(unittest.TestCase): + """The property: for any report, a FAIL on a required target never gives PROMOTE, even with + decoy results of other types, and the order of the results does not matter.""" + + SEEDS = (20261029, 1, 7, 424242, 99991) + REQUIRED = (*GATES, *(target(i) for i in ALL), BASELINE_ITEM) + TYPES = (T.STRUCTURAL, T.SECURITY_PREDICATE, T.RESOURCE, T.INVARIANT) + VALUES = (PASS, FAIL, UNKNOWN, UNSUPPORTED, ERROR) + + def generated(self, rng: random.Random) -> tuple[World, bool]: + failing = rng.sample(self.REQUIRED, rng.randint(0, 2)) + world = make_world(results={t: FAIL for t in failing if t in self.owed()}) + genuine_fail = any(t in self.owed() for t in failing) + extras = [] + for k in range(rng.randint(0, 4)): + tid = rng.choice([t for t in self.REQUIRED if t in world.records]) + as_type = rng.choice([t for t in self.TYPES if t != world.records[tid].target_type]) + value = rng.choice(self.VALUES) + try: + extras.append(decoy(world, tid, value, as_type=as_type, n=k)) + except DomainValidationError: + continue + return with_extra(world, extras), genuine_fail or any( + e.result is FAIL and e.target_id in self.REQUIRED for e in extras + ) + + def owed(self) -> set[str]: + return {*GATES, target(SEC1), target(FUNC1), BASELINE_ITEM} + + def test_a_fail_on_a_required_target_never_promotes(self) -> None: + promoted_clean = 0 + for seed in self.SEEDS: + rng = random.Random(seed) + for n in range(40): + world, has_fail = self.generated(rng) + out = run(world) + if has_fail: + self.assertIs(out.decision.decision, D.REJECT, f"seed {seed} world {n}") + promoted_clean += out.decision.decision is D.PROMOTE + # decoys of any type spoil a report: the clean ones still promote, and not every world does + self.assertGreater(promoted_clean, 0) + + def test_the_decision_does_not_depend_on_the_order_of_the_results(self) -> None: + rng = random.Random(self.SEEDS[0]) + for _ in range(60): + world, _ = self.generated(rng) + want = run(world) + shuffled = list(world.verification.results) + rng.shuffle(shuffled) + report = world.verification + object.__setattr__(report, "results", tuple(shuffled)) + object.__setattr__(report, "content_hash", report.compute_hash()) + self.assertEqual(run(world).decision.content_hash, want.decision.content_hash) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_promotion_carry.py b/tests/unit/test_promotion_carry.py new file mode 100644 index 0000000..5f79b33 --- /dev/null +++ b/tests/unit/test_promotion_carry.py @@ -0,0 +1,83 @@ +"""``InvariantProof.carry_forward`` with extra evidence (P6b step 3; C-164, C-45). + +HAND EXPECTATIONS, written before the code. A reference of vN+1 that is carried forward from vN +must also carry the id of the promotion binding record (C-164), because the next carry-forward +(vN+1 to vN+2) looks for that record in the reference's evidence. So ``carry_forward`` takes an +optional keyword ``extra_evidence_ids``: the proof's evidence is the parent reference's evidence +plus those ids, as a sorted set of UUIDs (the rule of ``_evidence_ids``), and nothing else about +the proof changes (status, time, origin, source state). The C-45 check does not move: an affected, +uncertain, unmentioned or ineligible invariant is refused whatever extra evidence is offered. +""" + +from __future__ import annotations + +import unittest + +from core.domain.enums import ProofOrigin +from core.domain.errors import DomainValidationError +from core.domain.invariant import InvariantProof +from tests.domain_builders import FUNC, SEC, baseline, new_candidate, parent_ref, uid +from tests.promotion_builders import impact_for + +EXTRA = uid(9901) +OTHER = uid(9902) + + +class TestExtraEvidence(unittest.TestCase): + def setUp(self) -> None: + self.v0 = baseline() + self.candidate = new_candidate(self.v0) + self.impact = impact_for(self.v0, self.candidate, carry=(SEC, FUNC)) + self.ref = parent_ref(self.v0, SEC) + + def carry(self, **options: object) -> InvariantProof: + return InvariantProof.carry_forward( + self.ref, + self.candidate, + self.impact, + **options, # type: ignore[arg-type] + ) + + def test_the_default_is_the_parents_evidence_unchanged(self) -> None: + self.assertEqual(self.carry().evidence_ids, self.ref.evidence_ids) + + def test_the_extra_evidence_is_added_as_a_sorted_set(self) -> None: + proof = self.carry(extra_evidence_ids=(OTHER, EXTRA)) + self.assertEqual(proof.evidence_ids, tuple(sorted({*self.ref.evidence_ids, EXTRA, OTHER}))) + + def test_duplicates_collapse(self) -> None: + both = self.carry(extra_evidence_ids=(EXTRA, EXTRA, *self.ref.evidence_ids)) + self.assertEqual(both.evidence_ids, tuple(sorted({*self.ref.evidence_ids, EXTRA}))) + + def test_nothing_else_about_the_proof_changes(self) -> None: + plain, extra = self.carry(), self.carry(extra_evidence_ids=(EXTRA,)) + for field in ("invariant_id", "invariant_version", "status", "verified_at", "origin"): + self.assertEqual(getattr(plain, field), getattr(extra, field), field) + self.assertIs(extra.origin, ProofOrigin.CARRIED_FORWARD) + self.assertEqual(extra.source_state_id, self.v0.state_id) + self.assertEqual(extra.candidate_id, self.candidate.candidate_id) + + def test_the_extra_evidence_must_be_a_list_of_uuids(self) -> None: + for bad in ("not-a-uuid", EXTRA, ("x",), [""], None, 5): + with self.subTest(bad=bad), self.assertRaises(DomainValidationError): + self.carry(extra_evidence_ids=bad) + + def test_the_impact_check_does_not_move(self) -> None: + for kind in ("affected", "uncertain", "ineligible"): + report = impact_for(self.v0, self.candidate, carry=(FUNC,), **{kind: (SEC,)}) + with self.subTest(kind=kind), self.assertRaises(DomainValidationError) as caught: + InvariantProof.carry_forward( + self.ref, self.candidate, report, extra_evidence_ids=(EXTRA,) + ) + self.assertIn("C-45", str(caught.exception)) + + def test_a_report_that_does_not_mention_the_invariant_is_still_refused(self) -> None: + report = impact_for(self.v0, self.candidate, carry=(FUNC,)) + with self.assertRaises(DomainValidationError): + InvariantProof.carry_forward( + self.ref, self.candidate, report, extra_evidence_ids=(EXTRA,) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_promotion_commit.py b/tests/unit/test_promotion_commit.py new file mode 100644 index 0000000..ae0bedd --- /dev/null +++ b/tests/unit/test_promotion_commit.py @@ -0,0 +1,662 @@ +"""The commit of a promotion (P6b step 5; Doc 09 §26, §27, §38-§40, Appendix B; Doc 06 §20, +§21, §27; Doc 05 §32; DATA-INT-003, 004, 009; C-38, C-163 to C-168). + +HAND EXPECTATIONS, written before the code. The canonical world (``tests/promotion_builders.py``): +v0 has INV-SEC-001 VIOLATED and the other three PROTECTED; the candidate fixes SSH; the decision +is a PROMOTE with INV-SEC-001 and INV-FUNC-001 VERIFIED and INV-SEC-002 and INV-SEC-003 +CARRIED_FORWARD. + +* ``record_decision`` stores every decision, keeps all its blocking conditions, moves the candidate + to the status of Doc 06 §5 that the decision names (PROMOTABLE, REJECTED, RETRY_REQUIRED or + ESCALATED), appends PROMOTION_DECIDED and, for a non-PROMOTE, the event Doc 11 §12 names, and + never touches ``trusted_states`` or ``lineage_heads`` (Doc 09 §40, SM-003, SM-004). Recording + the same decision twice changes nothing. +* A commit makes version 1: parent v0, the candidate's resources, the state id and the binding + record id derived from the decision id, ``commit_decision_id`` = the decision id; the reference of + INV-SEC-001 and INV-FUNC-001 are VERIFIED and PROTECTED, those of INV-SEC-002 and INV-SEC-003 are + CARRIED_FORWARD from v0 (status and time of the parent); every reference and the state carry the + binding record id (C-164); the binding record is STATE-kind evidence bound to vN+1 with its state + hash, parent v0 and event name ``promotion.committed``, and its payload names every source. +* The lineage head is v1, the candidate is PROMOTED, the decision is committed to v1, and one + STATE_PROMOTED event with the derived id names v1 and the decision (Doc 09 §26). +* The same decision committed again returns the same state with ``duplicate=True`` and writes + nothing (C-163); another content under the same decision id raises ``ConflictingDuplicateError``. +* A commit whose parent is no longer current loses (C-38 case 1, C-166): ``stale=True``, a REJECT + decision STALE_PARENT with no state, the candidate REJECTED, the head unchanged. +* A commit whose decision no longer holds (C-167) or whose evidence does not bind (C-164) rolls back + and escalates the candidate (C-38 case 2): ``escalated=True``, a PROMOTION_FAILED event, no state, + no binding record, the PROMOTE decision still uncommitted. +* v1 to v2: a reference carried forward from v1 needs the binding record of v1; with that record + tampered, absent from the reference, or bound to another state, the commit escalates. +""" + +from __future__ import annotations + +import dataclasses +import unittest +from typing import Any + +from core.domain.audit import AuditEventType as AE +from core.domain.enums import CandidateStatus as CS +from core.domain.enums import DecisionType, InvariantStatus, ProofOrigin +from core.domain.errors import DomainValidationError +from core.domain.evidence import ArtifactRef, EvidenceKind +from core.domain.promotion import ConflictingDuplicateError, DecisionReason, ProofSource +from core.domain.state import TrustedState +from core.persistence.sqlite import SqliteUnitOfWork +from core.promotion import ids +from core.promotion.commit import ( + PromotionCommitError, + PromotionContext, + commit_promotion, + record_decision, +) +from tests.domain_builders import uid +from tests.promotion_builders import ( + ERROR, + FAIL, + FUNC1, + SEC1, + SEC2, + SEC3, + UNKNOWN, + make_world, + run, + target, +) +from tests.promotion_commit_builders import CONTEXT, NOW, CommitCase + +D, R = DecisionType, DecisionReason +DECISION = uid(9100) + + +class TestRecordDecision(CommitCase): + stored_status = CS.ANALYZING + + def record(self, outcome: Any) -> Any: + with self.uow() as u: + return record_decision(u, outcome, self.world.candidate, CONTEXT, now=NOW) + + def counts(self) -> tuple[int, int, int]: + return ( + self.count("trusted_states"), + self.count("lineage_heads"), + self.count("invariant_refs"), + ) + + def test_a_promote_is_stored_uncommitted_and_the_candidate_becomes_promotable(self) -> None: + outcome = run(self.world) + moved = self.record(outcome) + self.assertIs(moved.status, CS.PROMOTABLE) + self.assertEqual(self.candidate_status(), "PROMOTABLE") + with self.uow() as u: + stored = u.decisions.get(outcome.decision.decision_id) + self.assertEqual(stored, outcome.decision) + self.assertIsNone(stored.committed_state_id) # type: ignore[union-attr] + self.assertEqual(self.audit_types(), ["PROMOTION_DECIDED"]) + + def test_every_non_promote_keeps_all_its_blocking_conditions_and_moves_the_candidate( + self, + ) -> None: + cases = ( + ({target(SEC1): FAIL, target(FUNC1): FAIL}, D.REJECT, "REJECTED", "CANDIDATE_REJECTED"), + ({target(FUNC1): UNKNOWN}, D.RETRY, "RETRY_REQUIRED", "RETRY_SCHEDULED"), + ({target(FUNC1): ERROR}, D.ESCALATE, "ESCALATED", "ESCALATED"), + ) + for index, (results, kind, status, event) in enumerate(cases): + with self.subTest(decision=kind): + self.tearDown() + self.setUp() + outcome = run(make_world(results=results)) # same ids as the stored candidate + self.assertIs(outcome.decision.decision, kind) + self.assertGreaterEqual(len(outcome.decision.blocking_conditions), 1) + moved = self.record(outcome) + self.assertEqual(moved.status.value, status) + with self.uow() as u: + stored = u.decisions.get(outcome.decision.decision_id) + self.assertEqual(stored, outcome.decision) # every condition, from the database + self.assertEqual(self.audit_types(), ["PROMOTION_DECIDED", event]) + self.assertEqual(self.counts(), (1, 1, 4)) + self.assertGreaterEqual(index, 0) + + def test_the_trusted_state_is_never_touched(self) -> None: + before = (self.counts(), self.current().state_hash) + self.record(run(self.world)) + self.assertEqual((self.counts(), self.current().state_hash), before) + + def test_recording_the_same_decision_twice_changes_nothing(self) -> None: + outcome = run(self.world) + self.record(outcome) + rows = (self.count("promotion_decisions"), self.count("audit_events")) + self.record(outcome) + self.assertEqual((self.count("promotion_decisions"), self.count("audit_events")), rows) + self.assertEqual(self.candidate_status(), "PROMOTABLE") + + def test_the_decision_event_carries_the_decision_and_the_policy(self) -> None: + outcome = run(self.world) + self.record(outcome) + with self.uow() as u: + (event,) = u.audit.list_for_candidate(self.world.candidate.candidate_id) + payload = u.evidence.resolve(ArtifactRef.parse(event.payload_ref)) + self.assertIs(event.event_type, AE.PROMOTION_DECIDED) + self.assertEqual(event.decision_id, outcome.decision.decision_id) + self.assertEqual(payload["parent_state_id"], self.world.trusted.state_id) + self.assertEqual(payload["decision"], "PROMOTE") + self.assertEqual(payload["policy_version"], "policy-1") + + +class TestCommitPromotion(CommitCase): + def setUp(self) -> None: + super().setUp() + self.outcome = run(self.world) + self.assertIs(self.outcome.decision.decision, D.PROMOTE) + + def test_the_result_names_the_new_state_and_the_committed_decision(self) -> None: + result = self.commit(outcome=self.outcome) + self.assertFalse(result.duplicate or result.stale or result.escalated) + assert result.state is not None + self.assertEqual(result.state, self.current()) + self.assertEqual(result.decision, self.outcome.decision.committed(result.state.state_id)) + + def test_the_new_state_has_the_right_parent_version_resources_and_ids(self) -> None: + state = self.commit(outcome=self.outcome).state + assert state is not None + self.assertEqual(state.version, 1) + self.assertEqual(state.parent_state_id, self.world.trusted.state_id) + self.assertEqual(state.resources, self.world.candidate.resources) + self.assertEqual(state.state_id, ids.state_id_for(DECISION)) + self.assertEqual(state.commit_decision_id, DECISION) + self.assertEqual(state.lineage_id, self.world.trusted.lineage_id) + + def test_the_references_are_verified_or_carried_with_the_right_status(self) -> None: + state = self.commit(outcome=self.outcome).state + assert state is not None + origins = {r.invariant_id: r.origin for r in state.invariant_refs} + self.assertEqual( + origins, + { + FUNC1: ProofOrigin.VERIFIED, + SEC1: ProofOrigin.VERIFIED, + SEC2: ProofOrigin.CARRIED_FORWARD, + SEC3: ProofOrigin.CARRIED_FORWARD, + }, + ) + for ref in state.invariant_refs: + self.assertIs(ref.status, InvariantStatus.PROTECTED, ref.invariant_id) + parent = {r.invariant_id: r for r in self.world.trusted.invariant_refs} + for carried in (SEC2, SEC3): + ref = next(r for r in state.invariant_refs if r.invariant_id == carried) + self.assertEqual(ref.last_verified_at, parent[carried].last_verified_at) + self.assertTrue(set(parent[carried].evidence_ids) <= set(ref.evidence_ids)) + + def test_every_reference_and_the_state_carry_the_binding_record(self) -> None: + state = self.commit(outcome=self.outcome).state + assert state is not None + binding = ids.evidence_id_for(DECISION) + for ref in state.invariant_refs: + self.assertIn(binding, ref.evidence_ids, ref.invariant_id) + self.assertIn(binding, state.evidence_refs) + union = {e for ref in state.invariant_refs for e in ref.evidence_ids} + self.assertEqual(set(state.evidence_refs), union) + + def test_the_binding_record_is_state_evidence_bound_to_the_new_state(self) -> None: + state = self.commit(outcome=self.outcome).state + assert state is not None + with self.uow() as u: + event = u.evidence.get(ids.evidence_id_for(DECISION)) + assert event is not None + payload = u.evidence.resolve(ArtifactRef.parse(event.payload_ref)) + self.assertIs(event.kind, EvidenceKind.STATE) + self.assertEqual(event.event_name, "promotion.committed") + self.assertEqual(event.state_id, state.state_id) + self.assertEqual(event.state_hash, state.state_hash) + self.assertEqual(event.parent_state_id, self.world.trusted.state_id) + self.assertIsNone(event.candidate_id) + self.assertEqual(event.validity.value, "VALID") + self.assertEqual(payload["decision"]["decision_id"], DECISION) + self.assertEqual(payload["decision"]["candidate_id"], self.world.candidate.candidate_id) + sources = {s["invariant_id"]: s for s in payload["invariants"]} + self.assertEqual(set(sources), {FUNC1, SEC1, SEC2, SEC3}) + for verified in (FUNC1, SEC1): + self.assertEqual(sources[verified]["source"], ProofSource.VERIFIED.value) + self.assertIsNone(sources[verified]["source_state_id"]) + self.assertEqual(len(sources[verified]["source_evidence_ids"]), 1) + for carried in (SEC2, SEC3): + self.assertEqual(sources[carried]["source"], ProofSource.CARRIED_FORWARD.value) + self.assertEqual(sources[carried]["source_state_id"], self.world.trusted.state_id) + + def test_the_head_the_candidate_and_the_decision_move_together(self) -> None: + state = self.commit(outcome=self.outcome).state + assert state is not None + self.assertEqual(self.current().state_id, state.state_id) + self.assertEqual(self.candidate_status(), "PROMOTED") + with self.uow() as u: + stored = u.decisions.get(DECISION) + assert stored is not None + self.assertEqual(stored.committed_state_id, state.state_id) + self.assertEqual(self.count("trusted_states"), 2) + self.assertEqual(self.count("promotion_decisions"), 1) + + def test_one_state_promoted_event_with_the_derived_id(self) -> None: + state = self.commit(outcome=self.outcome).state + assert state is not None + with self.uow() as u: + event = u.audit.get(ids.event_id_for(DECISION)) + assert event is not None + payload = u.evidence.resolve(ArtifactRef.parse(event.payload_ref)) + self.assertIs(event.event_type, AE.STATE_PROMOTED) + self.assertEqual((event.state_id, event.decision_id), (state.state_id, DECISION)) + self.assertEqual(payload["parent_state_id"], self.world.trusted.state_id) + self.assertEqual(payload["new_state_id"], state.state_id) + self.assertEqual(self.count("audit_events", "event_type = 'STATE_PROMOTED'"), 1) + + def test_the_decision_is_stored_even_when_it_was_not_recorded_before(self) -> None: + self.assertEqual(self.count("promotion_decisions"), 0) + self.commit(outcome=self.outcome) + self.assertEqual(self.count("promotion_decisions"), 1) + + def test_a_recorded_decision_is_committed_in_place(self) -> None: + with self.uow() as u: + u.decisions.save(self.outcome.decision) + self.commit(outcome=self.outcome) + self.assertEqual(self.count("promotion_decisions"), 1) + + def test_only_a_promote_can_be_committed(self) -> None: + reject = run(make_world(results={target(FUNC1): FAIL})) + with self.assertRaises(Exception) as caught: + self.commit(outcome=reject) + self.assertNotIsInstance(caught.exception, PromotionCommitError) + self.assertEqual(self.count("trusted_states"), 1) + + +class TestIdempotency(CommitCase): + def setUp(self) -> None: + super().setUp() + self.outcome = run(self.world) + self.first = self.commit(outcome=self.outcome) + + def rows(self) -> dict[str, int]: + return { + t: self.count(t) + for t in ( + "trusted_states", + "invariant_refs", + "promotion_decisions", + "audit_events", + "evidence_events", + "state_resources", + ) + } + + def test_the_same_decision_again_returns_the_same_state_and_writes_nothing(self) -> None: + before = self.rows() + again = self.commit(outcome=self.outcome) + self.assertTrue(again.duplicate) + self.assertEqual(again.state, self.first.state) + self.assertEqual(again.decision, self.first.decision) + self.assertEqual(self.rows(), before) + + def test_the_same_id_with_other_content_is_a_conflicting_duplicate(self) -> None: + other = run(make_world(results={target(FUNC1): FAIL})) + forged = dataclasses.replace( + self.outcome, decision=self.outcome.decision, evidence=self.outcome.evidence + ) + policy = dataclasses.replace(self.world.policy, policy_version="policy-2") + other_content = run(dataclasses.replace(self.world, policy=policy)) + self.assertEqual(other_content.decision.decision_id, self.outcome.decision.decision_id) + self.assertNotEqual(other_content.decision.content_hash, self.outcome.decision.content_hash) + before = self.rows() + with self.assertRaises(ConflictingDuplicateError): + self.commit(outcome=other_content) + self.assertEqual(self.rows(), before) + self.assertIsNotNone(other) + self.assertIsNotNone(forged) + + def test_a_second_promote_for_a_promoted_candidate_is_refused(self) -> None: + second = run(self.world, decision_id=uid(9101)) + before = self.rows() + with self.assertRaises(ConflictingDuplicateError): + self.commit(outcome=second) + self.assertEqual(self.rows(), before) + + +class TestStaleParent(CommitCase): + def test_a_commit_whose_parent_is_no_longer_current_loses(self) -> None: + # candidate A promotes; candidate B, built from the same parent, then tries + winner = self.commit(outcome=run(self.world)) + assert winner.state is not None + other, evidence = self.rival() + result = self.commit( + other, outcome=run(other, decision_id=uid(9200)), evidence_ids=evidence + ) + self.assertTrue(result.stale) + self.assertIsNone(result.state) + self.assertIs(result.decision.decision, D.REJECT) + self.assertIs(result.decision.reason, R.STALE_PARENT) + self.assertIsNone(result.decision.committed_state_id) + self.assertEqual(self.candidate_status(other.candidate.candidate_id), "REJECTED") + self.assertEqual(self.current().state_id, winner.state.state_id) + self.assertEqual(self.count("trusted_states"), 2) + self.assertEqual(self.count("promotion_decisions", "committed_state_id IS NOT NULL"), 1) + + def test_a_loser_is_not_committed_twice(self) -> None: + self.commit(outcome=run(self.world)) + other, evidence = self.rival() + outcome = run(other, decision_id=uid(9200)) + first = self.commit(other, outcome=outcome, evidence_ids=evidence) + again = self.commit(other, outcome=outcome, evidence_ids=evidence) + self.assertTrue(first.stale and again.stale) + self.assertEqual(self.count("promotion_decisions", "decision = 'REJECT'"), 1) + self.assertEqual(self.candidate_status(other.candidate.candidate_id), "REJECTED") + + +class TestRevalidation(CommitCase): + """C-167 and C-164: a decision that no longer holds, or evidence that does not bind, rolls the + commit back and escalates the candidate (C-38 case 2).""" + + def setUp(self) -> None: + super().setUp() + self.outcome = run(self.world) + + def assert_escalated(self, result: Any) -> None: + self.assertTrue(result.escalated) + self.assertIsNone(result.state) + self.assertEqual(self.candidate_status(), "ESCALATED") + self.assertEqual(self.current().state_id, self.world.trusted.state_id) + self.assertEqual(self.count("trusted_states"), 1) + self.assertEqual(self.count("evidence_events", "event_name = 'promotion.committed'"), 0) + self.assertEqual(self.count("audit_events", "event_type = 'STATE_PROMOTED'"), 0) + self.assertEqual(self.count("promotion_decisions", "committed_state_id IS NOT NULL"), 0) + self.assertEqual(self.count("audit_events", "event_type = 'PROMOTION_FAILED'"), 1) + with self.uow() as u: + (failed,) = ( + e + for e in u.audit.list_for_candidate(self.world.candidate.candidate_id) + if e.event_type is AE.PROMOTION_FAILED + ) + payload = u.evidence.resolve(ArtifactRef.parse(failed.payload_ref)) + self.assertTrue(payload["reason"].startswith("REVALIDATION")) + + def test_a_verification_report_that_changed_since_the_decision(self) -> None: + changed = make_world(candidate_status=CS.PROMOTABLE, results={target(FUNC1): FAIL}) + result = self.commit( + dataclasses.replace(self.world, verification=changed.verification), + outcome=self.outcome, + ) + self.assert_escalated(result) + + def test_a_policy_that_changed_since_the_decision(self) -> None: + stricter = self.world.with_policy(carry_forward_enabled=False) + self.assert_escalated(self.commit(stricter, outcome=self.outcome)) + + def test_an_impact_report_of_another_candidate(self) -> None: + from tests.domain_builders import analyzing_candidate, canonical_resources + from tests.promotion_builders import impact_for + + elsewhere = analyzing_candidate( + self.world.trusted, canonical_resources(ssh_open=False, db_path=True), n=3 + ) + impact = impact_for( + self.world.trusted, elsewhere, affected=(SEC1, FUNC1), carry=(SEC2, SEC3) + ) + self.assert_escalated( + self.commit(dataclasses.replace(self.world, impact=impact), outcome=self.outcome) + ) + + def test_a_source_evidence_record_with_a_tampered_payload(self) -> None: + verified = next(e for e in self.outcome.proof_plan if e.source is ProofSource.VERIFIED) + evidence_id = self.evidence_ids[verified.verification_id or ""] + self.tamper( + "UPDATE evidence_artifacts SET payload_json = '{\"tampered\":true}' WHERE content_hash " + "= (SELECT content_hash FROM evidence_events WHERE evidence_id = ?)", + (evidence_id,), + ) + self.assert_escalated(self.commit(outcome=self.outcome)) + + def test_a_source_evidence_record_with_another_state_hash(self) -> None: + verified = next(e for e in self.outcome.proof_plan if e.source is ProofSource.VERIFIED) + evidence_id = self.evidence_ids[verified.verification_id or ""] + self.tamper( + "UPDATE evidence_events SET state_hash = ? WHERE evidence_id = ?", + ("1" * 64, evidence_id), + ) + self.assert_escalated(self.commit(outcome=self.outcome)) + + def test_a_source_evidence_record_that_is_missing_from_the_map(self) -> None: + verified = next(e for e in self.outcome.proof_plan if e.source is ProofSource.VERIFIED) + partial = {k: v for k, v in self.evidence_ids.items() if k != verified.verification_id} + self.assert_escalated(self.commit(outcome=self.outcome, evidence_ids=partial)) + + def test_a_source_evidence_record_of_another_invariant(self) -> None: + sec = next(e for e in self.outcome.proof_plan if e.invariant_id == SEC1) + func = next(e for e in self.outcome.proof_plan if e.invariant_id == FUNC1) + swapped = dict(self.evidence_ids) + swapped[sec.verification_id or ""] = self.evidence_ids[func.verification_id or ""] + self.assert_escalated(self.commit(outcome=self.outcome, evidence_ids=swapped)) + + def test_a_carried_reference_whose_parent_evidence_is_not_bound_to_the_parent(self) -> None: + carried = next( + e for e in self.outcome.proof_plan if e.source is ProofSource.CARRIED_FORWARD + ) + evidence_id = carried.evidence_ids[0] + self.tamper( + "UPDATE evidence_events SET state_hash = ? WHERE evidence_id = ?", + ("2" * 64, evidence_id), + ) + self.assert_escalated(self.commit(outcome=self.outcome)) + + def test_a_carried_reference_whose_evidence_is_not_the_one_decided_on(self) -> None: + # The decision was made against a copy of v0 whose INV-SEC-002 reference named other + # evidence (the state hash does not depend on evidence ids, C-33). The database's v0 names + # the stored seed evidence, so deciding again gives the same decision on a different proof + # plan: what was decided is not what would be committed (C-167). + data = self.world.trusted.to_dict() + for ref in data["invariant_refs"]: + if ref["invariant_id"] == SEC2: + ref["evidence_ids"] = [uid(4300)] + data["evidence_refs"] = sorted({*data["evidence_refs"], uid(4300)}) + decided_on = TrustedState.from_dict(data) + outcome = run(dataclasses.replace(self.world, trusted=decided_on)) + self.assertIs(outcome.decision.decision, D.PROMOTE) + again = run(self.world, decision_id=outcome.decision.decision_id) + self.assertEqual(outcome.decision.content_hash, again.decision.content_hash) + self.assertNotEqual(outcome.proof_plan, again.proof_plan) + self.assert_escalated(self.commit(outcome=outcome)) + + def test_the_candidate_must_still_be_promotable(self) -> None: + self.tamper( + "UPDATE candidates SET status = 'ANALYZING' WHERE candidate_id = ?", + (self.world.candidate.candidate_id,), + ) + result = self.commit(outcome=self.outcome) + self.assertTrue(result.escalated) + self.assertEqual(self.count("trusted_states"), 1) + + +class TestInfrastructureFailure(CommitCase): + def test_a_fault_leaves_the_previous_state_and_the_candidate_promotable(self) -> None: + def hook(name: str) -> None: + if name == "after_lineage_head_update": + raise RuntimeError(name) + + with self.assertRaises(PromotionCommitError) as caught: + self.commit(outcome=run(self.world), checkpoint=hook) + self.assertIsInstance(caught.exception.__cause__, RuntimeError) + self.assertEqual(self.current().state_id, self.world.trusted.state_id) + self.assertEqual(self.candidate_status(), "PROMOTABLE") + self.assertEqual(self.count("trusted_states"), 1) + self.assertEqual(self.count("evidence_events", "event_name = 'promotion.committed'"), 0) + self.assertEqual(self.count("audit_events", "event_type = 'STATE_PROMOTED'"), 0) + + def test_the_same_commit_then_succeeds_exactly_once(self) -> None: + outcome = run(self.world) + + def hook(name: str) -> None: + if name == "after_decision_commit": + raise RuntimeError(name) + + with self.assertRaises(PromotionCommitError): + self.commit(outcome=outcome, checkpoint=hook) + again = self.commit(outcome=outcome) + self.assertFalse(again.duplicate) + self.assertEqual(self.count("trusted_states"), 2) + self.assertEqual(self.current().state_id, ids.state_id_for(DECISION)) + + def test_a_failure_event_is_recorded_when_the_database_allows(self) -> None: + def hook(name: str) -> None: + if name == "after_trusted_state_insert": + raise RuntimeError(name) + + with self.assertRaises(PromotionCommitError): + self.commit(outcome=run(self.world), checkpoint=hook) + with self.uow() as u: + failed = [ + e + for e in u.audit.list_for_candidate(self.world.candidate.candidate_id) + if e.event_type is AE.PROMOTION_FAILED + ] + self.assertEqual(len(failed), 1) + self.assertEqual(failed[0].event_id, ids.failure_event_id_for(DECISION, "INFRASTRUCTURE")) + + +class _Spy: + """A repository that logs each write call before it makes it.""" + + WRITES = frozenset({"save", "append", "save_promoted", "save_transition", "commit"}) + + def __init__(self, name: str, real: Any, log: list[str]) -> None: + self._name = name + self._real = real + self._log = log + + def __getattr__(self, attr: str) -> Any: + member = getattr(self._real, attr) + if attr not in self.WRITES: + return member + + def logged(*args: Any, **kwargs: Any) -> Any: + detail = "" + if self._name == "audit": + detail = ":" + args[0].event_type.value + elif self._name == "evidence": + detail = ":" + args[0].event.event_name + self._log.append(f"{self._name}.{attr}{detail}") + return member(*args, **kwargs) + + return logged + + +class TestOrderOfWrites(CommitCase): + """Appendix B, in the order of the commit: the decision, then its event, the binding record, + the state with the lineage head, the candidate PROMOTED, the decision committed to the state, + and last the STATE_PROMOTED event (nothing says the state is promoted before it is). The order + is the one the P6b prompt gives for Appendix B.""" + + def test_the_writes_are_made_in_the_order_of_the_commit(self) -> None: + log: list[str] = [] + path = self.path + + class Watching(SqliteUnitOfWork): + @property + def trusted_states(self) -> Any: + return _Spy("trusted_states", super().trusted_states, log) + + @property + def candidates(self) -> Any: + return _Spy("candidates", super().candidates, log) + + @property + def decisions(self) -> Any: + return _Spy("decisions", super().decisions, log) + + @property + def evidence(self) -> Any: + return _Spy("evidence", super().evidence, log) + + @property + def audit(self) -> Any: + return _Spy("audit", super().audit, log) + + self.commit(outcome=run(self.world), opener=lambda: Watching(path)) + self.assertEqual( + log, + [ + "decisions.save", + "audit.append:PROMOTION_DECIDED", + "evidence.append:promotion.committed", + "trusted_states.save_promoted", + "candidates.save_transition", + "decisions.commit", + "audit.append:STATE_PROMOTED", + ], + ) + + +class TestChainOfCarryForward(CommitCase): + """v1 to v2 (C-164): a reference carried from v1 is proved by v1's binding record.""" + + def setUp(self) -> None: + super().setUp() + first = self.commit(outcome=run(self.world)) + assert first.state is not None + self.v1 = first.state + self.world2 = self.next_world(self.v1) + self.store_candidate(self.world2, n=2, sequence=2) + self.evidence_ids.update(self.store_results(self.world2, first=60)) + self.outcome2 = run(self.world2, decision_id=uid(9300)) + with self.uow() as u: + record_decision(u, self.outcome2, self.world2.candidate, CONTEXT, now=NOW) + + def test_the_second_promotion_carries_from_the_first(self) -> None: + self.assertIs(self.outcome2.decision.decision, D.PROMOTE) + sources = {e.invariant_id: e.source for e in self.outcome2.proof_plan} + self.assertEqual(sources[SEC1], ProofSource.CARRIED_FORWARD) + result = self.commit(self.world2, outcome=self.outcome2) + v2 = result.state + assert v2 is not None + self.assertEqual(v2.version, 2) + self.assertEqual(v2.parent_state_id, self.v1.state_id) + binding1 = ids.evidence_id_for(DECISION) + binding2 = ids.evidence_id_for(uid(9300)) + for ref in v2.invariant_refs: + self.assertIn(binding2, ref.evidence_ids) + carried = next(r for r in v2.invariant_refs if r.invariant_id == SEC1) + self.assertIn(binding1, carried.evidence_ids) # the record that proved it at v1 + self.assertIs(carried.origin, ProofOrigin.CARRIED_FORWARD) + + def test_with_the_first_binding_record_bound_to_another_state_it_escalates(self) -> None: + self.tamper( + "UPDATE evidence_events SET state_id = ? WHERE evidence_id = ?", + (self.world.trusted.state_id, ids.evidence_id_for(DECISION)), + ) + result = self.commit(self.world2, outcome=self.outcome2) + self.assertTrue(result.escalated) + self.assertEqual(self.current().state_id, self.v1.state_id) + + def test_with_the_first_binding_record_tampered_it_escalates(self) -> None: + self.tamper( + "UPDATE evidence_artifacts SET payload_json = '{\"x\":1}' WHERE content_hash = " + "(SELECT content_hash FROM evidence_events WHERE evidence_id = ?)", + (ids.evidence_id_for(DECISION),), + ) + self.assertTrue(self.commit(self.world2, outcome=self.outcome2).escalated) + + def test_with_the_first_binding_record_with_another_state_hash_it_escalates(self) -> None: + self.tamper( + "UPDATE evidence_events SET state_hash = ? WHERE evidence_id = ?", + ("3" * 64, ids.evidence_id_for(DECISION)), + ) + self.assertTrue(self.commit(self.world2, outcome=self.outcome2).escalated) + + +class TestRecordDecisionContext(unittest.TestCase): + def test_the_context_checks_its_ids(self) -> None: + for field in ("run_id", "correlation_id", "operation_id", "attempt_id"): + with self.subTest(field=field), self.assertRaises(DomainValidationError): + PromotionContext(**{**dataclasses.asdict(CONTEXT), field: "not-a-uuid"}) + PromotionContext(**dataclasses.asdict(CONTEXT)) + self.assertTrue(callable(commit_promotion)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_promotion_committed.py b/tests/unit/test_promotion_committed.py new file mode 100644 index 0000000..8b742bd --- /dev/null +++ b/tests/unit/test_promotion_committed.py @@ -0,0 +1,163 @@ +"""The committed decision and the committed evidence (P6b step 3; Doc 05 §15, DATA-INT-004; +C-163). + +HAND EXPECTATIONS, written before the code: + +* DATA-INT-004: ``committed_state_id`` is non-null only for a committed PROMOTE decision. So + ``PromotionDecision.committed(state_id)`` is allowed for a PROMOTE whose ``committed_state_id`` is + null and for nothing else; it returns a new object (the original is unchanged), recomputes + ``content_hash`` (the hash covers ``committed_state_id``; it still excludes the id and the clock), + and the result survives a ``to_dict`` / ``from_dict`` round trip. A committed value cannot be put + in by hand: ``create`` takes none and ``dataclasses.replace`` is refused. +* The same holds for ``PromotionEvidence.with_new_state``. +""" + +from __future__ import annotations + +import dataclasses +import unittest + +from core.domain.enums import DecisionType +from core.domain.errors import DomainValidationError, HashMismatchError +from core.domain.promotion import ( + ConflictingDuplicateError, + DecisionReason, + PromotionDecision, + PromotionEvidence, +) +from tests.domain_builders import uid +from tests.unit.test_promotion_domain import RESULT_A, RESULT_B, checks, decision, rejection + +STATE = uid(555) + + +class TestACommittedDecision(unittest.TestCase): + def test_a_promote_can_be_committed_once_and_the_original_is_unchanged(self) -> None: + made = decision() + done = made.committed(STATE) + self.assertEqual(done.committed_state_id, STATE) + self.assertIsNone(made.committed_state_id) + self.assertNotEqual(done.content_hash, made.content_hash) + self.assertEqual(done.decision_id, made.decision_id) + self.assertEqual(done.supporting_results, (RESULT_A, RESULT_B)) + + def test_the_hash_covers_the_state_but_not_the_id_or_the_clock(self) -> None: + one = decision().committed(STATE) + other = decision(decision_id=uid(9002), created_at=decision().created_at).committed(STATE) + self.assertEqual(one.content_hash, other.content_hash) + self.assertNotEqual(one.content_hash, decision().committed(uid(556)).content_hash) + + def test_it_cannot_be_committed_twice(self) -> None: + with self.assertRaises(DomainValidationError): + decision().committed(STATE).committed(uid(556)) + + def test_only_a_promote_can_be_committed(self) -> None: + for kind in (DecisionType.REJECT, DecisionType.RETRY, DecisionType.ESCALATE): + with self.subTest(decision=kind), self.assertRaises(DomainValidationError): + rejection(decision=kind).committed(STATE) + + def test_the_state_must_be_a_uuid(self) -> None: + for bad in ("", "not-a-uuid", None, 5): + with self.subTest(state=bad), self.assertRaises(DomainValidationError): + decision().committed(bad) # type: ignore[arg-type] + + def test_a_committed_value_cannot_be_put_in_by_hand(self) -> None: + with self.assertRaises(DomainValidationError): + dataclasses.replace(decision(), committed_state_id=STATE) + with self.assertRaises(TypeError): # create takes no committed state at all + PromotionDecision.create( # type: ignore[call-arg] + **{**{"committed_state_id": STATE}, **self._fields()} + ) + + def _fields(self) -> dict[str, object]: + made = decision() + return { + "decision_id": made.decision_id, + "candidate_id": made.candidate_id, + "parent_state_id": made.parent_state_id, + "decision": made.decision, + "reason": made.reason, + "policy_version": made.policy_version, + "attempt_number": made.attempt_number, + "required_checks": made.required_checks, + "blocking_conditions": made.blocking_conditions, + "supporting_results": made.supporting_results, + "created_at": made.created_at, + } + + def test_a_committed_decision_survives_a_round_trip(self) -> None: + done = decision().committed(STATE) + again = PromotionDecision.from_dict(done.to_dict()) + self.assertEqual(again, done) + self.assertEqual(again.committed_state_id, STATE) + + def test_a_stored_committed_value_on_another_decision_is_refused_on_reading(self) -> None: + # DATA-INT-004 in the domain: a REJECT row with a committed state cannot be rebuilt + data = rejection().to_dict() + data["committed_state_id"] = STATE + with self.assertRaises(DomainValidationError): + PromotionDecision.from_dict(data) + + def test_a_committed_decision_whose_hash_was_not_updated_is_refused(self) -> None: + data = decision().to_dict() + data["committed_state_id"] = STATE + with self.assertRaises(HashMismatchError): + PromotionDecision.from_dict(data) + + def test_committing_does_not_hide_a_changed_field(self) -> None: + done = decision().committed(STATE) + with self.assertRaises(DomainValidationError): # replace() cannot carry the commit token + dataclasses.replace(done, policy_version="policy-9") + + def test_the_conflicting_duplicate_error_is_a_domain_validation_error(self) -> None: + self.assertTrue(issubclass(ConflictingDuplicateError, DomainValidationError)) + self.assertEqual(DecisionReason.ALL_REQUIRED_PROVEN.value, "ALL_REQUIRED_PROVEN") + + +class TestCommittedEvidence(unittest.TestCase): + def evidence(self, **changes: object) -> PromotionEvidence: + fields: dict[str, object] = { + "decision_id": uid(9001), + "candidate_id": uid(3001), + "parent_state_id": uid(1), + "required_checks": checks(), + "verification_refs": (RESULT_A, RESULT_B), + "evidence_sufficiency": "SUFFICIENT", + "policy_version": "policy-1", + "decision": DecisionType.PROMOTE, + "reason": DecisionReason.ALL_REQUIRED_PROVEN, + } + fields.update(changes) + return PromotionEvidence.create(**fields) # type: ignore[arg-type] + + def test_a_promote_evidence_names_the_new_state_once(self) -> None: + made = self.evidence() + done = made.with_new_state(STATE) + self.assertEqual(done.new_state_id, STATE) + self.assertIsNone(made.new_state_id) + self.assertNotEqual(done.content_hash, made.content_hash) + self.assertEqual(PromotionEvidence.from_dict(done.to_dict()), done) + with self.assertRaises(DomainValidationError): + done.with_new_state(uid(556)) + + def test_only_a_promote_evidence_names_a_state(self) -> None: + other = self.evidence( + decision=DecisionType.REJECT, + reason=DecisionReason.INSUFFICIENT_EVIDENCE, + evidence_sufficiency="INSUFFICIENT", + verification_refs=(RESULT_A,), + ) + with self.assertRaises(DomainValidationError): + other.with_new_state(STATE) + + def test_a_state_cannot_be_put_in_by_hand(self) -> None: + with self.assertRaises(DomainValidationError): + dataclasses.replace(self.evidence(), new_state_id=STATE) + data = self.evidence().to_dict() + data["new_state_id"] = STATE + with self.assertRaises(HashMismatchError): + PromotionEvidence.from_dict(data) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_promotion_concurrency.py b/tests/unit/test_promotion_concurrency.py new file mode 100644 index 0000000..fd8ac2c --- /dev/null +++ b/tests/unit/test_promotion_concurrency.py @@ -0,0 +1,283 @@ +"""Two promotions racing from one parent (P6b step 6; Doc 09 §38-§39, Appendix B; Doc 06 §20, §25 +to §27; Doc 12 §19; C-38, C-163, C-166). + +HAND EXPECTATIONS, written before the run. Two candidates A and B were made from the same trusted +state v0 and both are PROMOTABLE with a PROMOTE decision (ids 9100 and 9200). Real threads, two +connections to one SQLite file, and the checkpoint seam of the unit of work stop one commit at a +named point while the other starts; nothing sleeps. + +Whatever the interleaving, the spec (Doc 09 §39: promotion only from the current parent; Doc 06 §20, +§27: a stale candidate is rejected, no new state is created for it) fixes the outcome: + +* the commit that finishes first is the winner: exactly one new state, version 1, parent v0, id + derived from the winner's decision, the lineage head, its candidate PROMOTED, one binding record, + one STATE_PROMOTED event; +* the other is the loser: ``stale=True``, no state, its candidate REJECTED, a stored REJECT decision + with reason STALE_PARENT under the derived rejection id and no committed state; +* a commit that fails after it moved the head (an injected fault, rolled back) leaves no trace but a + PROMOTION_FAILED event, so the other commit is then the winner, and the failed one, tried again, + is the loser; +* no table holds an orphan row, and ``check_database`` finds nothing. + +Rows of the finished database: 2 trusted states, 8 invariant references (4 each), 1 lineage head, +2 promotion decisions (the committed PROMOTE and the loser's REJECT), 1 ``promotion.committed`` +record. +""" + +from __future__ import annotations + +import random +import unittest +from collections import Counter +from itertools import product +from typing import Any + +from core.domain.audit import AuditEventType as AE +from core.domain.promotion import DecisionReason +from core.persistence.sqlite import ALL_CHECKPOINTS, SqliteUnitOfWork +from core.promotion import ids +from core.promotion.commit import CommitResult, PromotionCommitError, commit_promotion +from tests.domain_builders import uid +from tests.promotion_builders import run +from tests.promotion_commit_builders import ( + CONTEXT, + NOW, + CommitCase, + Gate, + InjectedFault, + Runner, + check_database, +) + +DECISIONS = {"A": uid(9100), "B": uid(9200)} +MODES = ("overlap", "sequential", "first_fails") +SEEDED_EXTRA = 24 +SEED = 20261029 + + +def every_schedule() -> list[tuple[str, str, str]]: + """Every first-runner, mode and pause point, then SEEDED_EXTRA more drawn with a seeded + ``random`` (so the run is the same every time and covers all 42 combinations at least once).""" + fixed = [(first, mode, point) for first, mode, point in product("AB", MODES, ALL_CHECKPOINTS)] + rng = random.Random(SEED) + drawn = [ + (rng.choice("AB"), rng.choice(MODES), rng.choice(ALL_CHECKPOINTS)) + for _ in range(SEEDED_EXTRA) + ] + return fixed + drawn + + +class RaceCase(CommitCase): + def setUp(self) -> None: + super().setUp() + self.second_world, self.second_ids = self.rival() + self.entries = { + "A": (self.world, self.evidence_ids), + "B": (self.second_world, self.second_ids), + } + self.outcomes = { + key: run(world, decision_id=DECISIONS[key]) for key, (world, _) in self.entries.items() + } + + def go(self, key: str, hook: Any = None) -> CommitResult: + world, evidence = self.entries[key] + return self.commit( + world, outcome=self.outcomes[key], evidence_ids=evidence, checkpoint=hook + ) + + def candidate_of(self, key: str) -> str: + return self.entries[key][0].candidate.candidate_id + + def assert_one_winner(self, winner: str, loser: str) -> None: + state_id = ids.state_id_for(DECISIONS[winner]) + self.assertEqual(self.current().state_id, state_id) + self.assertEqual(self.current().version, 1) + self.assertEqual(self.current().parent_state_id, self.world.trusted.state_id) + self.assertEqual(self.count("trusted_states"), 2) + self.assertEqual(self.count("lineage_heads"), 1) + self.assertEqual(self.count("invariant_refs"), 8) + self.assertEqual(self.candidate_status(self.candidate_of(winner)), "PROMOTED") + self.assertEqual(self.candidate_status(self.candidate_of(loser)), "REJECTED") + self.assertEqual(self.count("promotion_decisions"), 2) + self.assertEqual( + self.scalar( + "SELECT committed_state_id FROM promotion_decisions WHERE decision_id = ?", + (DECISIONS[winner],), + ), + state_id, + ) + rejection = ids.rejection_decision_id_for(DECISIONS[loser]) + self.assertEqual( + self.scalar( + "SELECT decision || '/' || reason || '/' || COALESCE(committed_state_id, 'none') " + "FROM promotion_decisions WHERE decision_id = ?", + (rejection,), + ), + "REJECT/STALE_PARENT/none", + ) + self.assertEqual(self.count("evidence_events", "event_name = 'promotion.committed'"), 1) + self.assertEqual(self.count("audit_events", "event_type = 'STATE_PROMOTED'"), 1) + self.assertEqual(check_database(self.path), []) + + +class TestSchedules(RaceCase): + def run_schedule(self, first: str, mode: str, point: str) -> str: + """Run one schedule; return the winner's key.""" + second = "B" if first == "A" else "A" + if mode == "sequential": + before = self.go(first) + after = self.go(second) + self.assertIsNotNone(before.state) + self.assertTrue(after.stale) + self.assert_one_winner(first, second) + return first + gate = Gate(point, fail=mode == "first_fails") + leader = Runner(lambda: self.go(first, gate)) + leader.start() + self.assertTrue(leader.wait_for(gate), f"{point} was never reached") + follower = Runner(lambda: self.go(second)) + follower.start() + follower.started_running.wait(60) + gate.release.set() + leader.join(60) + follower.join(60) + self.assertFalse(leader.is_alive() or follower.is_alive(), "a thread is stuck") + if mode == "overlap": + self.assertIsNone(leader.error) + self.assertIsNone(follower.error) + self.assertIsNotNone(leader.result.state) + self.assertTrue(follower.result.stale) + self.assert_one_winner(first, second) + return first + # the leader failed after the head had moved in its own transaction + self.assertIsInstance(leader.error, PromotionCommitError) + self.assertIsInstance(leader.error.__cause__, InjectedFault) + self.assertIsNone(follower.error) + self.assertIsNotNone(follower.result.state) + self.assertEqual(self.candidate_status(self.candidate_of(first)), "PROMOTABLE") + self.assertEqual(self.count("promotion_decisions", "committed_state_id IS NOT NULL"), 1) + retried = self.go(first) # the failed commit, tried again, now lost the race + self.assertTrue(retried.stale) + self.assert_one_winner(second, first) + failed = self.count("audit_events", "event_type = 'PROMOTION_FAILED'") + self.assertEqual(failed, 1) + return second + + def test_at_least_fifty_schedules_leave_exactly_one_winner(self) -> None: + schedules = every_schedule() + self.assertGreaterEqual(len(schedules), 50) + seen: Counter[tuple[str, str]] = Counter() + winners: Counter[str] = Counter() + for index, (first, mode, point) in enumerate(schedules): + with self.subTest(schedule=index, first=first, mode=mode, point=point): + if index: + self.tearDown() + self.setUp() + winners[self.run_schedule(first, mode, point)] += 1 + seen[(mode, point)] += 1 + self.assertEqual({m for m, _ in seen}, set(MODES)) + self.assertEqual({p for _, p in seen}, set(ALL_CHECKPOINTS)) + self.assertEqual(set(winners), {"A", "B"}) + self.assertEqual(sum(winners.values()), len(schedules)) + + +class TestCompareAndSwap(RaceCase): + """The write lock makes the stale check and the swap one step. If the stale check were fooled, + the compare-and-swap of ``save_promoted`` (SM-010) is the last line of defence.""" + + def test_a_commit_that_believes_the_head_has_not_moved_still_loses(self) -> None: + self.assertIsNotNone(self.go("A").state) + old_head = self.world.trusted + opened: list[int] = [] + path = self.path + + class Believer(SqliteUnitOfWork): + @property + def trusted_states(self) -> Any: + real = super().trusted_states + + class Stale: + def __getattr__(self, name: str) -> Any: + return getattr(real, name) + + def get_current(self, lineage_id: str) -> Any: + return old_head if lineage_id == old_head.lineage_id else None + + return Stale() + + def factory() -> Any: + opened.append(1) + return Believer(path) if len(opened) == 1 else SqliteUnitOfWork(path) + + world, evidence = self.entries["B"] + result = commit_promotion( + factory, + self.outcomes["B"], + world.candidate, + world.impact, + world.verification, + world.policy, + evidence, + CONTEXT, + now=NOW, + ) + self.assertTrue(result.stale) + self.assertEqual(self.count("trusted_states"), 2) + self.assertEqual(self.candidate_status(self.candidate_of("B")), "REJECTED") + self.assertEqual(check_database(self.path), []) + + +class TestRaceOutcomesStayDurable(RaceCase): + def test_the_loser_is_rejected_once_and_keeps_its_reason(self) -> None: + self.go("A") + first = self.go("B") + again = self.go("B") + self.assertTrue(first.stale and again.stale) + self.assertEqual(first.decision, again.decision) + self.assertIs(first.decision.reason, DecisionReason.STALE_PARENT) + self.assertEqual(self.count("audit_events", "event_type = 'CANDIDATE_REJECTED'"), 1) + self.assertEqual(check_database(self.path), []) + + def test_the_failure_event_names_the_failed_commit(self) -> None: + gate = Gate("after_lineage_head_update", fail=True) + gate.release.set() + with self.assertRaises(PromotionCommitError): + self.go("A", gate) + with self.uow() as u: + events = [ + e + for e in u.audit.list_for_candidate(self.candidate_of("A")) + if e.event_type is AE.PROMOTION_FAILED + ] + self.assertEqual( + [e.event_id for e in events], + [ids.failure_event_id_for(DECISIONS["A"], "INFRASTRUCTURE")], + ) + self.assertEqual(self.candidate_status(self.candidate_of("A")), "PROMOTABLE") + + +class TestGateAndRunner(unittest.TestCase): + """The test machinery is itself checked: a gate ignores other points and acts once.""" + + def test_a_gate_ignores_other_points_and_acts_once(self) -> None: + gate = Gate("here", fail=True) + gate("elsewhere") + self.assertFalse(gate.reached.is_set()) + gate.release.set() + with self.assertRaises(InjectedFault): + gate("here") + gate("here") # one shot + self.assertTrue(gate.reached.is_set()) + + def test_a_runner_keeps_a_result_or_an_error(self) -> None: + ok = Runner(lambda: 7) + bad = Runner(lambda: 1 // 0) + for runner in (ok, bad): + runner.start() + runner.join(10) + self.assertEqual(ok.result, 7) + self.assertIsInstance(bad.error, ZeroDivisionError) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_promotion_exit.py b/tests/unit/test_promotion_exit.py new file mode 100644 index 0000000..8b469af --- /dev/null +++ b/tests/unit/test_promotion_exit.py @@ -0,0 +1,72 @@ +"""The PROMOTABLE -> ESCALATED exit of C-38 (P6b step 4; Doc 06 §5, Doc 09 §24; C-38). + +HAND EXPECTATIONS, written before the code: the candidate lifecycle gains exactly one edge, +PROMOTABLE -> ESCALATED. REJECTED, ESCALATED, RETRY_REQUIRED, PROMOTED and FAILED stay terminal; +PROMOTABLE -> PROMOTED stays reserved to ``mark_promoted`` (SM-002) and PROMOTABLE -> REJECTED to +``reject_stale_candidate`` (C-29); PROMOTABLE -> RETRY_REQUIRED and every backward edge stay +forbidden. The edge is also legal in the database (schema 6). +""" + +from __future__ import annotations + +import unittest + +from core.domain.enums import CandidateStatus as C +from core.domain.errors import IllegalTransitionError +from core.domain.lifecycle import CANDIDATE_TRANSITIONS, check_candidate_transition +from core.domain.state import transition_candidate +from tests.domain_builders import analyzing_candidate, baseline, canonical_resources + + +class TestThePromotableToEscalatedExit(unittest.TestCase): + def setUp(self) -> None: + self.v0 = baseline() + analyzing = analyzing_candidate(self.v0, canonical_resources(ssh_open=False, db_path=True)) + self.promotable = transition_candidate(analyzing, C.PROMOTABLE) + + def test_the_table_gains_exactly_one_edge(self) -> None: + self.assertEqual( + CANDIDATE_TRANSITIONS[C.PROMOTABLE], frozenset({C.PROMOTED, C.REJECTED, C.ESCALATED}) + ) + check_candidate_transition(C.PROMOTABLE, C.ESCALATED) + + def test_a_promotable_candidate_can_be_escalated(self) -> None: + escalated = transition_candidate(self.promotable, C.ESCALATED) + self.assertEqual(escalated.status, C.ESCALATED) + self.assertEqual(escalated.candidate_id, self.promotable.candidate_id) + self.assertEqual(escalated.state_hash, self.promotable.state_hash) + self.assertEqual(self.promotable.status, C.PROMOTABLE) + + def test_the_neighbouring_edges_stay_forbidden(self) -> None: + for target in (C.RETRY_REQUIRED, C.ANALYZING, C.READY, C.BUILDING, C.CREATED, C.FAILED): + with self.subTest(target=target), self.assertRaises(IllegalTransitionError): + transition_candidate(self.promotable, target) + # reserved exits: mark_promoted (SM-002) and reject_stale_candidate (C-29) + with self.assertRaises(IllegalTransitionError) as promoted: + transition_candidate(self.promotable, C.PROMOTED) + self.assertEqual(promoted.exception.rule, "SM-002") + with self.assertRaises(IllegalTransitionError) as rejected: + transition_candidate(self.promotable, C.REJECTED) + self.assertEqual(rejected.exception.rule, "C-29") + + def test_every_terminal_status_stays_terminal(self) -> None: + for status in (C.FAILED, C.REJECTED, C.RETRY_REQUIRED, C.ESCALATED, C.PROMOTED): + self.assertEqual(CANDIDATE_TRANSITIONS[status], frozenset(), status) + escalated = transition_candidate(self.promotable, C.ESCALATED) + for target in C: + with self.subTest(target=target), self.assertRaises(IllegalTransitionError): + transition_candidate(escalated, target) + + def test_no_other_status_gained_an_edge(self) -> None: + was = { + C.CREATED: {C.BUILDING}, + C.BUILDING: {C.READY, C.FAILED}, + C.READY: {C.ANALYZING}, + C.ANALYZING: {C.REJECTED, C.RETRY_REQUIRED, C.ESCALATED, C.PROMOTABLE}, + } + for status, targets in was.items(): + self.assertEqual(CANDIDATE_TRANSITIONS[status], frozenset(targets), status) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_promotion_faults6.py b/tests/unit/test_promotion_faults6.py new file mode 100644 index 0000000..71e1f7d --- /dev/null +++ b/tests/unit/test_promotion_faults6.py @@ -0,0 +1,325 @@ +"""Faults in the middle of a promotion (P6b step 6; Doc 12 §19; Doc 09 §26, §41 "DB commit failure"; +Doc 06 §27; C-38 cases 2 and 3, C-163, C-165). + +HAND EXPECTATIONS, written before the run. A promotion that fails anywhere before the commit point +leaves the previous state current and nothing of the attempt behind (Doc 06 §27: "no new current +Trusted State"; Doc 09 §26: one transaction): + +* injected at each of the seven checkpoints of the unit of work, at the STATE_PROMOTED audit append + and at the binding-record append: ``PromotionCommitError`` (case 3), caused by the injected fault; + read from a new connection, the head is still v0, no state, reference, resource, binding record, + STATE_PROMOTED event or committed decision of the attempt exists, the candidate is PROMOTABLE, the + only new row is the best-effort PROMOTION_FAILED event, and the same commit then succeeds once + with exactly one new state; +* when the decision had been recorded before, its row (uncommitted) is still there; when it had + not, its row is gone with the rolled-back transaction; +* when even the failure event cannot be written, nothing is recorded and the caller still gets + ``PromotionCommitError``; +* evidence that does not bind (a tampered payload, a wrong ``state_hash``, a record produced for + another candidate) and a report whose hash no longer recomputes are revalidation failures (case + 2): the candidate is ESCALATED, PROMOTION_FAILED and ESCALATED are appended, nothing of the new + state exists, and a second attempt changes nothing. +""" + +from __future__ import annotations + +import dataclasses +import unittest +from typing import Any + +from core.domain.audit import AuditEventType as AE +from core.domain.enums import CandidateStatus +from core.domain.promotion import ProofSource +from core.persistence.sqlite import ALL_CHECKPOINTS, SqliteUnitOfWork +from core.promotion import ids +from core.promotion.commit import PromotionCommitError, record_decision +from tests.domain_builders import uid +from tests.promotion_builders import SEC1, run +from tests.promotion_commit_builders import ( + CONTEXT, + NOW, + CommitCase, + InjectedFault, + check_database, +) + +DECISION = uid(9100) +AUDIT_APPEND = "audit_append" +BINDING_APPEND = "binding_append" +POINTS = (*ALL_CHECKPOINTS, AUDIT_APPEND, BINDING_APPEND) +TABLES = ( + "trusted_states", + "lineage_heads", + "invariant_refs", + "state_resources", + "resources", + "promotion_decisions", + "candidates", + "evidence_events", + "evidence_artifacts", +) + + +class _Breaking: + """A repository whose ``append`` raises for the submissions ``match`` selects.""" + + def __init__(self, real: Any, match: Any) -> None: + self._real = real + self._match = match + + def __getattr__(self, name: str) -> Any: + return getattr(self._real, name) + + def append(self, submission: Any) -> Any: + if self._match(submission): + raise InjectedFault("append") + return self._real.append(submission) + + +class BreakingUnitOfWork(SqliteUnitOfWork): + """A unit of work whose audit append of STATE_PROMOTED, or whose append of the binding record, + fails.""" + + def __init__(self, path: Any, *, audit: bool = False, binding: bool = False) -> None: + super().__init__(path) + self._break_audit = audit + self._break_binding = binding + + @property + def audit(self) -> Any: + real = super().audit + if not self._break_audit: + return real + return _Breaking(real, lambda s: s.event_type is AE.STATE_PROMOTED) + + @property + def evidence(self) -> Any: + real = super().evidence + if not self._break_binding: + return real + return _Breaking(real, lambda s: s.event.event_name == "promotion.committed") + + +class FaultBase(CommitCase): + recorded = False + + def setUp(self) -> None: + super().setUp() + self.outcome = run(self.world) + if self.recorded: + with self.uow() as u: + record_decision(u, self.outcome, self.world.candidate, CONTEXT, now=NOW) + + def rows(self) -> dict[str, int]: + return {table: self.count(table) for table in TABLES} + + def audit_ids(self) -> set[str]: + with self.uow() as u: + return { + e.event_id for e in u.audit.list_for_candidate(self.world.candidate.candidate_id) + } + + def attempt(self, point: str) -> BaseException: + """Commit with a fault injected at ``point``; return the error the caller gets.""" + if point in (AUDIT_APPEND, BINDING_APPEND): + path = self.path + opened: list[int] = [] + + def opener() -> Any: + opened.append(1) # the first unit of work is the commit; later ones are honest + return BreakingUnitOfWork( + path, + audit=point == AUDIT_APPEND and len(opened) == 1, + binding=point == BINDING_APPEND and len(opened) == 1, + ) + + with self.assertRaises(PromotionCommitError) as caught: + self.commit(outcome=self.outcome, opener=opener) + else: + + fired: list[str] = [] + + def hook(name: str) -> None: + if name == point and not fired: + fired.append(name) + raise InjectedFault(name) + + with self.assertRaises(PromotionCommitError) as caught: + self.commit(outcome=self.outcome, checkpoint=hook) + self.assertEqual(fired, [point], f"{point} was never reached") + return caught.exception + + def inject(self, point: str) -> dict[str, Any]: + """Inject a fault at ``point``, check what is left and retry; return the row of the report's + table.""" + before = self.rows() + events_before = self.audit_ids() + error = self.attempt(point) + self.assertIsInstance(error.__cause__, InjectedFault) + after = self.rows() + left = { + "head": "v0" if self.current().state_id == self.world.trusted.state_id else "MOVED", + "candidate": self.candidate_status(), + "rows_changed": {t: (before[t], after[t]) for t in TABLES if before[t] != after[t]}, + "new_events": sorted(self.audit_ids() - events_before), + "committed_decisions": self.count( + "promotion_decisions", "committed_state_id IS NOT NULL" + ), + "binding_records": self.count("evidence_events", "event_name = 'promotion.committed'"), + "state_promoted_events": self.count("audit_events", "event_type = 'STATE_PROMOTED'"), + } + retry = self.commit(outcome=self.outcome) + left["retry"] = ( + "one new state" if retry.state is not None and not retry.duplicate else "FAILED" + ) + left["states_after_retry"] = self.count("trusted_states") + return left + + def check(self, point: str) -> None: + before = self.rows() + left = self.inject(point) + self.assertEqual(left["head"], "v0") + self.assertEqual(left["candidate"], "PROMOTABLE") + self.assertEqual(left["committed_decisions"], 0) + self.assertEqual(left["binding_records"], 0) + self.assertEqual(left["state_promoted_events"], 0) + self.assertEqual(left["new_events"], [ids.failure_event_id_for(DECISION, "INFRASTRUCTURE")]) + # the only new row is the payload of the PROMOTION_FAILED event (audit payloads are stored + # as content-addressed artifacts); the event itself is in audit_events, checked above + changed = left["rows_changed"] + self.assertEqual(set(changed), {"evidence_artifacts"}, "the attempt left rows behind") + self.assertEqual(changed["evidence_artifacts"][1], changed["evidence_artifacts"][0] + 1) + self.assertEqual(before["promotion_decisions"], 1 if self.recorded else 0) + self.assertEqual(left["retry"], "one new state") + self.assertEqual(left["states_after_retry"], 2) + self.assertEqual(self.count("audit_events", "event_type = 'STATE_PROMOTED'"), 1) + self.assertEqual(check_database(self.path), []) + + +class TestFaultsBeforeTheDecisionWasRecorded(FaultBase): + def test_every_point_leaves_nothing_and_the_retry_works(self) -> None: + for index, point in enumerate(POINTS): + with self.subTest(point=point): + if index: + self.tearDown() + self.setUp() + self.check(point) + + +class TestFaultsAfterTheDecisionWasRecorded(FaultBase): + recorded = True + stored_status = CandidateStatus.ANALYZING + + def test_every_point_the_commit_reaches_leaves_nothing_and_the_retry_works(self) -> None: + for index, point in enumerate(p for p in POINTS if p != "after_decision_insert"): + with self.subTest(point=point): + if index: + self.tearDown() + self.setUp() + self.check(point) + + def test_the_recorded_decision_row_survives_a_failed_commit(self) -> None: + self.attempt("after_lineage_head_update") + self.assertEqual(self.count("promotion_decisions"), 1) + self.assertEqual(self.count("promotion_decisions", "committed_state_id IS NOT NULL"), 0) + self.assertEqual(self.candidate_status(), "PROMOTABLE") + + +class TestEvenTheFailureEventFails(CommitCase): + def test_nothing_is_recorded_and_the_caller_still_gets_the_error(self) -> None: + outcome = run(self.world) + before = { + t: self.count(t) for t in ("audit_events", "trusted_states", "promotion_decisions") + } + + def hook(name: str) -> None: + raise InjectedFault(name) + + with self.assertRaises(PromotionCommitError) as caught: + self.commit(outcome=outcome, checkpoint=hook) + self.assertIsInstance(caught.exception.__cause__, InjectedFault) + after = {t: self.count(t) for t in before} + self.assertEqual(after, before) + self.assertEqual(self.candidate_status(), "PROMOTABLE") + self.assertFalse(self.commit(outcome=outcome).duplicate) + + +class TestRevalidationFailures(CommitCase): + """C-38 case 2: the candidate is ESCALATED and nothing of the new state exists.""" + + def setUp(self) -> None: + super().setUp() + self.outcome = run(self.world) + self.before = { + t: self.count(t) + for t in ("trusted_states", "invariant_refs", "state_resources", "lineage_heads") + } + + def sources(self, source: ProofSource) -> list[Any]: + return [e for e in self.outcome.proof_plan if e.source is source] + + def assert_escalated(self, result: Any) -> None: + self.assertTrue(result.escalated) + self.assertIsNone(result.state) + self.assertEqual(self.candidate_status(), "ESCALATED") + self.assertEqual({t: self.count(t) for t in self.before}, self.before) + self.assertEqual(self.count("evidence_events", "event_name = 'promotion.committed'"), 0) + self.assertEqual(self.count("audit_events", "event_type = 'STATE_PROMOTED'"), 0) + self.assertEqual(self.count("promotion_decisions", "committed_state_id IS NOT NULL"), 0) + self.assertEqual(self.count("audit_events", "event_type = 'PROMOTION_FAILED'"), 1) + self.assertEqual(self.count("audit_events", "event_type = 'ESCALATED'"), 1) + # trying again changes nothing: the candidate is ESCALATED and stays so + rows = (self.count("audit_events"), self.count("promotion_decisions")) + again = self.commit(outcome=self.outcome) + self.assertTrue(again.escalated) + self.assertEqual((self.count("audit_events"), self.count("promotion_decisions")), rows) + self.assertEqual(self.candidate_status(), "ESCALATED") + self.assertEqual(check_database(self.path), []) + + def test_a_tampered_evidence_payload(self) -> None: + evidence_id = self.evidence_ids[self.sources(ProofSource.VERIFIED)[0].verification_id or ""] + self.tamper( + "UPDATE evidence_artifacts SET payload_json = '{\"x\":1}' WHERE content_hash = " + "(SELECT content_hash FROM evidence_events WHERE evidence_id = ?)", + (evidence_id,), + ) + self.assert_escalated(self.commit(outcome=self.outcome)) + + def test_a_wrong_state_hash_on_a_source_record(self) -> None: + evidence_id = self.evidence_ids[self.sources(ProofSource.VERIFIED)[0].verification_id or ""] + self.tamper( + "UPDATE evidence_events SET state_hash = ? WHERE evidence_id = ?", + ("5" * 64, evidence_id), + ) + self.assert_escalated(self.commit(outcome=self.outcome)) + + def test_a_source_record_produced_for_another_candidate(self) -> None: + other, other_ids = self.rival() + entry = next(e for e in self.sources(ProofSource.VERIFIED) if e.invariant_id == SEC1) + theirs = next( + r.verification_id + for r in other.verification.results + if r.target_id == f"INVARIANT:{SEC1}:1" + ) + mapped = {**self.evidence_ids, entry.verification_id or "": other_ids[theirs]} + self.assert_escalated(self.commit(outcome=self.outcome, evidence_ids=mapped)) + + def test_a_verification_report_whose_hash_no_longer_recomputes(self) -> None: + forged = dataclasses.replace(self.world.verification) + object.__setattr__(forged, "content_hash", "0" * 64) + self.assertNotEqual(forged.content_hash, forged.compute_hash()) + self.assert_escalated( + self.commit(dataclasses.replace(self.world, verification=forged), outcome=self.outcome) + ) + + def test_an_impact_report_whose_hash_no_longer_recomputes(self) -> None: + forged = dataclasses.replace(self.world.impact) + object.__setattr__(forged, "content_hash", "0" * 64) + self.assertNotEqual(forged.content_hash, forged.compute_hash()) + self.assert_escalated( + self.commit(dataclasses.replace(self.world, impact=forged), outcome=self.outcome) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_promotion_idempotency.py b/tests/unit/test_promotion_idempotency.py new file mode 100644 index 0000000..6e70009 --- /dev/null +++ b/tests/unit/test_promotion_idempotency.py @@ -0,0 +1,268 @@ +"""The same promotion twice (P6b step 6; Doc 09 §38, §39, Appendix B; Doc 11 §38; C-163). + +HAND EXPECTATIONS, written before the run. The key of a promotion is its decision id (C-163): + +* committing a decision that is already committed returns the state it made, with ``duplicate=True`` + and the committed decision; no table gains or loses a row, whatever the clock or the context ids + of the second request say; +* the same decision id with other content is never merged: ``ConflictingDuplicateError``, and no + table changes (Doc 09 §38); the same holds for a decision that was stored but not committed; +* a candidate that a decision already promoted refuses a second PROMOTE under another id; +* recording the same decision twice changes nothing, for each of the four decisions; +* two threads committing the same decision at once make one state: one result is the commit, the + other the duplicate, whichever pause point holds the first thread. +""" + +from __future__ import annotations + +import dataclasses +import unittest + +from core.domain.enums import CandidateStatus, DecisionType +from core.domain.promotion import ConflictingDuplicateError +from core.persistence.sqlite import ALL_CHECKPOINTS +from core.promotion import ids +from core.promotion.commit import ( + PromotionCommitError, + PromotionContext, + commit_promotion, + record_decision, +) +from tests.domain_builders import at, uid +from tests.promotion_builders import ERROR, FAIL, FUNC1, UNKNOWN, make_world, run, target +from tests.promotion_commit_builders import ( + CONTEXT, + NOW, + CommitCase, + Gate, + InjectedFault, + Runner, + check_database, +) + +DECISION = uid(9100) +TABLES = ( + "trusted_states", + "lineage_heads", + "invariant_refs", + "state_resources", + "promotion_decisions", + "candidates", + "audit_events", + "evidence_events", + "evidence_artifacts", +) + + +class IdempotencyCase(CommitCase): + def rows(self) -> dict[str, int]: + return {table: self.count(table) for table in TABLES} + + +class TestSequentialDuplicates(IdempotencyCase): + def setUp(self) -> None: + super().setUp() + self.outcome = run(self.world) + self.first = self.commit(outcome=self.outcome) + self.assertFalse(self.first.duplicate) + + def test_each_repeat_is_the_same_state_and_writes_nothing(self) -> None: + before = self.rows() + for _ in range(3): + again = self.commit(outcome=self.outcome) + self.assertTrue(again.duplicate) + self.assertEqual(again.state, self.first.state) + self.assertEqual(again.decision, self.first.decision) + self.assertFalse(again.stale or again.escalated) + self.assertEqual(self.rows(), before) + self.assertEqual(before["trusted_states"], 2) + self.assertEqual(check_database(self.path), []) + + def test_another_clock_and_other_context_ids_change_nothing(self) -> None: + before = self.rows() + other = PromotionContext( + run_id=uid(1), correlation_id=uid(2), operation_id=uid(3), attempt_id=uid(4) + ) + again = commit_promotion( + self.factory(), + self.outcome, + self.world.candidate, + self.world.impact, + self.world.verification, + self.world.policy, + self.evidence_ids, + other, + now=at(99), + ) + self.assertTrue(again.duplicate) + self.assertEqual(again.state, self.first.state) + self.assertEqual(self.rows(), before) + + def test_the_state_is_the_stored_one_and_not_a_rebuilt_one(self) -> None: + again = self.commit(outcome=self.outcome) + assert again.state is not None and self.first.state is not None + self.assertEqual(again.state.created_at, self.first.state.created_at) + self.assertEqual(again.state.state_hash, self.first.state.state_hash) + self.assertEqual(again.state.state_id, ids.state_id_for(DECISION)) + + def test_recording_a_committed_decision_again_changes_nothing(self) -> None: + before = self.rows() + with self.uow() as u: + stored = record_decision(u, self.outcome, self.world.candidate, CONTEXT, now=NOW) + self.assertEqual(stored.status.value, "PROMOTED") + self.assertEqual(self.rows(), before) + + def test_a_second_promote_for_a_promoted_candidate_is_refused(self) -> None: + before = self.rows() + second = run(self.world, decision_id=uid(9101)) + with self.assertRaises(ConflictingDuplicateError): + self.commit(outcome=second) + self.assertEqual(self.rows(), before) + + +class TestConflictingDuplicates(IdempotencyCase): + def test_other_content_under_the_id_of_a_committed_decision(self) -> None: + outcome = run(self.world) + self.commit(outcome=outcome) + before = self.rows() + variants = { + "another policy version": run( + dataclasses.replace( + self.world, policy=dataclasses.replace(self.world.policy, policy_version="p-2") + ) + ), + "another attempt number": run(self.world, attempt_number=2), + } + for label, other in variants.items(): + with self.subTest(label), self.assertRaises(ConflictingDuplicateError): + self.commit(outcome=other) + self.assertEqual(self.rows(), before) + + def test_other_content_under_the_id_of_a_stored_decision(self) -> None: + outcome = run(self.world) + with self.uow() as u: + u.decisions.save(outcome.decision) + before = self.rows() + other = run(self.world, attempt_number=3) + self.assertEqual(other.decision.decision_id, outcome.decision.decision_id) + with self.assertRaises(ConflictingDuplicateError): + self.commit(outcome=other) + self.assertEqual(self.rows(), before) + self.assertEqual(self.candidate_status(), "PROMOTABLE") + + def test_a_decision_of_a_non_promote_cannot_be_overwritten_by_a_promote(self) -> None: + reject_world = make_world(results={target(FUNC1): FAIL}) + reject = run(reject_world) + self.assertIs(reject.decision.decision, DecisionType.REJECT) + # recorded for the stored (PROMOTABLE) candidate only as a decision row + with self.uow() as u: + u.decisions.save(reject.decision) + with self.assertRaises(ConflictingDuplicateError): + self.commit(outcome=run(self.world)) + self.assertEqual(self.count("trusted_states"), 1) + + +class TestRecordingTwice(CommitCase): + stored_status = ( + CandidateStatus.ANALYZING + ) # a decision is recorded for a candidate under analysis + + def test_each_decision_recorded_twice_changes_nothing_the_second_time(self) -> None: + cases = ( + {}, + {target(FUNC1): FAIL}, + {target(FUNC1): UNKNOWN}, + {target(FUNC1): ERROR}, + ) + seen = set() + for results in cases: + self.tearDown() + self.setUp() + outcome = run(make_world(results=results)) + seen.add(outcome.decision.decision) + with self.uow() as u: + record_decision(u, outcome, self.world.candidate, CONTEXT, now=NOW) + rows = {t: self.count(t) for t in ("promotion_decisions", "audit_events", "candidates")} + status = self.candidate_status() + with self.uow() as u: + record_decision(u, outcome, self.world.candidate, CONTEXT, now=NOW) + again = { + t: self.count(t) for t in ("promotion_decisions", "audit_events", "candidates") + } + with self.subTest(decision=outcome.decision.decision): + self.assertEqual(again, rows) + self.assertEqual(self.candidate_status(), status) + self.assertEqual( + seen, + {DecisionType.PROMOTE, DecisionType.REJECT, DecisionType.RETRY, DecisionType.ESCALATE}, + ) + + def test_the_same_id_with_other_content_is_a_conflict(self) -> None: + outcome = run(self.world) + with self.uow() as u: + record_decision(u, outcome, self.world.candidate, CONTEXT, now=NOW) + other = run(self.world, attempt_number=2) + with self.uow() as u, self.assertRaises(ConflictingDuplicateError): + record_decision(u, other, self.world.candidate, CONTEXT, now=NOW) + + +class TestConcurrentDuplicates(IdempotencyCase): + def setUp(self) -> None: + super().setUp() + self.outcome = run(self.world) + + def race(self, point: str) -> tuple[Runner, Runner]: + gate = Gate(point) + leader = Runner(lambda: self.commit(outcome=self.outcome, checkpoint=gate)) + leader.start() + self.assertTrue(leader.wait_for(gate), f"{point} was never reached") + follower = Runner(lambda: self.commit(outcome=self.outcome)) + follower.start() + follower.started_running.wait(60) + gate.release.set() + leader.join(60) + follower.join(60) + self.assertFalse(leader.is_alive() or follower.is_alive(), "a thread is stuck") + return leader, follower + + def test_two_threads_with_one_decision_make_one_state_at_every_point(self) -> None: + for index, point in enumerate(ALL_CHECKPOINTS): + with self.subTest(point=point): + if index: + self.tearDown() + self.setUp() + leader, follower = self.race(point) + self.assertIsNone(leader.error) + self.assertIsNone(follower.error) + self.assertFalse(leader.result.duplicate) + self.assertTrue(follower.result.duplicate) + self.assertEqual(leader.result.state, follower.result.state) + self.assertEqual(self.count("trusted_states"), 2) + self.assertEqual(self.count("audit_events", "event_type = 'STATE_PROMOTED'"), 1) + self.assertEqual(self.count("promotion_decisions"), 1) + self.assertEqual(check_database(self.path), []) + + def test_a_leader_that_fails_leaves_the_follower_to_make_the_state(self) -> None: + gate = Gate("after_lineage_head_update", fail=True) + leader = Runner(lambda: self.commit(outcome=self.outcome, checkpoint=gate)) + leader.start() + self.assertTrue(leader.wait_for(gate)) + follower = Runner(lambda: self.commit(outcome=self.outcome)) + follower.start() + follower.started_running.wait(60) + gate.release.set() + leader.join(60) + follower.join(60) + self.assertIsInstance(leader.error, PromotionCommitError) + self.assertIsInstance(leader.error.__cause__, InjectedFault) + self.assertIsNone(follower.error) + self.assertFalse(follower.result.duplicate) + again = self.commit(outcome=self.outcome) + self.assertTrue(again.duplicate) + self.assertEqual(again.state, follower.result.state) + self.assertEqual(self.count("trusted_states"), 2) + self.assertEqual(check_database(self.path), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_promotion_newstate.py b/tests/unit/test_promotion_newstate.py new file mode 100644 index 0000000..786216b --- /dev/null +++ b/tests/unit/test_promotion_newstate.py @@ -0,0 +1,375 @@ +"""Derived ids, proof building and the arguments of the new trusted state (P6b step 3; Doc 09 §26, +§27; Doc 06 §14, §21; C-40, C-41, C-45, C-163, C-164). + +HAND EXPECTATIONS, written before the code (canonical world of ``tests/promotion_builders.py``: +INV-SEC-001 and INV-FUNC-001 verified, INV-SEC-002 and INV-SEC-003 carried forward): + +* C-163: the new state id, the promotion-evidence id and the STATE_PROMOTED event id are + ``uuid5(NAMESPACE, "state:" + decision_id)``, ``... "promotion-evidence:" ...`` and + ``... "state-promoted:" ...``; the namespace is the constant written below. +* C-164: every reference of the new state carries the id of the binding record. A VERIFIED + reference has its source evidence plus the binding id (PROTECTED, origin VERIFIED, bound to the + candidate); a CARRIED_FORWARD one has the parent reference's evidence plus the binding id + (status and time copied, origin CARRIED_FORWARD, source state the parent). +* The proof set covers every invariant of the parent and nothing is built from a plan entry that + has no evaluation, no evidence id, a result that is not PASS, or a changed version (a version + change has no lifecycle path: fail closed). +* ``TrustedState.promote`` with those arguments gives version parent + 1, the candidate's resources, + ``commit_decision_id`` = the decision id, the derived state id, and a registry version that is not + lower than the parent's (C-41). +""" + +from __future__ import annotations + +import dataclasses +import unittest +import uuid + +from core.domain.enums import CandidateStatus, InvariantStatus, ProofOrigin, VerificationResult +from core.domain.errors import DomainValidationError +from core.domain.promotion import DecisionOutcome, ProofPlanEntry +from core.domain.state import TrustedState +from core.domain.verification import VerificationReason +from core.promotion import ids +from core.promotion.newstate import promotion_arguments +from core.promotion.proofs import build_proofs, evaluate_from_results +from tests.domain_builders import uid +from tests.promotion_builders import ( + FUNC1, + SEC1, + SEC2, + SEC3, + World, + impact_for, + make_world, + run, + target, +) + +NAMESPACE = uuid.UUID("c3b0e0d2-6f1a-4d8e-9a57-2b7c4e1f8a90") +DECISION = uid(9100) +BINDING = ids.evidence_id_for(DECISION) + + +class TestDerivedIds(unittest.TestCase): + def test_the_namespace_is_the_fixed_constant(self) -> None: + self.assertEqual(ids.PROMOTION_NAMESPACE, NAMESPACE) + + def test_the_three_ids_follow_the_formulas_of_c_163(self) -> None: + self.assertEqual( + ids.state_id_for(DECISION), str(uuid.uuid5(NAMESPACE, "state:" + DECISION)) + ) + self.assertEqual( + ids.evidence_id_for(DECISION), + str(uuid.uuid5(NAMESPACE, "promotion-evidence:" + DECISION)), + ) + self.assertEqual( + ids.event_id_for(DECISION), str(uuid.uuid5(NAMESPACE, "state-promoted:" + DECISION)) + ) + + def test_the_ids_are_distinct_and_stable(self) -> None: + found = { + ids.state_id_for(DECISION), + ids.evidence_id_for(DECISION), + ids.event_id_for(DECISION), + ids.rejection_decision_id_for(DECISION), + ids.failure_event_id_for(DECISION, "REVALIDATION"), + ids.failure_event_id_for(DECISION, "INFRASTRUCTURE"), + } + self.assertEqual(len(found), 6) + self.assertEqual(ids.state_id_for(DECISION), ids.state_id_for(DECISION)) + self.assertNotEqual(ids.state_id_for(DECISION), ids.state_id_for(uid(9101))) + + def test_a_decision_id_must_be_a_uuid(self) -> None: + for bad in ("", "x", None, 5): + with self.subTest(bad=bad), self.assertRaises(DomainValidationError): + ids.state_id_for(bad) # type: ignore[arg-type] + + def test_the_failure_kind_is_a_known_word(self) -> None: + with self.assertRaises(DomainValidationError): + ids.failure_event_id_for(DECISION, "OTHER") + + +def sources_for(world: World) -> tuple[dict[str, object], dict[str, str]]: + """The verification records by id and a made-up evidence id for each (n-th result).""" + by_id = {r.verification_id: r for r in world.verification.results} + evidence = {vid: uid(8800 + n) for n, vid in enumerate(sorted(by_id))} + return by_id, evidence + + +def evaluations_of(world: World, outcome: DecisionOutcome) -> dict[str, object]: + by_id, evidence = sources_for(world) + return evaluate_from_results( + outcome.proof_plan, + world.candidate, + world.trusted, + world.definitions, + by_id, # type: ignore[arg-type] + evidence, + BINDING, + ) + + +class TestBuildProofs(unittest.TestCase): + def setUp(self) -> None: + self.world = make_world() + self.outcome = run(self.world) + self.by_id, self.evidence = sources_for(self.world) + self.evaluations = evaluations_of(self.world, self.outcome) + self.proofs = build_proofs( + self.outcome.proof_plan, + self.world.candidate, + self.world.trusted, + self.world.impact, + self.evaluations, # type: ignore[arg-type] + binding_evidence_id=BINDING, + ) + + def by_invariant(self) -> dict[str, object]: + return {p.invariant_id: p for p in self.proofs} + + def test_one_proof_per_invariant_of_the_parent_sorted_by_id(self) -> None: + self.assertEqual([p.invariant_id for p in self.proofs], [FUNC1, SEC1, SEC2, SEC3]) + + def test_a_verified_proof_has_its_source_evidence_and_the_binding_record(self) -> None: + for invariant_id in (SEC1, FUNC1): + proof = self.by_invariant()[invariant_id] + source = self.evidence[self.world.records[target(invariant_id)].verification_id] + with self.subTest(invariant=invariant_id): + self.assertIs(proof.origin, ProofOrigin.VERIFIED) # type: ignore[attr-defined] + self.assertIs(proof.status, InvariantStatus.PROTECTED) # type: ignore[attr-defined] + self.assertEqual(proof.evidence_ids, tuple(sorted({source, BINDING}))) # type: ignore[attr-defined] + self.assertEqual(proof.candidate_id, self.world.candidate.candidate_id) # type: ignore[attr-defined] + + def test_a_carried_proof_has_the_parents_evidence_and_the_binding_record(self) -> None: + for invariant_id in (SEC2, SEC3): + parent = next( + r for r in self.world.trusted.invariant_refs if r.invariant_id == invariant_id + ) + proof = self.by_invariant()[invariant_id] + with self.subTest(invariant=invariant_id): + self.assertIs(proof.origin, ProofOrigin.CARRIED_FORWARD) # type: ignore[attr-defined] + self.assertEqual(proof.status, parent.status) # type: ignore[attr-defined] + self.assertEqual(proof.verified_at, parent.last_verified_at) # type: ignore[attr-defined] + self.assertEqual(proof.source_state_id, self.world.trusted.state_id) # type: ignore[attr-defined] + self.assertEqual( + proof.evidence_ids, # type: ignore[attr-defined] + tuple(sorted({*parent.evidence_ids, BINDING})), + ) + + def test_a_plan_entry_without_an_evaluation_is_refused(self) -> None: + partial = {k: v for k, v in self.evaluations.items() if k != SEC1} + with self.assertRaises(DomainValidationError): + build_proofs( + self.outcome.proof_plan, + self.world.candidate, + self.world.trusted, + self.world.impact, + partial, # type: ignore[arg-type] + binding_evidence_id=BINDING, + ) + + def test_a_plan_that_leaves_out_an_invariant_of_the_parent_is_refused(self) -> None: + short = tuple(e for e in self.outcome.proof_plan if e.invariant_id != SEC3) + with self.assertRaises(DomainValidationError): + build_proofs( + short, + self.world.candidate, + self.world.trusted, + self.world.impact, + self.evaluations, # type: ignore[arg-type] + binding_evidence_id=BINDING, + ) + + def test_two_entries_for_one_invariant_are_refused(self) -> None: + twice = (*self.outcome.proof_plan, self.outcome.proof_plan[0]) + with self.assertRaises(DomainValidationError): + build_proofs( + twice, + self.world.candidate, + self.world.trusted, + self.world.impact, + self.evaluations, # type: ignore[arg-type] + binding_evidence_id=BINDING, + ) + + def test_an_affected_invariant_cannot_be_carried_forward_here_either(self) -> None: + # C-45 at the point where proofs are built: the report says SEC-002 is affected + report = impact_for( + self.world.trusted, + self.world.candidate, + affected=(SEC1, FUNC1, SEC2), + carry=(SEC3,), + ) + with self.assertRaises(DomainValidationError) as caught: + build_proofs( + self.outcome.proof_plan, + self.world.candidate, + self.world.trusted, + report, + self.evaluations, # type: ignore[arg-type] + binding_evidence_id=BINDING, + ) + self.assertIn("C-45", str(caught.exception)) + + def test_the_binding_id_must_be_a_uuid(self) -> None: + with self.assertRaises(DomainValidationError): + build_proofs( + self.outcome.proof_plan, + self.world.candidate, + self.world.trusted, + self.world.impact, + self.evaluations, # type: ignore[arg-type] + binding_evidence_id="binding", + ) + + +class TestEvaluateFromResults(unittest.TestCase): + def setUp(self) -> None: + self.world = make_world() + self.outcome = run(self.world) + self.by_id, self.evidence = sources_for(self.world) + + def call( + self, + *, + plan: tuple[ProofPlanEntry, ...] | None = None, + by_id: dict[str, object] | None = None, + evidence: dict[str, str] | None = None, + ) -> dict[str, object]: + return evaluate_from_results( + self.outcome.proof_plan if plan is None else plan, + self.world.candidate, + self.world.trusted, + self.world.definitions, + self.by_id if by_id is None else by_id, # type: ignore[arg-type] + self.evidence if evidence is None else evidence, + BINDING, + ) + + def test_a_violated_reference_is_reopened_and_a_protected_one_is_affected(self) -> None: + got = self.call() + self.assertEqual(sorted(got), [FUNC1, SEC1]) # only the VERIFIED entries + for evaluation in got.values(): + self.assertIs(evaluation.status, InvariantStatus.PROTECTED) # type: ignore[attr-defined] + + def test_a_missing_result_or_evidence_id_is_refused(self) -> None: + some = next(iter(self.by_id)) + with self.assertRaises(DomainValidationError): + self.call(by_id={k: v for k, v in self.by_id.items() if k != some}) + with self.assertRaises(DomainValidationError): + self.call(evidence={k: v for k, v in self.evidence.items() if k != some}) + + def test_a_result_that_is_not_a_pass_is_refused(self) -> None: + failing = { + k: dataclasses.replace( + v, # type: ignore[type-var] + result=VerificationResult.FAIL, + reason=VerificationReason.PREDICATE_VIOLATED, + evidence={**v.evidence, "result": "FAIL"}, # type: ignore[attr-defined] + ) + for k, v in self.by_id.items() + } + with self.assertRaises(DomainValidationError): + self.call(by_id=failing) + + def test_a_version_change_has_no_lifecycle_path(self) -> None: + entry = next(e for e in self.outcome.proof_plan if e.invariant_id == SEC1) + bumped = dataclasses.replace(entry, invariant_version=2) + plan = tuple(bumped if e is entry else e for e in self.outcome.proof_plan) + with self.assertRaises(DomainValidationError): + self.call(plan=plan) + + +class TestThePromotionArguments(unittest.TestCase): + def setUp(self) -> None: + self.world = make_world(candidate_status=CandidateStatus.PROMOTABLE) + self.outcome = run(self.world) + self.proofs = build_proofs( + self.outcome.proof_plan, + self.world.candidate, + self.world.trusted, + self.world.impact, + evaluations_of(self.world, self.outcome), # type: ignore[arg-type] + binding_evidence_id=BINDING, + ) + + def args(self, **changes: object) -> dict[str, object]: + base: dict[str, object] = { + "registry_version": self.world.trusted.invariant_registry_version, + "now": self.world.trusted.created_at, + } + base.update(changes) + return promotion_arguments( + self.world.candidate, + self.world.trusted, + DECISION, + self.proofs, + registry_version=base["registry_version"], # type: ignore[arg-type] + now=base["now"], # type: ignore[arg-type] + ) + + def test_the_arguments_make_the_next_state(self) -> None: + # in a test the controller's own call is repeated; production code calls it in commit + state = TrustedState.promote(**self.args()) # type: ignore[arg-type] + self.assertEqual(state.version, self.world.trusted.version + 1) + self.assertEqual(state.parent_state_id, self.world.trusted.state_id) + self.assertEqual(state.state_id, ids.state_id_for(DECISION)) + self.assertEqual(state.commit_decision_id, DECISION) + self.assertEqual(state.resources, self.world.candidate.resources) + self.assertEqual({r.invariant_id for r in state.invariant_refs}, {FUNC1, SEC1, SEC2, SEC3}) + for ref in state.invariant_refs: + with self.subTest(invariant=ref.invariant_id): + self.assertIs(ref.status, InvariantStatus.PROTECTED) + self.assertIn(BINDING, ref.evidence_ids) + self.assertIn(BINDING, state.evidence_refs) + union = {e for p in self.proofs for e in p.evidence_ids} + self.assertEqual(set(state.evidence_refs), union) + + def test_the_ssh_reference_is_verified_not_carried(self) -> None: + state = TrustedState.promote(**self.args()) # type: ignore[arg-type] + origins = {r.invariant_id: r.origin for r in state.invariant_refs} + self.assertEqual( + origins, + { + FUNC1: ProofOrigin.VERIFIED, + SEC1: ProofOrigin.VERIFIED, + SEC2: ProofOrigin.CARRIED_FORWARD, + SEC3: ProofOrigin.CARRIED_FORWARD, + }, + ) + + def test_a_lower_registry_version_is_refused(self) -> None: + # C-41: the registry version is non-decreasing across promotions + world = make_world(registry_version=2) + with self.assertRaises(DomainValidationError): + promotion_arguments( + world.candidate, + world.trusted, + DECISION, + self.proofs, + registry_version=1, + now=world.trusted.created_at, + ) + + def test_a_higher_registry_version_is_accepted(self) -> None: + state = TrustedState.promote(**self.args(registry_version=3)) # type: ignore[arg-type] + self.assertEqual(state.invariant_registry_version, 3) + + def test_the_candidate_must_be_promotable(self) -> None: + # an ANALYZING candidate is refused here, before promote is called + analyzing = make_world() + self.assertIs(analyzing.candidate.status, CandidateStatus.ANALYZING) + with self.assertRaises(DomainValidationError): + promotion_arguments( + analyzing.candidate, + analyzing.trusted, + DECISION, + self.proofs, + registry_version=1, + now=self.world.trusted.created_at, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_promotion_persistence.py b/tests/unit/test_promotion_persistence.py new file mode 100644 index 0000000..404e8c2 --- /dev/null +++ b/tests/unit/test_promotion_persistence.py @@ -0,0 +1,581 @@ +"""Schema 6 and the decision repository (P6b step 4; Doc 05 §15, §20-§23, §32; DATA-INT-003 and +004; C-38, C-42, C-44, C-47, C-163, C-165, C-168). + +HAND EXPECTATIONS, written before the code (C-165 as amended by C-168, decision (b)): + +* ``SCHEMA_VERSION`` is "6"; the first 92 statements (all of schema 5) are byte-identical, pinned + by a SHA-256 taken from the schema-5 code before it was touched; the new statements are appended. +* ``promotion_decisions`` holds the thirteen fields of ``PromotionDecision``; its triggers refuse + DELETE and REPLACE, refuse a decision whose parent is not its candidate's parent (DATA-INT-003), + refuse a row stored with a committed state, and let ``committed_state_id`` change once, from NULL, + for a PROMOTE, to a state whose ``commit_decision_id`` is that decision and whose parent is the + decision's parent, with every other column immutable (DATA-INT-004). At most one committed + decision per candidate, one decision per committed state, one state per commit decision (unique + indexes, C-163). +* No trigger demands a decision row for a promoted state (C-168, decision (b)). +* The candidate-status trigger of schema 5 is dropped and created again in the appended statements + with the extra pair PROMOTABLE -> ESCALATED (C-38); nothing else about it changes. +* A database with schema version 5 is refused with the delete-and-retry message (C-44). +""" + +from __future__ import annotations + +import hashlib +import sqlite3 +import unittest +from contextlib import closing +from typing import Any + +from core.domain.enums import CandidateStatus as CS +from core.domain.enums import DecisionType +from core.domain.errors import DomainValidationError, PersistenceError +from core.domain.promotion import ( + BlockingCondition, + ConflictingDuplicateError, + DecisionReason, + PromotionDecision, + ProofSource, + RequiredCheck, +) +from core.domain.state import transition_candidate +from core.persistence.schema import DDL, SCHEMA_VERSION, initialize_database, open_connection +from tests.domain_builders import uid +from tests.persistence_builders import SAFE, RepoCase + +SCHEMA_5_STATEMENTS = 92 +SCHEMA_5_DDL_SHA256 = "917394cc04896ec806a305ac5e8ea0a0f58d62b5fd3049969ae3697a9912e4c3" +SCHEMA_6_DDL_SHA256 = "aae75310c5075681a40a46ec5ea1b6a6e50cb9afef533070d0ff090fecc51e93" +D, R = DecisionType, DecisionReason + +COLUMNS = [ + "decision_id", + "candidate_id", + "parent_state_id", + "decision", + "reason", + "policy_version", + "attempt_number", + "required_checks_json", + "blocking_conditions_json", + "supporting_results_json", + "content_hash", + "created_at", + "committed_state_id", +] +NEW_TRIGGERS = { + "promotion_decisions_no_delete", + "promotion_decisions_no_replace", + "promotion_decisions_insert_rules", + "promotion_decisions_insert_uncommitted", + "promotion_decisions_update_identity", + "promotion_decisions_update_commit", +} +NEW_INDEXES = { + "uq_promotion_decisions_committed_candidate", + "uq_trusted_states_commit_decision", + "ix_promotion_decisions_candidate", +} + + +def make_decision( + candidate: Any, + *, + kind: DecisionType = D.PROMOTE, + decision_id: str | None = None, + parent_state_id: str | None = None, + n: int = 1, +) -> PromotionDecision: + promote = kind is D.PROMOTE + return PromotionDecision.create( + decision_id=decision_id or uid(4000 + n), + candidate_id=candidate.candidate_id, + parent_state_id=parent_state_id or candidate.parent_state_id, + decision=kind, + reason=R.ALL_REQUIRED_PROVEN if promote else R.REQUIRED_CHECK_FAILED, + policy_version="policy-1", + attempt_number=1, + required_checks=[RequiredCheck("CONSISTENCY", "GATE", ProofSource.VERIFIED, uid(7001))], + blocking_conditions=( + [] + if promote + else [BlockingCondition(R.REQUIRED_CHECK_FAILED, kind, "INVARIANT:INV-SEC-001:1", "x")] + ), + supporting_results=[uid(7001)], + created_at=candidate.created_at, + ) + + +class DecisionCase(RepoCase): + """A stored baseline v0 and a stored PROMOTABLE candidate of it.""" + + def setUp(self) -> None: + super().setUp() + self.v0 = self.seed() + self.steps = self.store_stages(self.v0, SAFE) + self.promotable = self.steps[4] + self.connections: list[sqlite3.Connection] = [] + + def raw(self) -> sqlite3.Connection: + conn = open_connection(self.path) + self.connections.append(conn) + return conn + + def tearDown(self) -> None: + for conn in self.connections: # a Windows file cannot be removed while it is open + conn.close() + super().tearDown() + + INSERT = ( + "INSERT INTO promotion_decisions (decision_id, candidate_id, parent_state_id, decision, " + "reason, policy_version, attempt_number, required_checks_json, blocking_conditions_json, " + "supporting_results_json, content_hash, created_at, committed_state_id) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + + def row(self, decision: PromotionDecision, **override: Any) -> tuple[Any, ...]: + values = { + "decision_id": decision.decision_id, + "candidate_id": decision.candidate_id, + "parent_state_id": decision.parent_state_id, + "decision": decision.decision.value, + "reason": decision.reason.value, + "policy_version": decision.policy_version, + "attempt_number": decision.attempt_number, + "required_checks_json": "[]", + "blocking_conditions_json": "[]", + "supporting_results_json": "[]", + "content_hash": decision.content_hash, + "created_at": "2026-10-01T09:00:00Z", + "committed_state_id": None, + } + values.update(override) + return tuple(values[c] for c in COLUMNS) + + def insert_raw(self, decision: PromotionDecision, **override: Any) -> None: + conn = self.raw() + conn.execute("BEGIN IMMEDIATE") + try: + conn.execute(self.INSERT, self.row(decision, **override)) + conn.execute("COMMIT") + except BaseException: + conn.execute("ROLLBACK") + raise + + def abort(self, sql: str, params: tuple[Any, ...], needle: str) -> None: + conn = self.raw() + with self.assertRaises(sqlite3.Error) as caught: + conn.execute(sql, params) + self.assertIn(needle, str(caught.exception)) + + +class TestSchema6(RepoCase): + def test_the_version_is_six(self) -> None: + self.assertEqual(SCHEMA_VERSION, "6") + self.assertEqual(self.scalar("SELECT value FROM schema_meta"), "6") + + def test_the_schema_5_statements_are_byte_identical(self) -> None: + digest = hashlib.sha256("\n".join(DDL[:SCHEMA_5_STATEMENTS]).encode()).hexdigest() + self.assertEqual(digest, SCHEMA_5_DDL_SHA256) + + def test_the_schema_6_statements_follow_and_are_pinned(self) -> None: + self.assertGreater(len(DDL), SCHEMA_5_STATEMENTS) + self.assertTrue( + DDL[SCHEMA_5_STATEMENTS].startswith("CREATE TABLE IF NOT EXISTS promotion_decisions") + ) + self.assertEqual(hashlib.sha256("\n".join(DDL).encode()).hexdigest(), SCHEMA_6_DDL_SHA256) + + def test_the_decision_table_has_the_fields_of_the_decision(self) -> None: + with closing(open_connection(self.path)) as conn: + columns = [r[1] for r in conn.execute("PRAGMA table_info(promotion_decisions)")] + self.assertEqual(columns, COLUMNS) + + def test_the_new_triggers_and_indexes_exist(self) -> None: + with closing(open_connection(self.path)) as conn: + triggers = { + r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='trigger'") + } + indexes = { + r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='index'") + } + self.assertTrue(triggers >= NEW_TRIGGERS) + self.assertTrue(indexes >= NEW_INDEXES) + # no reverse trigger: a promoted state is not required to have a decision row (C-168) + self.assertNotIn("trusted_states_insert_decision", triggers) + + def test_initialising_twice_changes_nothing(self) -> None: + before = self.scalar("SELECT count(*) FROM sqlite_master") + initialize_database(self.path) + self.assertEqual(self.scalar("SELECT count(*) FROM sqlite_master"), before) + + +class TestSchema5DatabaseIsRefused(RepoCase): + def test_a_database_with_schema_5_is_refused_with_the_delete_message(self) -> None: + old = self.path.with_name("old.db") + with closing(sqlite3.connect(old)) as conn: + for statement in DDL[:SCHEMA_5_STATEMENTS]: + conn.execute(statement) + conn.execute("INSERT INTO schema_meta VALUES ('schema_version', '5')") + conn.commit() + with self.assertRaises(PersistenceError) as caught: + initialize_database(old) + message = str(caught.exception) + self.assertIn("schema version 5", message) + self.assertIn("needs 6", message) + self.assertIn("delete", message) + with closing(sqlite3.connect(old)) as conn: # and nothing was changed + names = {r[0] for r in conn.execute("SELECT name FROM sqlite_master")} + self.assertNotIn("promotion_decisions", names) + + +class TestDecisionTriggers(DecisionCase): + def test_a_consistent_decision_can_be_inserted(self) -> None: + self.insert_raw(make_decision(self.promotable)) + self.assertEqual(self.count("promotion_decisions"), 1) + + def test_data_int_003_the_parent_must_be_the_candidates_parent(self) -> None: + other = make_decision(self.promotable, parent_state_id=uid(77)) + with self.assertRaises(sqlite3.Error) as caught: + self.insert_raw(other) + self.assertIn("DATA-INT-003", str(caught.exception)) + self.assertEqual(self.count("promotion_decisions"), 0) + + def test_data_int_003_the_candidate_must_exist(self) -> None: + ghost = make_decision(self.promotable) + with self.assertRaises(sqlite3.Error): + self.insert_raw(ghost, candidate_id=uid(66)) + + def test_a_row_is_stored_uncommitted(self) -> None: + with self.assertRaises(sqlite3.Error) as caught: + self.insert_raw(make_decision(self.promotable), committed_state_id=self.v0.state_id) + self.assertIn("DATA-INT-004", str(caught.exception)) + + def test_a_decision_is_never_deleted_or_replaced(self) -> None: + decision = make_decision(self.promotable) + self.insert_raw(decision) + self.abort("DELETE FROM promotion_decisions", (), "DATA-INT-004") + self.abort( + self.INSERT.replace("INSERT INTO", "INSERT OR REPLACE INTO"), + self.row(decision, reason="REQUIRED_UNKNOWN"), + "DATA-INT-006", + ) + + def test_every_column_but_the_committed_state_is_immutable(self) -> None: + self.insert_raw(make_decision(self.promotable)) + for column, value in ( + ("reason", "REQUIRED_UNKNOWN"), + ("policy_version", "other"), + ("attempt_number", 2), + ("content_hash", "0" * 64), + ("created_at", "2027-01-01T00:00:00Z"), + ("required_checks_json", "[1]"), + ): + with self.subTest(column=column): + self.abort(f"UPDATE promotion_decisions SET {column} = ?", (value,), "DATA-INT-006") + + def commit_state(self) -> Any: + """Promote the stored candidate in the database; return the new state.""" + return self.promote_stored(self.promotable, self.v0) + + def test_the_commit_sets_the_state_from_null_once(self) -> None: + decision = make_decision(self.promotable) # uid(4001), the id the helper promotes with + self.insert_raw(decision) + state = self.commit_state() + self.assertEqual(state.commit_decision_id, decision.decision_id) + conn = self.raw() + conn.execute("UPDATE promotion_decisions SET committed_state_id = ?", (state.state_id,)) + self.assertEqual( + self.scalar("SELECT committed_state_id FROM promotion_decisions"), state.state_id + ) + self.abort("UPDATE promotion_decisions SET committed_state_id = NULL", (), "DATA-INT-004") + self.abort( + "UPDATE promotion_decisions SET committed_state_id = ?", + (self.v0.state_id,), + "DATA-INT-004", + ) + + def test_the_state_must_name_this_decision_as_its_commit_decision(self) -> None: + decision = make_decision(self.promotable, decision_id=uid(4099), n=99) + self.insert_raw(decision) + state = self.commit_state() # its commit_decision_id is uid(4001), not uid(4099) + self.assertNotEqual(state.commit_decision_id, decision.decision_id) + self.abort( + "UPDATE promotion_decisions SET committed_state_id = ?", + (state.state_id,), + "DATA-INT-004", + ) + + def test_only_a_promote_can_be_committed(self) -> None: + reject = make_decision(self.promotable, kind=D.REJECT, decision_id=uid(4001)) + self.insert_raw(reject) + state = self.commit_state() + self.abort( + "UPDATE promotion_decisions SET committed_state_id = ?", + (state.state_id,), + "DATA-INT-004", + ) + + def test_the_state_must_be_a_child_of_the_decisions_parent(self) -> None: + # a decision whose parent is v0 cannot be committed to the baseline itself + decision = make_decision(self.promotable) + self.insert_raw(decision) + self.abort( + "UPDATE promotion_decisions SET committed_state_id = ?", + (self.v0.state_id,), + "DATA-INT-004", + ) + + def test_at_most_one_committed_decision_per_candidate(self) -> None: + # C-163: two decisions of one candidate cannot both be committed. The triggers are dropped + # (an attacker with file access can), so only the unique partial index is left to refuse. + first = make_decision(self.promotable) # uid(4001), the id the helper promotes with + second = make_decision(self.promotable, decision_id=uid(4002), n=2) + self.insert_raw(first) + self.insert_raw(second) + state = self.commit_state() + self.tamper( + "UPDATE promotion_decisions SET committed_state_id = ? WHERE decision_id = ?", + (state.state_id, first.decision_id), + ) + self.tamper( + "INSERT INTO trusted_states (state_id, lineage_id, version, parent_state_id, " + "state_hash, commit_decision_id, content_json) SELECT ?, lineage_id, 7, " + "parent_state_id, state_hash, ?, content_json FROM trusted_states WHERE state_id = ?", + (uid(4300), second.decision_id, state.state_id), + ) + self.abort( + "UPDATE promotion_decisions SET committed_state_id = ? WHERE decision_id = ?", + (uid(4300), second.decision_id), + "UNIQUE constraint failed: promotion_decisions.candidate_id", + ) + + def test_a_promoted_state_needs_no_decision_row_at_the_database(self) -> None: + # C-168, decision (b): the reverse direction is enforced by commit_promotion, not here + self.commit_state() + self.assertEqual(self.count("promotion_decisions"), 0) + self.assertEqual(self.count("trusted_states"), 2) + + +class TestCandidateStatusTrigger(DecisionCase): + def test_promotable_to_escalated_is_legal_in_the_database(self) -> None: + conn = self.raw() + conn.execute("BEGIN IMMEDIATE") + conn.execute( + "UPDATE candidates SET status = 'ESCALATED' WHERE candidate_id = ?", + (self.promotable.candidate_id,), + ) + conn.execute("COMMIT") + self.assertEqual( + self.scalar( + "SELECT status FROM candidates WHERE candidate_id = ?", + (self.promotable.candidate_id,), + ), + "ESCALATED", + ) + + def test_the_neighbouring_pairs_stay_illegal_in_the_database(self) -> None: + for target in ("RETRY_REQUIRED", "ANALYZING", "READY", "CREATED", "BUILDING", "FAILED"): + with self.subTest(target=target): + self.abort( + "UPDATE candidates SET status = ? WHERE candidate_id = ?", + (target, self.promotable.candidate_id), + "illegal candidate status transition", + ) + + def test_the_repository_stores_the_escalation(self) -> None: + escalated = transition_candidate(self.promotable, CS.ESCALATED) + with self.uow() as u: + u.candidates.save_transition(escalated) + with self.uow() as u: + stored = u.candidates.get(self.promotable.candidate_id) + assert stored is not None + self.assertIs(stored.status, CS.ESCALATED) + + +class TestDecisionRepository(DecisionCase): + def test_every_kind_of_decision_round_trips(self) -> None: + for n, kind in enumerate(DecisionType, start=1): + decision = make_decision(self.promotable, kind=kind, n=10 + n) + with self.uow() as u: + u.decisions.save(decision) + with self.uow() as u: + self.assertEqual(u.decisions.get(decision.decision_id), decision) + + def test_unknown_decisions_are_none_and_the_list_is_in_order(self) -> None: + with self.uow() as u: + self.assertIsNone(u.decisions.get(uid(1))) + self.assertEqual(u.decisions.for_candidate(self.promotable.candidate_id), ()) + first = make_decision(self.promotable, n=21) + second = make_decision(self.promotable, kind=D.REJECT, n=22) + with self.uow() as u: + u.decisions.save(first) + u.decisions.save(second) + with self.uow() as u: + found = u.decisions.for_candidate(self.promotable.candidate_id) + self.assertEqual([d.decision_id for d in found], [first.decision_id, second.decision_id]) + + def test_saving_the_same_decision_again_changes_nothing(self) -> None: + decision = make_decision(self.promotable) + with self.uow() as u: + u.decisions.save(decision) + with self.uow() as u: + u.decisions.save(decision) + self.assertEqual(self.count("promotion_decisions"), 1) + + def test_the_same_id_with_other_content_is_a_conflicting_duplicate(self) -> None: + decision = make_decision(self.promotable) + other = PromotionDecision.create( + decision_id=decision.decision_id, + candidate_id=decision.candidate_id, + parent_state_id=decision.parent_state_id, + decision=D.PROMOTE, + reason=R.ALL_REQUIRED_PROVEN, + policy_version="policy-2", + attempt_number=1, + required_checks=decision.required_checks, + blocking_conditions=(), + supporting_results=decision.supporting_results, + created_at=decision.created_at, + ) + with self.uow() as u: + u.decisions.save(decision) + with self.uow() as u, self.assertRaises(ConflictingDuplicateError): + u.decisions.save(other) + self.assertEqual(self.count("promotion_decisions"), 1) + + def test_a_committed_decision_cannot_be_saved(self) -> None: + committed = make_decision(self.promotable).committed(uid(555)) + with self.uow() as u, self.assertRaises(DomainValidationError): + u.decisions.save(committed) + + def test_only_a_decision_object_is_accepted(self) -> None: + with self.uow() as u, self.assertRaises(DomainValidationError): + u.decisions.save(make_decision(self.promotable).to_dict()) # type: ignore[arg-type] + + def test_the_commit_sets_the_state_and_returns_the_committed_decision(self) -> None: + decision = make_decision(self.promotable) + with self.uow() as u: + u.decisions.save(decision) + state = self.promote_stored(self.promotable, self.v0) + with self.uow() as u: + done = u.decisions.commit(decision.decision_id, state.state_id) + self.assertEqual(done, decision.committed(state.state_id)) + with self.uow() as u: + self.assertEqual(u.decisions.get(decision.decision_id), done) + + def test_a_decision_is_committed_once(self) -> None: + decision = make_decision(self.promotable) + with self.uow() as u: + u.decisions.save(decision) + state = self.promote_stored(self.promotable, self.v0) + with self.uow() as u: + u.decisions.commit(decision.decision_id, state.state_id) + with self.uow() as u, self.assertRaises((PersistenceError, DomainValidationError)): + u.decisions.commit(decision.decision_id, state.state_id) + + def test_a_decision_that_is_not_a_promote_cannot_be_committed(self) -> None: + reject = make_decision(self.promotable, kind=D.REJECT, decision_id=uid(4001)) + with self.uow() as u: + u.decisions.save(reject) + state = self.promote_stored(self.promotable, self.v0) + with self.uow() as u, self.assertRaises((PersistenceError, DomainValidationError)): + u.decisions.commit(reject.decision_id, state.state_id) + + def test_an_unknown_decision_or_state_cannot_be_committed(self) -> None: + with self.uow() as u, self.assertRaises(PersistenceError): + u.decisions.commit(uid(1), uid(2)) + + def test_a_save_that_is_rolled_back_leaves_nothing(self) -> None: + decision = make_decision(self.promotable) + with self.assertRaises(RuntimeError), self.uow() as u: + u.decisions.save(decision) + raise RuntimeError("boom") + self.assertEqual(self.count("promotion_decisions"), 0) + + def test_a_tampered_row_is_not_returned(self) -> None: + decision = make_decision(self.promotable) + with self.uow() as u: + u.decisions.save(decision) + for column, value in ( + ("reason", "REQUIRED_UNKNOWN"), + ("content_hash", "0" * 64), + ("supporting_results_json", "[]"), + ("policy_version", "tampered"), + ): + with self.subTest(column=column): + self.tamper(f"UPDATE promotion_decisions SET {column} = ?", (value,)) + with self.uow() as u, self.assertRaises(PersistenceError): + u.decisions.get(decision.decision_id) + self.tamper( + f"UPDATE promotion_decisions SET {column} = ?", + ( + { + "reason": decision.reason.value, + "content_hash": decision.content_hash, + "supporting_results_json": '["' + decision.supporting_results[0] + '"]', + "policy_version": decision.policy_version, + }[column], + ), + ) + with self.uow() as u: + self.assertEqual(u.decisions.get(decision.decision_id), decision) + + +class TestCheckpoints(DecisionCase): + def setUp(self) -> None: + super().setUp() + self.hits: list[str] = [] + + def test_a_decision_save_hits_after_decision_insert(self) -> None: + with self.uow(checkpoint=self.hits.append) as u: + u.decisions.save(make_decision(self.promotable)) + self.assertEqual(self.hits[0], "after_decision_insert") + self.assertEqual(self.hits[-1], "before_commit") + + def test_a_candidate_transition_hits_after_candidate_update(self) -> None: + escalated = transition_candidate(self.promotable, CS.ESCALATED) + with self.uow(checkpoint=self.hits.append) as u: + u.candidates.save_transition(escalated) + self.assertIn("after_candidate_update", self.hits) + + def test_a_decision_commit_hits_after_decision_commit(self) -> None: + decision = make_decision(self.promotable) + with self.uow() as u: + u.decisions.save(decision) + state = self.promote_stored(self.promotable, self.v0) + with self.uow(checkpoint=self.hits.append) as u: + u.decisions.commit(decision.decision_id, state.state_id) + self.assertEqual(self.hits[0], "after_decision_commit") + + def test_a_fault_at_each_new_checkpoint_rolls_the_unit_back(self) -> None: + decision = make_decision(self.promotable) + + def boom_at(name: str) -> Any: + def hook(hit: str) -> None: + if hit == name: + raise RuntimeError(name) + + return hook + + with ( + self.assertRaises(RuntimeError), + self.uow(checkpoint=boom_at("after_decision_insert")) as u, + ): + u.decisions.save(decision) + self.assertEqual(self.count("promotion_decisions"), 0) + escalated = transition_candidate(self.promotable, CS.ESCALATED) + with ( + self.assertRaises(RuntimeError), + self.uow(checkpoint=boom_at("after_candidate_update")) as u, + ): + u.candidates.save_transition(escalated) + self.assertEqual( + self.scalar( + "SELECT status FROM candidates WHERE candidate_id = ?", + (self.promotable.candidate_id,), + ), + "PROMOTABLE", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_promotion_pipeline6.py b/tests/unit/test_promotion_pipeline6.py new file mode 100644 index 0000000..63b98b1 --- /dev/null +++ b/tests/unit/test_promotion_pipeline6.py @@ -0,0 +1,263 @@ +"""The canonical transitions through the database (P6b step 6; Doc 06 §25, §26; Doc 09 §33, §34; +Doc 12 §5-§7, §19; C-22, C-45, C-52, C-164). + +The real P6a pipeline (normalisation, identity, graphs, impact, scope policy, verification engine, +``decide``) runs on the canonical fixtures; the run is stored in SQLite and committed by +``commit_promotion``. Nothing here edits a fixture or computes an expected decision. + +HAND EXPECTATIONS (CLAUDE.md, the three canonical fixtures, the P6b prompt): + +* ``canonical_safe_repair_promote_v1``: v0 has public SSH open (INV-SEC-001 VIOLATED); the repair + closes it and keeps EC2 -> RDS 5432. It is promoted into version 1, parent v0, with INV-SEC-001 + VERIFIED (a VIOLATED reference cannot be carried), INV-SEC-003 CARRIED_FORWARD from v0, and every + reference PROTECTED. Every reference of v1 carries the binding record, and the binding record is + proof for v1 (``usable_as_proof``) while the evidence of the sources is not: a candidate's + evidence is valid for the candidate, and v0's evidence for v0 (C-52, C-164). +* ``canonical_security_repair_breaks_function_reject_v1``: fixes SSH and breaks the database path. + The decision is REJECT, stored with its blocking condition; the candidate is REJECTED; v0 is still + current and no state exists. A second candidate from v0 (the safe repair) then promotes (Doc 06 + §25-§26). A REJECT cannot be committed. +* ``ec2_rds_5432_pass_v1`` from v1 (its parent directory is the safe repair): promoted into + version 2; what the impact report leaves unaffected is carried forward from v1 through v1's + binding record, so those references carry both binding records. +* With the Stateful-Full policy every invariant is verified again: nothing is carried forward. +""" + +from __future__ import annotations + +import unittest +from functools import cache +from typing import Any + +from core.domain.enums import CandidateStatus, DecisionType, InvariantStatus, ProofOrigin +from core.domain.evidence import EvidenceContext +from core.domain.promotion import PromotionInputError, ProofSource +from core.promotion import ids +from core.promotion.commit import commit_promotion +from evidence.service import EvidenceService +from tests.domain_builders import uid +from tests.promotion_commit_builders import CONTEXT, NOW, check_database +from tests.promotion_pipeline import PipelineRun, fixtures_with_a_parent, pipeline +from tests.promotion_pipeline_commit import PipelineCommitCase + +SAFE = "canonical_safe_repair_promote_v1" +REGRESSION = "canonical_security_repair_breaks_function_reject_v1" +NEXT = "ec2_rds_5432_pass_v1" +SEC1, SEC3, FUNC = "INV-SEC-001", "INV-SEC-003", "INV-FUNC-001" +D1, D2, D3 = uid(9301), uid(9302), uid(9303) +SECOND_CANDIDATE = uid(3998) + + +@cache +def run_of(fixture_id: str, *, stateful_full: bool = False) -> PipelineRun: + return pipeline(fixtures_with_a_parent()[fixture_id], stateful_full=stateful_full) + + +class TestSafeRepair(PipelineCommitCase): + def setUp(self) -> None: + super().setUp() + self.run = run_of(SAFE) + self.install(self.run) + self.evidence_ids = self.prepare(self.run) + self.outcome, self.result = self.promote(self.run, self.evidence_ids, decision_id=D1) + assert self.result is not None and self.result.state is not None + self.v1 = self.result.state + + def usable(self, evidence_id: str, context: EvidenceContext) -> bool: + with self.uow() as u: + return EvidenceService().usable_as_proof(u, evidence_id, context).usable + + def test_the_safe_repair_becomes_version_one(self) -> None: + self.assertIs(self.outcome.decision.decision, DecisionType.PROMOTE) + self.assertEqual(self.v1.version, 1) + self.assertEqual(self.v1.parent_state_id, self.run.trusted.state_id) + self.assertEqual(self.v1.commit_decision_id, D1) + self.assertEqual(self.v1.state_id, ids.state_id_for(D1)) + self.assertEqual(self.current(self.run), self.v1) + self.assertEqual(self.status(self.run.candidate.candidate_id), "PROMOTED") + self.assertEqual(self.v1.resources, self.run.candidate.resources) + + def test_ssh_is_verified_the_untouched_invariants_are_carried_and_all_are_protected( + self, + ) -> None: + refs = {r.invariant_id: r for r in self.v1.invariant_refs} + self.assertEqual(refs[SEC1].origin, ProofOrigin.VERIFIED) + self.assertEqual(refs[SEC3].origin, ProofOrigin.CARRIED_FORWARD) + for ref in refs.values(): + self.assertIs(ref.status, InvariantStatus.PROTECTED, ref.invariant_id) + planned = {e.invariant_id: e.source for e in self.outcome.proof_plan} + for invariant, ref in refs.items(): + expected = ( + ProofOrigin.VERIFIED + if planned[invariant] is ProofSource.VERIFIED + else ProofOrigin.CARRIED_FORWARD + ) + self.assertIs(ref.origin, expected, invariant) + + def test_every_reference_carries_the_binding_record_and_it_is_proof_for_v1(self) -> None: + binding = ids.evidence_id_for(D1) + self.assertIn(binding, self.v1.evidence_refs) + context = EvidenceContext.state(self.v1.state_id) + for ref in self.v1.invariant_refs: + self.assertIn(binding, ref.evidence_ids, ref.invariant_id) + self.assertTrue(self.usable(binding, context)) + + def test_the_evidence_of_the_sources_is_proof_only_where_it_was_produced(self) -> None: + v1_context = EvidenceContext.state(self.v1.state_id) + v0_context = EvidenceContext.state(self.run.trusted.state_id) + candidate_context = EvidenceContext.candidate(self.run.candidate.candidate_id) + for entry in self.outcome.proof_plan: + if entry.source is ProofSource.VERIFIED: + source = self.evidence_ids[entry.verification_id or ""] + with self.subTest(invariant=entry.invariant_id, source="VERIFIED"): + self.assertTrue(self.usable(source, candidate_context)) + self.assertFalse(self.usable(source, v1_context)) + else: + for source in entry.evidence_ids: + with self.subTest(invariant=entry.invariant_id, source="CARRIED_FORWARD"): + self.assertTrue(self.usable(source, v0_context)) + self.assertFalse(self.usable(source, v1_context)) + + def test_the_database_holds_nothing_that_breaks_the_rules_of_promoted_history(self) -> None: + self.assertEqual(check_database(self.path), []) + self.assertEqual(self.count("audit_events", "event_type = 'STATE_PROMOTED'"), 1) + + def test_the_same_decision_again_is_the_same_state(self) -> None: + again = commit_promotion( + lambda: self.uow(), + self.outcome, + self.run.candidate, + self.run.impact, + self.run.verification, + self.run.policy(), + self.evidence_ids, + CONTEXT, + now=NOW, + ) + self.assertTrue(again.duplicate) + self.assertEqual(again.state, self.v1) + + def test_carrying_forward_from_v1_goes_through_the_binding_record(self) -> None: + second = pipeline( + fixtures_with_a_parent()[NEXT], + parent_trusted=self.v1, + candidate_id=SECOND_CANDIDATE, + sequence=2, + patch_n=8, + ) + evidence = self.prepare(second, sequence=2, patch_n=8, first=40) + outcome, result = self.promote(second, evidence, decision_id=D2) + self.assertIs(outcome.decision.decision, DecisionType.PROMOTE) + assert result is not None and result.state is not None + v2 = result.state + self.assertEqual((v2.version, v2.parent_state_id), (2, self.v1.state_id)) + carried = [e for e in outcome.proof_plan if e.source is ProofSource.CARRIED_FORWARD] + self.assertTrue(carried, "the fixture should leave something unaffected") + first_binding, second_binding = ids.evidence_id_for(D1), ids.evidence_id_for(D2) + refs = {r.invariant_id: r for r in v2.invariant_refs} + for ref in v2.invariant_refs: + self.assertIn(second_binding, ref.evidence_ids, ref.invariant_id) + for entry in carried: + ref = refs[entry.invariant_id] + with self.subTest(carried=entry.invariant_id): + self.assertEqual(entry.source_state_id, self.v1.state_id) + self.assertIn(first_binding, ref.evidence_ids) + self.assertIs(ref.origin, ProofOrigin.CARRIED_FORWARD) + self.assertTrue(self.usable(first_binding, EvidenceContext.state(self.v1.state_id))) + self.assertTrue(self.usable(second_binding, EvidenceContext.state(v2.state_id))) + self.assertEqual(self.current(second), v2) + self.assertEqual(check_database(self.path), []) + + +class TestRegressionThenSafe(PipelineCommitCase): + def test_the_regression_is_rejected_and_a_second_candidate_then_promotes(self) -> None: + bad = run_of(REGRESSION) + self.install(bad) + evidence = self.prepare(bad) + outcome, result = self.promote(bad, evidence, decision_id=D1) + self.assertIs(outcome.decision.decision, DecisionType.REJECT) + self.assertIsNone(result) + self.assertEqual(self.status(bad.candidate.candidate_id), "REJECTED") + self.assertEqual(self.current(bad), bad.trusted) # v0 is still current + self.assertEqual(self.count("trusted_states"), 1) + with self.uow() as u: + stored = u.decisions.get(D1) + assert stored is not None + self.assertEqual(stored, outcome.decision) + self.assertEqual([c.target for c in stored.blocking_conditions], [f"INVARIANT:{FUNC}:1"]) + # a REJECT cannot be committed + with self.assertRaises(PromotionInputError): + commit_promotion( + lambda: self.uow(), + outcome, + bad.candidate, + bad.impact, + bad.verification, + bad.policy(), + evidence, + CONTEXT, + now=NOW, + ) + self.assertEqual(self.count("trusted_states"), 1) + + good = pipeline( + fixtures_with_a_parent()[SAFE], candidate_id=SECOND_CANDIDATE, sequence=2, patch_n=8 + ) + # the same v0: same id and hash (the resource records are renormalised, with new record ids) + self.assertEqual( + (good.trusted.state_id, good.trusted.state_hash), + (bad.trusted.state_id, bad.trusted.state_hash), + ) + second = self.prepare(good, sequence=2, patch_n=8, first=40) + outcome2, result2 = self.promote(good, second, decision_id=D2) + self.assertIs(outcome2.decision.decision, DecisionType.PROMOTE) + assert result2 is not None and result2.state is not None + self.assertEqual(result2.state.version, 1) + self.assertEqual(result2.state.parent_state_id, bad.trusted.state_id) + self.assertEqual(self.status(bad.candidate.candidate_id), "REJECTED") + self.assertEqual(self.status(SECOND_CANDIDATE), "PROMOTED") + self.assertEqual(self.count("trusted_states"), 2) + self.assertEqual(check_database(self.path), []) + + +class TestStatefulFull(PipelineCommitCase): + def test_nothing_is_carried_forward_and_every_reference_is_verified(self) -> None: + run = run_of(SAFE, stateful_full=True) + self.install(run) + evidence = self.prepare(run) + outcome, result = self.promote(run, evidence, decision_id=D3, stateful_full=True) + self.assertIs(outcome.decision.decision, DecisionType.PROMOTE) + assert result is not None and result.state is not None + origins = {r.origin for r in result.state.invariant_refs} + self.assertEqual(origins, {ProofOrigin.VERIFIED}) + self.assertEqual(len(result.state.invariant_refs), 4) + binding = ids.evidence_id_for(D3) + for ref in result.state.invariant_refs: + self.assertEqual( + len(ref.evidence_ids), 2, ref.invariant_id + ) # the source and the record + self.assertIn(binding, ref.evidence_ids) + self.assertEqual(check_database(self.path), []) + + +class TestTheRunsAreStillTheRunsOfP6a(unittest.TestCase): + """The helper change of this package leaves the P6a behaviour alone.""" + + def test_the_default_candidate_is_the_one_of_p6a(self) -> None: + from tests.pipeline_builders import CANDIDATE_STATE, PARENT_STATE + + run = run_of(SAFE) + self.assertEqual(run.candidate.candidate_id, CANDIDATE_STATE) + self.assertEqual(run.trusted.state_id, PARENT_STATE) + self.assertIs(run.candidate.status, CandidateStatus.ANALYZING) + self.assertEqual(run.decide().decision.decision_id, uid(9300)) + + def test_arguments_are_not_shared_between_runs(self) -> None: + other: Any = pipeline( + fixtures_with_a_parent()[SAFE], candidate_id=SECOND_CANDIDATE, sequence=2, patch_n=8 + ) + self.assertNotEqual(other.candidate.candidate_id, run_of(SAFE).candidate.candidate_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_promotion_properties6.py b/tests/unit/test_promotion_properties6.py new file mode 100644 index 0000000..d45b733 --- /dev/null +++ b/tests/unit/test_promotion_properties6.py @@ -0,0 +1,251 @@ +"""Properties of promoted history (P6b step 6; Doc 09 §26-§27, §38-§40; Doc 06 §6, §20-§21, §27; +Doc 11 §8, §44; C-41, C-45, C-163, C-164). + +Seeded chains of decisions (stdlib ``random``; the seeds are fixed, so a run repeats exactly): each +chain starts from v0, makes a candidate whose INV-FUNC-001 result is drawn from PASS, FAIL, UNKNOWN +or ERROR, records the decision and commits a PROMOTE, sometimes commits it again, and goes on from +the new head until a decision is not a PROMOTE or the chain has three states. + +HAND EXPECTATIONS (the rules, from the specs, written before the run): + +* a PASS gives PROMOTE, FAIL gives REJECT, UNKNOWN gives RETRY, ERROR gives ESCALATE (Doc 09 §19, + §20); only a PROMOTE changes ``trusted_states`` or ``lineage_heads``, and a non-PROMOTE changes + neither, however often it is recorded (SM-003, SM-004); +* a committed state's references cover every invariant of its parent with no lower version (C-41); +* every reference of a promoted state carries the state's binding record, and the record is proof + for that state (``usable_as_proof``, C-164); +* a state has at most one child, a decision commits at most one state, and ``commit_decision_id`` + is unique (C-163); +* replaying a decision (a second commit, a second record) changes no row of any table; +* ``check_database`` reports each of these rules when a database breaks it, and only then. +""" + +from __future__ import annotations + +import random +import unittest +from collections import Counter +from typing import Any + +from core.domain.enums import CandidateStatus, DecisionType, VerificationResult +from core.domain.evidence import EvidenceContext +from core.promotion import ids +from core.promotion.commit import record_decision +from evidence.service import EvidenceService +from tests.domain_builders import uid +from tests.promotion_builders import ERROR, FAIL, FUNC1, PASS, UNKNOWN, make_world, run, target +from tests.promotion_commit_builders import CONTEXT, NOW, CommitCase, check_database, dump + +SEEDS = 40 +OUTCOMES = { + PASS: DecisionType.PROMOTE, + FAIL: DecisionType.REJECT, + UNKNOWN: DecisionType.RETRY, + ERROR: DecisionType.ESCALATE, +} +STATE_TABLES = ("trusted_states", "lineage_heads", "invariant_refs", "state_resources") +DECISION = uid(9100) + + +class TestChains(CommitCase): + stored_status = CandidateStatus.ANALYZING + + def generation(self, g: int, head: Any, result: VerificationResult) -> tuple[Any, Any]: + """The world and the evidence-id map of the g-th candidate of a chain.""" + if g == 0: + if result is PASS: + return self.world, self.evidence_ids + return make_world(results={target(FUNC1): result}), self.evidence_ids + world = self.next_world(head, sequence=g + 1, n=10 + g, func_result=result) + self.store_candidate(world, n=10 + g, sequence=g + 1) + return world, self.store_results(world, first=100 + 10 * g) + + def promoted_properties(self) -> None: + with self.uow() as u: + head = u.trusted_states.get_current(self.world.trusted.lineage_id) + assert head is not None + lineage = u.trusted_states.lineage(head.state_id) + for child, parent in zip(lineage, lineage[1:], strict=False): + held = {r.invariant_id: r.invariant_version for r in child.invariant_refs} + for ref in parent.invariant_refs: + self.assertGreaterEqual(held.get(ref.invariant_id, 0), ref.invariant_version) + assert child.commit_decision_id is not None + binding = ids.evidence_id_for(child.commit_decision_id) + context = EvidenceContext.state(child.state_id) + for ref in child.invariant_refs: + self.assertIn(binding, ref.evidence_ids) + self.assertIn(binding, child.evidence_refs) + check = EvidenceService().usable_as_proof(u, binding, context) + self.assertTrue(check.usable, check.reasons) + self.assertEqual(check_database(self.path), []) + + def play(self, seed: int) -> Counter[str]: + rng = random.Random(seed) + seen: Counter[str] = Counter() + head = self.world.trusted + for g in range(3): + result = rng.choices([PASS, FAIL, UNKNOWN, ERROR], weights=[6, 1.5, 1, 1.5])[0] + world, evidence = self.generation(g, head, result) + outcome = run(world, decision_id=uid(9400 + g)) + self.assertIs(outcome.decision.decision, OUTCOMES[result]) + seen[outcome.decision.decision.value] += 1 + before = dump(self.path) + with self.uow() as u: + record_decision(u, outcome, world.candidate, CONTEXT, now=NOW) + if outcome.decision.decision is not DecisionType.PROMOTE: + after = dump(self.path) + for table in STATE_TABLES: + self.assertEqual(after[table], before[table], table) + with self.uow() as u: + record_decision(u, outcome, world.candidate, CONTEXT, now=NOW) + self.assertEqual(dump(self.path), after) # recording it again writes nothing + break + committed = self.commit(world, outcome=outcome, evidence_ids=evidence) + assert committed.state is not None + head = committed.state + if rng.random() < 0.5: + settled = dump(self.path) + again = self.commit(world, outcome=outcome, evidence_ids=evidence) + self.assertTrue(again.duplicate) + self.assertEqual(again.state, head) + self.assertEqual(dump(self.path), settled) # committing it again writes nothing + seen["duplicate"] += 1 + self.promoted_properties() + seen["promoted"] += 1 + seen[f"length {head.version}"] += 1 + return seen + + def test_seeded_chains_keep_every_rule(self) -> None: + total: Counter[str] = Counter() + for seed in range(SEEDS): + with self.subTest(seed=seed): + if seed: + self.tearDown() + self.setUp() + total += self.play(seed) + for kind in ("PROMOTE", "REJECT", "RETRY", "ESCALATE", "duplicate", "length 3"): + self.assertGreater(total[kind], 0, f"no chain reached {kind}") + + +class TestTheCheckerFires(CommitCase): + """Each rule of ``check_database`` is shown to fire when the database breaks it: a checker that + cannot fail proves nothing.""" + + def setUp(self) -> None: + super().setUp() + self.state = self.commit(outcome=run(self.world)).state + assert self.state is not None + self.v0 = self.world.trusted.state_id + self.v1 = self.state.state_id + self.binding = ids.evidence_id_for(DECISION) + + def corruptions(self) -> dict[str, tuple[str, str, tuple[Any, ...]]]: + copy = ( + "INSERT INTO trusted_states (state_id, lineage_id, version, parent_state_id, " + "state_hash, commit_decision_id, content_json) SELECT ?, lineage_id, ?, " + "parent_state_id, state_hash, ?, content_json FROM trusted_states WHERE state_id = ?" + ) + return { + "the head is not the newest state": ( + "the head is not its newest state", + "UPDATE lineage_heads SET current_state_id = ?", + (self.v0,), + ), + "two children": ( + "2 children", + copy, + (uid(5001), 9, None, self.v1), + ), + "a decision that commits two states": ( + "commits 2 states", + copy, + (uid(5002), 9, DECISION, self.v1), + ), + "a state no decision names": ( + "no committed decision", + "UPDATE promotion_decisions SET committed_state_id = NULL", + (), + ), + "a decision that names a state that is not its own": ( + "committed to a state that is not its own", + "UPDATE promotion_decisions SET committed_state_id = ?", + (self.v0,), + ), + "a version that is not the parent's plus one": ( + "not its parent's plus one", + "UPDATE trusted_states SET version = 5 WHERE state_id = ?", + (self.v1,), + ), + "a baseline with a parent": ( + "a baseline with a parent or a decision", + "UPDATE trusted_states SET parent_state_id = ? WHERE state_id = ?", + (self.v1, self.v0), + ), + "a binding record that is not one": ( + "its binding record is", + "UPDATE evidence_events SET event_name = 'x' WHERE evidence_id = ?", + (self.binding,), + ), + "a reference without the binding record": ( + "a reference without the binding record", + "UPDATE invariant_refs SET evidence_ids = '[]' WHERE state_id = ?", + (self.v1,), + ), + "no STATE_PROMOTED event": ( + "0 STATE_PROMOTED events", + "DELETE FROM audit_events WHERE event_type = 'STATE_PROMOTED'", + (), + ), + "an invariant dropped": ( + "dropped or lowered", + "DELETE FROM invariant_refs WHERE state_id = ? AND invariant_id = 'INV-SEC-002'", + (self.v1,), + ), + "an orphan reference": ( + "orphan rows in invariant_refs", + "INSERT INTO invariant_refs SELECT ?, invariant_id, invariant_version, status, " + "origin, evidence_ids, last_verified_at, invalidated_by_candidate_id, " + "invalidation_reason FROM invariant_refs WHERE state_id = ? LIMIT 1", + (uid(5003), self.v1), + ), + "an orphan evidence record": ( + "orphan rows in evidence_events", + "UPDATE evidence_events SET state_id = ? WHERE evidence_id = ?", + (uid(5004), self.binding), + ), + } + + def test_the_database_is_clean_before_it_is_broken(self) -> None: + self.assertEqual(check_database(self.path), []) + + def test_each_rule_fires_on_a_database_that_breaks_it(self) -> None: + for index, (label, (expected, sql, params)) in enumerate(self.corruptions().items()): + with self.subTest(rule=label): + if index: + self.tearDown() + self.setUp() + self.assertEqual(check_database(self.path), []) + if "commits two" in label: # the unique index would stop it; an attacker drops it + self.tamper("DROP INDEX uq_trusted_states_commit_decision", ()) + self.tamper(sql, params) + found = check_database(self.path) + self.assertTrue(any(expected in p for p in found), f"{expected!r} not in {found}") + + def test_a_promoted_candidate_without_a_committed_decision_fires(self) -> None: + other, _ = self.rival() + self.tamper( + "UPDATE candidates SET status = 'PROMOTED' WHERE candidate_id = ?", + (other.candidate.candidate_id,), + ) + found = check_database(self.path) + self.assertTrue(any("PROMOTED without a committed decision" in p for p in found), found) + + def test_a_dump_changes_when_anything_is_written(self) -> None: + before = dump(self.path) + self.assertEqual(dump(self.path), before) + self.tamper("UPDATE candidates SET status_reason = 'x'", ()) + self.assertNotEqual(dump(self.path), before) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_repository_ports.py b/tests/unit/test_repository_ports.py index 7147cc8..4e64f40 100644 --- a/tests/unit/test_repository_ports.py +++ b/tests/unit/test_repository_ports.py @@ -49,6 +49,8 @@ "supersedes", }, "AuditRepository": {"append", "get", "list_for_correlation", "list_for_candidate"}, + # P6b (C-163, C-165) + "DecisionRepository": {"save", "get", "for_candidate", "commit"}, "UnitOfWork": { "trusted_states", "candidates", @@ -56,6 +58,7 @@ "invariants", "evidence", "audit", + "decisions", }, } @@ -68,7 +71,7 @@ "upsert", "set_status", "update_status", - "save", # allowed only on PatchRepository, checked below + "save", # allowed only on PatchRepository and DecisionRepository, checked below } @@ -87,7 +90,9 @@ def test_every_port_is_a_protocol_with_exactly_the_specified_members(self) -> No def test_no_port_offers_a_lifecycle_bypass(self) -> None: for name in EXPECTED: names = public_names(getattr(repositories, name)) - allowed_save = {"save"} if name == "PatchRepository" else set() + # a patch and a promotion decision are not lifecycle objects: a decision changes only + # through commit (DATA-INT-004) + allowed_save = {"save"} if name in ("PatchRepository", "DecisionRepository") else set() with self.subTest(port=name): self.assertEqual((names & CONVENIENCE_NAMES) - allowed_save, set())