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,650 @@
1
+ """The review queue (D24, registries §8, lifecycle §4): reversible verdicts.
2
+
3
+ Clusters, not pairs; only the expected-impact middle band reaches humans;
4
+ every action appends a reversible, provenance-stamped record — a merge
5
+ verdict lands in `merge_events` (decided_by=human), a support_withdrawn
6
+ verdict lands as a currency event (restore) or an invalidation with its
7
+ adjudication (invalidate). `uncertain` is the only non-terminal outcome and
8
+ deliberately leaves the marker standing.
9
+ """
10
+
11
+ from typing import Final
12
+ from uuid import UUID
13
+ from uuid import uuid4
14
+
15
+ from sqlalchemy import bindparam
16
+ from sqlalchemy import JSON
17
+ from sqlalchemy import text
18
+ from sqlalchemy.engine import Connection
19
+ from sqlalchemy.engine import Engine
20
+
21
+ from rememberstack.model import ReviewDecisionError
22
+ from rememberstack.model import ReviewItem
23
+ from rememberstack.spine.clustering import apply_merge
24
+
25
+ REVIEW_RECONCILIATION_NAMESPACE: Final = UUID("5e51e77e-0000-4000-8000-000000000000")
26
+
27
+
28
+ class ReviewQueue:
29
+ """List, flag, and decide review items over an explicitly composed engine."""
30
+
31
+ def __init__(self, *, engine: Engine) -> None:
32
+ """Bind the queue to the spine database."""
33
+ self._engine = engine
34
+
35
+ def pending(
36
+ self, *, deployment_id: UUID, limit: int = 20
37
+ ) -> tuple[ReviewItem, ...]:
38
+ """Open items ranked by expected impact (D24's routing score)."""
39
+ with self._engine.connect() as connection:
40
+ rows = (
41
+ connection.execute(
42
+ _SELECT_PENDING, {"deployment_id": deployment_id, "limit": limit}
43
+ )
44
+ .mappings()
45
+ .all()
46
+ )
47
+ return tuple(ReviewItem.model_validate(dict(row)) for row in rows)
48
+
49
+ def has_open_support_withdrawn(self, *, fact_id: UUID) -> bool:
50
+ """Whether an undecided support_withdrawn flag already marks the fact.
51
+
52
+ The reconcile stage's retry guard: a replayed run must not stack a
53
+ second flag on a fact the first attempt already queued.
54
+ """
55
+ with self._engine.connect() as connection:
56
+ return (
57
+ connection.execute(
58
+ _SELECT_OPEN_FLAG, {"fact_id": str(fact_id)}
59
+ ).scalar_one_or_none()
60
+ is not None
61
+ )
62
+
63
+ def flag_support_withdrawn(
64
+ self,
65
+ *,
66
+ deployment_id: UUID,
67
+ fact_kind: str,
68
+ fact_id: UUID,
69
+ claim_id: UUID,
70
+ diff: dict[str, object],
71
+ ) -> UUID:
72
+ """Queue the only support_withdrawn trigger (lifecycle §4): a new
73
+ toolchain generation re-read the unchanged file and did not re-derive
74
+ the claim — no mechanical verdict is derivable."""
75
+ review_id = uuid4()
76
+ with self._engine.begin() as connection:
77
+ self._require_bound(
78
+ connection=connection,
79
+ deployment_id=deployment_id,
80
+ fact_kind=fact_kind,
81
+ fact_id=fact_id,
82
+ claim_id=claim_id,
83
+ )
84
+ connection.execute(
85
+ _INSERT_REVIEW,
86
+ {
87
+ "review_id": review_id,
88
+ "deployment_id": deployment_id,
89
+ "item_kind": "support_withdrawn",
90
+ "candidate": {
91
+ "fact_kind": fact_kind,
92
+ "fact_id": str(fact_id),
93
+ "claim_id": str(claim_id),
94
+ "diff": diff,
95
+ },
96
+ "blast_radius": 1,
97
+ "confidence": 0.5,
98
+ "expected_impact": 0.5,
99
+ },
100
+ )
101
+ return review_id
102
+
103
+ def decide_merge(
104
+ self,
105
+ *,
106
+ deployment_id: UUID,
107
+ review_id: UUID,
108
+ verdict: str,
109
+ reviewer: str,
110
+ note: str | None = None,
111
+ ) -> tuple[UUID, ...]:
112
+ """Apply a merge-cluster verdict: merge performs it, not_merge closes.
113
+
114
+ Returns the merge-event ids a `merge` verdict produced (empty for
115
+ `not_merge`). Every merge is decided_by=human and fully reversible.
116
+ """
117
+ if verdict not in ("merge", "not_merge"):
118
+ raise ReviewDecisionError(
119
+ f"verdict {verdict!r} is not valid for a merge_cluster item"
120
+ )
121
+ with self._engine.begin() as connection:
122
+ item = self._claim_item(
123
+ connection=connection,
124
+ deployment_id=deployment_id,
125
+ review_id=review_id,
126
+ expected_kind="merge_cluster",
127
+ verdict=verdict,
128
+ )
129
+ if item is None:
130
+ return () # idempotent retry of the same verdict
131
+ events: tuple[UUID, ...] = ()
132
+ if verdict == "merge":
133
+ candidate = _candidate(item=item)
134
+ survivor = UUID(str(candidate["survivor_id"]))
135
+ events = tuple(
136
+ apply_merge(
137
+ connection=connection,
138
+ deployment_id=deployment_id,
139
+ survivor_id=survivor,
140
+ absorbed_id=UUID(str(absorbed)),
141
+ trigger_lemmas=[str(candidate.get("trigger_lemma", ""))],
142
+ evidence={"review_id": str(review_id), "note": note},
143
+ blast_radius=int(str(item["blast_radius"])),
144
+ decided_by="human",
145
+ )
146
+ for absorbed in list(candidate["absorbed_ids"]) # type: ignore[arg-type]
147
+ )
148
+ events = tuple(event for event in events if event is not None)
149
+ self._close(
150
+ connection=connection,
151
+ review_id=review_id,
152
+ status="accepted" if verdict == "merge" else "rejected",
153
+ verdict=verdict,
154
+ note=note,
155
+ reviewer=reviewer,
156
+ result_decision_id=events[0] if events else None,
157
+ )
158
+ return events
159
+
160
+ def decide_support_withdrawn(
161
+ self,
162
+ *,
163
+ deployment_id: UUID,
164
+ review_id: UUID,
165
+ verdict: str,
166
+ reviewer: str,
167
+ note: str | None = None,
168
+ ) -> None:
169
+ """Apply a support_withdrawn triage verdict (lifecycle §4).
170
+
171
+ restore_support: the old claim was right (the extractor regressed) —
172
+ a currency event reinstates it and the fact's support recounts.
173
+ invalidate_fact: the old claim was an extraction artifact — the fact
174
+ leaves the current layer with a recorded adjudication.
175
+ uncertain: non-terminal; the marker deliberately stands (deferred).
176
+ """
177
+ if verdict not in ("restore_support", "invalidate_fact", "uncertain"):
178
+ raise ReviewDecisionError(
179
+ f"verdict {verdict!r} is not valid for a support_withdrawn item"
180
+ )
181
+ with self._engine.begin() as connection:
182
+ item = self._claim_item(
183
+ connection=connection,
184
+ deployment_id=deployment_id,
185
+ review_id=review_id,
186
+ expected_kind="support_withdrawn",
187
+ verdict=verdict,
188
+ )
189
+ if item is None:
190
+ return # idempotent retry of the same verdict
191
+ candidate = _candidate(item=item)
192
+ fact_kind = str(candidate["fact_kind"])
193
+ fact_id = UUID(str(candidate["fact_id"]))
194
+ claim_id = UUID(str(candidate["claim_id"]))
195
+ if verdict == "restore_support":
196
+ self._restore_support(
197
+ connection=connection,
198
+ deployment_id=deployment_id,
199
+ fact_kind=fact_kind,
200
+ fact_id=fact_id,
201
+ claim_id=claim_id,
202
+ review_id=review_id,
203
+ )
204
+ elif verdict == "invalidate_fact":
205
+ self._invalidate_fact(
206
+ connection=connection,
207
+ deployment_id=deployment_id,
208
+ fact_kind=fact_kind,
209
+ fact_id=fact_id,
210
+ claim_id=claim_id,
211
+ review_id=review_id,
212
+ )
213
+ self._close(
214
+ connection=connection,
215
+ review_id=review_id,
216
+ status="deferred" if verdict == "uncertain" else "accepted",
217
+ verdict=verdict,
218
+ note=note,
219
+ reviewer=reviewer,
220
+ result_decision_id=None,
221
+ )
222
+
223
+ def _restore_support(
224
+ self,
225
+ *,
226
+ connection: Connection,
227
+ deployment_id: UUID,
228
+ fact_kind: str,
229
+ fact_id: UUID,
230
+ claim_id: UUID,
231
+ review_id: UUID,
232
+ ) -> None:
233
+ """The restore path: currency event + claim flag + support recount."""
234
+ self._require_bound(
235
+ connection=connection,
236
+ deployment_id=deployment_id,
237
+ fact_kind=fact_kind,
238
+ fact_id=fact_id,
239
+ claim_id=claim_id,
240
+ )
241
+ doc_id = connection.execute(
242
+ _CLAIM_DOC, {"claim_id": claim_id, "deployment_id": deployment_id}
243
+ ).scalar_one()
244
+ already = connection.execute(
245
+ _CURRENCY_EVENT_EXISTS,
246
+ {"claim_id": claim_id, "reconciliation_id": review_id},
247
+ ).scalar_one()
248
+ if not already: # review-keyed reconciliation: retries re-emit nothing
249
+ connection.execute(
250
+ _INSERT_CURRENCY_EVENT,
251
+ {
252
+ "event_id": uuid4(),
253
+ "deployment_id": deployment_id,
254
+ "claim_id": claim_id,
255
+ "doc_id": doc_id,
256
+ "reconciliation_id": review_id,
257
+ },
258
+ )
259
+ connection.execute(
260
+ _RESTORE_CLAIM_CURRENCY,
261
+ {"claim_id": claim_id, "deployment_id": deployment_id},
262
+ )
263
+ self._recount(connection=connection, fact_kind=fact_kind, fact_id=fact_id)
264
+ # plant the D35 canary (lifecycle §4): no future extractor ships
265
+ # while silently missing this claim again — the lifecycle eval
266
+ # pack re-checks it per version. Idempotent per review.
267
+ connection.execute(
268
+ _PLANT_LIFECYCLE_CANARY,
269
+ {
270
+ "canary_id": uuid4(),
271
+ "deployment_id": deployment_id,
272
+ "description": (
273
+ f"restore_support review {review_id}: the claim must"
274
+ " remain current testimony"
275
+ ),
276
+ "input": {
277
+ "review_id": str(review_id),
278
+ "fact_kind": fact_kind,
279
+ "fact_id": str(fact_id),
280
+ },
281
+ "expected": {
282
+ "fact_kind": fact_kind,
283
+ "fact_id": str(fact_id),
284
+ "restored_claim_id": str(claim_id),
285
+ },
286
+ "review_id": str(review_id),
287
+ },
288
+ )
289
+
290
+ def _require_bound(
291
+ self,
292
+ *,
293
+ connection: Connection,
294
+ deployment_id: UUID,
295
+ fact_kind: str,
296
+ fact_id: UUID,
297
+ claim_id: UUID,
298
+ ) -> None:
299
+ """The claim and the fact must both belong to the deployment (D50):
300
+ a candidate carrying foreign ids writes nothing (Codex review)."""
301
+ claim_ok = connection.execute(
302
+ _CLAIM_BOUND, {"claim_id": claim_id, "deployment_id": deployment_id}
303
+ ).scalar_one()
304
+ fact_statement = (
305
+ _RELATION_BOUND if fact_kind == "relation" else _OBSERVATION_BOUND
306
+ )
307
+ fact_ok = connection.execute(
308
+ fact_statement, {"fact_id": fact_id, "deployment_id": deployment_id}
309
+ ).scalar_one()
310
+ if not (claim_ok and fact_ok):
311
+ raise ReviewDecisionError(
312
+ "the claim or fact does not belong to this deployment"
313
+ )
314
+
315
+ def _invalidate_fact(
316
+ self,
317
+ *,
318
+ connection: Connection,
319
+ deployment_id: UUID,
320
+ fact_kind: str,
321
+ fact_id: UUID,
322
+ claim_id: UUID,
323
+ review_id: UUID,
324
+ ) -> None:
325
+ """The invalidate path: learned-wrong, recorded, out of the current layer."""
326
+ if fact_kind == "relation":
327
+ connection.execute(
328
+ _INVALIDATE_RELATION,
329
+ {"deployment_id": deployment_id, "relation_id": fact_id},
330
+ )
331
+ connection.execute(
332
+ _INSERT_RELATION_ADJUDICATION,
333
+ {
334
+ "adjudication_id": uuid4(),
335
+ "deployment_id": deployment_id,
336
+ "relation_id": fact_id,
337
+ "features": {
338
+ "action": "invalidate_fact",
339
+ "review_id": str(review_id),
340
+ "claim_id": str(claim_id),
341
+ },
342
+ },
343
+ )
344
+ else:
345
+ connection.execute(
346
+ _INVALIDATE_OBSERVATION,
347
+ {"deployment_id": deployment_id, "observation_id": fact_id},
348
+ )
349
+ connection.execute(
350
+ _INSERT_OBSERVATION_ADJUDICATION,
351
+ {
352
+ "adjudication_id": uuid4(),
353
+ "deployment_id": deployment_id,
354
+ "observation_id": fact_id,
355
+ "triggering_claim_id": claim_id,
356
+ "features": {
357
+ "action": "invalidate_fact",
358
+ "review_id": str(review_id),
359
+ },
360
+ },
361
+ )
362
+
363
+ def _recount(
364
+ self, *, connection: Connection, fact_kind: str, fact_id: UUID
365
+ ) -> None:
366
+ """The D54 lineage-distinct recount after a currency change."""
367
+ statement = (
368
+ _RECOUNT_RELATION if fact_kind == "relation" else _RECOUNT_OBSERVATION
369
+ )
370
+ connection.execute(statement, {"fact_id": fact_id})
371
+
372
+ def _claim_item(
373
+ self,
374
+ *,
375
+ connection: Connection,
376
+ deployment_id: UUID,
377
+ review_id: UUID,
378
+ expected_kind: str,
379
+ verdict: str,
380
+ ) -> dict[str, object] | None:
381
+ """Lock one open item of the expected kind, or raise a typed error.
382
+
383
+ A retried IDENTICAL verdict on a closed item is an idempotent no-op
384
+ (returns None — a lost CLI response can safely re-send); a DIFFERENT
385
+ verdict on a closed item is refused.
386
+ """
387
+ row = (
388
+ connection.execute(
389
+ _SELECT_ITEM_LOCKED,
390
+ {"deployment_id": deployment_id, "review_id": review_id},
391
+ )
392
+ .mappings()
393
+ .one_or_none()
394
+ )
395
+ if row is None:
396
+ raise ReviewDecisionError(f"review item {review_id} does not exist")
397
+ if row["item_kind"] != expected_kind:
398
+ raise ReviewDecisionError(
399
+ f"review item {review_id} is a {row['item_kind']}, "
400
+ f"not a {expected_kind}"
401
+ )
402
+ if row["status"] not in ("pending", "deferred"):
403
+ if row["verdict"] == verdict:
404
+ return None # idempotent retry
405
+ raise ReviewDecisionError(
406
+ f"review item {review_id} is already {row['status']} "
407
+ f"with verdict {row['verdict']!r}"
408
+ )
409
+ return dict(row)
410
+
411
+ def _close(
412
+ self,
413
+ *,
414
+ connection: Connection,
415
+ review_id: UUID,
416
+ status: str,
417
+ verdict: str,
418
+ note: str | None,
419
+ reviewer: str,
420
+ result_decision_id: UUID | None,
421
+ ) -> None:
422
+ """Close (or defer) the item, APPENDING to the verdict history.
423
+
424
+ The history lives in the candidate payload, so a later terminal
425
+ verdict never erases an earlier deferral's rationale (append-only
426
+ provenance, D24).
427
+ """
428
+ connection.execute(
429
+ _CLOSE_REVIEW,
430
+ {
431
+ "review_id": review_id,
432
+ "status": status,
433
+ "verdict": verdict,
434
+ "verdict_note": note,
435
+ "assigned_to": reviewer,
436
+ "result_decision_id": result_decision_id,
437
+ "history_entry": {
438
+ "verdict": verdict,
439
+ "reviewer": reviewer,
440
+ "note": note,
441
+ },
442
+ },
443
+ )
444
+
445
+
446
+ _SELECT_PENDING = text(
447
+ """
448
+ SELECT review_id, item_kind::text AS item_kind, candidate, blast_radius,
449
+ confidence, expected_impact, status::text AS status, created_at
450
+ FROM review_queue
451
+ WHERE deployment_id = :deployment_id AND status IN ('pending', 'deferred')
452
+ ORDER BY expected_impact DESC, created_at
453
+ LIMIT :limit
454
+ """
455
+ )
456
+
457
+ _SELECT_ITEM_LOCKED = text(
458
+ """
459
+ SELECT review_id, item_kind::text AS item_kind, candidate, blast_radius,
460
+ status::text AS status, verdict::text AS verdict
461
+ FROM review_queue
462
+ WHERE deployment_id = :deployment_id AND review_id = :review_id
463
+ FOR UPDATE
464
+ """
465
+ )
466
+
467
+ _SELECT_OPEN_FLAG = text(
468
+ """
469
+ SELECT review_id FROM review_queue
470
+ WHERE item_kind = 'support_withdrawn'
471
+ AND status IN ('pending', 'deferred')
472
+ AND candidate ->> 'fact_id' = :fact_id
473
+ LIMIT 1
474
+ """
475
+ )
476
+
477
+ _PLANT_LIFECYCLE_CANARY = text(
478
+ """
479
+ INSERT INTO canary_cases (
480
+ canary_id, deployment_id, suite, description, input, expected
481
+ )
482
+ SELECT :canary_id, :deployment_id, 'lifecycle', :description,
483
+ :input, :expected
484
+ WHERE NOT EXISTS (
485
+ SELECT 1 FROM canary_cases c
486
+ WHERE c.suite = 'lifecycle'
487
+ AND c.input ->> 'review_id' = :review_id
488
+ )
489
+ """
490
+ ).bindparams(bindparam("input", type_=JSON), bindparam("expected", type_=JSON))
491
+
492
+ _INSERT_REVIEW = text(
493
+ """
494
+ INSERT INTO review_queue (
495
+ review_id, deployment_id, item_kind, candidate, blast_radius,
496
+ confidence, expected_impact
497
+ ) VALUES (
498
+ :review_id, :deployment_id, :item_kind, :candidate, :blast_radius,
499
+ :confidence, :expected_impact
500
+ )
501
+ """
502
+ ).bindparams(bindparam("candidate", type_=JSON))
503
+
504
+ _CLOSE_REVIEW = text(
505
+ """
506
+ UPDATE review_queue
507
+ SET status = :status, verdict = :verdict, verdict_note = :verdict_note,
508
+ assigned_to = :assigned_to, result_decision_id = :result_decision_id,
509
+ resolved_at = now(),
510
+ candidate = jsonb_set(
511
+ candidate,
512
+ '{verdict_history}',
513
+ coalesce(candidate->'verdict_history', '[]'::jsonb)
514
+ || CAST(:history_entry AS jsonb),
515
+ true
516
+ )
517
+ WHERE review_id = :review_id
518
+ """
519
+ ).bindparams(bindparam("history_entry", type_=JSON))
520
+
521
+ _CLAIM_DOC = text(
522
+ """
523
+ SELECT doc_id FROM claims
524
+ WHERE claim_id = :claim_id AND deployment_id = :deployment_id
525
+ """
526
+ )
527
+
528
+ _CLAIM_BOUND = text(
529
+ """
530
+ SELECT count(*) > 0 FROM claims
531
+ WHERE claim_id = :claim_id AND deployment_id = :deployment_id
532
+ """
533
+ )
534
+
535
+ _RELATION_BOUND = text(
536
+ """
537
+ SELECT count(*) > 0 FROM relations
538
+ WHERE relation_id = :fact_id AND deployment_id = :deployment_id
539
+ """
540
+ )
541
+
542
+ _OBSERVATION_BOUND = text(
543
+ """
544
+ SELECT count(*) > 0 FROM observations
545
+ WHERE observation_id = :fact_id AND deployment_id = :deployment_id
546
+ """
547
+ )
548
+
549
+ _CURRENCY_EVENT_EXISTS = text(
550
+ """
551
+ SELECT count(*) > 0 FROM testimony_currency_events
552
+ WHERE claim_id = :claim_id AND reconciliation_id = :reconciliation_id
553
+ """
554
+ )
555
+
556
+ _INSERT_CURRENCY_EVENT = text(
557
+ """
558
+ INSERT INTO testimony_currency_events (
559
+ event_id, deployment_id, claim_id, doc_id, reconciliation_id,
560
+ became_current, reason
561
+ ) VALUES (
562
+ :event_id, :deployment_id, :claim_id, :doc_id, :reconciliation_id,
563
+ true, 'review_restored'
564
+ )
565
+ """
566
+ )
567
+
568
+ _RESTORE_CLAIM_CURRENCY = text(
569
+ """
570
+ UPDATE claims SET is_current_testimony = true
571
+ WHERE claim_id = :claim_id AND deployment_id = :deployment_id
572
+ """
573
+ )
574
+
575
+ _INVALIDATE_RELATION = text(
576
+ """
577
+ UPDATE relations SET invalidated_at = now(), updated_at = now()
578
+ WHERE deployment_id = :deployment_id AND relation_id = :relation_id
579
+ AND invalidated_at IS NULL
580
+ """
581
+ )
582
+
583
+ _INVALIDATE_OBSERVATION = text(
584
+ """
585
+ UPDATE observations SET invalidated_at = now(), updated_at = now()
586
+ WHERE deployment_id = :deployment_id AND observation_id = :observation_id
587
+ AND invalidated_at IS NULL
588
+ """
589
+ )
590
+
591
+ _INSERT_RELATION_ADJUDICATION = text(
592
+ """
593
+ INSERT INTO relation_adjudications (
594
+ adjudication_id, deployment_id, relation_id, outcome, method,
595
+ confidence, features, adjudicator_version, decided_by
596
+ ) VALUES (
597
+ :adjudication_id, :deployment_id, :relation_id, 'invalidated', 'exact',
598
+ 1.0, :features, 'review-2026.07', 'human'
599
+ )
600
+ """
601
+ ).bindparams(bindparam("features", type_=JSON))
602
+
603
+ _INSERT_OBSERVATION_ADJUDICATION = text(
604
+ """
605
+ INSERT INTO observation_adjudications (
606
+ adjudication_id, deployment_id, observation_id, outcome, method,
607
+ confidence, triggering_claim_id, features, adjudicator_version,
608
+ decided_by
609
+ ) VALUES (
610
+ :adjudication_id, :deployment_id, :observation_id, 'invalidated', 'exact',
611
+ 1.0, :triggering_claim_id, :features, 'review-2026.07', 'human'
612
+ )
613
+ """
614
+ ).bindparams(bindparam("features", type_=JSON))
615
+
616
+ _RECOUNT_RELATION = text(
617
+ """
618
+ UPDATE relations SET evidence_count = (
619
+ SELECT count(DISTINCT evidence.doc_id)
620
+ FROM relation_evidence evidence
621
+ JOIN claims ON claims.claim_id = evidence.claim_id
622
+ WHERE evidence.relation_id = :fact_id
623
+ AND evidence.stance = 'supports'
624
+ AND claims.is_current_testimony
625
+ ), updated_at = now()
626
+ WHERE relation_id = :fact_id
627
+ """
628
+ )
629
+
630
+ _RECOUNT_OBSERVATION = text(
631
+ """
632
+ UPDATE observations SET evidence_count = (
633
+ SELECT count(DISTINCT evidence.doc_id)
634
+ FROM observation_evidence evidence
635
+ JOIN claims ON claims.claim_id = evidence.claim_id
636
+ WHERE evidence.observation_id = :fact_id
637
+ AND evidence.stance = 'supports'
638
+ AND claims.is_current_testimony
639
+ ), updated_at = now()
640
+ WHERE observation_id = :fact_id
641
+ """
642
+ )
643
+
644
+
645
+ def _candidate(*, item: dict[str, object]) -> dict[str, object]:
646
+ """The item's candidate payload as a typed mapping."""
647
+ candidate = item["candidate"]
648
+ if not isinstance(candidate, dict):
649
+ raise ReviewDecisionError("review item carries a malformed candidate")
650
+ return candidate
@@ -0,0 +1,22 @@
1
+ """Typed settings for the PostgreSQL spine."""
2
+
3
+ from pydantic import SecretStr
4
+ from pydantic_settings import BaseSettings
5
+ from pydantic_settings import SettingsConfigDict
6
+
7
+
8
+ class DatabaseSettings(BaseSettings):
9
+ """Validated database connection settings read at the spine boundary."""
10
+
11
+ model_config = SettingsConfigDict(env_prefix="REMEMBERSTACK_", extra="ignore")
12
+
13
+ database_url: SecretStr
14
+
15
+ def sqlalchemy_url(self) -> str:
16
+ """Return the database URL only at the SQLAlchemy connection call site."""
17
+ return self.database_url.get_secret_value()
18
+
19
+
20
+ def load_database_settings() -> DatabaseSettings:
21
+ """Load database settings from their typed environment-backed source."""
22
+ return DatabaseSettings.model_validate({})