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,816 @@
1
+ """Transactional operations on the D67 work ledger (processing_state + cost_ledger).
2
+
3
+ The ledger is the sole work-truth (D12/D67): enqueue is idempotent on the
4
+ (deployment, target, stage, component version) key; claiming uses SKIP LOCKED
5
+ over the (deployment, stage, lane) route; attempts count handler executions that
6
+ actually began; failures keep their full traceback; the DLQ is
7
+ status='dead_letter' rows; billed calls copy their attribution from the locked
8
+ running row and callers can never supply it.
9
+ """
10
+
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from datetime import timedelta
14
+ from decimal import Decimal
15
+ from typing import cast
16
+ from typing import Self
17
+ from uuid import UUID
18
+ from uuid import uuid4
19
+
20
+ from pydantic import model_validator
21
+ from pydantic_settings import BaseSettings
22
+ from pydantic_settings import SettingsConfigDict
23
+ from sqlalchemy import bindparam
24
+ from sqlalchemy import Connection
25
+ from sqlalchemy import DateTime
26
+ from sqlalchemy import JSON
27
+ from sqlalchemy import text
28
+ from sqlalchemy.engine import Engine
29
+ from sqlalchemy.engine import RowMapping
30
+
31
+ from rememberstack.model import BudgetParked
32
+ from rememberstack.model import ClaimedWork
33
+ from rememberstack.model import CostBudget
34
+ from rememberstack.model import CostBudgetStatus
35
+ from rememberstack.model import CostTierSpend
36
+ from rememberstack.model import DeadLetterReplayResult
37
+ from rememberstack.model import EnqueueOutcome
38
+ from rememberstack.model import EnqueueWork
39
+ from rememberstack.model import ForgetInProgressError
40
+ from rememberstack.model import LaneRouteError
41
+ from rememberstack.model import PipelineStage
42
+ from rememberstack.model import ProcessingLane
43
+ from rememberstack.model import QueueRoute
44
+ from rememberstack.model import RecordCall
45
+ from rememberstack.model import WorkNotDeadLetterError
46
+ from rememberstack.model import WorkNotFoundError
47
+ from rememberstack.model import WorkNotRunningError
48
+ from rememberstack.spine.admission import active_forget_id_on
49
+ from rememberstack.spine.catalog_contract import lane_is_valid
50
+
51
+
52
+ class WorkLedgerSettings(BaseSettings):
53
+ """Retry backoff plus explicit route budgets for one worker deployment."""
54
+
55
+ model_config = SettingsConfigDict(env_prefix="REMEMBERSTACK_WORK_", extra="ignore")
56
+
57
+ retry_backoff_base_s: float = 2.0
58
+ retry_backoff_max_s: float = 60.0
59
+ budgets: tuple[CostBudget, ...] = ()
60
+
61
+ @model_validator(mode="after")
62
+ def require_unique_valid_budget_routes(self) -> Self:
63
+ """Reject ambiguous ceilings and stage/lane pairs that cannot be queued."""
64
+ routes: set[tuple[UUID, PipelineStage, ProcessingLane | None]] = set()
65
+ for budget in self.budgets:
66
+ if not lane_is_valid(
67
+ stage=budget.stage,
68
+ lane=None if budget.lane is None else budget.lane.value,
69
+ ):
70
+ raise ValueError(
71
+ f"stage {budget.stage} does not accept budget lane {budget.lane!r}"
72
+ )
73
+ route = (budget.deployment_id, budget.stage, budget.lane)
74
+ if route in routes:
75
+ raise ValueError(
76
+ "only one cost budget may be configured per deployment, stage, and lane"
77
+ )
78
+ routes.add(route)
79
+ return self
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class _BudgetWindowSpend:
84
+ """The database-clock window and deduplicated spend used by one pre-flight."""
85
+
86
+ started_at: datetime
87
+ ends_at: datetime
88
+ spent_usd: Decimal
89
+
90
+
91
+ class WorkLedger:
92
+ """The spine's typed gateway to processing_state and cost_ledger (D12/D67)."""
93
+
94
+ def __init__(self, *, engine: Engine, settings: WorkLedgerSettings) -> None:
95
+ """Bind the ledger to an explicit engine and explicit settings."""
96
+ self._engine = engine
97
+ self._settings = settings
98
+
99
+ def enqueue(self, *, work: EnqueueWork) -> EnqueueOutcome:
100
+ """Insert one unit of work idempotently and return what happened.
101
+
102
+ A duplicate of an existing (deployment, target, stage, version) key never
103
+ creates a second unit of work. A steady enqueue promotes a pending/failed
104
+ backfill duplicate to the steady lane (live work keeps its freshness
105
+ guarantee); a backfill enqueue can never demote steady work (D67).
106
+ """
107
+ _require_valid_lane(stage=work.stage, lane=work.lane)
108
+ with self._engine.begin() as connection:
109
+ return enqueue_on(connection=connection, work=work)
110
+
111
+ def claim_one(
112
+ self, *, deployment_id: UUID, stage: PipelineStage, lane: ProcessingLane | None
113
+ ) -> ClaimedWork | BudgetParked | None:
114
+ """Claim, budget-park, or find no due row on one route.
115
+
116
+ The due row is locked before the current route-window spend is checked.
117
+ Exhaustion durably parks it until the aligned window rolls, without
118
+ consuming an attempt or touching its last error. Otherwise claiming
119
+ clears any defer reason, moves it to running, and increments attempts
120
+ exactly once immediately before the handler begins.
121
+ """
122
+ _require_valid_lane(stage=stage, lane=lane)
123
+ with self._engine.begin() as connection:
124
+ if stage is not PipelineStage.HARD_FORGET:
125
+ active_forget = active_forget_id_on(
126
+ connection=connection, deployment_id=deployment_id
127
+ )
128
+ if active_forget is not None:
129
+ raise ForgetInProgressError(
130
+ f"deployment {deployment_id} is honoring forget_id"
131
+ f" {active_forget}"
132
+ )
133
+ row = (
134
+ connection.execute(
135
+ _CLAIM_SELECT,
136
+ {"deployment_id": deployment_id, "stage": stage, "lane": lane},
137
+ )
138
+ .mappings()
139
+ .first()
140
+ )
141
+ if row is None:
142
+ return None
143
+ budget = self._budget_for(
144
+ deployment_id=deployment_id, stage=stage, lane=lane
145
+ )
146
+ if budget is not None:
147
+ spend = _budget_window_spend(connection=connection, budget=budget)
148
+ if spend.spent_usd >= budget.ceiling_usd:
149
+ connection.execute(
150
+ _PARK_BUDGET,
151
+ {
152
+ "processing_id": row["processing_id"],
153
+ "resume_at": spend.ends_at,
154
+ },
155
+ )
156
+ return BudgetParked(
157
+ processing_id=row["processing_id"],
158
+ resume_at=spend.ends_at,
159
+ spent_usd=spend.spent_usd,
160
+ ceiling_usd=budget.ceiling_usd,
161
+ )
162
+ started = (
163
+ connection.execute(
164
+ _CLAIM_START, {"processing_id": row["processing_id"]}
165
+ )
166
+ .mappings()
167
+ .one()
168
+ )
169
+ return _claimed_work(row=started)
170
+
171
+ def budget_status(self, *, deployment_id: UUID) -> tuple[CostBudgetStatus, ...]:
172
+ """Return current spend and parked work for every configured deployment budget."""
173
+ statuses: list[CostBudgetStatus] = []
174
+ with self._engine.connect() as connection:
175
+ for budget in self._settings.budgets:
176
+ if budget.deployment_id != deployment_id:
177
+ continue
178
+ spend = _budget_window_spend(connection=connection, budget=budget)
179
+ tier_rows = connection.execute(
180
+ _BUDGET_TIER_SPEND,
181
+ {
182
+ "deployment_id": budget.deployment_id,
183
+ "stage": budget.stage,
184
+ "lane": budget.lane,
185
+ "window_started_at": spend.started_at,
186
+ "window_ends_at": spend.ends_at,
187
+ },
188
+ ).mappings()
189
+ tiers = tuple(
190
+ CostTierSpend(
191
+ tier=cast(str | None, row["tier"]),
192
+ cost_usd=_decimal(row["cost_usd"]),
193
+ )
194
+ for row in tier_rows
195
+ )
196
+ parked_work = int(
197
+ connection.execute(
198
+ _BUDGET_PARKED_COUNT,
199
+ {
200
+ "deployment_id": budget.deployment_id,
201
+ "stage": budget.stage,
202
+ "lane": budget.lane,
203
+ },
204
+ ).scalar_one()
205
+ )
206
+ remaining = max(Decimal(0), budget.ceiling_usd - spend.spent_usd)
207
+ statuses.append(
208
+ CostBudgetStatus(
209
+ deployment_id=budget.deployment_id,
210
+ stage=budget.stage,
211
+ lane=budget.lane,
212
+ window_seconds=budget.window_seconds,
213
+ window_started_at=spend.started_at,
214
+ window_ends_at=spend.ends_at,
215
+ ceiling_usd=budget.ceiling_usd,
216
+ spent_usd=spend.spent_usd,
217
+ remaining_usd=remaining,
218
+ exhausted=spend.spent_usd >= budget.ceiling_usd,
219
+ parked_work=parked_work,
220
+ tiers=tiers,
221
+ )
222
+ )
223
+ return tuple(statuses)
224
+
225
+ def complete(
226
+ self, *, processing_id: UUID, follow_up: tuple[EnqueueWork, ...] = ()
227
+ ) -> tuple[EnqueueOutcome, ...]:
228
+ """Mark a running attempt succeeded and enqueue its chain follow-ups atomically.
229
+
230
+ The chain rule (a completing stage enqueues the next stage for its target)
231
+ commits in the same transaction as the success mark, so a crash can never
232
+ record success without the follow-up work existing.
233
+ """
234
+ for work in follow_up:
235
+ _require_valid_lane(stage=work.stage, lane=work.lane)
236
+ with self._engine.begin() as connection:
237
+ updated = connection.execute(
238
+ _COMPLETE, {"processing_id": processing_id}
239
+ ).rowcount
240
+ if updated == 0:
241
+ raise WorkNotRunningError(
242
+ f"processing row {processing_id} is not running; cannot complete"
243
+ )
244
+ return tuple(
245
+ enqueue_on(connection=connection, work=work) for work in follow_up
246
+ )
247
+
248
+ def fail(
249
+ self, *, processing_id: UUID, error: str, retryable: bool
250
+ ) -> datetime | None:
251
+ """Record a failed attempt with its full traceback; never bury it (core value 6).
252
+
253
+ A retryable failure with attempts remaining schedules a retry backoff
254
+ (status failed, defer_reason retry_backoff, not_before in the future) and
255
+ returns the scheduled time — the caller re-announces it through the
256
+ queue port (packaging §3: retry paths call the port in both profiles). A
257
+ failure at the attempt limit, or a non-retryable one, dead-letters the
258
+ row and returns None.
259
+ """
260
+ with self._engine.begin() as connection:
261
+ row = (
262
+ connection.execute(_SELECT_FOR_FAIL, {"processing_id": processing_id})
263
+ .mappings()
264
+ .first()
265
+ )
266
+ if row is None:
267
+ raise WorkNotFoundError(
268
+ f"processing row {processing_id} does not exist"
269
+ )
270
+ if row["status"] != "running":
271
+ raise WorkNotRunningError(
272
+ f"processing row {processing_id} is not running; cannot fail it"
273
+ )
274
+ attempts, max_attempts = int(row["attempts"]), int(row["max_attempts"])
275
+ if retryable and attempts < max_attempts:
276
+ backoff_s = min(
277
+ self._settings.retry_backoff_base_s * 2 ** (attempts - 1),
278
+ self._settings.retry_backoff_max_s,
279
+ )
280
+ scheduled = connection.execute(
281
+ _FAIL_RETRY,
282
+ {
283
+ "processing_id": processing_id,
284
+ "error": error,
285
+ "backoff_s": backoff_s,
286
+ },
287
+ ).scalar_one()
288
+ return scheduled
289
+ connection.execute(
290
+ _FAIL_DEAD_LETTER, {"processing_id": processing_id, "error": error}
291
+ )
292
+ return None
293
+
294
+ def park_for_budget(self, *, processing_id: UUID, resume_at: datetime) -> None:
295
+ """Park queued work until its budget window rolls (D67).
296
+
297
+ Parking happens at claim-time pre-flight, before an attempt starts: it
298
+ applies only to pending/failed rows (a running attempt is never parked — that
299
+ would allow a second concurrent claim), sets defer_reason budget with a
300
+ future not_before, consumes no attempt, and touches no error state, so
301
+ it can never cause dead-lettering.
302
+ """
303
+ with self._engine.begin() as connection:
304
+ updated = connection.execute(
305
+ _PARK_BUDGET, {"processing_id": processing_id, "resume_at": resume_at}
306
+ ).rowcount
307
+ if updated == 0:
308
+ raise WorkNotRunningError(
309
+ f"processing row {processing_id} is not queued; only queued "
310
+ "work can be budget-parked"
311
+ )
312
+
313
+ def wake(self, *, processing_id: UUID) -> None:
314
+ """Announce an existing committed row on the self-host wake channel.
315
+
316
+ The initial wake after enqueue is the schema-owned insert trigger; this
317
+ primitive re-announces for retry, replay, and janitor paths. It never
318
+ creates or mutates work state (the port contract), and SQL stays in the
319
+ spine — adapters call this, never NOTIFY directly.
320
+ """
321
+ with self._engine.begin() as connection:
322
+ connection.execute(_WAKE, {"processing_id": str(processing_id)})
323
+
324
+ def replay_dead_letter(
325
+ self,
326
+ *,
327
+ deployment_id: UUID,
328
+ processing_id: UUID,
329
+ attempt_allowance: int = 1,
330
+ lane: ProcessingLane | None = None,
331
+ not_before: datetime | None = None,
332
+ ) -> DeadLetterReplayResult:
333
+ """Reopen exactly one deployment-owned dead letter for explicit replay.
334
+
335
+ The transition preserves the attempts already consumed and the complete
336
+ previous error. It grants only the requested additional attempt budget,
337
+ defaults to due now, and may reroute only to a lane accepted by the
338
+ row's immutable stage. Delivery is announced separately after commit.
339
+ """
340
+ if attempt_allowance < 1:
341
+ raise ValueError("dead-letter replay requires at least one new attempt")
342
+ if not_before is not None and (
343
+ not_before.tzinfo is None or not_before.utcoffset() != timedelta(0)
344
+ ):
345
+ raise ValueError("dead-letter replay not_before must be UTC")
346
+ with self._engine.begin() as connection:
347
+ existing = (
348
+ connection.execute(
349
+ _SELECT_DEAD_LETTER_FOR_REPLAY,
350
+ {"deployment_id": deployment_id, "processing_id": processing_id},
351
+ )
352
+ .mappings()
353
+ .one_or_none()
354
+ )
355
+ if existing is None:
356
+ raise WorkNotFoundError(
357
+ f"processing row {processing_id} does not exist in deployment "
358
+ f"{deployment_id}"
359
+ )
360
+ if existing["status"] != "dead_letter":
361
+ raise WorkNotDeadLetterError(
362
+ f"processing row {processing_id} has status {existing['status']}; "
363
+ "only dead-letter rows can be replayed"
364
+ )
365
+ stage = PipelineStage(existing["stage"])
366
+ replay_lane = (
367
+ None
368
+ if existing["lane"] is None and lane is None
369
+ else lane or ProcessingLane(existing["lane"])
370
+ )
371
+ _require_valid_lane(stage=stage, lane=replay_lane)
372
+ attempts = int(existing["attempts"])
373
+ if attempts + attempt_allowance > 32_767:
374
+ raise ValueError("dead-letter replay attempt budget exceeds smallint")
375
+ replayed = (
376
+ connection.execute(
377
+ _REPLAY_DEAD_LETTER,
378
+ {
379
+ "processing_id": processing_id,
380
+ "lane": replay_lane,
381
+ "attempt_allowance": attempt_allowance,
382
+ "not_before": not_before,
383
+ },
384
+ )
385
+ .mappings()
386
+ .one()
387
+ )
388
+ return DeadLetterReplayResult(
389
+ processing_id=processing_id,
390
+ route=QueueRoute(
391
+ deployment_id=deployment_id, stage=stage, lane=replay_lane
392
+ ),
393
+ not_before=replayed["not_before"],
394
+ attempts=int(replayed["attempts"]),
395
+ max_attempts=int(replayed["max_attempts"]),
396
+ )
397
+
398
+ def record_call(self, *, call: RecordCall) -> bool:
399
+ """Attribute one billed call to the running attempt; idempotent per call key.
400
+
401
+ Stage, lane, attempt, and target attribution are copied from the locked
402
+ running row (D67) — a caller or delivery envelope can never choose them.
403
+ Returns False when the (processing, attempt, call_key) row already exists,
404
+ so an acknowledged-late retry cannot double-bill.
405
+ """
406
+ with self._engine.begin() as connection:
407
+ row = (
408
+ connection.execute(
409
+ _SELECT_FOR_COST, {"processing_id": call.processing_id}
410
+ )
411
+ .mappings()
412
+ .first()
413
+ )
414
+ if row is None:
415
+ raise WorkNotFoundError(
416
+ f"processing row {call.processing_id} does not exist"
417
+ )
418
+ if row["status"] != "running":
419
+ raise WorkNotRunningError(
420
+ f"processing row {call.processing_id} is not running; "
421
+ "cost attribution requires a running attempt"
422
+ )
423
+ inserted = connection.execute(
424
+ _INSERT_COST,
425
+ {
426
+ "cost_id": uuid4(),
427
+ "deployment_id": row["deployment_id"],
428
+ "processing_id": call.processing_id,
429
+ "stage": row["stage"],
430
+ "lane": row["lane"],
431
+ "target_kind": row["target_kind"],
432
+ "target_id": row["target_id"],
433
+ "component_version": row["component_version"],
434
+ "attempt": row["attempts"],
435
+ "call_key": call.call_key,
436
+ "model_name": call.model_name,
437
+ "tier": call.tier,
438
+ "tokens_in": call.tokens_in,
439
+ "tokens_out": call.tokens_out,
440
+ "cost_usd": call.cost_usd,
441
+ "latency_ms": call.latency_ms,
442
+ },
443
+ ).rowcount
444
+ return inserted == 1
445
+
446
+ def _budget_for(
447
+ self, *, deployment_id: UUID, stage: PipelineStage, lane: ProcessingLane | None
448
+ ) -> CostBudget | None:
449
+ """Return the one validated ceiling for a route, if the operator configured it."""
450
+ return next(
451
+ (
452
+ budget
453
+ for budget in self._settings.budgets
454
+ if budget.deployment_id == deployment_id
455
+ and budget.stage == stage
456
+ and budget.lane == lane
457
+ ),
458
+ None,
459
+ )
460
+
461
+
462
+ def _budget_window_spend(
463
+ *, connection: Connection, budget: CostBudget
464
+ ) -> _BudgetWindowSpend:
465
+ """Read one aligned window and its deduplicated cost using the database clock."""
466
+ row = (
467
+ connection.execute(
468
+ _BUDGET_WINDOW_SPEND,
469
+ {
470
+ "deployment_id": budget.deployment_id,
471
+ "stage": budget.stage,
472
+ "lane": budget.lane,
473
+ "window_seconds": budget.window_seconds,
474
+ },
475
+ )
476
+ .mappings()
477
+ .one()
478
+ )
479
+ return _BudgetWindowSpend(
480
+ started_at=cast(datetime, row["window_started_at"]),
481
+ ends_at=cast(datetime, row["window_ends_at"]),
482
+ spent_usd=_decimal(row["spent_usd"]),
483
+ )
484
+
485
+
486
+ def _decimal(value: object) -> Decimal:
487
+ """Normalize a PostgreSQL numeric aggregate without introducing float rounding."""
488
+ return value if isinstance(value, Decimal) else Decimal(str(value))
489
+
490
+
491
+ def _require_valid_lane(*, stage: PipelineStage, lane: ProcessingLane | None) -> None:
492
+ """Reject a lane value that is illegal for the stage's route (D67 pairing)."""
493
+ if not lane_is_valid(stage=stage, lane=None if lane is None else lane.value):
494
+ raise LaneRouteError(
495
+ f"stage {stage} does not accept lane {lane!r}: plane-E stages require "
496
+ "steady or backfill; scheduled K/P stages must be unlaned"
497
+ )
498
+
499
+
500
+ def enqueue_on(*, connection: Connection, work: EnqueueWork) -> EnqueueOutcome:
501
+ """Run the idempotent insert (+ steady-promotion rule) on an open transaction.
502
+
503
+ Public for spine services whose own row writes must commit atomically with
504
+ the work they chain (e.g. document ingest enqueueing convert): the caller
505
+ owns the transaction; the initial-wake trigger fires on its commit.
506
+ """
507
+ _require_valid_lane(stage=work.stage, lane=work.lane)
508
+ inserted = (
509
+ connection.execute(
510
+ _INSERT_WORK,
511
+ {
512
+ "processing_id": uuid4(),
513
+ "deployment_id": work.deployment_id,
514
+ "target_kind": work.target_kind,
515
+ "target_id": work.target_id,
516
+ "stage": work.stage,
517
+ "component_version": work.component_version,
518
+ "content_hash": work.content_hash,
519
+ "lane": work.lane,
520
+ "payload": work.payload,
521
+ "not_before": work.not_before,
522
+ },
523
+ )
524
+ .mappings()
525
+ .first()
526
+ )
527
+ if inserted is not None:
528
+ return EnqueueOutcome(
529
+ processing_id=inserted["processing_id"],
530
+ created=True,
531
+ promoted_to_steady=False,
532
+ )
533
+ existing = (
534
+ connection.execute(
535
+ _SELECT_EXISTING,
536
+ {
537
+ "deployment_id": work.deployment_id,
538
+ "target_kind": work.target_kind,
539
+ "target_id": work.target_id,
540
+ "stage": work.stage,
541
+ "component_version": work.component_version,
542
+ },
543
+ )
544
+ .mappings()
545
+ .one()
546
+ )
547
+ promoted = False
548
+ if (
549
+ work.lane is ProcessingLane.STEADY
550
+ and existing["lane"] == ProcessingLane.BACKFILL.value
551
+ and existing["status"] in ("pending", "failed")
552
+ ):
553
+ promoted = (
554
+ connection.execute(
555
+ _PROMOTE_TO_STEADY, {"processing_id": existing["processing_id"]}
556
+ ).rowcount
557
+ == 1
558
+ )
559
+ if promoted:
560
+ # Promotion re-routes live work: wake steady listeners on commit
561
+ # (a backfill row parked under the backfill budget also became due).
562
+ connection.execute(_WAKE, {"processing_id": str(existing["processing_id"])})
563
+ return EnqueueOutcome(
564
+ processing_id=existing["processing_id"],
565
+ created=False,
566
+ promoted_to_steady=promoted,
567
+ )
568
+
569
+
570
+ def _claimed_work(*, row: RowMapping) -> ClaimedWork:
571
+ """Build the typed claimed-work record from a returned ledger row."""
572
+ return ClaimedWork(
573
+ processing_id=row["processing_id"],
574
+ deployment_id=row["deployment_id"],
575
+ target_kind=row["target_kind"],
576
+ target_id=row["target_id"],
577
+ stage=row["stage"],
578
+ component_version=row["component_version"],
579
+ content_hash=row["content_hash"],
580
+ lane=None if row["lane"] is None else ProcessingLane(row["lane"]),
581
+ attempt=int(row["attempts"]),
582
+ payload=row["payload"],
583
+ )
584
+
585
+
586
+ _INSERT_WORK = text(
587
+ """
588
+ INSERT INTO processing_state (
589
+ processing_id, deployment_id, target_kind, target_id, stage,
590
+ component_version, content_hash, lane, payload, not_before
591
+ ) VALUES (
592
+ :processing_id, :deployment_id, :target_kind, :target_id, :stage,
593
+ :component_version, :content_hash, :lane,
594
+ :payload, COALESCE(:not_before, now())
595
+ )
596
+ ON CONFLICT (deployment_id, target_kind, target_id, stage, component_version)
597
+ DO NOTHING
598
+ RETURNING processing_id
599
+ """
600
+ ).bindparams(bindparam("payload", type_=JSON))
601
+
602
+ _SELECT_EXISTING = text(
603
+ """
604
+ SELECT processing_id, lane, status
605
+ FROM processing_state
606
+ WHERE deployment_id = :deployment_id
607
+ AND target_kind = :target_kind
608
+ AND target_id = :target_id
609
+ AND stage = :stage
610
+ AND component_version = :component_version
611
+ """
612
+ )
613
+
614
+ _PROMOTE_TO_STEADY = text(
615
+ """
616
+ UPDATE processing_state
617
+ SET lane = 'steady',
618
+ defer_reason = CASE WHEN defer_reason = 'budget' THEN NULL
619
+ ELSE defer_reason END,
620
+ not_before = CASE WHEN defer_reason = 'budget' THEN now()
621
+ ELSE not_before END
622
+ WHERE processing_id = :processing_id
623
+ AND lane = 'backfill'
624
+ AND status IN ('pending', 'failed')
625
+ """
626
+ )
627
+
628
+ _CLAIM_SELECT = text(
629
+ """
630
+ SELECT processing_id
631
+ FROM processing_state
632
+ WHERE deployment_id = :deployment_id
633
+ AND stage = :stage
634
+ AND lane IS NOT DISTINCT FROM :lane
635
+ AND status IN ('pending', 'failed')
636
+ AND not_before <= now()
637
+ AND attempts < max_attempts
638
+ ORDER BY not_before, enqueued_at, processing_id
639
+ LIMIT 1
640
+ FOR UPDATE SKIP LOCKED
641
+ """
642
+ )
643
+
644
+ _CLAIM_START = text(
645
+ """
646
+ UPDATE processing_state
647
+ SET status = 'running',
648
+ defer_reason = NULL,
649
+ attempts = attempts + 1,
650
+ started_at = now()
651
+ WHERE processing_id = :processing_id
652
+ RETURNING processing_id, deployment_id, target_kind, target_id, stage,
653
+ component_version, content_hash, lane, attempts, payload
654
+ """
655
+ )
656
+
657
+ _COMPLETE = text(
658
+ """
659
+ UPDATE processing_state
660
+ SET status = 'succeeded', finished_at = now()
661
+ WHERE processing_id = :processing_id AND status = 'running'
662
+ """
663
+ )
664
+
665
+ _SELECT_FOR_FAIL = text(
666
+ """
667
+ SELECT status, attempts, max_attempts
668
+ FROM processing_state
669
+ WHERE processing_id = :processing_id
670
+ FOR UPDATE
671
+ """
672
+ )
673
+
674
+ _FAIL_RETRY = text(
675
+ """
676
+ UPDATE processing_state
677
+ SET status = 'failed',
678
+ defer_reason = 'retry_backoff',
679
+ not_before = now() + make_interval(secs => :backoff_s),
680
+ last_error = :error
681
+ WHERE processing_id = :processing_id
682
+ RETURNING not_before
683
+ """
684
+ )
685
+
686
+ _FAIL_DEAD_LETTER = text(
687
+ """
688
+ UPDATE processing_state
689
+ SET status = 'dead_letter',
690
+ defer_reason = NULL,
691
+ last_error = :error,
692
+ finished_at = now()
693
+ WHERE processing_id = :processing_id
694
+ """
695
+ )
696
+
697
+ _PARK_BUDGET = text(
698
+ """
699
+ UPDATE processing_state
700
+ SET status = 'pending', defer_reason = 'budget', not_before = :resume_at
701
+ WHERE processing_id = :processing_id AND status IN ('pending', 'failed')
702
+ """
703
+ )
704
+
705
+ _SELECT_DEAD_LETTER_FOR_REPLAY = text(
706
+ """
707
+ SELECT status, stage, lane, attempts
708
+ FROM processing_state
709
+ WHERE deployment_id = :deployment_id
710
+ AND processing_id = :processing_id
711
+ FOR UPDATE
712
+ """
713
+ )
714
+
715
+ _REPLAY_DEAD_LETTER = text(
716
+ """
717
+ UPDATE processing_state
718
+ SET status = 'pending',
719
+ lane = :lane,
720
+ defer_reason = CASE
721
+ WHEN :not_before IS NOT NULL AND :not_before > now()
722
+ THEN 'scheduled'::processing_defer_reason
723
+ ELSE NULL
724
+ END,
725
+ not_before = COALESCE(:not_before, now()),
726
+ max_attempts = attempts + :attempt_allowance,
727
+ finished_at = NULL
728
+ WHERE processing_id = :processing_id
729
+ AND status = 'dead_letter'
730
+ RETURNING attempts, max_attempts, not_before
731
+ """
732
+ ).bindparams(bindparam("not_before", type_=DateTime(timezone=True)))
733
+
734
+ _BUDGET_WINDOW_SPEND = text(
735
+ """
736
+ WITH bounds AS (
737
+ SELECT
738
+ to_timestamp(
739
+ floor(extract(epoch FROM now()) / :window_seconds)
740
+ * :window_seconds
741
+ ) AS window_started_at,
742
+ to_timestamp(
743
+ (floor(extract(epoch FROM now()) / :window_seconds) + 1)
744
+ * :window_seconds
745
+ ) AS window_ends_at
746
+ )
747
+ SELECT bounds.window_started_at,
748
+ bounds.window_ends_at,
749
+ COALESCE(sum(cost_ledger.cost_usd), 0) AS spent_usd
750
+ FROM bounds
751
+ LEFT JOIN cost_ledger
752
+ ON cost_ledger.deployment_id = :deployment_id
753
+ AND cost_ledger.stage = :stage
754
+ AND cost_ledger.lane IS NOT DISTINCT FROM :lane
755
+ AND cost_ledger.occurred_at >= bounds.window_started_at
756
+ AND cost_ledger.occurred_at < bounds.window_ends_at
757
+ GROUP BY bounds.window_started_at, bounds.window_ends_at
758
+ """
759
+ )
760
+
761
+ _BUDGET_TIER_SPEND = text(
762
+ """
763
+ SELECT tier, COALESCE(sum(cost_usd), 0) AS cost_usd
764
+ FROM cost_ledger
765
+ WHERE deployment_id = :deployment_id
766
+ AND stage = :stage
767
+ AND lane IS NOT DISTINCT FROM :lane
768
+ AND occurred_at >= :window_started_at
769
+ AND occurred_at < :window_ends_at
770
+ GROUP BY tier
771
+ ORDER BY tier NULLS FIRST
772
+ """
773
+ )
774
+
775
+ _BUDGET_PARKED_COUNT = text(
776
+ """
777
+ SELECT count(*)
778
+ FROM processing_state
779
+ WHERE deployment_id = :deployment_id
780
+ AND stage = :stage
781
+ AND lane IS NOT DISTINCT FROM :lane
782
+ AND status = 'pending'
783
+ AND defer_reason = 'budget'
784
+ """
785
+ )
786
+
787
+ _SELECT_FOR_COST = text(
788
+ """
789
+ SELECT deployment_id, status, stage, lane, attempts,
790
+ target_kind, target_id, component_version
791
+ FROM processing_state
792
+ WHERE processing_id = :processing_id
793
+ FOR UPDATE
794
+ """
795
+ )
796
+
797
+ _INSERT_COST = text(
798
+ """
799
+ INSERT INTO cost_ledger (
800
+ cost_id, deployment_id, processing_id, stage, lane, target_kind,
801
+ target_id, component_version, attempt, call_key, model_name, tier,
802
+ tokens_in, tokens_out, cost_usd, latency_ms
803
+ ) VALUES (
804
+ :cost_id, :deployment_id, :processing_id, :stage, :lane, :target_kind,
805
+ :target_id, :component_version, :attempt, :call_key, :model_name, :tier,
806
+ :tokens_in, :tokens_out, :cost_usd, :latency_ms
807
+ )
808
+ ON CONFLICT (deployment_id, processing_id, attempt, call_key) DO NOTHING
809
+ """
810
+ )
811
+
812
+ _WAKE = text(
813
+ """
814
+ SELECT pg_notify('queue_wake', :processing_id)
815
+ """
816
+ )