Coverage for node / src / stigmem_node / lifecycle / immutability.py: 93%

48 statements  

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

1"""ADR-016 L1 append-only journal and projection helpers.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import uuid 

7from datetime import UTC, datetime 

8from typing import Any 

9 

10 

11def utc_now_iso() -> str: 

12 return datetime.now(UTC).replace(microsecond=0).isoformat() 

13 

14 

15def write_fact_journal( 

16 conn: Any, 

17 *, 

18 fact_id: str, 

19 event_type: str, 

20 tenant_id: str, 

21 actor_uri: str | None, 

22 source: str | None, 

23 scope: str | None, 

24 cid: str | None, 

25 body: dict[str, Any], 

26) -> None: 

27 """Append one fact event to the immutable L1 journal.""" 

28 conn.execute( 

29 "INSERT INTO fact_journal " 

30 "(id, fact_id, event_type, event_ts, tenant_id, actor_uri, source, scope, cid, body_json) " 

31 "VALUES (?,?,?,?,?,?,?,?,?,?)", 

32 ( 

33 str(uuid.uuid4()), 

34 fact_id, 

35 event_type, 

36 utc_now_iso(), 

37 tenant_id, 

38 actor_uri, 

39 source, 

40 scope, 

41 cid, 

42 json.dumps(body, sort_keys=True, separators=(",", ":")), 

43 ), 

44 ) 

45 

46 

47def set_embedding_status( 

48 conn: Any, 

49 *, 

50 fact_id: str, 

51 embedding_missing: bool, 

52 updated_by: str | None = None, 

53 last_error: str | None = None, 

54) -> None: 

55 """Upsert embedding status in the projection table, not on ``facts``.""" 

56 conn.execute( 

57 "INSERT INTO fact_embedding_status " 

58 "(fact_id, embedding_missing, updated_at, last_error, updated_by) " 

59 "VALUES (?,?,?,?,?) " 

60 "ON CONFLICT(fact_id) DO UPDATE SET " 

61 "embedding_missing = excluded.embedding_missing, " 

62 "updated_at = excluded.updated_at, " 

63 "last_error = excluded.last_error, " 

64 "updated_by = excluded.updated_by", 

65 ( 

66 fact_id, 

67 1 if embedding_missing else 0, 

68 utc_now_iso(), 

69 last_error, 

70 updated_by, 

71 ), 

72 ) 

73 

74 

75def set_fact_validity_override( 

76 conn: Any, 

77 *, 

78 fact_id: str, 

79 valid_until: str | None = None, 

80 confidence: float | None = None, 

81 reason: str | None = None, 

82 updated_by: str | None = None, 

83) -> None: 

84 """Upsert derived validity/confidence state outside the base fact row.""" 

85 conn.execute( 

86 "INSERT INTO fact_validity_overrides " 

87 "(fact_id, valid_until, confidence, reason, updated_at, updated_by) " 

88 "VALUES (?,?,?,?,?,?) " 

89 "ON CONFLICT(fact_id) DO UPDATE SET " 

90 "valid_until = excluded.valid_until, " 

91 "confidence = excluded.confidence, " 

92 "reason = excluded.reason, " 

93 "updated_at = excluded.updated_at, " 

94 "updated_by = excluded.updated_by", 

95 (fact_id, valid_until, confidence, reason, utc_now_iso(), updated_by), 

96 ) 

97 

98 

99def set_fact_quarantine_status( 

100 conn: Any, 

101 *, 

102 fact_id: str, 

103 quarantine_garden_id: str | None, 

104 quarantine_status: str | None, 

105 quarantine_reason: str | None = None, 

106 quarantine_acted_by: str | None = None, 

107 quarantine_acted_at: str | None = None, 

108) -> None: 

109 """Upsert quarantine workflow state outside the base fact row.""" 

110 conn.execute( 

111 "INSERT INTO fact_quarantine_status " 

112 "(fact_id, quarantine_garden_id, quarantine_status, quarantine_reason, " 

113 " quarantine_acted_by, quarantine_acted_at, updated_at) " 

114 "VALUES (?,?,?,?,?,?,?) " 

115 "ON CONFLICT(fact_id) DO UPDATE SET " 

116 "quarantine_garden_id = excluded.quarantine_garden_id, " 

117 "quarantine_status = excluded.quarantine_status, " 

118 "quarantine_reason = excluded.quarantine_reason, " 

119 "quarantine_acted_by = excluded.quarantine_acted_by, " 

120 "quarantine_acted_at = excluded.quarantine_acted_at, " 

121 "updated_at = excluded.updated_at", 

122 ( 

123 fact_id, 

124 quarantine_garden_id, 

125 quarantine_status, 

126 quarantine_reason, 

127 quarantine_acted_by, 

128 quarantine_acted_at, 

129 utc_now_iso(), 

130 ), 

131 ) 

132 

133 

134def set_fact_garden_membership( 

135 conn: Any, 

136 *, 

137 fact_id: str, 

138 garden_id: str | None, 

139 updated_by: str | None = None, 

140) -> None: 

141 """Upsert derived garden membership outside the base fact row.""" 

142 conn.execute( 

143 "INSERT INTO fact_garden_membership " 

144 "(fact_id, garden_id, updated_at, updated_by) " 

145 "VALUES (?,?,?,?) " 

146 "ON CONFLICT(fact_id) DO UPDATE SET " 

147 "garden_id = excluded.garden_id, " 

148 "updated_at = excluded.updated_at, " 

149 "updated_by = excluded.updated_by", 

150 (fact_id, garden_id, utc_now_iso(), updated_by), 

151 ) 

152 

153 

154def set_fact_cid_backfill_status( 

155 conn: Any, 

156 *, 

157 fact_id: str, 

158 status: str, 

159 error: str | None = None, 

160) -> None: 

161 """Record CID backfill progress without mutating ``facts.cid``.""" 

162 conn.execute( 

163 "INSERT INTO fact_cid_backfill " 

164 "(fact_id, status, attempted_at, error, updated_at) " 

165 "VALUES (?,?,?,?,?) " 

166 "ON CONFLICT(fact_id) DO UPDATE SET " 

167 "status = excluded.status, " 

168 "attempted_at = excluded.attempted_at, " 

169 "error = excluded.error, " 

170 "updated_at = excluded.updated_at", 

171 (fact_id, status, utc_now_iso(), error, utc_now_iso()), 

172 ) 

173 

174 

175def rebind_facts_to_cid_v2(conn: Any, *, batch_size: int = 500) -> dict[str, int]: 

176 """Re-point every fact's CID alias to its CID v2 (binds ``interpret_as``). 

177 

178 CID v2 added ``interpret_as`` to the canonical body. The immutable 

179 ``facts.cid`` column is left untouched; the rebuildable ``fact_cid_aliases`` 

180 projection — which ``projected_cid`` reads and which the read-path CID 

181 verification prefers — is repointed to the v2 CID so migrated facts verify 

182 under CID v2. The pre-v2 (v1) alias is removed, so a lookup by a fact's old 

183 v1 CID stops resolving (the accepted pre-1.0 clean break). 

184 

185 Idempotent and collision-safe. Returns ``{"rebound": n, "skipped_collision": n}``. 

186 """ 

187 from ..cid import compute_cid 

188 

189 rebound = 0 

190 skipped_collision = 0 

191 last_id: str | None = None 

192 while True: 

193 if last_id is None: 

194 rows = conn.execute( 

195 "SELECT id, entity, relation, value_type, value_v, source, scope, " 

196 "confidence, interpret_as, tenant_id FROM facts ORDER BY id LIMIT ?", 

197 (batch_size,), 

198 ).fetchall() 

199 else: 

200 rows = conn.execute( 

201 "SELECT id, entity, relation, value_type, value_v, source, scope, " 

202 "confidence, interpret_as, tenant_id FROM facts WHERE id > ? ORDER BY id LIMIT ?", 

203 (last_id, batch_size), 

204 ).fetchall() 

205 if not rows: 

206 break 

207 for row in rows: 

208 last_id = row["id"] 

209 v2 = compute_cid( 

210 entity=row["entity"], 

211 relation=row["relation"], 

212 value_type=row["value_type"], 

213 value_v=row["value_v"] or "", 

214 source=row["source"], 

215 scope=row["scope"], 

216 confidence=float(row["confidence"]), 

217 interpret_as=(row["interpret_as"] or "content"), 

218 ) 

219 existing = { 

220 c["cid"] 

221 for c in conn.execute( 

222 "SELECT cid FROM fact_cid_aliases WHERE fact_id = ?", (row["id"],) 

223 ).fetchall() 

224 } 

225 if existing == {v2}: 

226 set_fact_cid_backfill_status(conn, fact_id=row["id"], status="complete") 

227 continue 

228 # Tenant-scoped collision check: alias uniqueness is per (cid, tenant_id) 

229 # after migration 052 (F-SBOLA4). 

230 owner = conn.execute( 

231 "SELECT fact_id FROM fact_cid_aliases WHERE cid = ? AND tenant_id = ?", 

232 (v2, row["tenant_id"]), 

233 ).fetchone() 

234 if owner is not None and owner["fact_id"] != row["id"]: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

235 set_fact_cid_backfill_status( 

236 conn, fact_id=row["id"], status="skipped", error="cid_v2_collision" 

237 ) 

238 skipped_collision += 1 

239 continue 

240 conn.execute("DELETE FROM fact_cid_aliases WHERE fact_id = ?", (row["id"],)) 

241 conn.execute( 

242 "INSERT OR IGNORE INTO fact_cid_aliases (fact_id, cid, tenant_id) VALUES (?, ?, ?)", 

243 (row["id"], v2, row["tenant_id"]), 

244 ) 

245 set_fact_cid_backfill_status(conn, fact_id=row["id"], status="complete") 

246 rebound += 1 

247 conn.commit() 

248 return {"rebound": rebound, "skipped_collision": skipped_collision}