Coverage for node / src / stigmem_node / routes / federation / audit_conflicts.py: 85%

108 statements  

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

1"""Federation audit and conflict routes.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import uuid 

7from datetime import UTC, datetime 

8from typing import Annotated, Any 

9 

10from fastapi import Depends, HTTPException, Query, status 

11 

12from ...auth import Identity, resolve_identity 

13from ...db import db 

14from ...hlc import node_hlc 

15from ...models.facts import row_to_record 

16from ...models.federation import ConflictResolveRequest 

17from .common import router 

18 

19 

20@router.get("/v1/federation/audit") 

21def get_audit_log( 

22 identity: Annotated[Identity, Depends(resolve_identity)], 

23 peer_id: str | None = Query(None), 

24 event_type: str | None = Query(None), 

25 limit: int = Query(50, ge=1, le=500), 

26 cursor: str | None = Query(None), 

27) -> dict[str, Any]: 

28 if not identity.can_federate(): 28 ↛ 29line 28 didn't jump to line 29 because the condition on line 28 was never true

29 raise HTTPException(status_code=403, detail="federate permission required") 

30 

31 conditions: list[str] = [] 

32 params: list[Any] = [] 

33 if peer_id: 

34 conditions.append("peer_id = ?") 

35 params.append(peer_id) 

36 if event_type: 

37 conditions.append("event_type = ?") 

38 params.append(event_type) 

39 if cursor: 39 ↛ 40line 39 didn't jump to line 40 because the condition on line 39 was never true

40 conditions.append("id > ?") 

41 params.append(cursor) 

42 

43 where = f"WHERE {' AND '.join(conditions)}" if conditions else "" 

44 params.append(limit + 1) 

45 

46 with db() as conn: 

47 rows = conn.execute( 

48 f"SELECT * FROM federation_audit {where} ORDER BY ts DESC, id DESC LIMIT ?", # noqa: S608 # nosec B608 — where built from literal fragments; values in params 

49 params, 

50 ).fetchall() 

51 

52 has_more = len(rows) > limit 

53 rows = rows[:limit] 

54 next_cursor = rows[-1]["id"] if has_more and rows else None 

55 

56 return { 

57 "entries": [ 

58 { 

59 "id": r["id"], 

60 "peer_id": r["peer_id"], 

61 "event_type": r["event_type"], 

62 "detail": json.loads(r["detail"]) if r["detail"] else None, 

63 "ts": r["ts"], 

64 } 

65 for r in rows 

66 ], 

67 "cursor": next_cursor, 

68 "has_more": has_more, 

69 } 

70 

71 

72# --------------------------------------------------------------------------- 

73# GET /v1/conflicts — list conflicts (§5.9) 

74# --------------------------------------------------------------------------- 

75 

76 

77@router.get("/v1/conflicts") 

78def list_conflicts( 

79 identity: Annotated[Identity, Depends(resolve_identity)], 

80 conflict_status: str | None = Query(None, alias="status"), 

81 cursor: str | None = Query(None), 

82 limit: int = Query(50, ge=1, le=500), 

83) -> dict[str, Any]: 

84 if not identity.can_read(): 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true

85 raise HTTPException(status_code=403, detail="read permission required") 

86 

87 # F-FED-CONFLICT-TENANT: the conflicts table carries no tenant_id, but the 

88 # facts it references do (ingest stamps tenant on both the facts and the 

89 # conflict facts). Scope the operator view to the caller's tenant by requiring 

90 # the conflict's fact_a to live in identity.tenant_id — otherwise an admin 

91 # scoped to tenant A could enumerate a conflict (and its facts) from tenant B. 

92 conditions: list[str] = [ 

93 "EXISTS (SELECT 1 FROM facts f WHERE f.id = c.fact_a_id AND f.tenant_id = ?)" 

94 ] 

95 params: list[Any] = [identity.tenant_id] 

96 if conflict_status: 

97 conditions.append("c.status = ?") 

98 params.append(conflict_status) 

99 if cursor: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true

100 conditions.append("c.id > ?") 

101 params.append(cursor) 

102 

103 where = f"WHERE {' AND '.join(conditions)}" 

104 params.append(limit + 1) 

105 

106 with db() as conn: 

107 sql = ( 

108 "SELECT c.id, c.fact_a_id, c.fact_b_id, c.status, c.resolution_fact_id, " # noqa: S608 # nosec B608 

109 f"c.detected_at FROM conflicts c {where} ORDER BY c.detected_at DESC, " 

110 "c.id DESC LIMIT ?" 

111 ) 

112 rows = conn.execute( 

113 sql, 

114 params, 

115 ).fetchall() 

116 

117 conflicts: list[dict[str, Any]] = [] 

118 for r in rows[:limit]: 

119 fa = conn.execute( 

120 "SELECT * FROM facts WHERE id = ? AND tenant_id = ?", 

121 (r["fact_a_id"], identity.tenant_id), 

122 ).fetchone() 

123 fb = conn.execute( 

124 "SELECT * FROM facts WHERE id = ? AND tenant_id = ?", 

125 (r["fact_b_id"], identity.tenant_id), 

126 ).fetchone() 

127 conflicts.append( 

128 { 

129 "conflict_id": r["id"], 

130 "fact_a": row_to_record(fa).model_dump() if fa else None, 

131 "fact_b": row_to_record(fb).model_dump() if fb else None, 

132 "status": r["status"], 

133 "resolved_by": r["resolution_fact_id"], 

134 "detected_at": r["detected_at"], 

135 } 

136 ) 

137 

138 has_more = len(rows) > limit 

139 next_cursor = rows[limit - 1]["id"] if has_more and len(rows) >= limit else None 

140 return {"conflicts": conflicts, "cursor": next_cursor, "has_more": has_more} 

141 

142 

143# --------------------------------------------------------------------------- 

144# POST /v1/conflicts/:conflict_id/resolve — resolve a conflict (§5.10) 

145# --------------------------------------------------------------------------- 

146 

147 

148def _encode_value(vtype: str, v: Any) -> str: 

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

150 return "null" 

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

152 return "true" if v else "false" 

153 return str(v) 

154 

155 

156@router.post("/v1/conflicts/{conflict_id}/resolve") 

157def resolve_conflict( 

158 conflict_id: str, 

159 req: ConflictResolveRequest, 

160 identity: Annotated[Identity, Depends(resolve_identity)], 

161) -> dict[str, Any]: 

162 """Assert a canonical resolution fact and close the conflict (Spec-15-Fact-Semantics).""" 

163 if not identity.can_write(): 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 raise HTTPException( 

165 status_code=status.HTTP_403_FORBIDDEN, detail="write permission required" 

166 ) 

167 

168 with db() as conn: 

169 conflict = conn.execute("SELECT * FROM conflicts WHERE id = ?", (conflict_id,)).fetchone() 

170 

171 # F-FED-CONFLICT-TENANT: a conflict is only visible/resolvable to a caller 

172 # whose tenant owns the conflicting facts. Treat a cross-tenant conflict as 

173 # not found (don't leak its existence) by requiring fact_a to live in the 

174 # caller's tenant. 

175 if conflict is not None: 

176 owns = conn.execute( 

177 "SELECT 1 FROM facts WHERE id = ? AND tenant_id = ?", 

178 (conflict["fact_a_id"], identity.tenant_id), 

179 ).fetchone() 

180 if owns is None: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true

181 conflict = None 

182 

183 if conflict is None: 

184 raise HTTPException(status_code=404, detail="conflict not found") 

185 if conflict["status"] == "resolved": 

186 raise HTTPException(status_code=409, detail="conflict already resolved") 

187 

188 fact_a = conn.execute( 

189 "SELECT * FROM facts WHERE id = ? AND tenant_id = ?", 

190 (conflict["fact_a_id"], identity.tenant_id), 

191 ).fetchone() 

192 fact_b = conn.execute( 

193 "SELECT * FROM facts WHERE id = ? AND tenant_id = ?", 

194 (conflict["fact_b_id"], identity.tenant_id), 

195 ).fetchone() 

196 

197 if fact_a is None or fact_b is None: 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true

198 raise HTTPException(status_code=500, detail="conflicting facts not found in store") 

199 

200 # Determine value for the resolution fact 

201 if req.new_value is not None: 

202 res_type = req.new_value.type 

203 res_v = _encode_value(req.new_value.type, req.new_value.v) 

204 elif req.winning_fact_id is not None: 

205 if req.winning_fact_id == fact_a["id"]: 

206 winner = fact_a 

207 elif req.winning_fact_id == fact_b["id"]: 207 ↛ 210line 207 didn't jump to line 210 because the condition on line 207 was always true

208 winner = fact_b 

209 else: 

210 raise HTTPException( 

211 status_code=422, 

212 detail="winning_fact_id must be one of the conflicting facts", 

213 ) 

214 res_type = winner["value_type"] 

215 res_v = winner["value_v"] 

216 else: 

217 raise HTTPException(status_code=422, detail="provide winning_fact_id or new_value") 

218 

219 resolution_fact_id = str(uuid.uuid4()) 

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

221 caller = identity.entity_uri 

222 

223 # 1. Assert resolution fact under a namespaced entity so it never shares the 

224 # (entity, relation, scope) triple with the conflicting facts. Writing under 

225 # the original entity+relation would trigger a new contradiction wave when the 

226 # fact is federated to peers (spec §resolution-semantics, EG-51). 

227 resolution_entity = f"stigmem:resolution:{conflict_id}" 

228 hlc_res = node_hlc.tick() 

229 conn.execute( 

230 """INSERT INTO facts 

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

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

233 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", 

234 ( 

235 resolution_fact_id, 

236 resolution_entity, 

237 fact_a["relation"], 

238 res_type, 

239 res_v, 

240 caller, 

241 now, 

242 None, 

243 1.0, 

244 fact_a["scope"], 

245 hlc_res, 

246 None, 

247 identity.tenant_id, 

248 ), 

249 ) 

250 

251 # 2. Assert stigmem:resolves meta-fact (spec §5.10) 

252 hlc_meta = node_hlc.tick() 

253 conn.execute( 

254 """INSERT INTO facts 

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

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

257 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", 

258 ( 

259 str(uuid.uuid4()), 

260 resolution_fact_id, 

261 "stigmem:resolves", 

262 "ref", 

263 conflict_id, 

264 "system:stigmem", 

265 now, 

266 None, 

267 1.0, 

268 fact_a["scope"], 

269 hlc_meta, 

270 None, 

271 identity.tenant_id, 

272 ), 

273 ) 

274 

275 # 3. Record updated conflict:status as a new fact (status changes are immutable appends) 

276 hlc_status = node_hlc.tick() 

277 conn.execute( 

278 """INSERT INTO facts 

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

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

281 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", 

282 ( 

283 str(uuid.uuid4()), 

284 conflict_id, 

285 "stigmem:conflict:status", 

286 "string", 

287 "resolved", 

288 "system:stigmem", 

289 now, 

290 None, 

291 1.0, 

292 fact_a["scope"], 

293 hlc_status, 

294 None, 

295 identity.tenant_id, 

296 ), 

297 ) 

298 

299 # 4. Update conflicts table 

300 conn.execute( 

301 "UPDATE conflicts SET status = 'resolved', resolution_fact_id = ? WHERE id = ?", 

302 (resolution_fact_id, conflict_id), 

303 ) 

304 

305 return {"resolution_fact_id": resolution_fact_id, "conflict_status": "resolved"}