Coverage for node / src / stigmem_node / federation / federation_ingest.py: 93%

195 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-18 05:34 +0000

1"""Idempotent fact ingestion from federated peers (spec §6.3, §6.5, §19.4–19.5). 

2 

3ingest_fact() is the single entry-point for all federated facts. 

4It is safe to call multiple times for the same fact (no-op after first write). 

5 

6Phase 8 (§19): source-trust score is computed at ingest time and stored as a 

7snapshot in facts.source_trust. In trust_mode=strict, facts with t < 0.2 are 

8routed to the node's designated quarantine garden instead of the main fact table. 

9""" 

10 

11from __future__ import annotations 

12 

13import json 

14import uuid 

15from datetime import UTC, datetime 

16from typing import Any 

17 

18from ..db import db 

19from ..hlc import HLCRemoteSkewError, node_hlc 

20from ..models.facts import VALID_INTERPRET_AS 

21from ..observability.audit_event import ( 

22 INSTRUCTION_QUARANTINED, 

23 emit_instruction_event_if_applicable, 

24 is_instruction_fact, 

25) 

26from ..observability.metrics import PEER_HLC_ANOMALY 

27 

28 

29class FederationHlcSkewError(ValueError): 

30 """Inbound federated fact was rejected because its HLC wall time is implausible.""" 

31 

32 def __init__(self, fact_id: str, sender_node_id: str, cause: HLCRemoteSkewError) -> None: 

33 self.fact_id = fact_id 

34 self.sender_node_id = sender_node_id 

35 self.direction = cause.direction 

36 self.skew_ms = cause.skew_ms 

37 self.remote_wall_ms = cause.remote_wall_ms 

38 self.local_wall_ms = cause.local_wall_ms 

39 super().__init__( 

40 "remote HLC skew outside configured bound " 

41 f"(fact_id={fact_id}, sender={sender_node_id}, direction={self.direction})" 

42 ) 

43 

44 

45class FederationIntegrityError(ValueError): 

46 """Inbound federated fact failed integrity verification before ingest.""" 

47 

48 def __init__( 

49 self, 

50 *, 

51 fact_id: str, 

52 sender_node_id: str, 

53 reason: str, 

54 stored_cid: str | None = None, 

55 computed_cid: str | None = None, 

56 ) -> None: 

57 self.fact_id = fact_id 

58 self.sender_node_id = sender_node_id 

59 self.reason = reason 

60 self.stored_cid = stored_cid 

61 self.computed_cid = computed_cid 

62 super().__init__(f"inbound fact integrity verification failed: {reason}") 

63 

64 

65class FederationValidUntilExtensionError(ValueError): 

66 """Inbound federated fact tried to extend an existing valid_until.""" 

67 

68 def __init__( 

69 self, 

70 *, 

71 fact_id: str, 

72 sender_node_id: str, 

73 stored_valid_until: str | None, 

74 incoming_valid_until: str | None, 

75 ) -> None: 

76 self.fact_id = fact_id 

77 self.sender_node_id = sender_node_id 

78 self.stored_valid_until = stored_valid_until 

79 self.incoming_valid_until = incoming_valid_until 

80 super().__init__( 

81 "federation ingest rejected: incoming valid_until " 

82 f"({incoming_valid_until}) extends stored ({stored_valid_until}) " 

83 f"for fact_id={fact_id}, sender={sender_node_id} (R-18)" 

84 ) 

85 

86 

87def _encode_v(value: dict[str, Any]) -> str: 

88 vtype = value["type"] 

89 v = value.get("v") 

90 if vtype == "null": 90 ↛ 91line 90 didn't jump to line 91 because the condition on line 90 was never true

91 return "null" 

92 if vtype == "boolean": 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true

93 return "true" if v else "false" 

94 return str(v) 

95 

96 

97def _interpret_as(fact: dict[str, Any]) -> str: 

98 interpret_as = fact.get("value", {}).get("interpret_as", "content") 

99 if interpret_as not in VALID_INTERPRET_AS: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true

100 from fastapi import HTTPException 

101 

102 raise HTTPException(status_code=422, detail="invalid_interpret_as") 

103 return str(interpret_as) 

104 

105 

106def _verify_inbound_cid(fact: dict[str, Any], sender_node_id: str) -> str | None: 

107 from ..cid import compute_cid 

108 

109 stored_cid = fact.get("cid") 

110 if stored_cid is None: 

111 return None 

112 fact_id = str(fact.get("id", "")) 

113 value = fact.get("value", {}) 

114 computed_cid = compute_cid( 

115 entity=str(fact.get("entity", "")), 

116 relation=str(fact.get("relation", "")), 

117 value_type=str(value.get("type", "")), 

118 value_v=_encode_v(value), 

119 source=str(fact.get("source", "")), 

120 scope=str(fact.get("scope", "")), 

121 confidence=float(fact.get("confidence", 1.0)), 

122 interpret_as=str(value.get("interpret_as", "content")), 

123 ) 

124 if stored_cid != computed_cid: 

125 raise FederationIntegrityError( 

126 fact_id=fact_id, 

127 sender_node_id=sender_node_id, 

128 reason="cid_mismatch", 

129 stored_cid=str(stored_cid), 

130 computed_cid=computed_cid, 

131 ) 

132 return str(stored_cid) 

133 

134 

135def _resolve_quarantine_garden_id(failure_detail: str) -> str: 

136 from ..settings import settings 

137 

138 qg_id = settings.quarantine_garden_id 

139 if not qg_id: 

140 from fastapi import HTTPException 

141 

142 raise HTTPException(status_code=403, detail=failure_detail) 

143 

144 with db() as conn: 

145 qg_row = conn.execute( 

146 "SELECT id FROM gardens WHERE (id = ? OR slug = ?) AND quarantine = 1", 

147 (qg_id, qg_id), 

148 ).fetchone() 

149 if qg_row is None: 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true

150 from fastapi import HTTPException 

151 

152 raise HTTPException(status_code=403, detail=failure_detail) 

153 return str(qg_row["id"]) 

154 

155 

156def _audit_peer_hlc_anomaly( 

157 *, 

158 conn: Any, 

159 fact_id: str, 

160 sender_node_id: str, 

161 exc: HLCRemoteSkewError, 

162) -> None: 

163 from ..observability.audit_event import emit 

164 

165 PEER_HLC_ANOMALY.labels(peer_id=sender_node_id, direction=exc.direction).inc() 

166 emit( 

167 "peer_hlc_anomaly", 

168 entity_uri="system:federation", 

169 fact_id=fact_id, 

170 source=sender_node_id, 

171 detail={ 

172 "sender_node_id": sender_node_id, 

173 "direction": exc.direction, 

174 "skew_ms": exc.skew_ms, 

175 "remote_wall_ms": exc.remote_wall_ms, 

176 "local_wall_ms": exc.local_wall_ms, 

177 "max_future_skew_ms": exc.max_future_skew_ms, 

178 "max_past_skew_ms": exc.max_past_skew_ms, 

179 }, 

180 conn=conn, 

181 ) 

182 

183 

184def _audit_peer_integrity_failure( 

185 *, 

186 conn: Any, 

187 exc: FederationIntegrityError, 

188) -> None: 

189 from ..observability.audit_event import emit 

190 

191 emit( 

192 "federation_integrity_rejected", 

193 entity_uri="system:federation", 

194 fact_id=exc.fact_id, 

195 source=exc.sender_node_id, 

196 detail={ 

197 "sender_node_id": exc.sender_node_id, 

198 "reason": exc.reason, 

199 "stored_cid": exc.stored_cid, 

200 "computed_cid": exc.computed_cid, 

201 }, 

202 conn=conn, 

203 ) 

204 

205 

206def _audit_valid_until_extension( 

207 *, 

208 conn: Any, 

209 exc: FederationValidUntilExtensionError, 

210) -> None: 

211 from ..observability.audit_event import emit 

212 

213 emit( 

214 "federation_valid_until_extension_rejected", 

215 entity_uri="system:federation", 

216 fact_id=exc.fact_id, 

217 source=exc.sender_node_id, 

218 detail={ 

219 "sender_node_id": exc.sender_node_id, 

220 "stored_valid_until": exc.stored_valid_until, 

221 "incoming_valid_until": exc.incoming_valid_until, 

222 "reason": ( 

223 "R-18: federation peer attempted to extend valid_until beyond " 

224 "the locally-stored value; rejected per local recomputation " 

225 "invariant" 

226 ), 

227 }, 

228 conn=conn, 

229 ) 

230 

231 

232def _is_valid_until_extension(stored: str | None, incoming: str | None) -> bool: 

233 """Return True when incoming would extend locally observed visibility. 

234 

235 R-18 is independent of Phase 2b origin signing: a signed ``valid_until`` 

236 authenticates the *value* the origin asserted, it does NOT grant the right 

237 to extend an already-observed visibility window. This guard fires on the 

238 locally-stored value regardless of whether the inbound fact carried a valid 

239 origin_sig. 

240 """ 

241 

242 if stored is None: 

243 return False 

244 if incoming is None: 

245 return True 

246 

247 stored_ts = datetime.fromisoformat(stored.replace("Z", "+00:00")) 

248 incoming_ts = datetime.fromisoformat(incoming.replace("Z", "+00:00")) 

249 if stored_ts.tzinfo is None: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true

250 stored_ts = stored_ts.replace(tzinfo=UTC) 

251 if incoming_ts.tzinfo is None: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 incoming_ts = incoming_ts.replace(tzinfo=UTC) 

253 return incoming_ts > stored_ts 

254 

255 

256def ingest_fact( 

257 fact: dict[str, Any], 

258 sender_node_id: str, 

259 origin_node_id: str | None = None, 

260 origin_allowed_scopes: list[str] | None = None, 

261 *, 

262 tenant_id: str, 

263 origin_tenant: str | None = None, 

264 origin_allowed_tenants: list[str] | None = None, 

265 origin_sig: str | None = None, 

266 origin_entity_uri: str | None = None, 

267 identity_strength_boost: float | None = None, 

268) -> bool: 

269 """Idempotently ingest a federated fact. 

270 

271 Returns True if the fact was new, False if it already existed (no-op). 

272 Writes stigmem:received_from meta-fact atomically with the fact. 

273 Advances the local HLC. 

274 Detects contradictions and writes conflict entities (spec §6.5, §3.3). 

275 

276 Phase 8 (§19): computes source_trust at ingest; routes to quarantine garden 

277 when trust_mode=strict and t < 0.2, or rejects with 403 if no quarantine 

278 garden is configured. 

279 

280 origin_node_id / origin_allowed_scopes populate the v0.8 scope-propagation 

281 columns (spec §6.8.1, Migration 004). When None, defaults to sender_node_id 

282 and the fact's scope as a single-element list (first-hop inference). 

283 

284 Company-scope facts are re_federation_blocked=1 by default (spec §6.8.2): 

285 the originating node's grant is non-transitive. 

286 """ 

287 from ..settings import settings 

288 from ..source_trust import compute_source_trust 

289 

290 fact_id = fact["id"] 

291 scope = fact["scope"] 

292 source = fact["source"] 

293 try: 

294 inbound_cid = _verify_inbound_cid(fact, sender_node_id) 

295 except FederationIntegrityError as exc: 

296 with db() as conn: 

297 _audit_peer_integrity_failure(conn=conn, exc=exc) 

298 conn.commit() 

299 raise 

300 interpret_as = _interpret_as(fact) 

301 is_instruction = is_instruction_fact( 

302 fact.get("entity"), 

303 fact.get("relation"), 

304 interpret_as, 

305 ) 

306 

307 # Phase 8: compute source-trust snapshot (§19.4) 

308 trust_score: float | None = None 

309 quarantine_garden_db_id: str | None = None 

310 quarantine_status: str | None = None 

311 quarantine_reason: str | None = None 

312 

313 if is_instruction: 

314 quarantine_garden_db_id = _resolve_quarantine_garden_id("quarantine_garden_required") 

315 quarantine_status = "pending" 

316 quarantine_reason = "instruction_federation_inbound" 

317 

318 trust_mode = settings.trust_mode 

319 if trust_mode != "off": 

320 trust_score = compute_source_trust( 

321 source, 

322 scope, 

323 identity=None, 

324 identity_strength_override=identity_strength_boost, 

325 ) 

326 

327 if trust_mode == "strict" and trust_score < 0.2: 

328 quarantine_garden_db_id = _resolve_quarantine_garden_id("trust_below_threshold") 

329 quarantine_status = "pending" 

330 quarantine_reason = "trust_below_threshold" 

331 

332 # Scope-propagation columns (spec §6.8.1) 

333 # The v2 path passes explicit verified values for origin_node_id; the 

334 # origin==sender equality invariant is enforced at the route/client layer 

335 # (Phase 2b Task 5), not here — this resolver only fills the column. 

336 eff_origin_node_id = origin_node_id or sender_node_id 

337 eff_origin_scopes: str | None 

338 if origin_allowed_scopes is not None: 

339 eff_origin_scopes = json.dumps(sorted(origin_allowed_scopes)) 

340 else: 

341 eff_origin_scopes = json.dumps([scope]) 

342 # Phase 2b origin block (Migration 044): persist the verified per-origin tenant 

343 # claim byte-identical to the signed canonical form (F-8: json.dumps(sorted(...)) 

344 # mirrors eff_origin_scopes). Populated by the v2 route/client in Task 5. 

345 eff_origin_tenants: str | None = ( 

346 json.dumps(sorted(origin_allowed_tenants)) if origin_allowed_tenants is not None else None 

347 ) 

348 # Phase 2c W3.1 (Migration 046): persist the verified origin entity_uri (the value bound 

349 # into the v2.1 signed origin tuple). NULL for local-origin / pre-v2.1 facts. A relayed 

350 # fact forwards this stored value so its origin_sig verifies against the ORIGIN's manifest. 

351 eff_origin_entity_uri: str | None = origin_entity_uri 

352 # company-scope facts: re-federation is blocked by default (§6.8.2) 

353 re_fed_blocked = 1 if scope == "company" else 0 

354 

355 with db() as conn: 

356 existing = conn.execute( 

357 "SELECT id, valid_until, cid FROM facts WHERE id = ?", 

358 (fact_id,), 

359 ).fetchone() 

360 if existing is not None: 

361 stored_valid_until = existing["valid_until"] 

362 incoming_valid_until = fact.get("valid_until") 

363 if _is_valid_until_extension(stored_valid_until, incoming_valid_until): 

364 violation = FederationValidUntilExtensionError( 

365 fact_id=fact_id, 

366 sender_node_id=sender_node_id, 

367 stored_valid_until=stored_valid_until, 

368 incoming_valid_until=incoming_valid_until, 

369 ) 

370 _audit_valid_until_extension(conn=conn, exc=violation) 

371 conn.commit() 

372 raise violation 

373 # F-1 residual (Phase 2c W5.1): a wire id that already exists locally 

374 # with a DIFFERENT cid is either a relay pre-occupation attempt or a bug 

375 # in the sender. Fail closed: reject and audit; do NOT overwrite or 

376 # silently treat as a duplicate. Same-cid = legitimate idempotent re-pull 

377 # (spec §5.8) — fall through to the existing no-op path below. 

378 existing_cid = existing["cid"] 

379 if existing_cid is not None and inbound_cid is not None and existing_cid != inbound_cid: 

380 collision = FederationIntegrityError( 

381 fact_id=fact_id, 

382 sender_node_id=sender_node_id, 

383 reason="wire_id_collision", 

384 stored_cid=existing_cid, 

385 computed_cid=inbound_cid, 

386 ) 

387 _audit_peer_integrity_failure(conn=conn, exc=collision) 

388 conn.commit() 

389 raise collision 

390 return False # already ingested; silent no-op per spec §5.8 

391 

392 # Advance HLC (spec §6.3) 

393 remote_hlc = fact.get("hlc") 

394 try: 

395 new_hlc = ( 

396 node_hlc.receive( 

397 remote_hlc, 

398 max_future_skew_ms=settings.federation_hlc_max_future_skew_s * 1000, 

399 max_past_skew_ms=settings.federation_hlc_max_past_skew_s * 1000, 

400 ) 

401 if remote_hlc 

402 else node_hlc.tick() 

403 ) 

404 except HLCRemoteSkewError as exc: 

405 _audit_peer_hlc_anomaly( 

406 conn=conn, 

407 fact_id=fact_id, 

408 sender_node_id=sender_node_id, 

409 exc=exc, 

410 ) 

411 # The fact insert is rejected, but the anomaly audit event is the 

412 # security evidence. Commit it before raising so the surrounding 

413 # transaction rollback does not erase the rejection trail. 

414 conn.commit() 

415 raise FederationHlcSkewError(fact_id, sender_node_id, exc) from exc 

416 

417 # Insert the fact with received_from + scope-propagation columns (Migration 004) 

418 # Phase 8: also store source_trust snapshot + quarantine metadata 

419 conn.execute( 

420 """INSERT INTO facts 

421 (id, entity, relation, value_type, value_v, source, timestamp, 

422 valid_until, confidence, scope, hlc, received_from, 

423 origin_node_id, origin_allowed_scopes, re_federation_blocked, 

424 source_trust, quarantine_garden_id, quarantine_status, 

425 quarantine_reason, interpret_as, cid, tenant_id, 

426 origin_tenant, origin_allowed_tenants, origin_sig, origin_entity_uri) 

427 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", 

428 ( 

429 fact_id, 

430 fact["entity"], 

431 fact["relation"], 

432 fact["value"]["type"], 

433 _encode_v(fact["value"]), 

434 source, 

435 fact["timestamp"], 

436 fact.get("valid_until"), 

437 fact["confidence"], 

438 scope, 

439 new_hlc, 

440 sender_node_id, 

441 eff_origin_node_id, 

442 eff_origin_scopes, 

443 re_fed_blocked, 

444 trust_score, 

445 quarantine_garden_db_id, 

446 quarantine_status, 

447 quarantine_reason, 

448 interpret_as, 

449 inbound_cid, 

450 tenant_id, 

451 origin_tenant, 

452 eff_origin_tenants, 

453 origin_sig, 

454 eff_origin_entity_uri, 

455 ), 

456 ) 

457 

458 # Phase 8 §19.5.4: audit entry for ingest-time quarantine routing 

459 if quarantine_status == "pending": 

460 audit_id = str(uuid.uuid4()) 

461 audit_now = datetime.now(UTC).isoformat() 

462 conn.execute( 

463 """INSERT INTO fact_audit_log 

464 (id, fact_id, event_type, entity_uri, oidc_sub, source, 

465 attested_key_id, detail, ts) 

466 VALUES (?,?,?,?,?,?,?,?,?)""", 

467 ( 

468 audit_id, 

469 fact_id, 

470 "quarantine_ingest", 

471 "system:federation", 

472 None, 

473 sender_node_id, 

474 None, 

475 json.dumps( 

476 { 

477 "reason": quarantine_reason, 

478 "trust_score": trust_score, 

479 "interpret_as": interpret_as, 

480 } 

481 ), 

482 audit_now, 

483 ), 

484 ) 

485 emit_instruction_event_if_applicable( 

486 INSTRUCTION_QUARANTINED, 

487 fact_id=fact_id, 

488 fact_entity=fact.get("entity"), 

489 fact_relation=fact.get("relation"), 

490 fact_interpret_as=interpret_as, 

491 actor_uri="system:federation", 

492 source=sender_node_id, 

493 detail={ 

494 "reason": quarantine_reason, 

495 "trust_score": trust_score, 

496 "received_from": sender_node_id, 

497 }, 

498 conn=conn, 

499 ) 

500 

501 # Write stigmem:received_from meta-fact atomically (spec §3.1) 

502 meta_id = str(uuid.uuid4()) 

503 meta_now = datetime.now(UTC).isoformat() 

504 meta_hlc = node_hlc.tick() 

505 conn.execute( 

506 """INSERT INTO facts 

507 (id, entity, relation, value_type, value_v, source, timestamp, 

508 valid_until, confidence, scope, hlc, received_from, tenant_id) 

509 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", 

510 ( 

511 meta_id, 

512 fact_id, # entity = the ingested fact's ID 

513 "stigmem:received_from", 

514 "ref", 

515 sender_node_id, 

516 "system:stigmem", 

517 meta_now, 

518 None, 

519 1.0, 

520 "local", # meta-facts are local; MUST NOT be re-replicated (spec §3.1) 

521 meta_hlc, 

522 None, 

523 tenant_id, 

524 ), 

525 ) 

526 

527 # Contradiction detection — skip for quarantined facts (§19.5.2) 

528 if quarantine_status is None: 

529 _detect_and_record_contradiction(conn, fact, fact_id, tenant_id) 

530 

531 return True 

532 

533 

534_STIGMEM_NS = "stigmem:" 

535_STIGMEM_URI_NS = "stigmem://" 

536 

537 

538def _is_reserved_stigmem(s: str) -> bool: 

539 """True for bare stigmem: system names (e.g. 'stigmem:conflict:x'). 

540 False for stigmem:// URI entities which are user content.""" 

541 return s.startswith(_STIGMEM_NS) and not s.startswith(_STIGMEM_URI_NS) 

542 

543 

544def _detect_and_record_contradiction( 

545 conn: Any, 

546 fact: dict[str, Any], 

547 fact_id: str, 

548 tenant_id: str, 

549) -> None: 

550 """If a contradiction exists, assert conflict entities and write conflicts table.""" 

551 # Reserved stigmem: facts are system state (status transitions, meta-facts), not 

552 # semantic content. Two stigmem:conflict:status facts with different values represent 

553 # a state transition, not a contradiction — exempt them from sibling-detection (§9.1). 

554 # Note: stigmem:// URI entities are user content and ARE subject to detection. 

555 if _is_reserved_stigmem(fact["entity"]) or _is_reserved_stigmem(fact["relation"]): 

556 return 

557 

558 siblings = conn.execute( 

559 """SELECT id FROM facts 

560 WHERE entity = ? AND relation = ? AND scope = ? 

561 AND id != ? AND confidence > 0.0 AND tenant_id = ?""", 

562 (fact["entity"], fact["relation"], fact["scope"], fact_id, tenant_id), 

563 ).fetchall() 

564 

565 if not siblings: 

566 return 

567 

568 now = datetime.now(UTC).isoformat() 

569 

570 for sibling in siblings: 

571 sibling_id = sibling["id"] 

572 conflict_uuid = str(uuid.uuid4()) 

573 conflict_id = f"stigmem:conflict:{conflict_uuid}" 

574 

575 # Skip if this pair already has a conflict record 

576 already = conn.execute( 

577 """SELECT c.id FROM conflicts c 

578 WHERE (c.fact_a_id = ? AND c.fact_b_id = ?) 

579 OR (c.fact_a_id = ? AND c.fact_b_id = ?)""", 

580 (fact_id, sibling_id, sibling_id, fact_id), 

581 ).fetchone() 

582 if already: 582 ↛ 583line 582 didn't jump to line 583 because the condition on line 582 was never true

583 continue 

584 

585 hlc_between = node_hlc.tick() 

586 conn.execute( 

587 """INSERT INTO facts 

588 (id, entity, relation, value_type, value_v, source, timestamp, 

589 valid_until, confidence, scope, hlc, received_from, tenant_id) 

590 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", 

591 ( 

592 str(uuid.uuid4()), 

593 conflict_id, 

594 "stigmem:conflict:between", 

595 "text", 

596 f"{fact_id} {sibling_id}", 

597 "system:stigmem", 

598 now, 

599 None, 

600 1.0, 

601 fact["scope"], 

602 hlc_between, 

603 None, 

604 tenant_id, 

605 ), 

606 ) 

607 

608 hlc_status = node_hlc.tick() 

609 conn.execute( 

610 """INSERT INTO facts 

611 (id, entity, relation, value_type, value_v, source, timestamp, 

612 valid_until, confidence, scope, hlc, received_from, tenant_id) 

613 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", 

614 ( 

615 str(uuid.uuid4()), 

616 conflict_id, 

617 "stigmem:conflict:status", 

618 "string", 

619 "unresolved", 

620 "system:stigmem", 

621 now, 

622 None, 

623 1.0, 

624 fact["scope"], 

625 hlc_status, 

626 None, 

627 tenant_id, 

628 ), 

629 ) 

630 

631 conn.execute( 

632 """INSERT OR IGNORE INTO conflicts (id, fact_a_id, fact_b_id, status, detected_at) 

633 VALUES (?,?,?,?,?)""", 

634 (conflict_id, fact_id, sibling_id, "unresolved", now), 

635 ) 

636 

637 

638def write_audit_log( 

639 peer_id: str, 

640 event_type: str, 

641 detail: dict[str, Any] | None = None, 

642) -> None: 

643 """Write a federation audit log entry (spec §6.4).""" 

644 entry_id = str(uuid.uuid4()) 

645 now = datetime.now(UTC).isoformat() 

646 with db() as conn: 

647 conn.execute( 

648 "INSERT INTO federation_audit (id, peer_id, event_type, detail, ts) VALUES (?,?,?,?,?)", 

649 (entry_id, peer_id, event_type, json.dumps(detail) if detail else None, now), 

650 )