diff --git a/packages/contracts/src/hodlin_contracts/__init__.py b/packages/contracts/src/hodlin_contracts/__init__.py index 25390bc..b8e8c81 100644 --- a/packages/contracts/src/hodlin_contracts/__init__.py +++ b/packages/contracts/src/hodlin_contracts/__init__.py @@ -1,6 +1,10 @@ """Shared contracts imported by both domains. The only package both may import. -Frozen ``Proposal`` / ``EvidenceRef`` models plus the canonical-hash helpers. +Frozen proposal/evidence models plus the canonical-hash helpers. Proposal +versions live side by side (D32): ``ProposalV1_0`` is what M1 shipped and must +keep hashing identically forever, ``ProposalV1_1`` is what this codebase now +produces (``SCHEMA_VERSION``), and ``parse_proposal`` reads either by dispatching +on the version carried in the payload. """ from hodlin_contracts.canonical import ( @@ -9,20 +13,38 @@ canonical_json, ) from hodlin_contracts.proposal import ( - Action, + ActionV1_0, + ActionV1_1, + AnyProposal, EvidenceRef, Money, - Proposal, + ProposalLike, + ProposalV1_0, + ProposalV1_1, + is_expired, + parse_proposal, +) +from hodlin_contracts.version import ( + SCHEMA_VERSION, + SCHEMA_VERSION_1_0, + SCHEMA_VERSION_1_1, ) -from hodlin_contracts.version import SCHEMA_VERSION __all__ = [ "SCHEMA_VERSION", - "Action", + "SCHEMA_VERSION_1_0", + "SCHEMA_VERSION_1_1", + "ActionV1_0", + "ActionV1_1", + "AnyProposal", "EvidenceRef", "Money", - "Proposal", + "ProposalLike", + "ProposalV1_0", + "ProposalV1_1", "canonical_bytes", "canonical_hash", "canonical_json", + "is_expired", + "parse_proposal", ] diff --git a/packages/contracts/src/hodlin_contracts/proposal.py b/packages/contracts/src/hodlin_contracts/proposal.py index 387c75c..0a89018 100644 --- a/packages/contracts/src/hodlin_contracts/proposal.py +++ b/packages/contracts/src/hodlin_contracts/proposal.py @@ -1,18 +1,33 @@ """Frozen, validated contracts exchanged between the two domains (D7, D14). -``Proposal`` is what the recommend domain produces and the execute domain -consumes. It is immutable (``frozen=True``), rejects unknown fields +A proposal is what the recommend domain produces and the execute domain +consumes. Every version is immutable (``frozen=True``), rejects unknown fields (``extra="forbid"``), carries money as ``Decimal`` so it stays exact (never a float), uses timezone-aware datetimes only, and requires at least one piece of evidence. It deliberately carries **no raw destination address** — the recipient is named by label and resolved to an address inside the execute domain at tx-build time, so a prompt-injected recommend domain can't direct funds anywhere (D14). + +**Two versions live here side by side** (D32, T12): ``ProposalV1_0`` exactly as +M1 shipped it — byte-for-byte, so its historical digests still reproduce — and +``ProposalV1_1``, which adds the ``transfer`` action and an optional +``valid_until``. There is deliberately no bare ``Proposal`` alias: a name whose +meaning silently moves to the newest version is precisely the ambiguity that +versioning exists to remove. Call sites that mean "whatever we produce today" +use ``SCHEMA_VERSION``; call sites that accept input use ``parse_proposal``, +which dispatches on the version *in the payload*. + +Freshness is not enforced here. ``valid_until`` in the past parses fine, because +a historical proposal must stay parseable forever — re-reading a stored proposal +is not the same act as accepting a new one. Expiry is a decision for the gate +(T16), which asks ``is_expired`` at the moment of approval. """ +from collections.abc import Mapping from datetime import UTC, datetime from decimal import Decimal -from typing import Annotated, Literal +from typing import Annotated, Literal, Protocol, Self, cast, runtime_checkable from uuid import UUID from pydantic import ( @@ -22,9 +37,11 @@ BeforeValidator, ConfigDict, Field, + TypeAdapter, + model_validator, ) -from hodlin_contracts.version import SCHEMA_VERSION +from hodlin_contracts.version import SCHEMA_VERSION_1_0, SCHEMA_VERSION_1_1 def _reject_float(value: object) -> object: @@ -61,7 +78,14 @@ def _to_utc(value: datetime) -> datetime: UtcDatetime = Annotated[AwareDatetime, AfterValidator(_to_utc)] -Action = Literal["buy", "sell", "hold", "alert"] +#: 1.0's actions. Frozen with the version — every value here is inside a digest +#: that has to keep reproducing. +ActionV1_0 = Literal["buy", "sell", "hold", "alert"] + +#: 1.1 adds ``transfer``: the action that actually moves value, and therefore the +#: reason the execute gate exists. Adding it to 1.0 would have been the mutation +#: D32 forbids — a 1.0 proposal's digest must not depend on what 1.1 allows. +ActionV1_1 = Literal["buy", "sell", "hold", "alert", "transfer"] class _Frozen(BaseModel): @@ -75,7 +99,18 @@ class _Frozen(BaseModel): class EvidenceRef(_Frozen): """A single citable source behind a proposal — a news item, a price anomaly, or a sentiment score. At least one is required on every - proposal so a recommendation can always be traced back to what it saw.""" + proposal so a recommendation can always be traced back to what it saw. + + **Shared across proposal versions, and therefore inside every version's + freeze.** That is a real asymmetry with ``ActionV1_0``/``ActionV1_1``, so it + needs saying: widening ``kind`` here would widen what a *frozen 1.0* document + is allowed to say, without bumping 1.0. Existing digests would still + reproduce, which is exactly what makes it easy to miss. It stays shared + because the shape is genuinely identical across versions and duplicating it + would invite the two copies to drift — but the allowed ``kind`` values are + pinned by a test, so adding one is a deliberate act that forces the + "does this need a new proposal version?" conversation instead of sliding by. + """ kind: Literal["anomaly", "news", "sentiment", "price"] source: str = Field(min_length=1) @@ -83,16 +118,133 @@ class EvidenceRef(_Frozen): observed_at: UtcDatetime -class Proposal(_Frozen): - """An AI-authored recommendation. Self-describing and immutable; becomes - load-bearing (canonical-hashed and token-signed) in slice C.""" +class ProposalV1_0(_Frozen): + """An AI-authored recommendation, schema 1.0 — **exactly as M1 shipped it**. + + Nothing serialized here may change, ever. Its digests are already computed + and (from slice C on) minted into tokens, so a single added or renamed field + would silently invalidate every stored hash. New shapes go in a new class; + ``tests/fixtures/proposal_v1_0.json`` pins this one's digest as a regression. + """ + + schema_version: Literal["1.0"] = SCHEMA_VERSION_1_0 + proposal_id: UUID + asset: str = Field(min_length=1) + action: ActionV1_0 + amount: Money = Field(ge=0) + recipient_label: str = Field(min_length=1) + reasoning: str = Field(min_length=1) + evidence: tuple[EvidenceRef, ...] = Field(min_length=1) + created_at: UtcDatetime + + +class ProposalV1_1(_Frozen): + """Schema 1.1 — 1.0 plus the ``transfer`` action and an optional + ``valid_until``. Purely additive in meaning, and still a *separate document*: + the same business fields hash differently under 1.1 because + ``schema_version`` is inside the hash (D32). + + ``valid_until`` bounds how long the *proposal* is worth acting on, which is + not the same clock as the approval token's ``expires_at`` (T13): the first + says "this recommendation is stale", the second says "this authorization is + spent". A proposal can be fresh with an expired token and vice versa, so both + exist. + """ - schema_version: Literal["1.0"] = SCHEMA_VERSION + schema_version: Literal["1.1"] = SCHEMA_VERSION_1_1 proposal_id: UUID asset: str = Field(min_length=1) - action: Action + action: ActionV1_1 amount: Money = Field(ge=0) recipient_label: str = Field(min_length=1) reasoning: str = Field(min_length=1) evidence: tuple[EvidenceRef, ...] = Field(min_length=1) created_at: UtcDatetime + #: Optional deadline. ``None`` means the proposal states no expiry of its own + #: — the gate still bounds it by the approval token's lifetime. + valid_until: UtcDatetime | None = None + + @model_validator(mode="after") + def _value_moving_actions_need_an_amount(self) -> Self: + """``ge=0`` is right for ``hold``/``alert``, which carry no amount — but a + zero-amount ``transfer`` (or buy, or sell) is not something any human would + approve: it burns gas to do nothing, and it's the shape a prompt-injected + recommend domain emits when it's flailing. 1.1 is where the value-moving + action arrives, so it's where the floor belongs. 1.0 keeps its old rules + untouched — tightening validation on a frozen version could make a stored + proposal unparseable, which is the mutation D32 forbids in another guise. + """ + if self.action in ("buy", "sell", "transfer") and self.amount == 0: + raise ValueError(f"{self.action} requires an amount greater than zero") + return self + + @model_validator(mode="after") + def _valid_until_after_created_at(self) -> Self: + """A deadline at or before the moment of creation is self-contradictory — + the proposal was never actionable. Rejecting it here is safe precisely + because the check compares two of the proposal's *own* fields: the verdict + never changes with the passage of time, so no stored proposal can become + unparseable later. Anything that depends on "now" belongs at the gate. + """ + if self.valid_until is not None and self.valid_until <= self.created_at: + raise ValueError("valid_until must be after created_at") + return self + + +#: Any proposal version this codebase can read, tagged by the version in the +#: payload. A discriminated union rather than a try-each-model cascade: dispatch +#: is explicit, and an unknown version fails as "no such tag" instead of as a +#: pile of confusing field errors from every candidate. +AnyProposal = Annotated[ProposalV1_0 | ProposalV1_1, Field(discriminator="schema_version")] + +_PROPOSAL_ADAPTER: TypeAdapter[ProposalV1_0 | ProposalV1_1] = TypeAdapter(AnyProposal) + + +def parse_proposal(payload: Mapping[str, object]) -> ProposalV1_0 | ProposalV1_1: + """Parse a proposal of *any* known version, dispatching on ``schema_version``. + + Note the deliberate asymmetry with direct construction: ``ProposalV1_1(...)`` + defaults the version, because in-process we know what we're building — but a + payload arriving from outside must **say** which contract it is. Guessing on + behalf of a caller is how a 1.0 document gets read as a 1.1 one, and the + digest that guess produces would be wrong in a way nothing downstream can + detect. Raises ``pydantic.ValidationError`` for a missing or unknown version. + """ + return _PROPOSAL_ADAPTER.validate_python(payload) + + +@runtime_checkable +class ProposalLike(Protocol): + """Structural view of "some version of a proposal". + + Exists so version-spanning helpers don't have to enumerate classes. An + enumeration is the wrong shape for a module whose whole thesis is that new + shapes arrive as new classes: the compiler is perfectly happy when a widened + union reaches an ``isinstance`` chain that silently stops matching, and on + this path "no match" would mean *not expired* — failing open, on the money + side, for a version that plainly stated a deadline. + """ + + @property + def schema_version(self) -> str: ... + + +def is_expired(proposal: ProposalLike, at: datetime) -> bool: + """Whether the proposal states a deadline that ``at`` has passed. + + Freshness is asked as a question, not enforced at parse time (see the module + docstring). 1.0 has no ``valid_until`` and so never expires *of its own + accord* — which is not a loophole: the gate's other bounds (a short-lived, + single-use token) still apply, and they're what make a 1.0 approval + non-replayable. + + The deadline is read *structurally*, not by matching known classes, so a + future version that carries ``valid_until`` is honoured the moment it exists + rather than the moment someone remembers to extend this function. Opting in + by accident is fine here; opting out by accident is a stale proposal that the + gate believes it checked. + """ + if at.tzinfo is None: + raise ValueError("`at` must be timezone-aware to compare against valid_until") + deadline = cast("datetime | None", getattr(proposal, "valid_until", None)) + return deadline is not None and at > deadline diff --git a/packages/contracts/src/hodlin_contracts/version.py b/packages/contracts/src/hodlin_contracts/version.py index 78d6b7d..aa37edc 100644 --- a/packages/contracts/src/hodlin_contracts/version.py +++ b/packages/contracts/src/hodlin_contracts/version.py @@ -1,5 +1,21 @@ -"""Schema version for the frozen contracts. Bump only on a breaking change.""" +"""Schema versions for the frozen contracts. + +A hashed schema is never mutated — it gains a version (D32). The canonical hash +covers every serialized field, so adding a field to an existing version would +change the digest of proposals that were already hashed: stored hashes would stop +reproducing, and any token minted over one would become unverifiable. So each +version is its own class, and old versions stay parseable forever. + +``schema_version`` is *inside* the hash, which is what makes a cross-version +collision impossible by construction: the same business fields under 1.0 and 1.1 +are different documents with different digests, so an approval of one can never +be replayed as an approval of the other. +""" from typing import Literal -SCHEMA_VERSION: Literal["1.0"] = "1.0" +SCHEMA_VERSION_1_0: Literal["1.0"] = "1.0" +SCHEMA_VERSION_1_1: Literal["1.1"] = "1.1" + +#: The version this codebase *produces*. Older versions remain readable. +SCHEMA_VERSION: Literal["1.1"] = SCHEMA_VERSION_1_1 diff --git a/tests/fixtures/proposal_v1_0.json b/tests/fixtures/proposal_v1_0.json new file mode 100644 index 0000000..2b377cb --- /dev/null +++ b/tests/fixtures/proposal_v1_0.json @@ -0,0 +1,18 @@ +{ + "reasoning": "BTC-USD fell 6.2% in one hour, 4.1 sigma against the 30-day rolling window", + "created_at": "2026-06-28T13:05:00+01:00", + "schema_version": "1.0", + "recipient_label": "cold-wallet", + "asset": "BTC", + "amount": "0.50", + "action": "buy", + "proposal_id": "6f1e9c74-2f1b-4d5e-8a3c-9b0d7e5f4a21", + "evidence": [ + { + "observed_at": "2026-06-28T12:00:00+00:00", + "kind": "anomaly", + "source": "hodlin", + "ref": "BTC-USD@2026-06-28T12:00:00Z" + } + ] +} diff --git a/tests/fixtures/proposal_v1_1.json b/tests/fixtures/proposal_v1_1.json new file mode 100644 index 0000000..2e7e551 --- /dev/null +++ b/tests/fixtures/proposal_v1_1.json @@ -0,0 +1,25 @@ +{ + "reasoning": "BTC-USD fell 6.2% in one hour, 4.1 sigma against the 30-day rolling window", + "valid_until": "2026-06-28T14:35:00+01:00", + "created_at": "2026-06-28T13:05:00+01:00", + "schema_version": "1.1", + "recipient_label": "cold-wallet", + "asset": "ETH", + "amount": "0.2500", + "action": "transfer", + "proposal_id": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f", + "evidence": [ + { + "observed_at": "2026-06-28T12:00:00+00:00", + "kind": "anomaly", + "source": "hodlin", + "ref": "BTC-USD@2026-06-28T12:00:00Z" + }, + { + "observed_at": "2026-06-28T12:30:00+00:00", + "kind": "sentiment", + "source": "finbert", + "ref": "coindesk:article-4711" + } + ] +} diff --git a/tests/test_contracts.py b/tests/test_contracts.py index ecd7259..49d73a9 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1,8 +1,13 @@ -"""T2 contract guarantees: the Proposal/EvidenceRef shape and the canonical hash. +"""T2 contract guarantees: the proposal/evidence shape and the canonical hash. These tests pin the properties the rest of the system leans on — immutability, exact money, tz-aware time, traceable evidence, and a content hash that depends on meaning rather than field order. + +Since T12 there are two proposal versions, so every guarantee that is *not* +version-specific runs against **both** of them: a new version must not quietly +drop an invariant the old one had. What 1.1 adds, and how the two versions relate, +lives in ``test_contracts_versioning.py``. """ from datetime import UTC, datetime, timedelta, timezone @@ -13,12 +18,18 @@ from hodlin_contracts import ( SCHEMA_VERSION, EvidenceRef, - Proposal, + ProposalV1_0, + ProposalV1_1, canonical_hash, canonical_json, ) from pydantic import ValidationError +type ProposalModel = type[ProposalV1_0] | type[ProposalV1_1] + +#: Both versions, for the invariants neither may lose. +BOTH_VERSIONS = pytest.mark.parametrize("model", [ProposalV1_0, ProposalV1_1], ids=["1.0", "1.1"]) + def _evidence(**overrides: object) -> EvidenceRef: base: dict[str, object] = { @@ -31,7 +42,9 @@ def _evidence(**overrides: object) -> EvidenceRef: return EvidenceRef(**base) # type: ignore[arg-type] -def _proposal(**overrides: object) -> Proposal: +def _proposal( + model: ProposalModel = ProposalV1_1, **overrides: object +) -> ProposalV1_0 | ProposalV1_1: base: dict[str, object] = { "proposal_id": uuid4(), "asset": "BTC", @@ -43,65 +56,82 @@ def _proposal(**overrides: object) -> Proposal: "created_at": datetime(2026, 6, 28, 12, 5, tzinfo=UTC), } base.update(overrides) - return Proposal(**base) # type: ignore[arg-type] + return model(**base) # type: ignore[arg-type] -def test_valid_proposal_round_trips() -> None: - proposal = _proposal() - assert proposal.schema_version == SCHEMA_VERSION == "1.0" +@BOTH_VERSIONS +def test_valid_proposal_round_trips(model: ProposalModel) -> None: + proposal = _proposal(model) assert proposal.amount == Decimal("0.5") -def test_proposal_is_frozen() -> None: - proposal = _proposal() +def test_the_current_version_is_what_gets_produced() -> None: + assert SCHEMA_VERSION == "1.1" + assert _proposal(ProposalV1_1).schema_version == SCHEMA_VERSION + assert _proposal(ProposalV1_0).schema_version == "1.0" + + +@BOTH_VERSIONS +def test_proposal_is_frozen(model: ProposalModel) -> None: + proposal = _proposal( + model, + ) with pytest.raises(ValidationError): proposal.asset = "ETH" -def test_unknown_fields_rejected() -> None: +@BOTH_VERSIONS +def test_unknown_fields_rejected(model: ProposalModel) -> None: with pytest.raises(ValidationError): - _proposal(destination_address="0xdeadbeef") + _proposal(model, destination_address="0xdeadbeef") -def test_money_rejects_float() -> None: +@BOTH_VERSIONS +def test_money_rejects_float(model: ProposalModel) -> None: with pytest.raises(ValidationError): - _proposal(amount=0.5) + _proposal(model, amount=0.5) -def test_money_accepts_string_exactly() -> None: - proposal = _proposal(amount="0.1") +@BOTH_VERSIONS +def test_money_accepts_string_exactly(model: ProposalModel) -> None: + proposal = _proposal(model, amount="0.1") assert proposal.amount == Decimal("0.1") -def test_negative_amount_rejected() -> None: +@BOTH_VERSIONS +def test_negative_amount_rejected(model: ProposalModel) -> None: with pytest.raises(ValidationError): - _proposal(amount=Decimal("-1")) + _proposal(model, amount=Decimal("-1")) -def test_naive_datetime_rejected() -> None: +@BOTH_VERSIONS +def test_naive_datetime_rejected(model: ProposalModel) -> None: with pytest.raises(ValidationError): - _proposal(created_at=datetime(2026, 6, 28, 12, 5)) + _proposal(model, created_at=datetime(2026, 6, 28, 12, 5)) -def test_at_least_one_evidence_required() -> None: +@BOTH_VERSIONS +def test_at_least_one_evidence_required(model: ProposalModel) -> None: with pytest.raises(ValidationError): - _proposal(evidence=()) + _proposal(model, evidence=()) -def test_empty_required_strings_rejected() -> None: +@BOTH_VERSIONS +def test_empty_required_strings_rejected(model: ProposalModel) -> None: with pytest.raises(ValidationError): - _proposal(asset="") + _proposal(model, asset="") with pytest.raises(ValidationError): - _proposal(recipient_label="") + _proposal(model, recipient_label="") -def test_canonical_hash_stable_across_construction_order() -> None: +@BOTH_VERSIONS +def test_canonical_hash_stable_across_construction_order(model: ProposalModel) -> None: pid = uuid4() ts = datetime(2026, 6, 28, 12, 5, tzinfo=UTC) ev = _evidence() - first = _proposal(proposal_id=pid, created_at=ts, evidence=(ev,)) + first = _proposal(model, proposal_id=pid, created_at=ts, evidence=(ev,)) # Same meaning, fields supplied in a different order at construction. - second = Proposal( + second = model( created_at=ts, evidence=(ev,), reasoning="anomalous volume spike", @@ -114,60 +144,69 @@ def test_canonical_hash_stable_across_construction_order() -> None: assert canonical_hash(first) == canonical_hash(second) -def test_canonical_hash_changes_with_meaning() -> None: +@BOTH_VERSIONS +def test_canonical_hash_changes_with_meaning(model: ProposalModel) -> None: pid = uuid4() ts = datetime(2026, 6, 28, 12, 5, tzinfo=UTC) - a = _proposal(proposal_id=pid, created_at=ts, amount=Decimal("0.5")) - b = _proposal(proposal_id=pid, created_at=ts, amount=Decimal("0.6")) + a = _proposal(model, proposal_id=pid, created_at=ts, amount=Decimal("0.5")) + b = _proposal(model, proposal_id=pid, created_at=ts, amount=Decimal("0.6")) assert canonical_hash(a) != canonical_hash(b) -def test_canonical_hash_ignores_decimal_scale() -> None: +@BOTH_VERSIONS +def test_canonical_hash_ignores_decimal_scale(model: ProposalModel) -> None: # 0.5 and 0.50 are equal in meaning; their proposals must hash identically. pid = uuid4() ts = datetime(2026, 6, 28, 12, 5, tzinfo=UTC) - a = _proposal(proposal_id=pid, created_at=ts, amount=Decimal("0.5")) - b = _proposal(proposal_id=pid, created_at=ts, amount="0.50") + a = _proposal(model, proposal_id=pid, created_at=ts, amount=Decimal("0.5")) + b = _proposal(model, proposal_id=pid, created_at=ts, amount="0.50") assert a == b assert canonical_hash(a) == canonical_hash(b) -def test_canonical_hash_ignores_whole_number_scale() -> None: +@BOTH_VERSIONS +def test_canonical_hash_ignores_whole_number_scale(model: ProposalModel) -> None: # Whole amounts must not leak scientific notation ("1E+2") into the hash. pid = uuid4() ts = datetime(2026, 6, 28, 12, 5, tzinfo=UTC) - a = _proposal(proposal_id=pid, created_at=ts, amount=Decimal("100")) - b = _proposal(proposal_id=pid, created_at=ts, amount="100.00") + a = _proposal(model, proposal_id=pid, created_at=ts, amount=Decimal("100")) + b = _proposal(model, proposal_id=pid, created_at=ts, amount="100.00") assert canonical_hash(a) == canonical_hash(b) assert "E" not in canonical_json(a) -def test_large_whole_amount_normalizes_without_crash() -> None: +@BOTH_VERSIONS +def test_large_whole_amount_normalizes_without_crash(model: ProposalModel) -> None: # Digit count beyond the default decimal precision must not raise, and # must stay plain (no scientific notation) in the canonical form. pid = uuid4() ts = datetime(2026, 6, 28, 12, 5, tzinfo=UTC) - a = _proposal(proposal_id=pid, created_at=ts, amount="1E+30") - b = _proposal(proposal_id=pid, created_at=ts, amount="1" + "0" * 30) + a = _proposal(model, proposal_id=pid, created_at=ts, amount="1E+30") + b = _proposal(model, proposal_id=pid, created_at=ts, amount="1" + "0" * 30) assert canonical_hash(a) == canonical_hash(b) assert "E" not in canonical_json(a) -def test_negative_zero_hashes_as_zero() -> None: +@BOTH_VERSIONS +def test_negative_zero_hashes_as_zero(model: ProposalModel) -> None: + # ``hold`` rather than the default ``buy``: from 1.1 on, an action that moves + # value requires a non-zero amount, and the property under test here is the + # canonicalization of signed zero — not the business rule. pid = uuid4() ts = datetime(2026, 6, 28, 12, 5, tzinfo=UTC) - a = _proposal(proposal_id=pid, created_at=ts, amount=Decimal("-0")) - b = _proposal(proposal_id=pid, created_at=ts, amount=Decimal("0")) + a = _proposal(model, proposal_id=pid, created_at=ts, action="hold", amount=Decimal("-0")) + b = _proposal(model, proposal_id=pid, created_at=ts, action="hold", amount=Decimal("0")) assert canonical_hash(a) == canonical_hash(b) -def test_canonical_hash_ignores_timezone_offset() -> None: +@BOTH_VERSIONS +def test_canonical_hash_ignores_timezone_offset(model: ProposalModel) -> None: # The same instant in two timezones must hash identically. pid = uuid4() utc_ts = datetime(2026, 6, 28, 12, 5, tzinfo=UTC) offset_ts = datetime(2026, 6, 28, 13, 5, tzinfo=timezone(timedelta(hours=1))) - a = _proposal(proposal_id=pid, created_at=utc_ts) - b = _proposal(proposal_id=pid, created_at=offset_ts) + a = _proposal(model, proposal_id=pid, created_at=utc_ts) + b = _proposal(model, proposal_id=pid, created_at=offset_ts) assert a == b assert canonical_hash(a) == canonical_hash(b) diff --git a/tests/test_contracts_versioning.py b/tests/test_contracts_versioning.py new file mode 100644 index 0000000..2cf96a1 --- /dev/null +++ b/tests/test_contracts_versioning.py @@ -0,0 +1,289 @@ +"""T12: 1.1 alongside an untouched 1.0 — and why that is the only safe way (D32). + +The canonical hash covers every serialized field, so a hashed schema cannot be +edited in place: adding one field would change the digest of proposals that were +already hashed, and any token minted over such a digest would stop verifying. So +1.0 is frozen forever and 1.1 is a separate document. + +The load-bearing test here is the first one. It pins 1.0's digest against a +committed fixture, which is the only thing that can *prove* the claim "old +proposals still hash the same" — a claim no amount of careful reading enforces. +""" + +import json +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from pathlib import Path +from typing import Any, Literal, get_args + +import pytest +from hodlin_contracts import ( + SCHEMA_VERSION, + EvidenceRef, + ProposalV1_0, + ProposalV1_1, + canonical_hash, + is_expired, + parse_proposal, +) +from pydantic import ValidationError + +_FIXTURES = Path(__file__).parent / "fixtures" + +#: Pinned 2026-08-21 from the 1.0 model exactly as M1 shipped it. If a change to +#: the codebase moves this digest, then 1.0 has been mutated and every historical +#: hash and token minted over one is invalid — the code is the bug, not this +#: constant. A new shape belongs in a new version. +PROPOSAL_V1_0_DIGEST = "9dbaa8bdc4719b423311c0a90447a44fb3973ad10a7733941b057f65845a0803" + +#: And 1.1's, pinned the day it shipped rather than the day it becomes expensive. +#: 1.1 is the version this codebase *produces*, so from T13 on its digests are the +#: ones tokens are minted over — at which point it is exactly as frozen as 1.0. +#: Pinning it now means "add a field to 1.1" fails a test instead of quietly +#: invalidating stored hashes; without this, doing so breaks nothing in the suite. +PROPOSAL_V1_1_DIGEST = "94c9ff9d41c074e244a2a9b1bd835b541fc51843ffe75e7f7b201fe66a3d0a68" + +_CREATED_AT = datetime(2026, 6, 28, 12, 5, tzinfo=UTC) +_OBSERVED_AT = datetime(2026, 6, 28, 12, 0, tzinfo=UTC) + + +def _evidence() -> dict[str, Any]: + return { + "kind": "anomaly", + "source": "hodlin", + "ref": "BTC-USD@2026-06-28T12:00:00Z", + "observed_at": _OBSERVED_AT, + } + + +def _business_fields(**overrides: Any) -> dict[str, Any]: + """The fields both versions share — deliberately identical, so the only + difference between a 1.0 and a 1.1 built from these is the version itself.""" + base: dict[str, Any] = { + "proposal_id": "6f1e9c74-2f1b-4d5e-8a3c-9b0d7e5f4a21", + "asset": "BTC", + "action": "buy", + "amount": Decimal("0.5"), + "recipient_label": "cold-wallet", + "reasoning": "anomalous volume spike", + "evidence": (_evidence(),), + "created_at": _CREATED_AT, + } + base.update(overrides) + return base + + +@pytest.mark.parametrize( + ("fixture", "expected_class", "digest"), + [ + ("proposal_v1_0.json", ProposalV1_0, PROPOSAL_V1_0_DIGEST), + ("proposal_v1_1.json", ProposalV1_1, PROPOSAL_V1_1_DIGEST), + ], + ids=["1.0", "1.1"], +) +def test_a_committed_proposal_still_validates_and_hashes_identically( + fixture: str, expected_class: type[ProposalV1_0] | type[ProposalV1_1], digest: str +) -> None: + """The regression that gives D32 its teeth: a document captured on the day its + version shipped must still parse, and must still produce the digest it produced + then. Every *live* version needs this, not only the historical one — the moment + a version's digests are stored, editing that version invalidates them. + + Both fixtures are realistic wire payloads — keys out of order, a ``+01:00`` + offset, a trailing-zero amount — so this also asserts that canonicalization, + not the sender's formatting, is what fixes the digest. + """ + payload = json.loads((_FIXTURES / fixture).read_text()) + + proposal = parse_proposal(payload) + + assert isinstance(proposal, expected_class) + assert canonical_hash(proposal) == digest + + +def test_the_same_business_fields_hash_differently_across_versions() -> None: + """``schema_version`` is inside the hash, so a cross-version collision is + impossible by construction: an approval of the 1.0 document can never be + replayed as an approval of the 1.1 one, even though they say the same thing + about the same asset for the same amount.""" + fields = _business_fields() + + v1_0 = ProposalV1_0(**fields) + v1_1 = ProposalV1_1(**fields) + + assert v1_0.model_dump(exclude={"schema_version"}) == v1_1.model_dump( + exclude={"schema_version", "valid_until"} + ) + assert canonical_hash(v1_0) != canonical_hash(v1_1) + + +def test_transfer_is_expressible_only_in_1_1() -> None: + """The point of the bump. ``transfer`` is the action that moves value, so it + arrives with the version that the execute gate understands — and 1.0 keeps + rejecting it, which is what stops an old-shaped proposal from smuggling one + in.""" + assert ProposalV1_1(**_business_fields(action="transfer")).action == "transfer" + + with pytest.raises(ValidationError): + ProposalV1_0(**_business_fields(action="transfer")) + + +def test_a_1_1_payload_is_not_readable_as_1_0() -> None: + """``extra="forbid"`` doing real work: 1.1's own field is unknown to 1.0, so + a newer document cannot be silently narrowed into an older class (which would + hash as something the sender never signed). + + The version tag is *relabelled* to "1.0" first, on purpose. Left as "1.1" it + fails on the ``Literal["1.0"]`` mismatch alone, and the assertion would keep + passing even if cross-version narrowing became possible — a test that proves + something weaker than it claims. Relabelling is also the realistic attack: a + caller who wants a 1.1 payload treated as 1.0 would obviously edit the tag. + """ + payload = ProposalV1_1(**_business_fields(valid_until=_CREATED_AT + timedelta(hours=1))) + relabelled = {**payload.model_dump(mode="json"), "schema_version": "1.0"} + + with pytest.raises(ValidationError, match="valid_until"): + ProposalV1_0(**relabelled) + + +class TestParseProposal: + """Dispatch on the version *in the payload*, never on a guess.""" + + def test_each_version_parses_to_its_own_class(self) -> None: + for model in (ProposalV1_0, ProposalV1_1): + payload = model(**_business_fields()).model_dump(mode="json") + assert type(parse_proposal(payload)) is model + + def test_an_unknown_version_is_rejected(self) -> None: + """Not "fall back to the newest" — a version this build has never seen + may mean anything, and hashing it as 1.1 would produce a digest for a + document we didn't actually understand.""" + payload = ProposalV1_1(**_business_fields()).model_dump(mode="json") + + with pytest.raises(ValidationError): + parse_proposal({**payload, "schema_version": "2.0"}) + + def test_a_missing_version_is_rejected(self) -> None: + """Direct construction defaults the version because in-process we know + what we are building; a payload from outside has to say. Guessing here is + how a 1.0 document gets read as 1.1.""" + payload = ProposalV1_1(**_business_fields()).model_dump(mode="json") + del payload["schema_version"] + + with pytest.raises(ValidationError): + parse_proposal(payload) + + +def test_a_future_version_with_a_deadline_is_honoured_without_touching_is_expired() -> None: + """The failure this guards against is silent and lands on the money path. + + ``is_expired`` reads ``valid_until`` structurally rather than matching known + classes, so a version that doesn't exist yet is handled the moment it does. An + ``isinstance`` chain would type-check fine after the union was widened, return + ``False`` for a proposal whose deadline passed months ago, and let the gate + approve it believing it had asked. This stand-in *is* the next version as far as + the helper is concerned. + """ + + class ProposalV1_2Stub(ProposalV1_1): + schema_version: Literal["1.2"] = "1.2" # type: ignore[assignment] # a stand-in for the next version + + stale = ProposalV1_2Stub(**_business_fields(valid_until=_CREATED_AT + timedelta(minutes=5))) + + assert is_expired(stale, at=_CREATED_AT + timedelta(hours=1)) + assert not is_expired(stale, at=_CREATED_AT + timedelta(minutes=1)) + + +def test_the_allowed_evidence_kinds_are_pinned() -> None: + """``EvidenceRef`` is shared by every proposal version, so widening ``kind`` + widens what a *frozen* 1.0 document may say — without changing any existing + digest, which is what makes it easy to miss. This test doesn't forbid a new + kind; it forces the "does this need a new proposal version?" conversation + before one lands.""" + kind_field = EvidenceRef.model_fields["kind"] + + assert get_args(kind_field.annotation) == ("anomaly", "news", "sentiment", "price") + + +class TestAmountFloor: + """1.1 adds the action that moves value, so it adds the floor that makes a + value-moving proposal meaningful.""" + + @pytest.mark.parametrize("action", ["buy", "sell", "transfer"]) + def test_a_zero_amount_value_moving_action_is_rejected(self, action: str) -> None: + """A zero-amount transfer burns gas to do nothing, and is the shape a + prompt-injected recommend domain emits when it's flailing.""" + with pytest.raises(ValidationError, match="greater than zero"): + ProposalV1_1(**_business_fields(action=action, amount=Decimal("0"))) + + @pytest.mark.parametrize("action", ["hold", "alert"]) + def test_actions_that_move_nothing_may_carry_zero(self, action: str) -> None: + assert ProposalV1_1(**_business_fields(action=action, amount=Decimal("0"))).amount == 0 + + def test_1_0_keeps_its_own_rules(self) -> None: + """Tightening validation on a frozen version could make a stored proposal + unparseable — the D32 mutation in another guise. 1.0 still accepts what it + always accepted; the gate is free to refuse it.""" + assert ProposalV1_0(**_business_fields(action="buy", amount=Decimal("0"))).amount == 0 + + +class TestValidUntil: + """Freshness is asked at the gate, not enforced at parse time.""" + + def test_a_past_deadline_still_parses(self) -> None: + """A historical proposal must stay parseable forever — re-reading a + stored proposal is not the same act as accepting a new one. If parsing + rejected stale proposals, the audit trail would become unreadable with + the passage of time, and a stored digest unverifiable.""" + stale = ProposalV1_1(**_business_fields(valid_until=_CREATED_AT + timedelta(minutes=1))) + + assert stale.valid_until == _CREATED_AT + timedelta(minutes=1) + assert is_expired(stale, at=datetime(2026, 8, 21, tzinfo=UTC)) + + def test_a_deadline_in_the_future_is_not_expired(self) -> None: + fresh = ProposalV1_1(**_business_fields(valid_until=_CREATED_AT + timedelta(hours=2))) + + assert not is_expired(fresh, at=_CREATED_AT + timedelta(hours=1)) + + def test_no_deadline_never_expires(self) -> None: + assert not is_expired(ProposalV1_1(**_business_fields()), at=_CREATED_AT) + + def test_1_0_has_no_deadline_of_its_own(self) -> None: + """Not a loophole: the gate's short-lived, single-use token still bounds + a 1.0 approval. ``valid_until`` bounds the *recommendation*, the token + bounds the *authorization*, and they are different clocks.""" + assert not is_expired( + ProposalV1_0(**_business_fields()), at=datetime(2030, 1, 1, tzinfo=UTC) + ) + + def test_a_deadline_at_or_before_creation_is_rejected(self) -> None: + """A static contradiction between two of the proposal's own fields — it + was never actionable. Safe to reject at parse time precisely because the + verdict can't change later: no stored proposal becomes unparseable.""" + for offset in (timedelta(0), timedelta(seconds=-1)): + with pytest.raises(ValidationError): + ProposalV1_1(**_business_fields(valid_until=_CREATED_AT + offset)) + + def test_comparing_against_a_naive_now_is_refused(self) -> None: + """A naive datetime would silently compare against an unknown zone (or + raise deep inside), and this decision gates money.""" + proposal = ProposalV1_1(**_business_fields(valid_until=_CREATED_AT + timedelta(hours=1))) + + with pytest.raises(ValueError, match="timezone-aware"): + is_expired(proposal, at=datetime(2026, 6, 28, 13, 0)) + + def test_valid_until_is_inside_the_hash(self) -> None: + """It changes what was authorized, so it must change the digest.""" + without = ProposalV1_1(**_business_fields()) + with_deadline = ProposalV1_1( + **_business_fields(valid_until=_CREATED_AT + timedelta(hours=1)) + ) + + assert canonical_hash(without) != canonical_hash(with_deadline) + + +def test_the_current_version_constant_matches_the_class_it_names() -> None: + """One place says what this codebase produces, and it has to agree with the + class that produces it — otherwise a consumer trusting ``SCHEMA_VERSION`` + reads the wrong contract.""" + assert SCHEMA_VERSION == ProposalV1_1(**_business_fields()).schema_version