Coverage for node / src / stigmem_node / models / federation.py: 98%
84 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
1"""Federation and conflict-resolution models."""
3from __future__ import annotations
5from typing import Any
7from pydantic import BaseModel, Field, field_validator
9from .constants import VALID_SCOPES
10from .facts import FactRecord, FactValue
11from .tombstones import TombstoneRecord, TombstoneRevocationRecord
14class PeerRegisterRequest(BaseModel):
15 node_url: str = Field(..., min_length=1)
16 node_id: str = Field(..., min_length=1)
17 federation_pubkey: str = Field(..., min_length=1)
18 allowed_scopes: list[str]
19 declaration_sig: str = Field(..., min_length=1)
20 signed_at: str = Field(..., min_length=1)
22 @field_validator("allowed_scopes")
23 @classmethod
24 def check_scopes(cls, scopes: list[str]) -> list[str]:
25 invalid = set(scopes) - VALID_SCOPES
26 if invalid: 26 ↛ 27line 26 didn't jump to line 27 because the condition on line 26 was never true
27 raise ValueError(f"invalid scopes: {invalid}")
28 return scopes
31class PeerRecord(BaseModel):
32 peer_id: str
33 node_id: str
34 node_url: str
35 status: str
36 allowed_scopes: list[str]
37 established_at: str | None
40class PeerRegisterResponse(BaseModel):
41 peer_id: str
42 status: str
43 verified_at: str | None
46class PeerApprovalRequest(BaseModel):
47 pubkey_fingerprint: str = Field(..., min_length=1)
50class PeerApprovalResponse(BaseModel):
51 peer_id: str
52 node_id: str
53 status: str
54 approved_at: str
57class OriginBlock(BaseModel):
58 tenant: str
59 node_id: str
60 allowed_scopes: list[str]
61 allowed_tenants: list[str]
62 # Phase 2c W3.1: the origin's published entity_uri, bound INTO the signed origin
63 # tuple (v2.1). A receiver fetches/verifies the origin's manifest by this uri, so a
64 # relay cannot lie about which origin a relayed fact came from. Mandatory in v2.1.
65 entity_uri: str
68class OriginKeyProof(BaseModel):
69 """Phase 3 (Rev 6 §7) — the v2.2 envelope ``origin_key_proof`` transport copy.
71 A relay MAY attach its last-resolved DNSSEC binding snapshot (fingerprint /
72 epoch / host / outcome) for a RELAYED, DNSSEC-anchored origin. It is purely a
73 *transport copy* / forward-compat hint.
75 INVARIANT I7 — carried bytes are transport, NEVER trust. The receiver MUST
76 ignore this carried snapshot as a trust input: it independently re-resolves and
77 re-validates the origin key through the live ladder/recheck path
78 (``resolve_origin_key_for_relay`` -> ``resolve_dnssec_binding``). A carried
79 ``dnssec_binding`` with a fabricated ``fpr`` and no live validating record is
80 rejected EXACTLY as if it were absent — trust comes only from live
81 re-validation, never the carried bytes. The relay resolution entry point takes
82 no ``origin_key_proof`` argument, so this snapshot can never be threaded into a
83 trust decision; it exists for diagnostics / forward compatibility only.
85 Additive + optional: ``proof_version`` lets the snapshot's shape evolve without
86 breaking a v2.1 peer (which simply omits the whole field). ``dnssec_binding`` is
87 a free-form dict (fpr/epoch/host/outcome) and may be ``None`` for a non-binding
88 hint.
89 """
91 proof_version: int
92 dnssec_binding: dict[str, Any] | None = None
95class FederationEnvelopeEntry(BaseModel):
96 fact: FactRecord
97 origin: OriginBlock
98 origin_sig: str
99 # Phase 2c W4.2: the carried, self-verifying origin manifest BODY the relay attaches
100 # for RELAYED facts. It lets an UNREACHABLE downstream match the relayed origin's key
101 # against its operator pin / stored binding (offline trust). It is NOT itself trusted
102 # without a first-party anchor match — no proof/STH/Merkle fields, just the manifest
103 # body. Absent (None) for self-originated facts and for direct (origin==sender) entries.
104 origin_manifest: dict[str, Any] | None = None
105 # Phase 3 (Rev 6 §7 v2.2): an OPTIONAL, additive per-origin DNSSEC binding snapshot a
106 # relay MAY attach for a RELAYED, DNSSEC-anchored origin. Transport copy ONLY (I7): the
107 # receiver re-resolves + re-validates and NEVER trusts these carried bytes (see
108 # OriginKeyProof). Absent (None) for self-originated facts, direct entries, and any
109 # non-DNSSEC origin. A v2.1 peer that omits the field still parses (backward-compatible).
110 origin_key_proof: OriginKeyProof | None = None
113class FederationFactsResponse(BaseModel):
114 v: int = 2
115 facts: list[FederationEnvelopeEntry]
116 cursor: str | None
117 has_more: bool
120class AuditEntry(BaseModel):
121 id: str
122 peer_id: str
123 event_type: str
124 detail: str | None
125 ts: str
128class ConflictResolveRequest(BaseModel):
129 """Request body for POST /v1/conflicts/:id/resolve (Spec-15-Fact-Semantics)."""
131 winning_fact_id: str | None = None
132 resolution_note: str = ""
133 new_value: FactValue | None = None
136# ---------------------------------------------------------------------------
137# V2 tombstone envelope models (Phase 2c W6.4)
138# Mirror FederationEnvelopeEntry / FederationFactsResponse for tombstones.
139# TombstoneRecord + TombstoneRevocationRecord imported from tombstones.py.
140# ---------------------------------------------------------------------------
143class TombstoneEnvelopeEntry(BaseModel):
144 """V2 per-tombstone envelope carrying origin attestation.
146 Mirrors FederationEnvelopeEntry for facts; reuses OriginBlock unchanged.
147 """
149 tombstone: TombstoneRecord
150 origin: OriginBlock
151 origin_sig: str
152 # Carried, self-verifying origin manifest body for RELAYED tombstones.
153 # Absent (None) for self-originated tombstones and direct (origin==sender) entries.
154 origin_manifest: dict[str, Any] | None = None
157class RevocationEnvelopeEntry(BaseModel):
158 """V2 per-revocation envelope carrying origin attestation (Phase 2c Rev-2).
160 Mirrors TombstoneEnvelopeEntry for tombstone REVOCATIONS; reuses OriginBlock
161 unchanged. A revocation has no entity_uri/scope of its own — it references a
162 tombstone by ``tombstone_id`` — so its origin attestation binds the revocation
163 ``id`` + the referenced ``tombstone_id`` + the origin grant (Rev-1's
164 ``canonical_revocation_origin_tuple``), and the egress gate is TENANT-only.
165 """
167 revocation: TombstoneRevocationRecord
168 origin: OriginBlock
169 origin_sig: str
170 # Carried, self-verifying origin manifest body for RELAYED revocations.
171 # Absent (None) for self-originated revocations and direct (origin==sender) entries.
172 origin_manifest: dict[str, Any] | None = None
175class FederationTombstonesResponseV2(BaseModel):
176 """V2 federation tombstone poll response with per-tombstone origin envelopes.
178 Mirrors FederationFactsResponse; revocations are now ALSO enveloped (Rev-2) so a
179 relayed revocation carries its origin attestation on the wire. Back-compat:
180 FederationTombstonesResponse (v1) in tombstones.py is unchanged.
181 """
183 v: int = 2
184 tombstones: list[TombstoneEnvelopeEntry]
185 revocations: list[RevocationEnvelopeEntry]
186 cursor: str | None = None
187 has_more: bool = False