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

1"""Federation and conflict-resolution models.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6 

7from pydantic import BaseModel, Field, field_validator 

8 

9from .constants import VALID_SCOPES 

10from .facts import FactRecord, FactValue 

11from .tombstones import TombstoneRecord, TombstoneRevocationRecord 

12 

13 

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) 

21 

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 

29 

30 

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 

38 

39 

40class PeerRegisterResponse(BaseModel): 

41 peer_id: str 

42 status: str 

43 verified_at: str | None 

44 

45 

46class PeerApprovalRequest(BaseModel): 

47 pubkey_fingerprint: str = Field(..., min_length=1) 

48 

49 

50class PeerApprovalResponse(BaseModel): 

51 peer_id: str 

52 node_id: str 

53 status: str 

54 approved_at: str 

55 

56 

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 

66 

67 

68class OriginKeyProof(BaseModel): 

69 """Phase 3 (Rev 6 §7) — the v2.2 envelope ``origin_key_proof`` transport copy. 

70 

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. 

74 

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. 

84 

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 """ 

90 

91 proof_version: int 

92 dnssec_binding: dict[str, Any] | None = None 

93 

94 

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 

111 

112 

113class FederationFactsResponse(BaseModel): 

114 v: int = 2 

115 facts: list[FederationEnvelopeEntry] 

116 cursor: str | None 

117 has_more: bool 

118 

119 

120class AuditEntry(BaseModel): 

121 id: str 

122 peer_id: str 

123 event_type: str 

124 detail: str | None 

125 ts: str 

126 

127 

128class ConflictResolveRequest(BaseModel): 

129 """Request body for POST /v1/conflicts/:id/resolve (Spec-15-Fact-Semantics).""" 

130 

131 winning_fact_id: str | None = None 

132 resolution_note: str = "" 

133 new_value: FactValue | None = None 

134 

135 

136# --------------------------------------------------------------------------- 

137# V2 tombstone envelope models (Phase 2c W6.4) 

138# Mirror FederationEnvelopeEntry / FederationFactsResponse for tombstones. 

139# TombstoneRecord + TombstoneRevocationRecord imported from tombstones.py. 

140# --------------------------------------------------------------------------- 

141 

142 

143class TombstoneEnvelopeEntry(BaseModel): 

144 """V2 per-tombstone envelope carrying origin attestation. 

145 

146 Mirrors FederationEnvelopeEntry for facts; reuses OriginBlock unchanged. 

147 """ 

148 

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 

155 

156 

157class RevocationEnvelopeEntry(BaseModel): 

158 """V2 per-revocation envelope carrying origin attestation (Phase 2c Rev-2). 

159 

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 """ 

166 

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 

173 

174 

175class FederationTombstonesResponseV2(BaseModel): 

176 """V2 federation tombstone poll response with per-tombstone origin envelopes. 

177 

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 """ 

182 

183 v: int = 2 

184 tombstones: list[TombstoneEnvelopeEntry] 

185 revocations: list[RevocationEnvelopeEntry] 

186 cursor: str | None = None 

187 has_more: bool = False