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,434 @@
1
+ """The E3 normalizer (D2-D5, D17-D18, D43): claims → relations and observations.
2
+
3
+ Per claim, one normalizer call proposes (subject, predicate, object) relations
4
+ and entity-anchored observations. Deterministic gates then govern what lands:
5
+ the predicate must be in the registry vocabulary and its type signature must
6
+ match at some ancestor level (D18 — application-enforced at write time; a
7
+ dropped candidate is re-derivable from its immutable claim). Entities resolve
8
+ through T0; the fact catalog collapses redundancy (D2) and keeps the D54
9
+ lineage-distinct evidence counts.
10
+ """
11
+
12
+ import logging
13
+ from typing import Final
14
+ from uuid import UUID
15
+
16
+ from pydantic import Field
17
+ from pydantic_settings import BaseSettings
18
+ from pydantic_settings import SettingsConfigDict
19
+
20
+ from rememberstack.model import ClaimedWork
21
+ from rememberstack.model import ClaimForNormalization
22
+ from rememberstack.model import EnqueueWork
23
+ from rememberstack.model import ModelRequest
24
+ from rememberstack.model import NonRetryableHandlerError
25
+ from rememberstack.model import NormalizationResponse
26
+ from rememberstack.model import ObservationAssertion
27
+ from rememberstack.model import PipelineStage
28
+ from rememberstack.ports.cost_meter import CostMeterPort
29
+ from rememberstack.ports.model_provider import ModelProviderPort
30
+ from rememberstack.spine.chunk_catalog import ChunkCatalog
31
+ from rememberstack.spine.claim_catalog import ClaimCatalog
32
+ from rememberstack.spine.entity_registry import EntityRegistry
33
+ from rememberstack.spine.fact_catalog import FactCatalog
34
+ from rememberstack.spine.fact_catalog import OTHER_PREDICATE_GRAMMAR
35
+ from rememberstack.spine.observation_adjudication import ObservationAdjudicator
36
+ from rememberstack.spine.resolver import CascadeResolver
37
+ from rememberstack.spine.supersession import ADJUDICATOR_VERSION
38
+ from rememberstack.spine.supersession import SupersessionAdjudicator
39
+ from rememberstack.workers.base import HandlerOutcome
40
+ from rememberstack.workers.p1 import FACT_LABEL_VERSION
41
+ from rememberstack.workers.p1 import P1_EMBED_CLAIMS_VERSION
42
+ from rememberstack.workers.reconcile import RECONCILE_VERSION
43
+
44
+ _logger = logging.getLogger(__name__)
45
+
46
+ _OTHER_PREDICATE: Final = OTHER_PREDICATE_GRAMMAR
47
+ """The escape-value routing check (the spine re-validates authoritatively)."""
48
+
49
+ E3_NORMALIZER_VERSION: Final = "e3-normalize-2026.07"
50
+ """The normalize sub-worker's component version (D12 idempotency member)."""
51
+
52
+ _NORMALIZE_PROMPT: Final = """You are the normalizer of a memory system. Turn
53
+ the CLAIM into zero or more of:
54
+ - relations: (subject, predicate, object) between TWO named entities, using
55
+ ONLY the governed predicates listed below (map synonyms onto them). If a
56
+ clearly relational fact fits NO governed predicate, you may emit
57
+ `other:<short_snake_case>` (e.g. other:sponsors) — never invent a bare
58
+ predicate name;
59
+ - observations: a value/property/statement about ONE entity, as a standalone
60
+ statement ("Acme's headcount is 600"). An ATTRIBUTED stance claim ("X said /
61
+ believes / opposes Y") becomes a stance observation anchored on X — never a
62
+ fact about Y.
63
+ Entity names must be canonical nominative forms; entity types must come from
64
+ the registry types below. Time is never a relation object.
65
+
66
+ GOVERNED PREDICATES:
67
+ {predicates}
68
+ REGISTRY TYPES: {types}
69
+
70
+ CLAIM (attributed={is_attributed}): {claim_text}"""
71
+
72
+
73
+ class E3Settings(BaseSettings):
74
+ """The E3 model binding: interchangeable per-deployment port config (D70)."""
75
+
76
+ model_config = SettingsConfigDict(env_prefix="REMEMBERSTACK_E3_")
77
+
78
+ normalize_model: str = Field(default="openai/gpt-5.6-luna")
79
+
80
+
81
+ class NormalizeRelationsHandler:
82
+ """The normalize stage: every accepted claim of one representation."""
83
+
84
+ def __init__(
85
+ self,
86
+ *,
87
+ claim_catalog: ClaimCatalog,
88
+ chunk_catalog: ChunkCatalog,
89
+ registry: EntityRegistry,
90
+ resolver: CascadeResolver,
91
+ facts: FactCatalog,
92
+ observation_adjudicator: ObservationAdjudicator,
93
+ model_provider: ModelProviderPort,
94
+ settings: E3Settings,
95
+ chunker_version: str,
96
+ ) -> None:
97
+ """Bind the handler to its catalogs, registry, provider, and generation."""
98
+ self._claim_catalog = claim_catalog
99
+ self._chunk_catalog = chunk_catalog
100
+ self._registry = registry
101
+ self._resolver = resolver
102
+ self._facts = facts
103
+ self._observation_adjudicator = observation_adjudicator
104
+ self._model_provider = model_provider
105
+ self._settings = settings
106
+ self._chunker_version = chunker_version
107
+
108
+ def handle(self, *, work: ClaimedWork, meter: CostMeterPort) -> HandlerOutcome:
109
+ """Normalize one document version's claims into relations/observations.
110
+
111
+ Newly-created relations chain the supersession adjudicator (D3/D4)
112
+ alongside the P1 writers."""
113
+ source = self._chunk_catalog.chunk_source(
114
+ representation_id=_payload_uuid(work=work, field="representation_id")
115
+ )
116
+ chunks = self._chunk_catalog.chunks_for_embedding(
117
+ representation_id=source.representation_id,
118
+ chunker_version=self._chunker_version,
119
+ )
120
+ claims = self._claim_catalog.claims_for_chunks(
121
+ chunk_ids=tuple(chunk.chunk_id for chunk in chunks)
122
+ )
123
+ if not claims:
124
+ # a version whose extraction yielded nothing still completes its
125
+ # basis change: the chain must reach reconciliation (Codex
126
+ # review — a living replacement of pure boilerplate would
127
+ # otherwise never supersede the old claims)
128
+ return HandlerOutcome(
129
+ follow_up=(
130
+ EnqueueWork(
131
+ deployment_id=work.deployment_id,
132
+ target_kind=work.target_kind,
133
+ target_id=work.target_id,
134
+ stage=PipelineStage.ADJUDICATE_SUPERSESSION,
135
+ component_version=ADJUDICATOR_VERSION,
136
+ content_hash=work.content_hash,
137
+ lane=work.lane,
138
+ payload={**(work.payload or {}), "relation_ids": []},
139
+ ),
140
+ )
141
+ )
142
+ deployment_id = work.deployment_id
143
+ predicates = self._facts.active_predicates(deployment_id=deployment_id)
144
+ prompt_lines = self._facts.predicate_prompt_lines(deployment_id=deployment_id)
145
+ signatures = self._facts.predicate_signatures(deployment_id=deployment_id)
146
+ type_parents = self._facts.entity_type_parents(deployment_id=deployment_id)
147
+ created_relations: list[str] = []
148
+ normalized_claim_ids = self._registry.normalized_claim_ids(
149
+ claim_ids=tuple(claim.claim_id for claim in claims)
150
+ )
151
+ observations_by_entity: dict[UUID, list[ObservationAssertion]] = {}
152
+ for claim in claims:
153
+ if claim.claim_id in normalized_claim_ids:
154
+ continue # replay: stored mentions/facts are the output (D7)
155
+ self._normalize_claim(
156
+ created_relations=created_relations,
157
+ observations_by_entity=observations_by_entity,
158
+ deployment_id=deployment_id,
159
+ claim=claim,
160
+ predicates=predicates,
161
+ prompt_lines=prompt_lines,
162
+ signatures=signatures,
163
+ type_parents=type_parents,
164
+ meter=meter,
165
+ )
166
+ for entity_id, assertions in observations_by_entity.items():
167
+ self._observation_adjudicator.add_observations(
168
+ deployment_id=deployment_id,
169
+ subject_entity_id=entity_id,
170
+ assertions=tuple(assertions),
171
+ meter=meter,
172
+ call_key=f"observation:{entity_id}",
173
+ )
174
+ return HandlerOutcome(
175
+ follow_up=(
176
+ EnqueueWork(
177
+ deployment_id=work.deployment_id,
178
+ target_kind=work.target_kind,
179
+ target_id=work.target_id,
180
+ stage=PipelineStage.ADJUDICATE_SUPERSESSION,
181
+ component_version=ADJUDICATOR_VERSION,
182
+ content_hash=work.content_hash,
183
+ lane=work.lane,
184
+ payload={**(work.payload or {}), "relation_ids": created_relations},
185
+ ),
186
+ EnqueueWork(
187
+ deployment_id=work.deployment_id,
188
+ target_kind=work.target_kind,
189
+ target_id=work.target_id,
190
+ stage=PipelineStage.EMBED_CLAIM,
191
+ component_version=P1_EMBED_CLAIMS_VERSION,
192
+ content_hash=work.content_hash,
193
+ lane=work.lane,
194
+ payload=dict(work.payload or {}),
195
+ ),
196
+ EnqueueWork(
197
+ deployment_id=work.deployment_id,
198
+ target_kind=work.target_kind,
199
+ target_id=work.target_id,
200
+ stage=PipelineStage.LABEL_RELATION,
201
+ component_version=FACT_LABEL_VERSION,
202
+ content_hash=work.content_hash,
203
+ lane=work.lane,
204
+ payload={**(work.payload or {}), "doc_id": str(source.doc_id)},
205
+ ),
206
+ )
207
+ )
208
+
209
+ def _normalize_claim(
210
+ self,
211
+ *,
212
+ created_relations: list[str],
213
+ observations_by_entity: dict[UUID, list[ObservationAssertion]],
214
+ deployment_id: UUID,
215
+ claim: ClaimForNormalization,
216
+ predicates: dict[str, str | None],
217
+ prompt_lines: str,
218
+ signatures: dict[str, tuple[tuple[str, str], ...]],
219
+ type_parents: dict[str, str | None],
220
+ meter: CostMeterPort,
221
+ ) -> None:
222
+ """One claim through the normalizer call and the deterministic gates."""
223
+ response_call = self._model_provider.generate(
224
+ request=ModelRequest(
225
+ model=self._settings.normalize_model,
226
+ prompt=_NORMALIZE_PROMPT.format(
227
+ predicates=prompt_lines,
228
+ types=", ".join(sorted(type_parents)),
229
+ is_attributed=claim.is_attributed,
230
+ claim_text=claim.claim_text,
231
+ ),
232
+ ),
233
+ response_type=NormalizationResponse,
234
+ )
235
+ meter.record(
236
+ call_key=f"normalize:{claim.claim_id}",
237
+ tier="normalize",
238
+ usage=response_call.usage,
239
+ )
240
+ response = response_call.output
241
+ for relation_index, relation in enumerate(response.relations):
242
+ if _OTHER_PREDICATE.fullmatch(relation.predicate):
243
+ # the D5 escape funnel: register tier=other, unconstrained
244
+ # by signatures, ranked by usage for periodic promotion
245
+ self._facts.ensure_other_predicate(
246
+ deployment_id=deployment_id, predicate=relation.predicate
247
+ )
248
+ predicates = {**predicates, relation.predicate: "related_to"}
249
+ if relation.predicate not in predicates:
250
+ _logger.warning(
251
+ "unknown predicate %r dropped for claim %s (re-derivable)",
252
+ relation.predicate,
253
+ claim.claim_id,
254
+ )
255
+ continue
256
+ if not _signature_allows(
257
+ predicate=relation.predicate,
258
+ subject_type=relation.subject.type,
259
+ object_type=relation.object.type,
260
+ signatures=signatures,
261
+ type_parents=type_parents,
262
+ ):
263
+ _logger.warning(
264
+ "signature-rejected %r (%s -> %s) for claim %s (re-derivable)",
265
+ relation.predicate,
266
+ relation.subject.type,
267
+ relation.object.type,
268
+ claim.claim_id,
269
+ )
270
+ continue
271
+ subject = self._resolver.resolve(
272
+ deployment_id=deployment_id,
273
+ reference=relation.subject,
274
+ claim=claim,
275
+ meter=meter,
276
+ call_key=(
277
+ f"resolve:{claim.claim_id}:relation:{relation_index}:subject"
278
+ ),
279
+ )
280
+ object_ = self._resolver.resolve(
281
+ deployment_id=deployment_id,
282
+ reference=relation.object,
283
+ claim=claim,
284
+ meter=meter,
285
+ call_key=f"resolve:{claim.claim_id}:relation:{relation_index}:object",
286
+ )
287
+ if not _signature_allows(
288
+ predicate=relation.predicate,
289
+ subject_type=subject.entity_type,
290
+ object_type=object_.entity_type,
291
+ signatures=signatures,
292
+ type_parents=type_parents,
293
+ ):
294
+ # the gate binds on the RESOLVED entities' stored types too —
295
+ # T0 may map an emitted name onto a differently-typed entity
296
+ # (Codex review); the candidate stays re-derivable.
297
+ _logger.warning(
298
+ "signature-rejected %r on resolved types (%s -> %s), claim %s",
299
+ relation.predicate,
300
+ subject.entity_type,
301
+ object_.entity_type,
302
+ claim.claim_id,
303
+ )
304
+ continue
305
+ upserted = self._facts.upsert_relation(
306
+ deployment_id=deployment_id,
307
+ subject_entity_id=subject.entity_id,
308
+ predicate=relation.predicate,
309
+ object_entity_id=object_.entity_id,
310
+ claim_id=claim.claim_id,
311
+ doc_id=claim.doc_id,
312
+ normalizer_version=E3_NORMALIZER_VERSION,
313
+ )
314
+ if upserted.created:
315
+ created_relations.append(str(upserted.relation_id))
316
+ for observation_index, observation in enumerate(response.observations):
317
+ subject = self._resolver.resolve(
318
+ deployment_id=deployment_id,
319
+ reference=observation.subject,
320
+ claim=claim,
321
+ meter=meter,
322
+ call_key=(
323
+ f"resolve:{claim.claim_id}:observation:{observation_index}:subject"
324
+ ),
325
+ )
326
+ # the one write path for observations is the D43 adjudicator:
327
+ # block on the entity, gate cheaply, ladder the residue,
328
+ # fail safe to coexist (observations §3)
329
+ observations_by_entity.setdefault(subject.entity_id, []).append(
330
+ ObservationAssertion(
331
+ statement=observation.statement,
332
+ claim_id=claim.claim_id,
333
+ doc_id=claim.doc_id,
334
+ )
335
+ )
336
+
337
+
338
+ def _signature_allows(
339
+ *,
340
+ predicate: str,
341
+ subject_type: str,
342
+ object_type: str,
343
+ signatures: dict[str, tuple[tuple[str, str], ...]],
344
+ type_parents: dict[str, str | None],
345
+ ) -> bool:
346
+ """The D18 domain/range gate: some signature matches at any ancestor level.
347
+
348
+ Unknown emitted types fail closed; a predicate with no declared signatures
349
+ is unconstrained (the registry's permissive parents, e.g. related_to).
350
+ """
351
+ if subject_type not in type_parents or object_type not in type_parents:
352
+ return False
353
+ declared = signatures.get(predicate)
354
+ if not declared:
355
+ return True
356
+ subject_chain = _ancestor_chain(entity_type=subject_type, parents=type_parents)
357
+ object_chain = _ancestor_chain(entity_type=object_type, parents=type_parents)
358
+ return any(
359
+ allowed_subject in subject_chain and allowed_object in object_chain
360
+ for allowed_subject, allowed_object in declared
361
+ )
362
+
363
+
364
+ def _ancestor_chain(
365
+ *, entity_type: str, parents: dict[str, str | None]
366
+ ) -> frozenset[str]:
367
+ """The type plus every ancestor (extend-never-fork walk, cycle-safe)."""
368
+ chain: set[str] = set()
369
+ current: str | None = entity_type
370
+ while current is not None and current not in chain:
371
+ chain.add(current)
372
+ current = parents.get(current)
373
+ return frozenset(chain)
374
+
375
+
376
+ def _payload_uuid(*, work: ClaimedWork, field: str) -> UUID:
377
+ """Read a required UUID from the claimed payload; absence is non-retryable."""
378
+ value = (work.payload or {}).get(field)
379
+ if not isinstance(value, str):
380
+ raise NonRetryableHandlerError(
381
+ f"stage {work.stage} work {work.processing_id} carries no {field!r} payload"
382
+ )
383
+ return UUID(value)
384
+
385
+
386
+ class AdjudicateSupersessionHandler:
387
+ """The adjudication stage: each newly-created relation through the cascade."""
388
+
389
+ def __init__(self, *, adjudicator: SupersessionAdjudicator) -> None:
390
+ """Bind the handler to the composed adjudicator."""
391
+ self._adjudicator = adjudicator
392
+
393
+ def handle(self, *, work: ClaimedWork, meter: CostMeterPort) -> HandlerOutcome:
394
+ """Adjudicate every relation the normalize stage created (idempotent).
395
+
396
+ Chains the reconcile stage — the truth machinery for this version's
397
+ basis change is settled, so reconciliation (lifecycle §5: currency,
398
+ recount, zero-support policy, `evidence_changed`) can run on a
399
+ completed basis, never mid-flight.
400
+ """
401
+ payload = work.payload or {}
402
+ relation_ids = payload.get("relation_ids") or []
403
+ if not isinstance(relation_ids, list):
404
+ raise NonRetryableHandlerError(
405
+ f"work {work.processing_id} carries a malformed relation_ids payload"
406
+ )
407
+ for raw in relation_ids:
408
+ self._adjudicator.adjudicate_new_relation(
409
+ deployment_id=work.deployment_id,
410
+ relation_id=UUID(str(raw)),
411
+ meter=meter,
412
+ call_key=f"supersession:{raw}",
413
+ )
414
+ version_id = payload.get("version_id")
415
+ representation_id = payload.get("representation_id")
416
+ if not isinstance(version_id, str) or not isinstance(representation_id, str):
417
+ return HandlerOutcome() # pre-lifecycle work rows: nothing to chain
418
+ return HandlerOutcome(
419
+ follow_up=(
420
+ EnqueueWork(
421
+ deployment_id=work.deployment_id,
422
+ target_kind=work.target_kind,
423
+ target_id=work.target_id,
424
+ stage=PipelineStage.RECONCILE,
425
+ component_version=RECONCILE_VERSION,
426
+ content_hash=work.content_hash,
427
+ lane=work.lane,
428
+ payload={
429
+ "version_id": version_id,
430
+ "representation_id": representation_id,
431
+ },
432
+ ),
433
+ )
434
+ )