Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion core/domain/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
| DELETED | set | null | DIFFERENT | null |
| UNCERTAIN | either, not both null | | UNCERTAIN | LOW |

``moved`` (C-177, "every resource move counts as a change"): true exactly when both refs are set
and differ, i.e. a ``moved`` block or Terraform's implicit ``count`` move made the pair. A moved
pair is never UNCHANGED. The constructor refuses a flag that disagrees with the refs.

``IdentityReport`` is frozen. Its ``content_hash`` covers everything except ``report_id`` and
``created_at`` (C-33 helpers), so two runs over the same inputs can be compared by hash; the
constructor recomputes it (``HashMismatchError``). It also rejects a summary that is not the one
Expand Down Expand Up @@ -261,6 +265,7 @@ class IdentityMatch:
change_type: ChangeType
confidence: IdentityConfidence | None
rationale: Mapping[str, Any]
moved: bool = False

def __post_init__(self) -> None:
trusted = _require_ref(self.trusted_resource_ref, "IdentityMatch.trusted_resource_ref")
Expand All @@ -272,8 +277,25 @@ def __post_init__(self) -> None:
if self.confidence is not None:
require_enum(self.confidence, IdentityConfidence, "IdentityMatch.confidence")
self._check_combination(trusted, candidate)
self._check_moved(trusted, candidate)
object.__setattr__(self, "rationale", self._canonical_rationale(self.rationale))

def _check_moved(self, trusted: str | None, candidate: str | None) -> None:
"""C-177: the flag says exactly whether the pair crosses two addresses, and a pair that a
move made is never UNCHANGED."""
if not isinstance(self.moved, bool):
raise DomainValidationError("IdentityMatch.moved: must be a boolean")
across = trusted is not None and candidate is not None and trusted != candidate
if self.moved != across:
raise DomainValidationError(
f"IdentityMatch: moved is {self.moved} for refs {trusted!r} and {candidate!r}; it "
"is true exactly for a pair across two addresses (C-177)"
)
if self.moved and self.change_type is ChangeType.UNCHANGED:
raise DomainValidationError(
"IdentityMatch: a moved pair is never UNCHANGED; every move is a change (C-177)"
)

def _check_combination(self, trusted: str | None, candidate: str | None) -> None:
change = self.change_type
status = self.identity_status
Expand Down Expand Up @@ -344,8 +366,16 @@ def create(
change_type: ChangeType,
rule: EvidenceType,
evidence_ids: Iterable[str],
moved: bool | None = None,
) -> IdentityMatch:
"""A match whose status and confidence follow from the change type (C-122)."""
"""A match whose status and confidence follow from the change type (C-122). ``moved`` is
derived from the refs when it is not given (C-177); a value that disagrees is refused."""
if moved is None:
moved = (
trusted_ref is not None
and candidate_ref is not None
and trusted_ref != candidate_ref
)
if change_type in _PAIR_CHANGES:
status, confidence = IdentityStatus.SAME, IdentityConfidence.HIGH
elif change_type is ChangeType.UNCERTAIN:
Expand All @@ -359,6 +389,7 @@ def create(
change_type=change_type,
confidence=confidence,
rationale={"rule": rule.value, "evidence_ids": sorted(set(evidence_ids))},
moved=moved,
)

@property
Expand All @@ -384,6 +415,7 @@ def to_dict(self) -> dict[str, Any]:
"change_type": self.change_type.value,
"confidence": None if self.confidence is None else self.confidence.value,
"rationale": thaw_json(self.rationale),
"moved": self.moved,
}

@classmethod
Expand All @@ -397,6 +429,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> IdentityMatch:
"change_type",
"confidence",
"rationale",
"moved",
),
"IdentityMatch",
)
Expand All @@ -414,6 +447,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> IdentityMatch:
else parse_enum(raw_confidence, IdentityConfidence, "IdentityMatch.confidence")
),
rationale=fields["rationale"],
moved=fields["moved"],
)


Expand Down
9 changes: 9 additions & 0 deletions core/domain/impact.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ class ChangeFlag(StrEnum):

UNRESOLVED_CONTENT = "UNRESOLVED_CONTENT"
UNSUPPORTED_TYPE = "UNSUPPORTED_TYPE"
MOVED = "MOVED" # a pair that a move made: the change reason "moved" (C-177)


# --- helpers --------------------------------------------------------------------------------------
Expand Down Expand Up @@ -251,6 +252,14 @@ def __post_init__(self) -> None:
flags = tuple(require_enum(f, ChangeFlag, "ChangedResource.flags") for f in self.flags)
if list(flags) != sorted(set(flags)):
raise DomainValidationError("ChangedResource.flags: must be sorted and unique")
if ChangeFlag.MOVED in flags and (
self.trusted_ref is None
or self.candidate_ref is None
or self.trusted_ref == self.candidate_ref
):
raise DomainValidationError(
"ChangedResource.flags: MOVED needs a pair across two addresses (C-177)"
)
object.__setattr__(self, "flags", flags)

def refs(self) -> tuple[str, ...]:
Expand Down
57 changes: 57 additions & 0 deletions core/domain/registry_v1_1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""The four registry-v1.1 invariants as data (P6d; registry v1.1 §1.4, §3, §4, §10; C-178 to C-181).

Registry v1.1 changes the ``possible`` clause of §3.3, so INV-SEC-001 and INV-SEC-002 are version 2;
INV-SEC-003 and INV-FUNC-001 are unchanged at version 1. ``registry_v1_1_invariants`` therefore
takes the v1.0 definitions of ``registry_v1`` and:

* for INV-SEC-001 and INV-SEC-002 sets ``version = 2`` and cites ``registry-v1.1`` in
``predicate.registry`` and in the description; scope, verifier id and verifier version stay as
they are (registry §10 is unchanged, C-180);
* keeps INV-SEC-003 and INV-FUNC-001 exactly as v1.0 builds them, so their ``definition_hash`` is
the one already stored for that id and version (C-181 (1)).

``registry_v1`` is not changed: it still builds the v1.0 definitions, which the TerraPreserve v1.0
labels and the existing fixtures cite. The verifier reads ``predicate.registry`` to choose the
§3.3 clause (``core.verification.dispatch``).

DAIL stores registry v1.1 as the integer ``invariant_registry_version`` 2 (C-180, the user's
decision of 11 Nov 2026; the field is an integer, C-32): ``REGISTRY_VERSION_V1_1``.
"""

from __future__ import annotations

from dataclasses import replace
from datetime import datetime
from typing import Final

from core.domain.invariant import Invariant
from core.domain.jsonvalue import thaw_json
from core.domain.registry_v1 import REGISTRY_TAG, registry_v1_invariants

REGISTRY_TAG_V1_0: Final = REGISTRY_TAG
REGISTRY_TAG_V1_1: Final = "registry-v1.1"
REGISTRY_VERSION_V1_0: Final = 1
REGISTRY_VERSION_V1_1: Final = 2
# Registry v1.1 §1.4: the invariants whose version changes, and their new version.
_BUMPED: Final = {"INV-SEC-001": 2, "INV-SEC-002": 2}


def _bumped(definition: Invariant, version: int) -> Invariant:
predicate = thaw_json(definition.predicate)
predicate["registry"] = REGISTRY_TAG_V1_1
return replace(
definition,
version=version,
predicate=predicate,
description=definition.description.replace(REGISTRY_TAG_V1_0, REGISTRY_TAG_V1_1),
)


def registry_v1_1_invariants(
now: datetime, *, source: str, destination: str
) -> tuple[Invariant, ...]:
"""The four ``registry-v1.1`` invariant definitions, sorted by id (bindings as in v1.0)."""
definitions = registry_v1_invariants(now, source=source, destination=destination)
return tuple(
_bumped(d, _BUMPED[d.invariant_id]) if d.invariant_id in _BUMPED else d for d in definitions
)
10 changes: 10 additions & 0 deletions core/identity/findings.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ def match(self) -> IdentityMatch:
change_type=self.change,
rule=self.rule,
evidence_ids=[item.evidence_id for item in self.evidence],
moved=self.moved,
)

@property
def moved(self) -> bool:
"""A pair across two addresses: only a move can make one (C-177)."""
return (
self.trusted_ref is not None
and self.candidate_ref is not None
and self.trusted_ref != self.candidate_ref
)


Expand Down
8 changes: 7 additions & 1 deletion core/identity/strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
5. **AMB-1**: unpaired instances of one block that cannot be told apart are all UNCERTAIN.
6. The leftovers: unpaired trusted is DELETED, unpaired candidate CREATED.
7. A pair is UNCHANGED (equal fingerprints) or MODIFIED; **RT-1** may make it REPLACED or UNCERTAIN.
A pair that a move made is never UNCHANGED (C-177, "every resource move counts as a change"):
with equal fingerprints it is MODIFIED, decided by the move evidence, and the match is
``moved``.

``UNSUPPORTED_TYPE`` and ``UNRESOLVED_CONTENT`` are flags, not identity evidence: they are attached
by both strategies, so the two differ only in identity decisions.
Expand Down Expand Up @@ -411,10 +414,13 @@ def classify(self, trusted: Resource, candidate: Resource) -> Draft:
observed = {"trusted": trusted.fingerprint, "candidate": candidate.fingerprint}
if trusted.fingerprint == candidate.fingerprint:
change, rule = ChangeType.UNCHANGED, EvidenceType.FINGERPRINT_EQUAL
if steps:
# C-177: every resource move counts as a change; the move evidence decides
change, rule = ChangeType.MODIFIED, via.evidence_type
fingerprint = self.ev.about(
trusted,
candidate,
rule,
EvidenceType.FINGERPRINT_EQUAL,
EvidenceSource.BOTH,
observed,
"The canonical content of the paired resources is identical.",
Expand Down
15 changes: 9 additions & 6 deletions core/impact/changes.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"""The changed set (Doc 08 §6; registry §2.7, §2.9; C-117; C-140 item 1, C-143).

Every identity match that is not UNCHANGED is a change source, with its change type and the flags
the identity evidence carries (``UNRESOLVED_CONTENT``, ``UNSUPPORTED_TYPE``). An UNCHANGED match is
**also** a change source when the resource carries unresolved content: equal fingerprints do not
prove an unresolved element unchanged (registry §2.9 treats "unresolved on both sides" as changed;
C-117). Such a source is carried with change type UNCERTAIN and the flag ``UNRESOLVED_CONTENT``, and
the engine uses it only for invariants whose footprint contains the resource. It can only add
UNCERTAIN; it never removes anything.
the identity evidence carries (``UNRESOLVED_CONTENT``, ``UNSUPPORTED_TYPE``). A pair that a move
made is never UNCHANGED (C-177) and carries the flag ``MOVED``, the change reason "moved". An
UNCHANGED match is **also** a change source when the resource carries unresolved content: equal
fingerprints do not prove an unresolved element unchanged (registry §2.9 treats "unresolved on
both sides" as changed; C-117). Such a source is carried with change type UNCERTAIN and the flag
``UNRESOLVED_CONTENT``, and the engine uses it only for invariants whose footprint contains the
resource. It can only add UNCERTAIN; it never removes anything.

What counts as unresolved content (C-143, option A): an Unknown or Unsupported canonical value
in the resource, a security-group direction in the form ``unresolved``, ``dynamic`` or ``mixed``,
Expand Down Expand Up @@ -108,6 +109,8 @@ def _flags(identity: IdentityReport, match: IdentityMatch) -> tuple[ChangeFlag,
for e in identity.evidence_for(match)
if e.evidence_type in _FLAG_OF
}
if match.moved:
found.add(ChangeFlag.MOVED)
return tuple(sorted(found))


Expand Down
3 changes: 2 additions & 1 deletion core/impact/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,8 @@ def _work_item(invariant: Invariant, impacted: ImpactedInvariant) -> Verificatio
reason=strongest,
scope=tuple(scope or sorted(invariant.scope.resource_types)),
required_evidence=(
f"L1 {invariant.verifier_id} {invariant.verifier_version} (registry-v1.0)"
f"L1 {invariant.verifier_id} {invariant.verifier_version} "
f"({invariant.predicate.get('registry', 'registry-v1.0')})"
),
priority=WorkPriority.BLOCKING,
)
Expand Down
14 changes: 12 additions & 2 deletions core/verification/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from core.domain.invariant import Invariant
from core.verification.inputs import VerificationInputs
from core.verification.outcome import PredicateOutcome
from core.verification.public_ingress import evaluate_public_ingress
from core.verification.public_ingress import REGISTRIES, evaluate_public_ingress
from core.verification.rds_public_flag import evaluate_rds_public_flag
from core.verification.reachability import evaluate_reachability

Expand All @@ -41,13 +41,23 @@ def _port(definition: Invariant) -> int:
return port


def registry_of(definition: Invariant) -> str:
"""The registry tag the definition cites (``predicate.registry``); it selects the §3.3 text
of ``PIP(p)`` (C-181 (2)). A tag the verifier does not know raises: VERIFIER_ERROR."""
registry = definition.predicate.get("registry")
if not isinstance(registry, str) or registry not in REGISTRIES:
raise ValueError(f"{definition.invariant_id}: unknown registry {registry!r}")
return registry


def public_ingress(definition: Invariant, inputs: VerificationInputs) -> PredicateOutcome:
"""INV-SEC-001 and INV-SEC-002: ``PIP(p)`` with ``p`` from the definition."""
"""INV-SEC-001 and INV-SEC-002: ``PIP(p)`` with ``p`` and the registry from the definition."""
return evaluate_public_ingress(
_port(definition),
inputs.candidate_resources,
parent_resources=inputs.parent_resources,
identity=inputs.identity,
registry=registry_of(definition),
)


Expand Down
3 changes: 2 additions & 1 deletion core/verification/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,8 @@ def _predicate_text(plan: _Plan) -> str:
return plan.target_id
predicate = plan.definition.predicate
parameters = thaw_json(predicate.get("parameters", {}))
return f"registry-v1.0 {predicate.get('section', '')} {canonical_json(parameters)}"
registry = predicate.get("registry", "registry-v1.0") # C-181 (4): the definition's tag
return f"{registry} {predicate.get('section', '')} {canonical_json(parameters)}"


def _evidence(
Expand Down
Loading