rememberstack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. rememberstack/__init__.py +9 -0
  2. rememberstack/adapters/__init__.py +42 -0
  3. rememberstack/adapters/codex_writer.py +221 -0
  4. rememberstack/adapters/markitdown_converter.py +42 -0
  5. rememberstack/adapters/openrouter.py +136 -0
  6. rememberstack/adapters/selfhost/__init__.py +54 -0
  7. rememberstack/adapters/selfhost/forget.py +66 -0
  8. rememberstack/adapters/selfhost/git.py +374 -0
  9. rememberstack/adapters/selfhost/lance.py +328 -0
  10. rememberstack/adapters/selfhost/minio.py +279 -0
  11. rememberstack/adapters/selfhost/mounts.py +249 -0
  12. rememberstack/adapters/selfhost/object_store.py +130 -0
  13. rememberstack/adapters/selfhost/projection.py +80 -0
  14. rememberstack/adapters/selfhost/queue.py +137 -0
  15. rememberstack/adapters/selfhost/telemetry.py +45 -0
  16. rememberstack/adapters/selfhost/watcher.py +70 -0
  17. rememberstack/adapters/testing/__init__.py +15 -0
  18. rememberstack/adapters/testing/cost_meter.py +13 -0
  19. rememberstack/adapters/testing/model_provider.py +83 -0
  20. rememberstack/adapters/testing/queue.py +43 -0
  21. rememberstack/adapters/testing/telemetry.py +22 -0
  22. rememberstack/client.py +19 -0
  23. rememberstack/core/__init__.py +127 -0
  24. rememberstack/core/blockizer.py +189 -0
  25. rememberstack/core/chunker.py +216 -0
  26. rememberstack/core/consumption_skill.py +275 -0
  27. rememberstack/core/conversion.py +76 -0
  28. rememberstack/core/core_manifest.py +598 -0
  29. rememberstack/core/extension_packs.py +124 -0
  30. rememberstack/core/forget.py +17 -0
  31. rememberstack/core/knowledge_authored.py +276 -0
  32. rememberstack/core/knowledge_compile.py +215 -0
  33. rememberstack/core/knowledge_fact_sheet.py +210 -0
  34. rememberstack/core/knowledge_hashing.py +68 -0
  35. rememberstack/core/knowledge_planner.py +64 -0
  36. rememberstack/core/knowledge_writer.py +175 -0
  37. rememberstack/core/ranking.py +200 -0
  38. rememberstack/core/recipe_linter.py +149 -0
  39. rememberstack/core/section_snap.py +209 -0
  40. rememberstack/core/storage_routing.py +27 -0
  41. rememberstack/eval/__init__.py +53 -0
  42. rememberstack/eval/consumption.py +141 -0
  43. rememberstack/eval/contradiction.py +184 -0
  44. rememberstack/eval/harness.py +136 -0
  45. rememberstack/eval/lifecycle.py +400 -0
  46. rememberstack/eval/operational_scale.py +49 -0
  47. rememberstack/eval/resolution.py +255 -0
  48. rememberstack/eval/retrieval_spikes.py +50 -0
  49. rememberstack/eval/skeleton.py +231 -0
  50. rememberstack/llm/__init__.py +1 -0
  51. rememberstack/model/__init__.py +589 -0
  52. rememberstack/model/adjudication.py +100 -0
  53. rememberstack/model/auth.py +27 -0
  54. rememberstack/model/blocks.py +30 -0
  55. rememberstack/model/chunks.py +190 -0
  56. rememberstack/model/claims.py +162 -0
  57. rememberstack/model/client.py +98 -0
  58. rememberstack/model/clustering.py +54 -0
  59. rememberstack/model/component_version.py +124 -0
  60. rememberstack/model/consumption.py +88 -0
  61. rememberstack/model/conversion.py +31 -0
  62. rememberstack/model/deployment.py +53 -0
  63. rememberstack/model/documents.py +168 -0
  64. rememberstack/model/envelope.py +513 -0
  65. rememberstack/model/evaluation.py +72 -0
  66. rememberstack/model/forget.py +143 -0
  67. rememberstack/model/git.py +13 -0
  68. rememberstack/model/knowledge.py +840 -0
  69. rememberstack/model/knowledge_authored.py +325 -0
  70. rememberstack/model/knowledge_planner.py +431 -0
  71. rememberstack/model/lifecycle.py +42 -0
  72. rememberstack/model/model_provider.py +78 -0
  73. rememberstack/model/mounts.py +24 -0
  74. rememberstack/model/object_store.py +21 -0
  75. rememberstack/model/operational_scale.py +59 -0
  76. rememberstack/model/operations.py +153 -0
  77. rememberstack/model/processing.py +228 -0
  78. rememberstack/model/queue.py +73 -0
  79. rememberstack/model/recipes.py +83 -0
  80. rememberstack/model/relations.py +79 -0
  81. rememberstack/model/resolution.py +83 -0
  82. rememberstack/model/retrieval_spikes.py +62 -0
  83. rememberstack/model/sections.py +120 -0
  84. rememberstack/model/telemetry.py +30 -0
  85. rememberstack/ports/__init__.py +29 -0
  86. rememberstack/ports/auth.py +16 -0
  87. rememberstack/ports/connector.py +23 -0
  88. rememberstack/ports/cost_meter.py +17 -0
  89. rememberstack/ports/forget.py +20 -0
  90. rememberstack/ports/git.py +20 -0
  91. rememberstack/ports/model_provider.py +28 -0
  92. rememberstack/ports/mounts.py +16 -0
  93. rememberstack/ports/object_store.py +27 -0
  94. rememberstack/ports/p1_index.py +92 -0
  95. rememberstack/ports/purge.py +93 -0
  96. rememberstack/ports/queue.py +23 -0
  97. rememberstack/ports/telemetry.py +21 -0
  98. rememberstack/profiles/__init__.py +22 -0
  99. rememberstack/profiles/selfhost.py +324 -0
  100. rememberstack/profiles/selfhost_forget.py +158 -0
  101. rememberstack/profiles/selfhost_operations.py +95 -0
  102. rememberstack/py.typed +1 -0
  103. rememberstack/spine/__init__.py +93 -0
  104. rememberstack/spine/admission.py +26 -0
  105. rememberstack/spine/backfill.py +168 -0
  106. rememberstack/spine/catalog_contract.py +742 -0
  107. rememberstack/spine/chunk_catalog.py +237 -0
  108. rememberstack/spine/claim_catalog.py +298 -0
  109. rememberstack/spine/clustering.py +740 -0
  110. rememberstack/spine/component_versions.py +208 -0
  111. rememberstack/spine/consumption.py +81 -0
  112. rememberstack/spine/deployment_bootstrap.py +445 -0
  113. rememberstack/spine/document_catalog.py +621 -0
  114. rememberstack/spine/entity_registry.py +205 -0
  115. rememberstack/spine/extension_packs.py +220 -0
  116. rememberstack/spine/fact_catalog.py +571 -0
  117. rememberstack/spine/forget.py +1753 -0
  118. rememberstack/spine/knowledge.py +5467 -0
  119. rememberstack/spine/lifecycle.py +1071 -0
  120. rememberstack/spine/migrations/__init__.py +1 -0
  121. rememberstack/spine/migrations/_helpers.py +153 -0
  122. rememberstack/spine/migrations/env.py +58 -0
  123. rememberstack/spine/migrations/script.py.mako +27 -0
  124. rememberstack/spine/migrations/versions/__init__.py +1 -0
  125. rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
  126. rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
  127. rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
  128. rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
  129. rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
  130. rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
  131. rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
  132. rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
  133. rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
  134. rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
  135. rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
  136. rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
  137. rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
  138. rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
  139. rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
  140. rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
  141. rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
  142. rememberstack/spine/observation_adjudication.py +778 -0
  143. rememberstack/spine/operations.py +298 -0
  144. rememberstack/spine/projection.py +662 -0
  145. rememberstack/spine/recipes.py +276 -0
  146. rememberstack/spine/resolver.py +763 -0
  147. rememberstack/spine/review.py +650 -0
  148. rememberstack/spine/settings.py +22 -0
  149. rememberstack/spine/supersession.py +510 -0
  150. rememberstack/spine/sync.py +128 -0
  151. rememberstack/spine/work_ledger.py +816 -0
  152. rememberstack/surfaces/__init__.py +110 -0
  153. rememberstack/surfaces/cli.py +447 -0
  154. rememberstack/surfaces/consumption_skill.py +87 -0
  155. rememberstack/surfaces/graph_queries.py +698 -0
  156. rememberstack/surfaces/http_api.py +377 -0
  157. rememberstack/surfaces/mcp.py +67 -0
  158. rememberstack/surfaces/query_engine.py +1591 -0
  159. rememberstack/surfaces/recipe_executor.py +185 -0
  160. rememberstack/surfaces/recipe_surface.py +219 -0
  161. rememberstack/surfaces/remote_mcp.py +133 -0
  162. rememberstack/surfaces/sdk.py +324 -0
  163. rememberstack/workers/__init__.py +155 -0
  164. rememberstack/workers/base.py +312 -0
  165. rememberstack/workers/e0.py +577 -0
  166. rememberstack/workers/e1.py +425 -0
  167. rememberstack/workers/e2.py +525 -0
  168. rememberstack/workers/e3.py +434 -0
  169. rememberstack/workers/forget.py +299 -0
  170. rememberstack/workers/knowledge_authored.py +146 -0
  171. rememberstack/workers/knowledge_driver.py +735 -0
  172. rememberstack/workers/knowledge_fact_sheet.py +123 -0
  173. rememberstack/workers/knowledge_planner.py +325 -0
  174. rememberstack/workers/knowledge_writer.py +393 -0
  175. rememberstack/workers/operations.py +42 -0
  176. rememberstack/workers/p1.py +234 -0
  177. rememberstack/workers/p2.py +513 -0
  178. rememberstack/workers/p2_analytics.py +276 -0
  179. rememberstack/workers/p3.py +673 -0
  180. rememberstack/workers/reconcile.py +485 -0
  181. rememberstack/workers/sync.py +168 -0
  182. rememberstack-0.1.0.dist-info/METADATA +213 -0
  183. rememberstack-0.1.0.dist-info/RECORD +186 -0
  184. rememberstack-0.1.0.dist-info/WHEEL +4 -0
  185. rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
  186. rememberstack-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,1071 @@
1
+ """The lifecycle catalog (D54/D55): currency, recount, closure, deletion.
2
+
3
+ Spine-owned SQL for the reconciliation flow (lifecycle §5) and the deletion
4
+ grains (§8). The `testimony_currency_events` ledger is truth and
5
+ `claims.is_current_testimony` is cache; every write here is idempotent under
6
+ a stable `reconciliation_id` (a retried run re-emits its rows as no-ops) so
7
+ reconciliation can ride the ordinary work ledger.
8
+ """
9
+
10
+ from uuid import UUID
11
+ from uuid import uuid4
12
+
13
+ from sqlalchemy import bindparam
14
+ from sqlalchemy import JSON
15
+ from sqlalchemy import text
16
+ from sqlalchemy.engine import Connection
17
+ from sqlalchemy.engine import Engine
18
+
19
+ from rememberstack.model import CurrencyTransition
20
+ from rememberstack.model import ReconciliationDelta
21
+
22
+ CURRENCY_CACHE_MISMATCH_SQL = """
23
+ SELECT cl.claim_id,
24
+ cl.is_current_testimony AS cached_current,
25
+ coalesce(last.became_current, true) AS ledger_current
26
+ FROM claims cl
27
+ LEFT JOIN LATERAL (
28
+ SELECT e.became_current
29
+ FROM testimony_currency_events e
30
+ WHERE e.claim_id = cl.claim_id
31
+ ORDER BY e.occurred_at DESC, e.event_id DESC
32
+ LIMIT 1
33
+ ) last ON true
34
+ WHERE cl.deployment_id = :deployment_id
35
+ AND cl.is_current_testimony <> coalesce(last.became_current, true)
36
+ """
37
+ """Single source for the D33 currency-cache versus append-only-ledger invariant."""
38
+
39
+
40
+ class LifecycleCatalog:
41
+ """Currency transitions, the D54 recount, per-shape closure, deletion."""
42
+
43
+ def __init__(self, *, engine: Engine) -> None:
44
+ """Bind the catalog to the spine database."""
45
+ self._engine = engine
46
+
47
+ def reconciliation_context(self, *, version_id: UUID) -> dict[str, object]:
48
+ """What reconciling one completed version needs to know."""
49
+ with self._engine.connect() as connection:
50
+ row = (
51
+ connection.execute(_SELECT_CONTEXT, {"version_id": version_id})
52
+ .mappings()
53
+ .one()
54
+ )
55
+ return dict(row)
56
+
57
+ def stale_for_supersession(
58
+ self, *, deployment_id: UUID, doc_id: UUID, current_version_id: UUID
59
+ ) -> tuple[CurrencyTransition, ...]:
60
+ """Living-mode §3 rule: current claims the current version left behind.
61
+
62
+ A claim stays current iff SOME chunk of the current version carries
63
+ it — by origin or by a reuse occurrence link. Everything else of the
64
+ lineage flips `version_superseded`.
65
+ """
66
+ with self._engine.connect() as connection:
67
+ rows = (
68
+ connection.execute(
69
+ _SELECT_STALE_SUPERSEDED,
70
+ {
71
+ "deployment_id": deployment_id,
72
+ "doc_id": doc_id,
73
+ "current_version_id": current_version_id,
74
+ },
75
+ )
76
+ .mappings()
77
+ .all()
78
+ )
79
+ return tuple(
80
+ CurrencyTransition(
81
+ claim_id=row["claim_id"],
82
+ doc_id=doc_id,
83
+ became_current=False,
84
+ reason="version_superseded",
85
+ from_version_id=row["from_version_id"],
86
+ )
87
+ for row in rows
88
+ )
89
+
90
+ def stale_for_reextraction(
91
+ self,
92
+ *,
93
+ version_id: UUID,
94
+ representation_id: UUID,
95
+ chunker_version: str,
96
+ extractor_version: str,
97
+ ) -> tuple[CurrencyTransition, ...]:
98
+ """Re-derivation §3 rule: older-BASIS claims on a re-read version.
99
+
100
+ The reason covers ANY basis-coordinate change (§3): an extractor
101
+ bump, a converter bump (a different representation), or a
102
+ blockizer/chunker bump (a different packing generation) — a claim
103
+ of this version whose (representation, chunker generation,
104
+ extractor generation) differs from the completing basis flips
105
+ `reextracted`, wholesale, by coordinates, no content matching.
106
+ """
107
+ with self._engine.connect() as connection:
108
+ rows = (
109
+ connection.execute(
110
+ _SELECT_STALE_REEXTRACTED,
111
+ {
112
+ "version_id": version_id,
113
+ "representation_id": representation_id,
114
+ "chunker_version": chunker_version,
115
+ "extractor_version": extractor_version,
116
+ },
117
+ )
118
+ .mappings()
119
+ .all()
120
+ )
121
+ return tuple(
122
+ CurrencyTransition(
123
+ claim_id=row["claim_id"],
124
+ doc_id=row["doc_id"],
125
+ became_current=False,
126
+ reason="reextracted",
127
+ from_extractor_version=row["from_extractor_version"],
128
+ )
129
+ for row in rows
130
+ )
131
+
132
+ def stale_for_deletion(
133
+ self, *, deployment_id: UUID, doc_id: UUID
134
+ ) -> tuple[CurrencyTransition, ...]:
135
+ """Deletion §8 rule: every current claim of the lineage ends."""
136
+ with self._engine.connect() as connection:
137
+ rows = (
138
+ connection.execute(
139
+ _SELECT_STALE_DELETED,
140
+ {"deployment_id": deployment_id, "doc_id": doc_id},
141
+ )
142
+ .mappings()
143
+ .all()
144
+ )
145
+ return tuple(
146
+ CurrencyTransition(
147
+ claim_id=row["claim_id"],
148
+ doc_id=doc_id,
149
+ became_current=False,
150
+ reason="version_deleted",
151
+ from_version_id=row["from_version_id"],
152
+ )
153
+ for row in rows
154
+ )
155
+
156
+ def stale_for_version_deletion(
157
+ self, *, deployment_id: UUID, version_id: UUID
158
+ ) -> tuple[CurrencyTransition, ...]:
159
+ """§8 version grain: ONLY the deleted version's exclusive testimony ends.
160
+
161
+ A claim flips `version_deleted` iff it is carried by the deleted
162
+ version and by no other live version of the lineage — snapshot
163
+ lineages keep every other version's testimony current.
164
+ """
165
+ with self._engine.connect() as connection:
166
+ rows = (
167
+ connection.execute(
168
+ _SELECT_STALE_VERSION_DELETED,
169
+ {"deployment_id": deployment_id, "version_id": version_id},
170
+ )
171
+ .mappings()
172
+ .all()
173
+ )
174
+ return tuple(
175
+ CurrencyTransition(
176
+ claim_id=row["claim_id"],
177
+ doc_id=row["doc_id"],
178
+ became_current=False,
179
+ reason="version_deleted",
180
+ from_version_id=version_id,
181
+ )
182
+ for row in rows
183
+ )
184
+
185
+ def regained_by_current_version(
186
+ self, *, deployment_id: UUID, doc_id: UUID, current_version_id: UUID
187
+ ) -> tuple[CurrencyTransition, ...]:
188
+ """Non-current claims the (new) current version carries — regained.
189
+
190
+ Deleting the newest version repoints the lineage at its predecessor
191
+ (§8: "the lineage continues"); the predecessor's testimony IS the
192
+ current basis again, so its claims regain currency — an append-only
193
+ `became_current = true` event, never a rewrite.
194
+ """
195
+ with self._engine.connect() as connection:
196
+ rows = (
197
+ connection.execute(
198
+ _SELECT_REGAINED,
199
+ {
200
+ "deployment_id": deployment_id,
201
+ "doc_id": doc_id,
202
+ "current_version_id": current_version_id,
203
+ },
204
+ )
205
+ .mappings()
206
+ .all()
207
+ )
208
+ return tuple(
209
+ CurrencyTransition(
210
+ claim_id=row["claim_id"],
211
+ doc_id=doc_id,
212
+ became_current=True,
213
+ reason="version_deleted",
214
+ from_version_id=row["from_version_id"],
215
+ )
216
+ for row in rows
217
+ )
218
+
219
+ def recorded_transitions(
220
+ self, *, reconciliation_id: UUID
221
+ ) -> tuple[CurrencyTransition, ...]:
222
+ """This run's already-ledgered transitions (the retry recovery read).
223
+
224
+ A crash between the currency transaction and the downstream steps
225
+ must not orphan the run: a retry unions what the ledger already
226
+ holds under this reconciliation_id with whatever it recomputes, so
227
+ recount/closure/flags/emission always see the full set.
228
+ """
229
+ with self._engine.connect() as connection:
230
+ rows = (
231
+ connection.execute(
232
+ _SELECT_RUN_EVENTS, {"reconciliation_id": reconciliation_id}
233
+ )
234
+ .mappings()
235
+ .all()
236
+ )
237
+ return tuple(
238
+ CurrencyTransition(
239
+ claim_id=row["claim_id"],
240
+ doc_id=row["doc_id"],
241
+ became_current=row["became_current"],
242
+ reason=row["reason"],
243
+ from_extractor_version=row["from_extractor_version"],
244
+ from_version_id=row["from_version_id"],
245
+ )
246
+ for row in rows
247
+ )
248
+
249
+ def apply_transitions(
250
+ self,
251
+ *,
252
+ deployment_id: UUID,
253
+ reconciliation_id: UUID,
254
+ transitions: tuple[CurrencyTransition, ...],
255
+ ) -> int:
256
+ """Append ledger rows and update the cache flags (D54, F11-idempotent).
257
+
258
+ The guarded insert makes a retried run's re-emission a no-op: an
259
+ event with the same (claim, reconciliation, reason, direction)
260
+ already in the ledger is never duplicated. The cache follows the
261
+ ledger in the same transaction. Returns how many events were new.
262
+ """
263
+ if not transitions:
264
+ return 0
265
+ # Collapse exact repeats before the set write. The former row loop
266
+ # made the first repeat win through its NOT EXISTS guard.
267
+ unique: dict[tuple[UUID, str, bool], CurrencyTransition] = {}
268
+ for transition in transitions:
269
+ unique.setdefault(
270
+ (transition.claim_id, transition.reason, transition.became_current),
271
+ transition,
272
+ )
273
+ event_rows = [
274
+ {
275
+ "event_id": str(uuid4()),
276
+ "claim_id": str(transition.claim_id),
277
+ "doc_id": str(transition.doc_id),
278
+ "became_current": transition.became_current,
279
+ "reason": transition.reason,
280
+ "from_extractor_version": transition.from_extractor_version,
281
+ "from_version_id": (
282
+ str(transition.from_version_id)
283
+ if transition.from_version_id is not None
284
+ else None
285
+ ),
286
+ }
287
+ for transition in unique.values()
288
+ ]
289
+ # A claim can legitimately flip more than once in a composed run.
290
+ # Match the former loop: the final transition owns the cache value.
291
+ cache_rows = [
292
+ {"claim_id": str(claim_id), "is_current": is_current}
293
+ for claim_id, is_current in {
294
+ transition.claim_id: transition.became_current
295
+ for transition in transitions
296
+ }.items()
297
+ ]
298
+ with self._engine.begin() as connection:
299
+ applied = connection.execute(
300
+ _INSERT_CURRENCY_EVENTS,
301
+ {
302
+ "deployment_id": deployment_id,
303
+ "reconciliation_id": reconciliation_id,
304
+ "events": event_rows,
305
+ },
306
+ ).scalar_one()
307
+ connection.execute(_UPDATE_CURRENCY_CACHES, {"claims": cache_rows})
308
+ return int(applied)
309
+
310
+ def affected_relation_ids(self, *, claim_ids: tuple[UUID, ...]) -> tuple[UUID, ...]:
311
+ """Relations evidenced by any of these claims (the recount scope)."""
312
+ if not claim_ids:
313
+ return ()
314
+ with self._engine.connect() as connection:
315
+ return tuple(
316
+ connection.execute(
317
+ _SELECT_AFFECTED_RELATIONS, {"claim_ids": list(claim_ids)}
318
+ ).scalars()
319
+ )
320
+
321
+ def affected_observation_ids(
322
+ self, *, claim_ids: tuple[UUID, ...]
323
+ ) -> tuple[UUID, ...]:
324
+ """Observations evidenced by any of these claims (the recount scope)."""
325
+ if not claim_ids:
326
+ return ()
327
+ with self._engine.connect() as connection:
328
+ return tuple(
329
+ connection.execute(
330
+ _SELECT_AFFECTED_OBSERVATIONS, {"claim_ids": list(claim_ids)}
331
+ ).scalars()
332
+ )
333
+
334
+ def recount(
335
+ self, *, relation_ids: tuple[UUID, ...], observation_ids: tuple[UUID, ...]
336
+ ) -> tuple[tuple[UUID, ...], tuple[UUID, ...]]:
337
+ """Recompute the D54 counts for the touched facts (bounded, indexed).
338
+
339
+ Returns the facts whose counts actually CHANGED — the stale-storm
340
+ guard's input: a re-extraction that changes no fact state must
341
+ stale nothing, so only changed facts belong in the emitted delta.
342
+ """
343
+ with self._engine.begin() as connection:
344
+ changed_relation_ids = (
345
+ frozenset(
346
+ connection.execute(
347
+ _RECOUNT_RELATIONS, {"relation_ids": list(relation_ids)}
348
+ ).scalars()
349
+ )
350
+ if relation_ids
351
+ else frozenset()
352
+ )
353
+ changed_observation_ids = (
354
+ frozenset(
355
+ connection.execute(
356
+ _RECOUNT_OBSERVATIONS,
357
+ {"observation_ids": list(observation_ids)},
358
+ ).scalars()
359
+ )
360
+ if observation_ids
361
+ else frozenset()
362
+ )
363
+ # RETURNING order is not a SQL contract; retain the prior row-loop's
364
+ # caller-visible input order while keeping the write set-based.
365
+ return (
366
+ tuple(item for item in relation_ids if item in changed_relation_ids),
367
+ tuple(item for item in observation_ids if item in changed_observation_ids),
368
+ )
369
+
370
+ def open_zero_support_relations(
371
+ self, *, relation_ids: tuple[UUID, ...]
372
+ ) -> tuple[UUID, ...]:
373
+ """Still-open, still-believed relations whose support hit zero."""
374
+ if not relation_ids:
375
+ return ()
376
+ with self._engine.connect() as connection:
377
+ return tuple(
378
+ connection.execute(
379
+ _SELECT_ZERO_RELATIONS, {"relation_ids": list(relation_ids)}
380
+ ).scalars()
381
+ )
382
+
383
+ def open_zero_support_observations(
384
+ self, *, observation_ids: tuple[UUID, ...]
385
+ ) -> tuple[UUID, ...]:
386
+ """Still-believed observations whose support hit zero."""
387
+ if not observation_ids:
388
+ return ()
389
+ with self._engine.connect() as connection:
390
+ return tuple(
391
+ connection.execute(
392
+ _SELECT_ZERO_OBSERVATIONS,
393
+ {"observation_ids": list(observation_ids)},
394
+ ).scalars()
395
+ )
396
+
397
+ def close_relations(
398
+ self,
399
+ *,
400
+ deployment_id: UUID,
401
+ relation_ids: tuple[UUID, ...],
402
+ boundary: object,
403
+ reconciliation_id: UUID,
404
+ ) -> tuple[UUID, ...]:
405
+ """Close solely-supported relations per shape (§4 source-acted rule).
406
+
407
+ A relation is a stated world-time window: `valid_until` caps at the
408
+ boundary (the withdrawing version's source-modified time) and an
409
+ append-only `retracted_source_removal` adjudication records why —
410
+ loud, attributed, reversible; `invalidated_at` stays NULL (the fact
411
+ was believed while supported; retraction is not "learned wrong").
412
+ """
413
+ closed: list[UUID] = []
414
+ with self._engine.begin() as connection:
415
+ for relation_id in relation_ids:
416
+ capped = connection.execute(
417
+ _CAP_RELATION, {"relation_id": relation_id, "boundary": boundary}
418
+ ).scalar_one_or_none()
419
+ if capped is None:
420
+ continue # already closed by an earlier attempt
421
+ closed.append(relation_id)
422
+ _record_adjudication(
423
+ connection=connection,
424
+ sql=_INSERT_RELATION_RETRACTION,
425
+ deployment_id=deployment_id,
426
+ fact_id=relation_id,
427
+ reconciliation_id=reconciliation_id,
428
+ )
429
+ return tuple(closed)
430
+
431
+ def close_observations(
432
+ self,
433
+ *,
434
+ deployment_id: UUID,
435
+ observation_ids: tuple[UUID, ...],
436
+ reconciliation_id: UUID,
437
+ ) -> tuple[UUID, ...]:
438
+ """Close solely-supported observations per shape (§4, D43 no-cap).
439
+
440
+ Observations are untyped (state vs measurement is semantic), so the
441
+ mechanical exit that is safe for BOTH shapes is `invalidated_at` —
442
+ belief ends without asserting a world-time end (capping a
443
+ measurement's window is forbidden by the no-cap rule).
444
+ """
445
+ closed: list[UUID] = []
446
+ with self._engine.begin() as connection:
447
+ for observation_id in observation_ids:
448
+ marked = connection.execute(
449
+ _INVALIDATE_OBSERVATION, {"observation_id": observation_id}
450
+ ).scalar_one_or_none()
451
+ if marked is None:
452
+ continue
453
+ closed.append(observation_id)
454
+ _record_adjudication(
455
+ connection=connection,
456
+ sql=_INSERT_OBSERVATION_RETRACTION,
457
+ deployment_id=deployment_id,
458
+ fact_id=observation_id,
459
+ reconciliation_id=reconciliation_id,
460
+ )
461
+ return tuple(closed)
462
+
463
+ def emit_evidence_changed(
464
+ self, *, deployment_id: UUID, delta: ReconciliationDelta
465
+ ) -> bool:
466
+ """Queue one fact-level `evidence_changed` trigger (D45).
467
+
468
+ Guarded by the reconciliation id inside the payload, so a retried
469
+ run never double-fires the K compile driver. Nothing is emitted for
470
+ an empty delta ("a new claim row for the same testimony is not an
471
+ evidence change").
472
+ """
473
+ if not (
474
+ delta.recounted_relations
475
+ or delta.recounted_observations
476
+ or delta.relations_closed
477
+ or delta.observations_closed
478
+ or delta.flags_raised
479
+ ):
480
+ return False
481
+ with self._engine.begin() as connection:
482
+ inserted = connection.execute(
483
+ _INSERT_EVIDENCE_CHANGED,
484
+ {
485
+ "refresh_id": uuid4(),
486
+ "deployment_id": deployment_id,
487
+ "reconciliation_id": str(delta.reconciliation_id),
488
+ "payload": delta.model_dump(mode="json"),
489
+ },
490
+ ).scalar_one_or_none()
491
+ return inserted is not None
492
+
493
+ def delete_version(self, *, version_id: UUID) -> dict[str, object]:
494
+ """Mark one version deleted; repoint currency if it was current (§8).
495
+
496
+ The lineage continues: if the deleted version held the current
497
+ pointer, the latest remaining live version takes it (NULL when none
498
+ remain). Returns the lineage context for the caller's cascade.
499
+ """
500
+ with self._engine.begin() as connection:
501
+ row = (
502
+ connection.execute(_TOMBSTONE_VERSION, {"version_id": version_id})
503
+ .mappings()
504
+ .one()
505
+ )
506
+ connection.execute(
507
+ _REPOINT_AFTER_VERSION_DELETE,
508
+ {"doc_id": row["doc_id"], "version_id": version_id},
509
+ )
510
+ return dict(row)
511
+
512
+ def delete_lineage(self, *, doc_id: UUID) -> None:
513
+ """Tombstone a lineage by operator decision (§8; audit-visible).
514
+
515
+ Claims are retained as history — normal deletion never scrubs
516
+ content (forgotten ≠ deleted); the caller runs the currency cascade.
517
+ """
518
+ with self._engine.begin() as connection:
519
+ connection.execute(_TOMBSTONE_LINEAGE_BY_ID, {"doc_id": doc_id})
520
+
521
+ def cycles_ready_to_finalize(
522
+ self, *, deployment_id: UUID
523
+ ) -> tuple[tuple[UUID, int], ...]:
524
+ """Completed, unfinalized cycles whose observed versions are done.
525
+
526
+ A cycle is ready when no version it stamped still has pending,
527
+ running, or retrying (failed) chain work — lineages still
528
+ extracting defer the cycle to the next finalization pass (the
529
+ recorded grace). Each entry carries the cycle's `failed_items`
530
+ count: a LOSSY cycle's observation set is incomplete and must not
531
+ drive absence-based closure.
532
+ """
533
+ with self._engine.connect() as connection:
534
+ rows = (
535
+ connection.execute(
536
+ _SELECT_READY_CYCLES, {"deployment_id": deployment_id}
537
+ )
538
+ .mappings()
539
+ .all()
540
+ )
541
+ return tuple((row["cycle_id"], row["failed_items"]) for row in rows)
542
+
543
+ def cycle_lineages(self, *, cycle_id: UUID) -> tuple[UUID, ...]:
544
+ """Lineages the cycle observed via ingested versions."""
545
+ with self._engine.connect() as connection:
546
+ return tuple(
547
+ connection.execute(
548
+ _SELECT_CYCLE_LINEAGES, {"cycle_id": cycle_id}
549
+ ).scalars()
550
+ )
551
+
552
+ def tombstoned_lineages_needing_cascade(
553
+ self, *, deployment_id: UUID
554
+ ) -> tuple[UUID, ...]:
555
+ """Source-tombstoned lineages that still hold current testimony.
556
+
557
+ Deployment-wide, not per cycle: the cascade re-derives from current
558
+ state, so a finalizer crash between claiming a cycle and finishing
559
+ its cascades self-heals on the next pass instead of orphaning the
560
+ tombstone.
561
+ """
562
+ with self._engine.connect() as connection:
563
+ return tuple(
564
+ connection.execute(
565
+ _SELECT_TOMBSTONES_NEEDING_CASCADE, {"deployment_id": deployment_id}
566
+ ).scalars()
567
+ )
568
+
569
+ def lineage_claim_ids(
570
+ self, *, deployment_id: UUID, doc_id: UUID
571
+ ) -> tuple[UUID, ...]:
572
+ """Every claim the lineage ever produced (the closure-scan scope)."""
573
+ with self._engine.connect() as connection:
574
+ return tuple(
575
+ connection.execute(
576
+ _SELECT_LINEAGE_CLAIMS,
577
+ {"deployment_id": deployment_id, "doc_id": doc_id},
578
+ ).scalars()
579
+ )
580
+
581
+ def closure_boundary(self, *, doc_id: UUID) -> object:
582
+ """The §4 cap boundary: the current version's source-modified time."""
583
+ with self._engine.connect() as connection:
584
+ return connection.execute(
585
+ _SELECT_CLOSURE_BOUNDARY, {"doc_id": doc_id}
586
+ ).scalar_one_or_none()
587
+
588
+ def claim_finalization(self, *, cycle_id: UUID) -> bool:
589
+ """Atomically claim one cycle's finalization (single winner).
590
+
591
+ Two finalizer instances can never both run a cycle's retraction
592
+ evaluation: the first UPDATE wins, the loser sees False. A crash
593
+ after claiming leaves any unfinished closure to the next
594
+ observation of the same lineages (a brief, visible, self-healing
595
+ gap — the cascade re-derives from current state, never from
596
+ per-cycle deltas) and tombstone cascades to the deployment-wide
597
+ sweep.
598
+ """
599
+ with self._engine.begin() as connection:
600
+ claimed = connection.execute(
601
+ _CLAIM_FINALIZATION, {"cycle_id": cycle_id}
602
+ ).scalar_one_or_none()
603
+ return claimed is not None
604
+
605
+
606
+ def _record_adjudication(
607
+ *,
608
+ connection: Connection,
609
+ sql: object,
610
+ deployment_id: UUID,
611
+ fact_id: UUID,
612
+ reconciliation_id: UUID,
613
+ ) -> None:
614
+ """Append one retraction adjudication, once per (fact, reconciliation)."""
615
+ connection.execute(
616
+ sql, # type: ignore[arg-type]
617
+ {
618
+ "adjudication_id": uuid4(),
619
+ "deployment_id": deployment_id,
620
+ "fact_id": fact_id,
621
+ "features": {"reconciliation_id": str(reconciliation_id)},
622
+ "reconciliation_id": str(reconciliation_id),
623
+ },
624
+ )
625
+
626
+
627
+ _SELECT_CONTEXT = text(
628
+ """
629
+ SELECT v.deployment_id, v.doc_id, v.version_id, v.version_no,
630
+ v.sync_cycle_id, d.versioning_mode::text AS versioning_mode,
631
+ d.current_version_id,
632
+ (SELECT cv.source_modified_at FROM document_versions cv
633
+ WHERE cv.version_id = d.current_version_id) AS current_source_modified_at
634
+ FROM document_versions v
635
+ JOIN documents d ON d.doc_id = v.doc_id
636
+ WHERE v.version_id = :version_id
637
+ """
638
+ )
639
+
640
+ _SELECT_STALE_SUPERSEDED = text(
641
+ """
642
+ SELECT cl.claim_id, c.version_id AS from_version_id
643
+ FROM claims cl
644
+ JOIN chunks c ON c.chunk_id = cl.chunk_id
645
+ WHERE cl.deployment_id = :deployment_id
646
+ AND cl.doc_id = :doc_id
647
+ AND cl.is_current_testimony
648
+ AND c.version_id <> :current_version_id
649
+ AND NOT EXISTS (SELECT 1 FROM chunk_claims cc
650
+ JOIN chunks cur ON cur.chunk_id = cc.chunk_id
651
+ WHERE cc.claim_id = cl.claim_id
652
+ AND cur.version_id = :current_version_id)
653
+ """
654
+ )
655
+
656
+ _SELECT_STALE_REEXTRACTED = text(
657
+ """
658
+ SELECT cl.claim_id, cl.doc_id, cl.extractor_version AS from_extractor_version
659
+ FROM claims cl
660
+ JOIN chunks c ON c.chunk_id = cl.chunk_id
661
+ WHERE c.version_id = :version_id
662
+ AND cl.is_current_testimony
663
+ AND (c.representation_id <> :representation_id
664
+ OR c.chunker_version <> :chunker_version
665
+ OR cl.extractor_version <> :extractor_version)
666
+ AND NOT (
667
+ -- carried into the completing basis by a same-generation reuse
668
+ -- link: the claim IS part of the current transcription. An OLD
669
+ -- generation's own links never count — reuse keys embed the
670
+ -- extractor version, so re-attachment is same-generation only.
671
+ cl.extractor_version = :extractor_version
672
+ AND EXISTS (
673
+ SELECT 1 FROM chunk_claims cc
674
+ JOIN chunks cur ON cur.chunk_id = cc.chunk_id
675
+ WHERE cc.claim_id = cl.claim_id
676
+ AND cur.representation_id = :representation_id
677
+ AND cur.chunker_version = :chunker_version
678
+ )
679
+ )
680
+ """
681
+ )
682
+
683
+ _SELECT_STALE_DELETED = text(
684
+ """
685
+ SELECT cl.claim_id, c.version_id AS from_version_id
686
+ FROM claims cl
687
+ JOIN chunks c ON c.chunk_id = cl.chunk_id
688
+ WHERE cl.deployment_id = :deployment_id
689
+ AND cl.doc_id = :doc_id
690
+ AND cl.is_current_testimony
691
+ """
692
+ )
693
+
694
+ _SELECT_STALE_VERSION_DELETED = text(
695
+ """
696
+ SELECT cl.claim_id, cl.doc_id
697
+ FROM claims cl
698
+ WHERE cl.deployment_id = :deployment_id
699
+ AND cl.is_current_testimony
700
+ AND EXISTS (SELECT 1 FROM chunks c
701
+ WHERE c.chunk_id = cl.chunk_id
702
+ AND c.version_id = :version_id
703
+ UNION ALL
704
+ SELECT 1 FROM chunk_claims cc
705
+ JOIN chunks oc ON oc.chunk_id = cc.chunk_id
706
+ WHERE cc.claim_id = cl.claim_id
707
+ AND oc.version_id = :version_id)
708
+ AND NOT EXISTS (
709
+ SELECT 1 FROM chunk_claims cc2
710
+ JOIN chunks other ON other.chunk_id = cc2.chunk_id
711
+ JOIN document_versions ov ON ov.version_id = other.version_id
712
+ WHERE cc2.claim_id = cl.claim_id
713
+ AND other.version_id <> :version_id
714
+ AND ov.deleted_at IS NULL
715
+ UNION ALL
716
+ SELECT 1 FROM chunks origin
717
+ JOIN document_versions ov2 ON ov2.version_id = origin.version_id
718
+ WHERE origin.chunk_id = cl.chunk_id
719
+ AND origin.version_id <> :version_id
720
+ AND ov2.deleted_at IS NULL
721
+ )
722
+ """
723
+ )
724
+
725
+ _SELECT_REGAINED = text(
726
+ """
727
+ SELECT cl.claim_id, c.version_id AS from_version_id
728
+ FROM claims cl
729
+ JOIN chunks c ON c.chunk_id = cl.chunk_id
730
+ WHERE cl.deployment_id = :deployment_id
731
+ AND cl.doc_id = :doc_id
732
+ AND NOT cl.is_current_testimony
733
+ AND (c.version_id = :current_version_id
734
+ OR EXISTS (SELECT 1 FROM chunk_claims cc
735
+ JOIN chunks cur ON cur.chunk_id = cc.chunk_id
736
+ WHERE cc.claim_id = cl.claim_id
737
+ AND cur.version_id = :current_version_id))
738
+ """
739
+ )
740
+
741
+ _SELECT_RUN_EVENTS = text(
742
+ """
743
+ SELECT claim_id, doc_id, became_current, reason::text AS reason,
744
+ from_extractor_version, from_version_id
745
+ FROM testimony_currency_events
746
+ WHERE reconciliation_id = :reconciliation_id
747
+ """
748
+ )
749
+
750
+ _INSERT_CURRENCY_EVENTS = text(
751
+ """
752
+ WITH input AS (
753
+ SELECT *
754
+ FROM jsonb_to_recordset(CAST(:events AS jsonb)) AS event (
755
+ event_id uuid,
756
+ claim_id uuid,
757
+ doc_id uuid,
758
+ became_current boolean,
759
+ reason text,
760
+ from_extractor_version text,
761
+ from_version_id uuid
762
+ )
763
+ ), inserted AS (
764
+ INSERT INTO testimony_currency_events (
765
+ event_id, deployment_id, claim_id, doc_id, reconciliation_id,
766
+ became_current, reason, from_extractor_version, from_version_id
767
+ )
768
+ SELECT input.event_id, :deployment_id, input.claim_id, input.doc_id,
769
+ :reconciliation_id, input.became_current,
770
+ CAST(input.reason AS currency_reason),
771
+ input.from_extractor_version, input.from_version_id
772
+ FROM input
773
+ WHERE NOT EXISTS (
774
+ SELECT 1 FROM testimony_currency_events existing
775
+ WHERE existing.claim_id = input.claim_id
776
+ AND existing.reconciliation_id = :reconciliation_id
777
+ AND existing.reason = CAST(input.reason AS currency_reason)
778
+ AND existing.became_current = input.became_current
779
+ )
780
+ RETURNING event_id
781
+ )
782
+ SELECT count(*) FROM inserted
783
+ """
784
+ ).bindparams(bindparam("events", type_=JSON))
785
+
786
+ _UPDATE_CURRENCY_CACHES = text(
787
+ """
788
+ WITH input AS (
789
+ SELECT *
790
+ FROM jsonb_to_recordset(CAST(:claims AS jsonb)) AS claim (
791
+ claim_id uuid,
792
+ is_current boolean
793
+ )
794
+ )
795
+ UPDATE claims
796
+ SET is_current_testimony = input.is_current
797
+ FROM input
798
+ WHERE claims.claim_id = input.claim_id
799
+ AND claims.is_current_testimony <> input.is_current
800
+ """
801
+ ).bindparams(bindparam("claims", type_=JSON))
802
+
803
+ _SELECT_AFFECTED_RELATIONS = text(
804
+ """
805
+ SELECT DISTINCT relation_id FROM relation_evidence
806
+ WHERE claim_id = ANY(:claim_ids)
807
+ """
808
+ )
809
+
810
+ _SELECT_AFFECTED_OBSERVATIONS = text(
811
+ """
812
+ SELECT DISTINCT observation_id FROM observation_evidence
813
+ WHERE claim_id = ANY(:claim_ids)
814
+ """
815
+ )
816
+
817
+ _RECOUNT_RELATIONS = text(
818
+ """
819
+ WITH fresh AS (
820
+ SELECT requested.relation_id,
821
+ count(DISTINCT evidence.doc_id) FILTER (
822
+ WHERE evidence.stance = 'supports'
823
+ AND claims.is_current_testimony
824
+ ) AS supports,
825
+ count(DISTINCT evidence.doc_id) FILTER (
826
+ WHERE evidence.stance = 'contradicts'
827
+ AND claims.is_current_testimony
828
+ ) AS contradicts
829
+ FROM unnest(CAST(:relation_ids AS uuid[])) AS requested(relation_id)
830
+ LEFT JOIN relation_evidence evidence
831
+ ON evidence.relation_id = requested.relation_id
832
+ LEFT JOIN claims ON claims.claim_id = evidence.claim_id
833
+ GROUP BY requested.relation_id
834
+ )
835
+ UPDATE relations r
836
+ SET evidence_count = fresh.supports,
837
+ contradict_count = fresh.contradicts,
838
+ updated_at = now()
839
+ FROM fresh
840
+ WHERE r.relation_id = fresh.relation_id
841
+ AND (r.evidence_count IS DISTINCT FROM fresh.supports
842
+ OR r.contradict_count IS DISTINCT FROM fresh.contradicts)
843
+ RETURNING r.relation_id
844
+ """
845
+ )
846
+
847
+ _RECOUNT_OBSERVATIONS = text(
848
+ """
849
+ WITH fresh AS (
850
+ SELECT requested.observation_id,
851
+ count(DISTINCT evidence.doc_id) FILTER (
852
+ WHERE evidence.stance = 'supports'
853
+ AND claims.is_current_testimony
854
+ ) AS supports,
855
+ count(DISTINCT evidence.doc_id) FILTER (
856
+ WHERE evidence.stance = 'contradicts'
857
+ AND claims.is_current_testimony
858
+ ) AS contradicts
859
+ FROM unnest(CAST(:observation_ids AS uuid[]))
860
+ AS requested(observation_id)
861
+ LEFT JOIN observation_evidence evidence
862
+ ON evidence.observation_id = requested.observation_id
863
+ LEFT JOIN claims ON claims.claim_id = evidence.claim_id
864
+ GROUP BY requested.observation_id
865
+ )
866
+ UPDATE observations o
867
+ SET evidence_count = fresh.supports,
868
+ contradict_count = fresh.contradicts,
869
+ updated_at = now()
870
+ FROM fresh
871
+ WHERE o.observation_id = fresh.observation_id
872
+ AND (o.evidence_count IS DISTINCT FROM fresh.supports
873
+ OR o.contradict_count IS DISTINCT FROM fresh.contradicts)
874
+ RETURNING o.observation_id
875
+ """
876
+ )
877
+
878
+ _SELECT_ZERO_RELATIONS = text(
879
+ """
880
+ SELECT relation_id FROM relations
881
+ WHERE relation_id = ANY(:relation_ids)
882
+ AND evidence_count = 0
883
+ AND invalidated_at IS NULL
884
+ AND valid_until IS NULL
885
+ AND NOT EXISTS (
886
+ -- a fact under an open support_withdrawn review is the
887
+ -- transcription-only branch: a reviewer decides, never mechanics
888
+ SELECT 1 FROM review_queue q
889
+ WHERE q.item_kind = 'support_withdrawn'
890
+ AND q.status IN ('pending', 'deferred')
891
+ AND q.candidate ->> 'fact_id' = relations.relation_id::text
892
+ )
893
+ """
894
+ )
895
+
896
+ _SELECT_ZERO_OBSERVATIONS = text(
897
+ """
898
+ SELECT observation_id FROM observations
899
+ WHERE observation_id = ANY(:observation_ids)
900
+ AND evidence_count = 0
901
+ AND invalidated_at IS NULL
902
+ AND NOT EXISTS (
903
+ SELECT 1 FROM review_queue q
904
+ WHERE q.item_kind = 'support_withdrawn'
905
+ AND q.status IN ('pending', 'deferred')
906
+ AND q.candidate ->> 'fact_id' = observations.observation_id::text
907
+ )
908
+ """
909
+ )
910
+
911
+ _CAP_RELATION = text(
912
+ """
913
+ UPDATE relations
914
+ SET valid_until = coalesce(CAST(:boundary AS timestamptz), now())
915
+ WHERE relation_id = :relation_id
916
+ AND valid_until IS NULL
917
+ AND invalidated_at IS NULL
918
+ RETURNING relation_id
919
+ """
920
+ )
921
+
922
+ _INVALIDATE_OBSERVATION = text(
923
+ """
924
+ UPDATE observations
925
+ SET invalidated_at = now()
926
+ WHERE observation_id = :observation_id AND invalidated_at IS NULL
927
+ RETURNING observation_id
928
+ """
929
+ )
930
+
931
+ _INSERT_RELATION_RETRACTION = text(
932
+ """
933
+ INSERT INTO relation_adjudications (
934
+ adjudication_id, deployment_id, relation_id, outcome, method,
935
+ features, adjudicator_version
936
+ )
937
+ SELECT :adjudication_id, :deployment_id, :fact_id,
938
+ 'retracted_source_removal', 'exact', :features, 'reconcile-2026.07'
939
+ WHERE NOT EXISTS (
940
+ SELECT 1 FROM relation_adjudications a
941
+ WHERE a.relation_id = :fact_id
942
+ AND a.outcome = 'retracted_source_removal'
943
+ AND a.features ->> 'reconciliation_id' = :reconciliation_id
944
+ )
945
+ """
946
+ ).bindparams(bindparam("features", type_=JSON))
947
+
948
+ _INSERT_OBSERVATION_RETRACTION = text(
949
+ """
950
+ INSERT INTO observation_adjudications (
951
+ adjudication_id, deployment_id, observation_id, outcome, method,
952
+ features, adjudicator_version
953
+ )
954
+ SELECT :adjudication_id, :deployment_id, :fact_id,
955
+ 'retracted_source_removal', 'exact', :features, 'reconcile-2026.07'
956
+ WHERE NOT EXISTS (
957
+ SELECT 1 FROM observation_adjudications a
958
+ WHERE a.observation_id = :fact_id
959
+ AND a.outcome = 'retracted_source_removal'
960
+ AND a.features ->> 'reconciliation_id' = :reconciliation_id
961
+ )
962
+ """
963
+ ).bindparams(bindparam("features", type_=JSON))
964
+
965
+ _INSERT_EVIDENCE_CHANGED = text(
966
+ """
967
+ INSERT INTO knowledge_refresh_queue (
968
+ refresh_id, deployment_id, trigger, payload
969
+ )
970
+ SELECT :refresh_id, :deployment_id, 'evidence_changed', :payload
971
+ WHERE NOT EXISTS (
972
+ SELECT 1 FROM knowledge_refresh_queue q
973
+ WHERE q.deployment_id = :deployment_id
974
+ AND q.trigger = 'evidence_changed'
975
+ AND q.payload ->> 'reconciliation_id' = :reconciliation_id
976
+ )
977
+ RETURNING refresh_id
978
+ """
979
+ ).bindparams(bindparam("payload", type_=JSON))
980
+
981
+ _TOMBSTONE_VERSION = text(
982
+ """
983
+ UPDATE document_versions SET deleted_at = coalesce(deleted_at, now())
984
+ WHERE version_id = :version_id
985
+ RETURNING deployment_id, doc_id, version_id
986
+ """
987
+ )
988
+
989
+ _REPOINT_AFTER_VERSION_DELETE = text(
990
+ """
991
+ UPDATE documents d
992
+ SET current_version_id = (
993
+ SELECT v.version_id FROM document_versions v
994
+ WHERE v.doc_id = d.doc_id
995
+ AND v.deleted_at IS NULL
996
+ AND v.status = 'ready'
997
+ ORDER BY v.version_no DESC
998
+ LIMIT 1
999
+ )
1000
+ WHERE d.doc_id = :doc_id AND d.current_version_id = :version_id
1001
+ """
1002
+ )
1003
+
1004
+ _TOMBSTONE_LINEAGE_BY_ID = text(
1005
+ """
1006
+ UPDATE documents SET deleted_at = now()
1007
+ WHERE doc_id = :doc_id AND deleted_at IS NULL
1008
+ """
1009
+ )
1010
+
1011
+ _SELECT_READY_CYCLES = text(
1012
+ """
1013
+ SELECT y.cycle_id, y.failed_items
1014
+ FROM connector_sync_cycles y
1015
+ WHERE y.deployment_id = :deployment_id
1016
+ AND y.completed_at IS NOT NULL
1017
+ AND y.finalized_at IS NULL
1018
+ AND NOT EXISTS (
1019
+ SELECT 1
1020
+ FROM document_versions v
1021
+ JOIN processing_state w
1022
+ ON w.target_id = v.version_id
1023
+ AND w.target_kind = 'document_version'
1024
+ WHERE v.sync_cycle_id = y.cycle_id
1025
+ AND w.status IN ('pending', 'running', 'failed')
1026
+ )
1027
+ ORDER BY y.started_at
1028
+ """
1029
+ )
1030
+
1031
+ _SELECT_CYCLE_LINEAGES = text(
1032
+ """
1033
+ SELECT DISTINCT doc_id FROM document_versions
1034
+ WHERE sync_cycle_id = :cycle_id
1035
+ """
1036
+ )
1037
+
1038
+ _SELECT_TOMBSTONES_NEEDING_CASCADE = text(
1039
+ """
1040
+ SELECT d.doc_id FROM documents d
1041
+ WHERE d.deployment_id = :deployment_id
1042
+ AND d.deleted_at IS NOT NULL
1043
+ AND d.deleted_sync_cycle_id IS NOT NULL
1044
+ AND EXISTS (SELECT 1 FROM claims cl
1045
+ WHERE cl.doc_id = d.doc_id AND cl.is_current_testimony)
1046
+ """
1047
+ )
1048
+
1049
+ _SELECT_LINEAGE_CLAIMS = text(
1050
+ """
1051
+ SELECT claim_id FROM claims
1052
+ WHERE deployment_id = :deployment_id AND doc_id = :doc_id
1053
+ """
1054
+ )
1055
+
1056
+ _SELECT_CLOSURE_BOUNDARY = text(
1057
+ """
1058
+ SELECT v.source_modified_at
1059
+ FROM documents d
1060
+ JOIN document_versions v ON v.version_id = d.current_version_id
1061
+ WHERE d.doc_id = :doc_id
1062
+ """
1063
+ )
1064
+
1065
+ _CLAIM_FINALIZATION = text(
1066
+ """
1067
+ UPDATE connector_sync_cycles SET finalized_at = now()
1068
+ WHERE cycle_id = :cycle_id AND finalized_at IS NULL
1069
+ RETURNING cycle_id
1070
+ """
1071
+ )