mesa-memory 0.9.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 (157) hide show
  1. mesa_api/__init__.py +9 -0
  2. mesa_api/admission.py +23 -0
  3. mesa_api/identifier_validation.py +67 -0
  4. mesa_api/routers/control/router.py +276 -0
  5. mesa_api/schemas.py +16 -0
  6. mesa_api/v4_router.py +1624 -0
  7. mesa_client/__init__.py +27 -0
  8. mesa_client/client.py +1265 -0
  9. mesa_mcp/__init__.py +5 -0
  10. mesa_mcp/adapter.py +226 -0
  11. mesa_mcp/antigravity_bridge.py +466 -0
  12. mesa_mcp/antigravity_cli.py +334 -0
  13. mesa_mcp/bounded_cache.py +133 -0
  14. mesa_mcp/codex_cli.py +631 -0
  15. mesa_mcp/codex_hooks.py +161 -0
  16. mesa_mcp/configuration.py +100 -0
  17. mesa_mcp/errors.py +24 -0
  18. mesa_mcp/gateway/__init__.py +1 -0
  19. mesa_mcp/gateway/anomaly.py +30 -0
  20. mesa_mcp/gateway/app.py +270 -0
  21. mesa_mcp/gateway/approval.py +132 -0
  22. mesa_mcp/gateway/auth.py +32 -0
  23. mesa_mcp/gateway/codex_transport.py +148 -0
  24. mesa_mcp/gateway/middleware.py +182 -0
  25. mesa_mcp/gateway/operations.py +955 -0
  26. mesa_mcp/gateway/policy/__init__.py +1 -0
  27. mesa_mcp/gateway/policy/engine.py +80 -0
  28. mesa_mcp/gateway/policy/simulator.py +45 -0
  29. mesa_mcp/http_service.py +63 -0
  30. mesa_mcp/local_cli.py +374 -0
  31. mesa_mcp/security.py +68 -0
  32. mesa_mcp/server.py +317 -0
  33. mesa_mcp/service.py +25 -0
  34. mesa_mcp/v4_service.py +582 -0
  35. mesa_mcp/workspace.py +23 -0
  36. mesa_memory/adapter/base.py +49 -0
  37. mesa_memory/adapter/claude.py +220 -0
  38. mesa_memory/adapter/data/cl100k_base.tiktoken +100256 -0
  39. mesa_memory/adapter/endpoints.py +14 -0
  40. mesa_memory/adapter/factory.py +288 -0
  41. mesa_memory/adapter/live.py +274 -0
  42. mesa_memory/adapter/ollama.py +113 -0
  43. mesa_memory/adapter/tokenizer.py +98 -0
  44. mesa_memory/api/middleware.py +91 -0
  45. mesa_memory/api/server.py +926 -0
  46. mesa_memory/config.py +993 -0
  47. mesa_memory/consolidation/loop.py +1179 -0
  48. mesa_memory/consolidation/parser.py +271 -0
  49. mesa_memory/consolidation/policy.py +173 -0
  50. mesa_memory/consolidation/router.py +392 -0
  51. mesa_memory/consolidation/schemas.py +255 -0
  52. mesa_memory/consolidation/validator.py +286 -0
  53. mesa_memory/container_health.py +25 -0
  54. mesa_memory/context_builder.py +693 -0
  55. mesa_memory/document_chunking.py +99 -0
  56. mesa_memory/embedding/__init__.py +21 -0
  57. mesa_memory/embedding/service.py +752 -0
  58. mesa_memory/extraction/service.py +614 -0
  59. mesa_memory/graph/__init__.py +5 -0
  60. mesa_memory/graph/projector.py +43 -0
  61. mesa_memory/observability/http.py +122 -0
  62. mesa_memory/observability/logger.py +259 -0
  63. mesa_memory/observability/metrics.py +252 -0
  64. mesa_memory/rebuild_runner.py +505 -0
  65. mesa_memory/retrieval/adaptive.py +311 -0
  66. mesa_memory/retrieval/core.py +2 -0
  67. mesa_memory/retrieval/legal_resolver.py +15 -0
  68. mesa_memory/runtime_entrypoint.py +49 -0
  69. mesa_memory/security/admin_cli.py +204 -0
  70. mesa_memory/security/api_keys.py +267 -0
  71. mesa_memory/security/input_validation.py +50 -0
  72. mesa_memory/security/rbac.py +787 -0
  73. mesa_memory/security/rbac_constants.py +25 -0
  74. mesa_memory/security/untrusted_memory.py +45 -0
  75. mesa_memory/utils.py +36 -0
  76. mesa_memory-0.9.0.dist-info/METADATA +213 -0
  77. mesa_memory-0.9.0.dist-info/RECORD +157 -0
  78. mesa_memory-0.9.0.dist-info/WHEEL +5 -0
  79. mesa_memory-0.9.0.dist-info/entry_points.txt +8 -0
  80. mesa_memory-0.9.0.dist-info/licenses/LICENSE +21 -0
  81. mesa_memory-0.9.0.dist-info/top_level.txt +6 -0
  82. mesa_storage/__init__.py +42 -0
  83. mesa_storage/alembic/README +1 -0
  84. mesa_storage/alembic/env.py +133 -0
  85. mesa_storage/alembic/script.py.mako +28 -0
  86. mesa_storage/alembic/versions/076eef5d1b6c_add_daily_limits.py +34 -0
  87. mesa_storage/alembic/versions/087de6628c51_add_mcp_activity_approval.py +81 -0
  88. mesa_storage/alembic/versions/0a7b8c9d0e1f_scope_catalog_physical_ids.py +42 -0
  89. mesa_storage/alembic/versions/1e7a061f7f9e_add_mcp_clients_and_bindings.py +61 -0
  90. mesa_storage/alembic/versions/41402f316580_add_temporal_validity_to_nodes.py +30 -0
  91. mesa_storage/alembic/versions/4933fb5fd0ea_initial_schema.py +143 -0
  92. mesa_storage/alembic/versions/9a1b2c3d4e5f_add_v4_catalog_provenance.py +367 -0
  93. mesa_storage/alembic/versions/9c6c7ae69ed7_add_mcp_policy_rules.py +41 -0
  94. mesa_storage/alembic/versions/a1d2e3f4b5c6_add_wal_projection_states.py +53 -0
  95. mesa_storage/alembic/versions/a2b3c4d5e6f7_add_declared_content_hash.py +24 -0
  96. mesa_storage/alembic/versions/a94df5d14fce_add_connections_and_settings.py +55 -0
  97. mesa_storage/alembic/versions/b2e3f4a5c6d7_add_session_finalization_journal.py +32 -0
  98. mesa_storage/alembic/versions/b3c4d5e6f7a8_allow_unknown_revision_content_hash.py +29 -0
  99. mesa_storage/alembic/versions/bb2355d0cdd4_add_epistemic_columns.py +38 -0
  100. mesa_storage/alembic/versions/c3d4e5f6a7b8_replace_daily_limit_credential_subject.py +45 -0
  101. mesa_storage/alembic/versions/c4d5e6f7a8b9_add_assertion_fts_and_representation.py +119 -0
  102. mesa_storage/alembic/versions/c4f1a8e2d9b0_add_purge_journal.py +62 -0
  103. mesa_storage/alembic/versions/c5d6e7f8a9b0_add_mcp_operation_ledger.py +58 -0
  104. mesa_storage/alembic/versions/d4e5f6a7b8c9_add_v4_mutation_ledger.py +96 -0
  105. mesa_storage/alembic/versions/d5e6f7a8b9c0_add_typed_legal_identity.py +65 -0
  106. mesa_storage/alembic/versions/d6e7f8a9b0c1_add_mcp_client_credentials.py +35 -0
  107. mesa_storage/alembic/versions/e7f8a9b0c1d2_add_mcp_codex_profiles.py +33 -0
  108. mesa_storage/alembic/versions/e9b7c3a1d4f2_add_claim_leases.py +61 -0
  109. mesa_storage/alembic/versions/f6d4a7b8c9e0_add_dispatch_journal.py +48 -0
  110. mesa_storage/alembic/versions/f7e5b9c0d1a2_add_dispatch_queue_payload_bytes.py +33 -0
  111. mesa_storage/alembic/versions/f8a6c0d1e2b3_add_dispatch_completion_receipts.py +44 -0
  112. mesa_storage/alembic/versions/f8a9b0c1d2e3_generalize_mcp_context_profiles.py +41 -0
  113. mesa_storage/alembic/versions/f9a0b1c2d3e4_enforce_catalog_scope_integrity.py +44 -0
  114. mesa_storage/alembic/versions/fa1b2c3d4e5f_add_mcp_credential_expiry.py +16 -0
  115. mesa_storage/alembic/versions/fb2c3d4e5f6a_add_revision_manifest_hash.py +18 -0
  116. mesa_storage/alembic/versions/fc3d4e5f6a7b_add_durable_projection_operations.py +172 -0
  117. mesa_storage/alembic/versions/fd4e5f6a7b8c_add_embedding_provider_identity.py +20 -0
  118. mesa_storage/alembic/versions/fe5f6a7b8c9d_enforce_active_head_invariant.py +35 -0
  119. mesa_storage/alembic/versions/ff6a7b8c9d0e_freeze_revision_manifests.py +29 -0
  120. mesa_storage/alembic.ini +148 -0
  121. mesa_storage/control/__init__.py +0 -0
  122. mesa_storage/control/activity_repo.py +159 -0
  123. mesa_storage/control/approval_repo.py +141 -0
  124. mesa_storage/control/client_repo.py +175 -0
  125. mesa_storage/control/codex_profile_repo.py +105 -0
  126. mesa_storage/control/connection_repo.py +118 -0
  127. mesa_storage/control/credential_repo.py +196 -0
  128. mesa_storage/control/policy_repo.py +81 -0
  129. mesa_storage/control/settings_repo.py +56 -0
  130. mesa_storage/dao.py +10747 -0
  131. mesa_storage/embedding_identity.py +157 -0
  132. mesa_storage/kuzu_migration.py +450 -0
  133. mesa_storage/kuzu_provider.py +1768 -0
  134. mesa_storage/kuzu_schema_migration.py +340 -0
  135. mesa_storage/kuzu_setup.py +207 -0
  136. mesa_storage/legal_identity.py +473 -0
  137. mesa_storage/projection_generations.py +728 -0
  138. mesa_storage/rebuild_cutover.py +573 -0
  139. mesa_storage/rebuild_health.py +112 -0
  140. mesa_storage/rebuild_observability.py +92 -0
  141. mesa_storage/rebuild_preparation.py +533 -0
  142. mesa_storage/rebuild_replay.py +745 -0
  143. mesa_storage/recovery.py +503 -0
  144. mesa_storage/repositories/__init__.py +20 -0
  145. mesa_storage/repositories/catalog.py +230 -0
  146. mesa_storage/repositories/operations.py +690 -0
  147. mesa_storage/representation.py +99 -0
  148. mesa_storage/retrieval_scope.py +160 -0
  149. mesa_storage/schema_contract.py +453 -0
  150. mesa_storage/schemas.py +292 -0
  151. mesa_storage/sqlite_engine.py +498 -0
  152. mesa_storage/vector_engine.py +1697 -0
  153. mesa_storage/writer_lock.py +116 -0
  154. mesa_workers/__init__.py +6 -0
  155. mesa_workers/ingestion_worker.py +465 -0
  156. mesa_workers/projection_worker.py +322 -0
  157. mesa_workers/supervision.py +124 -0
mesa_api/v4_router.py ADDED
@@ -0,0 +1,1624 @@
1
+ # mypy: disable-error-code="no-untyped-def,untyped-decorator,no-any-return"
2
+ """Versioned V4 full-cognitive API contract.
3
+
4
+ V3 remains the lexical-core compatibility surface. V4 admission creates a
5
+ canonical mutation before background processing and every later operation is
6
+ authorized against a verified principal-to-agent-to-session binding.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import hashlib
13
+ import logging
14
+ from datetime import datetime
15
+ from typing import Any, Callable, Literal
16
+
17
+ from fastapi import APIRouter, Depends, Header, HTTPException, Request
18
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
19
+
20
+ from mesa_api.admission import require_mutation_admission as _require_mutation_admission
21
+ from mesa_api.identifier_validation import PublicIdentifier, SourceIdentifier
22
+ from mesa_memory.adapter.factory import AdapterFactory
23
+ from mesa_memory.config import config, configured_embedding_identity
24
+ from mesa_memory.consolidation.policy import ValidationPolicy
25
+ from mesa_memory.context_builder import ContextBuilder
26
+ from mesa_memory.document_chunking import chunk_document
27
+ from mesa_memory.embedding.service import (
28
+ EmbeddingGenerationError,
29
+ EmbeddingIdentityMismatchError,
30
+ EmbeddingUnavailableError,
31
+ ExternalProviderForbiddenError,
32
+ )
33
+ from mesa_memory.retrieval.adaptive import (
34
+ BoundedAdaptiveQueryRetriever,
35
+ BoundedQueryPlanner,
36
+ )
37
+ from mesa_memory.security.input_validation import validate_write_payload
38
+ from mesa_memory.security.rbac import AccessControl
39
+ from mesa_storage.dao import (
40
+ MemoryDAO,
41
+ NonHeadRollbackConflictError,
42
+ NonReplayableMutationConflictError,
43
+ PurgeRetryPendingError,
44
+ QueueOverCapacityError,
45
+ QueueRecordTooLargeError,
46
+ QueueUnavailableError,
47
+ )
48
+ from mesa_storage.kuzu_provider import GraphSearchError
49
+ from mesa_storage.legal_identity import canonicalize_trusted_legal_identity
50
+ from mesa_storage.repositories.catalog import CatalogIdentityNotFoundError
51
+ from mesa_storage.repositories.operations import (
52
+ OperationActiveConflictError,
53
+ OperationIdempotencyConflictError,
54
+ OperationNotFoundError,
55
+ OperationStateError,
56
+ )
57
+ from mesa_storage.vector_engine import (
58
+ EmbeddingMigrationRequiredError,
59
+ VectorSearchError,
60
+ )
61
+
62
+ logger = logging.getLogger("MESA_V4_API")
63
+
64
+
65
+ class V4MutationStatusResponse(BaseModel):
66
+ model_config = ConfigDict(frozen=True)
67
+
68
+ mutation_id: str
69
+ candidate_id: str
70
+ state: str
71
+ failure_class: str | None = None
72
+ rejection_reason: str | None = None
73
+ tier3_audit: dict | None = None
74
+ pipeline_run: dict | None = None
75
+ artifacts: list[dict] = Field(default_factory=list)
76
+ projections: list[dict] = Field(default_factory=list)
77
+
78
+
79
+ class V4CapabilityFlags(BaseModel):
80
+ model_config = ConfigDict(frozen=True)
81
+
82
+ canonical_ledger: bool = True
83
+ projection_outbox: bool = True
84
+ idempotent_ingestion: bool = True
85
+ vector_retrieval: bool = True
86
+ lexical_retrieval: bool = True
87
+ assertion_relational_lane: bool = True
88
+ validity_interval_filtering: bool = True
89
+ graph_projection: bool = True
90
+ graph_neighbor_retrieval: bool = False
91
+ associative_ppr: bool = False
92
+ bitemporal_query: bool = False
93
+ durable_rebuild: bool = False
94
+ human_review: bool = False
95
+
96
+
97
+ class V4ValidationCapability(BaseModel):
98
+ model_config = ConfigDict(frozen=True)
99
+
100
+ mode: int = 0
101
+ policy: str = "deterministic_only"
102
+ llm_validation_enabled: bool = False
103
+ validator_count: int = 0
104
+
105
+
106
+ class V4CapabilityLimits(BaseModel):
107
+ model_config = ConfigDict(frozen=True)
108
+
109
+ rebuild_kind: str = "projection"
110
+ rebuild_scope: str = "storage_root"
111
+ requires_offline_runner: bool = True
112
+
113
+
114
+ class V4CapabilityResponse(BaseModel):
115
+ model_config = ConfigDict(frozen=True)
116
+
117
+ api_version: str = "v4"
118
+ features: list[str]
119
+ capabilities: V4CapabilityFlags
120
+ validation: V4ValidationCapability = Field(default_factory=V4ValidationCapability)
121
+ limits: V4CapabilityLimits = Field(default_factory=V4CapabilityLimits)
122
+
123
+
124
+ class V4OperationProgress(BaseModel):
125
+ model_config = ConfigDict(frozen=True)
126
+
127
+ completed: int = Field(ge=0)
128
+ total: int = Field(ge=0)
129
+
130
+
131
+ class V4OperationResponse(BaseModel):
132
+ """Content-free control-plane view of one durable operation."""
133
+
134
+ model_config = ConfigDict(frozen=True)
135
+
136
+ operation_id: str
137
+ operation_kind: str = "projection_rebuild"
138
+ scope: str = "storage_root"
139
+ state: str
140
+ attempt: int = Field(ge=0)
141
+ progress: V4OperationProgress
142
+ error_class: str | None = None
143
+ retry_available: bool
144
+ cancel_available: bool
145
+ created_at: str
146
+ updated_at: str
147
+ completed_at: str | None = None
148
+
149
+
150
+ class V4DatasetRequest(BaseModel):
151
+ model_config = ConfigDict(strict=True, frozen=True)
152
+
153
+ tenant_id: PublicIdentifier
154
+ workspace_id: PublicIdentifier
155
+ dataset_id: PublicIdentifier
156
+ tenant_name: str | None = Field(default=None, max_length=256)
157
+ workspace_name: str | None = Field(default=None, max_length=256)
158
+ dataset_name: str | None = Field(default=None, max_length=256)
159
+
160
+
161
+ class V4WorkspaceRequest(BaseModel):
162
+ model_config = ConfigDict(strict=True, frozen=True)
163
+
164
+ tenant_id: PublicIdentifier
165
+ workspace_id: PublicIdentifier
166
+ tenant_name: str | None = Field(default=None, max_length=256)
167
+ workspace_name: str | None = Field(default=None, max_length=256)
168
+
169
+
170
+ class V4DocumentRequest(BaseModel):
171
+ model_config = ConfigDict(strict=True, frozen=True)
172
+
173
+ tenant_id: PublicIdentifier
174
+ workspace_id: PublicIdentifier
175
+ dataset_id: PublicIdentifier
176
+ document_id: SourceIdentifier
177
+ title: str = Field(min_length=1, max_length=512)
178
+ external_ref: str | None = Field(default=None, max_length=2048)
179
+
180
+
181
+ class V4RevisionRequest(BaseModel):
182
+ model_config = ConfigDict(strict=True, frozen=True)
183
+
184
+ tenant_id: PublicIdentifier
185
+ workspace_id: PublicIdentifier
186
+ dataset_id: PublicIdentifier
187
+ document_id: SourceIdentifier
188
+ revision_id: SourceIdentifier
189
+ revision_number: int = Field(ge=1)
190
+ content_sha256: str = Field(pattern=r"^[0-9a-fA-F]{64}$")
191
+ supersedes_revision_id: SourceIdentifier | None = None
192
+
193
+
194
+ class V4SourceChunkRequest(BaseModel):
195
+ model_config = ConfigDict(strict=True, frozen=True)
196
+
197
+ tenant_id: PublicIdentifier
198
+ workspace_id: PublicIdentifier
199
+ dataset_id: PublicIdentifier
200
+ document_id: SourceIdentifier
201
+ revision_id: SourceIdentifier
202
+ chunk_id: SourceIdentifier
203
+ title: str = Field(min_length=1, max_length=512)
204
+ content: str = Field(min_length=1, max_length=32768)
205
+ source_ref: str = Field(min_length=1, max_length=2048)
206
+ revision_number: int = Field(default=1, ge=1)
207
+ chunk_ordinal: int = Field(default=0, ge=0)
208
+ finalize_revision: bool = True
209
+ external_ref: str | None = Field(default=None, max_length=2048)
210
+ supersedes_revision_id: SourceIdentifier | None = None
211
+
212
+
213
+ class V4SessionStartRequest(BaseModel):
214
+ model_config = ConfigDict(strict=True, frozen=True)
215
+
216
+ tenant_id: PublicIdentifier
217
+ workspace_id: PublicIdentifier
218
+ dataset_ids: list[PublicIdentifier] = Field(min_length=1, max_length=64)
219
+ agent_id: PublicIdentifier
220
+
221
+
222
+ class V4LegalIdentityRequest(BaseModel):
223
+ """Typed, validated legal identity supplied by an authorized ingestion writer."""
224
+
225
+ model_config = ConfigDict(strict=True, frozen=True)
226
+
227
+ law_id: SourceIdentifier
228
+ statute_code: SourceIdentifier | None = None
229
+ article_id: str = Field(
230
+ pattern=r"^(?:madde[- ]*)?\d+[A-Za-zÇĞİÖŞÜçğıöşü]?$", max_length=32
231
+ )
232
+ version_id: SourceIdentifier | None = None
233
+ paragraph_id: SourceIdentifier | None = None
234
+ clause_id: SourceIdentifier | None = None
235
+
236
+ @model_validator(mode="after")
237
+ def validate_legal_identity(self) -> "V4LegalIdentityRequest":
238
+ canonicalize_trusted_legal_identity(
239
+ {
240
+ **self.model_dump(exclude_none=True),
241
+ "identity_source": "verified_ingestion",
242
+ }
243
+ )
244
+ return self
245
+
246
+
247
+ class V4MemoryInsertRequest(BaseModel):
248
+ model_config = ConfigDict(strict=True, frozen=True)
249
+
250
+ session_id: PublicIdentifier
251
+ dataset_id: PublicIdentifier
252
+ document_id: SourceIdentifier
253
+ revision_id: SourceIdentifier
254
+ chunk_id: SourceIdentifier
255
+ title: str = Field(min_length=1, max_length=512)
256
+ source_ref: str = Field(min_length=1, max_length=2048)
257
+ content: str = Field(min_length=1, max_length=32768)
258
+ evidence_span: str = Field(default="", max_length=4096)
259
+ revision_number: int = Field(default=1, ge=1)
260
+ chunk_ordinal: int = Field(default=0, ge=0)
261
+ finalize_revision: bool = True
262
+ supersedes_revision_id: SourceIdentifier | None = None
263
+ metadata: dict = Field(default_factory=dict)
264
+ legal_identity: V4LegalIdentityRequest | None = None
265
+ idempotency_key: str | None = Field(default=None, max_length=128)
266
+
267
+ @model_validator(mode="after")
268
+ def validate_write_boundary(self) -> "V4MemoryInsertRequest":
269
+ validate_write_payload(self.content, self.metadata)
270
+ return self
271
+
272
+
273
+ class V4DocumentIngestRequest(BaseModel):
274
+ model_config = ConfigDict(strict=True, frozen=True)
275
+
276
+ session_id: PublicIdentifier
277
+ dataset_id: PublicIdentifier
278
+ document_id: SourceIdentifier
279
+ title: str = Field(min_length=1, max_length=512)
280
+ source_ref: str = Field(min_length=1, max_length=2048)
281
+ content: str = Field(min_length=1)
282
+ metadata: dict = Field(default_factory=dict)
283
+
284
+ @model_validator(mode="after")
285
+ def validate_write_boundary(self) -> "V4DocumentIngestRequest":
286
+ if len(self.content.encode("utf-8")) > 5_000_000:
287
+ raise HTTPException(
288
+ status_code=413, detail="document exceeds 5,000,000 UTF-8 bytes"
289
+ )
290
+ if not self.content.strip():
291
+ raise ValueError("content must not be empty")
292
+ validate_write_payload(self.content, self.metadata)
293
+ return self
294
+
295
+
296
+ class V4DocumentIngestResponse(BaseModel):
297
+ document_id: str
298
+ revision_id: str
299
+ revision_number: int
300
+ chunk_count: int
301
+ mutation_ids: list[str]
302
+ duplicate: bool
303
+
304
+
305
+ class V4SearchRequest(BaseModel):
306
+ model_config = ConfigDict(strict=True, frozen=True)
307
+
308
+ session_id: PublicIdentifier
309
+ dataset_ids: list[PublicIdentifier] | None = Field(
310
+ default=None, min_length=1, max_length=64
311
+ )
312
+ query: str = Field(min_length=1, max_length=4096)
313
+ limit: int = Field(default=10, ge=1, le=50)
314
+ jurisdiction: str | None = Field(default=None, max_length=256)
315
+ valid_at: datetime | None = None
316
+ valid_from: datetime | None = None
317
+ valid_to: datetime | None = None
318
+ graph_mode: Literal["enabled", "disabled"] = Field(
319
+ default="enabled",
320
+ description="Enable or disable only the graph retrieval lane for matched ablation.",
321
+ )
322
+
323
+ @field_validator("valid_at", "valid_from", "valid_to", mode="before")
324
+ @classmethod
325
+ def parse_iso8601_temporal(cls, value: Any) -> Any:
326
+ if value is None:
327
+ return None
328
+ if isinstance(value, datetime):
329
+ return value
330
+ if isinstance(value, str):
331
+ raw = value.strip()
332
+ if not raw or len(raw) > 128 or any(ord(char) < 32 for char in raw):
333
+ raise ValueError(
334
+ "temporal value must be a valid ISO-8601 timestamp string"
335
+ )
336
+ if raw.endswith(("Z", "z")):
337
+ normalized = raw[:-1] + "+00:00"
338
+ else:
339
+ normalized = raw
340
+ try:
341
+ parsed = datetime.fromisoformat(normalized)
342
+ except ValueError as exc:
343
+ raise ValueError(
344
+ "temporal value must be a valid ISO-8601 timestamp string"
345
+ ) from exc
346
+ if parsed.tzinfo is None or parsed.tzinfo.utcoffset(parsed) is None:
347
+ raise ValueError(
348
+ "temporal value must include a timezone offset (e.g. 'Z' or '+00:00')"
349
+ )
350
+ return parsed
351
+ return value
352
+
353
+ @model_validator(mode="after")
354
+ def validate_temporal_range(self) -> "V4SearchRequest":
355
+ if self.valid_from and self.valid_to and self.valid_from > self.valid_to:
356
+ raise ValueError("valid_from must not be after valid_to")
357
+ return self
358
+
359
+
360
+ class V4ScopeAudit(BaseModel):
361
+ """Versioned proof that the canonical candidate pool was scoped pre-rank."""
362
+
363
+ model_config = ConfigDict(frozen=True)
364
+
365
+ contract_version: str
366
+ enforcement_stage: Literal["pre_rank"]
367
+ requested_scope: dict[str, Any]
368
+ requested_scope_identity: str
369
+ query_identity: str
370
+ evaluated_candidate_count: int = Field(ge=0)
371
+ excluded_candidate_count: int = Field(ge=0)
372
+ eligible_candidate_count: int = Field(ge=0)
373
+ exclusion_audit_hash: str
374
+
375
+
376
+ class V4GraphAblation(BaseModel):
377
+ """Versioned identity for a supported graph ON/OFF matched execution."""
378
+
379
+ model_config = ConfigDict(frozen=True)
380
+
381
+ contract_version: str
382
+ mode: Literal["enabled", "disabled"]
383
+ pair_identity: str
384
+ query_identity: str
385
+ retrieval_config_identity: str
386
+ scope_identity: str
387
+
388
+
389
+ class V4VectorLaneDiagnostics(BaseModel):
390
+ """Bounded explanation of vector-lane availability for one search."""
391
+
392
+ model_config = ConfigDict(frozen=True)
393
+
394
+ contract_version: str
395
+ status: str
396
+ representation_version: str
397
+ embedding_space_id: str | None = None
398
+ registry_artifact_count: int = Field(ge=0)
399
+ compatible_registry_artifact_count: int = Field(ge=0)
400
+ allowed_vector_id_count: int = Field(ge=0)
401
+ rejected_artifact_count: int = Field(ge=0)
402
+ rejection_reasons: dict[str, int]
403
+ search_executed: bool
404
+ search_result_count: int = Field(ge=0)
405
+ candidate_count: int = Field(ge=0)
406
+ physical_vector_id_count: int = Field(ge=0)
407
+ missing_vector_id_count: int = Field(ge=0)
408
+
409
+
410
+ class V4SearchResponse(BaseModel):
411
+ model_config = ConfigDict(frozen=True)
412
+
413
+ session_id: str
414
+ dataset_ids: list[str]
415
+ results: list[dict[str, Any]]
416
+ degraded_sources: list[str] = []
417
+ scope_audit: V4ScopeAudit | None = None
418
+ graph_ablation: V4GraphAblation | None = None
419
+ vector_lane: V4VectorLaneDiagnostics | None = None
420
+
421
+
422
+ def _active_principal(request: Request):
423
+ principal = getattr(request.state, "principal", None)
424
+ if principal is None or getattr(principal, "status", None) != "active":
425
+ raise HTTPException(
426
+ status_code=401, detail="Active authenticated principal required"
427
+ )
428
+ return principal
429
+
430
+
431
+ async def _require_control_admin(
432
+ request: Request,
433
+ access_control: AccessControl,
434
+ *,
435
+ hide_existence: bool = False,
436
+ ):
437
+ principal = _active_principal(request)
438
+ if not await access_control.check_control_role(
439
+ str(principal.principal_id), "ADMIN"
440
+ ):
441
+ if hide_existence:
442
+ raise HTTPException(status_code=404, detail="Operation not found")
443
+ raise HTTPException(
444
+ status_code=403, detail="Control administrator role required"
445
+ )
446
+ return principal
447
+
448
+
449
+ def _public_operation(operation: dict) -> V4OperationResponse:
450
+ """Whitelist bounded operation fields safe to expose outside storage."""
451
+ state = str(operation["state"])
452
+ return V4OperationResponse(
453
+ operation_id=str(operation["operation_id"]),
454
+ state=state,
455
+ attempt=int(operation["attempt_count"]),
456
+ progress=V4OperationProgress(
457
+ completed=int(operation["progress_completed"]),
458
+ total=int(operation["progress_total"]),
459
+ ),
460
+ error_class=(
461
+ str(operation["last_error_class"])
462
+ if operation.get("last_error_class") is not None
463
+ else None
464
+ ),
465
+ retry_available=state == "RETRYABLE_FAILED",
466
+ cancel_available=state in {"PENDING", "RETRYABLE_FAILED"},
467
+ created_at=str(operation["created_at"]),
468
+ updated_at=str(operation["updated_at"]),
469
+ completed_at=(
470
+ str(operation["completed_at"])
471
+ if operation.get("completed_at") is not None
472
+ else None
473
+ ),
474
+ )
475
+
476
+
477
+ async def _require_session_access(
478
+ request: Request,
479
+ access_control: AccessControl,
480
+ *,
481
+ agent_id: str,
482
+ session_id: str,
483
+ level: str,
484
+ allow_closed: bool = False,
485
+ ) -> None:
486
+ principal = _active_principal(request)
487
+ if not await access_control.check_principal_session_access(
488
+ principal.principal_id, agent_id, session_id, level
489
+ ):
490
+ raise HTTPException(status_code=403, detail="Principal/session access denied")
491
+ if not allow_closed and not await access_control.check_access(
492
+ agent_id, session_id, level
493
+ ):
494
+ raise HTTPException(
495
+ status_code=403, detail="Session is not active for requested operation"
496
+ )
497
+
498
+
499
+ async def _require_dataset_roles(
500
+ request: Request,
501
+ access_control: AccessControl,
502
+ *,
503
+ tenant_id: str,
504
+ workspace_id: str,
505
+ dataset_ids: list[str],
506
+ required_role: str,
507
+ ) -> None:
508
+ principal = _active_principal(request)
509
+ for dataset_id in sorted(set(dataset_ids)):
510
+ if not await access_control.check_scope_role(
511
+ principal.principal_id,
512
+ tenant_id=tenant_id,
513
+ workspace_id=workspace_id,
514
+ dataset_id=dataset_id,
515
+ required_role=required_role,
516
+ ):
517
+ raise HTTPException(
518
+ status_code=403,
519
+ detail="Principal lacks required dataset role",
520
+ )
521
+
522
+
523
+ async def _authorized_v4_session(
524
+ request: Request,
525
+ dao: MemoryDAO,
526
+ access_control: AccessControl,
527
+ session_id: str,
528
+ *,
529
+ level: str,
530
+ allow_closed: bool = False,
531
+ ) -> dict:
532
+ principal = _active_principal(request)
533
+ session = await dao.get_v4_session(
534
+ session_id,
535
+ principal_id=str(principal.principal_id),
536
+ )
537
+ if session is None:
538
+ raise HTTPException(status_code=404, detail="Unknown session")
539
+ await _require_session_access(
540
+ request,
541
+ access_control,
542
+ agent_id=str(session["agent_id"]),
543
+ session_id=session_id,
544
+ level=level,
545
+ allow_closed=allow_closed,
546
+ )
547
+ await _require_dataset_roles(
548
+ request,
549
+ access_control,
550
+ tenant_id=str(session["tenant_id"]),
551
+ workspace_id=str(session["workspace_id"]),
552
+ dataset_ids=list(session["dataset_ids"]),
553
+ required_role="WRITER" if level == "WRITE" else "READER",
554
+ )
555
+ if not allow_closed and session["status"] != "ACTIVE" and level == "WRITE":
556
+ raise HTTPException(status_code=409, detail="Session is not active")
557
+ return session
558
+
559
+
560
+ def _require_historical_mutation_scope(mutation: dict, session: dict) -> None:
561
+ """Bind historical administration to the mutation's immutable session scope."""
562
+ mutation_workspace = mutation.get("workspace_id")
563
+ if (
564
+ str(mutation.get("tenant_id") or "") != str(session["tenant_id"])
565
+ or str(mutation.get("agent_id") or "") != str(session["agent_id"])
566
+ or str(mutation.get("dataset_id") or "")
567
+ not in {str(item) for item in session["dataset_ids"]}
568
+ or (
569
+ mutation_workspace
570
+ and str(mutation_workspace) != str(session["workspace_id"])
571
+ )
572
+ ):
573
+ raise HTTPException(
574
+ status_code=403,
575
+ detail="Mutation is outside historical session scope",
576
+ )
577
+
578
+
579
+ def create_v4_router(
580
+ get_dao: Callable[[], MemoryDAO],
581
+ *,
582
+ get_access_control: Callable[[], AccessControl],
583
+ get_composed_validation_policy: Callable[[], ValidationPolicy | None] | None = None,
584
+ get_extraction_enabled: Callable[[], bool] | None = None,
585
+ ) -> APIRouter:
586
+ router = APIRouter(prefix="/v4", tags=["v4-full-cognitive"])
587
+ # ponytail: serialize document uploads; use per-document locks if throughput requires it.
588
+ document_admission_lock = asyncio.Lock()
589
+
590
+ def current_validation_policy() -> ValidationPolicy | None:
591
+ return (
592
+ get_composed_validation_policy()
593
+ if get_composed_validation_policy is not None
594
+ else None
595
+ )
596
+
597
+ def is_extraction_enabled() -> bool:
598
+ if get_extraction_enabled is not None:
599
+ return bool(get_extraction_enabled())
600
+ return True
601
+
602
+ @router.post("/catalog/workspaces", status_code=201)
603
+ async def create_workspace(
604
+ request: Request,
605
+ payload: V4WorkspaceRequest,
606
+ dao: MemoryDAO = Depends(get_dao),
607
+ access_control: AccessControl = Depends(get_access_control),
608
+ ) -> dict:
609
+ await _require_dataset_roles(
610
+ request,
611
+ access_control,
612
+ tenant_id=payload.tenant_id,
613
+ workspace_id=payload.workspace_id,
614
+ dataset_ids=[""],
615
+ required_role="OWNER",
616
+ )
617
+ await _require_mutation_admission(dao)
618
+ try:
619
+ return await dao.create_v4_workspace(
620
+ tenant_id=payload.tenant_id,
621
+ workspace_id=payload.workspace_id,
622
+ tenant_name=payload.tenant_name,
623
+ workspace_name=payload.workspace_name,
624
+ )
625
+ except ValueError as exc:
626
+ raise HTTPException(status_code=409, detail=str(exc))
627
+
628
+ @router.get("/catalog/workspaces")
629
+ async def list_workspaces(
630
+ tenant_id: PublicIdentifier,
631
+ request: Request,
632
+ dao: MemoryDAO = Depends(get_dao),
633
+ access_control: AccessControl = Depends(get_access_control),
634
+ ) -> dict:
635
+ workspaces = await dao.list_v4_workspaces(tenant_id=tenant_id)
636
+ visible = []
637
+ for workspace in workspaces:
638
+ try:
639
+ await _require_dataset_roles(
640
+ request,
641
+ access_control,
642
+ tenant_id=tenant_id,
643
+ workspace_id=str(workspace["workspace_id"]),
644
+ dataset_ids=[""],
645
+ required_role="READER",
646
+ )
647
+ except HTTPException:
648
+ continue
649
+ visible.append(workspace)
650
+ return {"workspaces": visible}
651
+
652
+ @router.post("/catalog/datasets", status_code=201)
653
+ async def create_dataset(
654
+ request: Request,
655
+ payload: V4DatasetRequest,
656
+ dao: MemoryDAO = Depends(get_dao),
657
+ access_control: AccessControl = Depends(get_access_control),
658
+ ) -> dict:
659
+ await _require_dataset_roles(
660
+ request,
661
+ access_control,
662
+ tenant_id=payload.tenant_id,
663
+ workspace_id=payload.workspace_id,
664
+ dataset_ids=[payload.dataset_id],
665
+ required_role="OWNER",
666
+ )
667
+ await _require_mutation_admission(dao)
668
+ try:
669
+ await dao.ensure_v4_catalog_scope(
670
+ tenant_id=payload.tenant_id,
671
+ workspace_id=payload.workspace_id,
672
+ dataset_id=payload.dataset_id,
673
+ tenant_name=payload.tenant_name,
674
+ workspace_name=payload.workspace_name,
675
+ dataset_name=payload.dataset_name,
676
+ )
677
+ except ValueError as exc:
678
+ raise HTTPException(status_code=409, detail=str(exc))
679
+ return payload.model_dump()
680
+
681
+ @router.get("/catalog/datasets")
682
+ async def list_datasets(
683
+ tenant_id: PublicIdentifier,
684
+ workspace_id: PublicIdentifier,
685
+ request: Request,
686
+ dao: MemoryDAO = Depends(get_dao),
687
+ access_control: AccessControl = Depends(get_access_control),
688
+ ) -> dict:
689
+ datasets = await dao.list_v4_datasets(
690
+ tenant_id=tenant_id, workspace_id=workspace_id
691
+ )
692
+ visible = []
693
+ for dataset in datasets:
694
+ try:
695
+ await _require_dataset_roles(
696
+ request,
697
+ access_control,
698
+ tenant_id=tenant_id,
699
+ workspace_id=workspace_id,
700
+ dataset_ids=[str(dataset["dataset_id"])],
701
+ required_role="READER",
702
+ )
703
+ except HTTPException:
704
+ continue
705
+ visible.append(dataset)
706
+ return {"datasets": visible}
707
+
708
+ @router.post("/catalog/documents", status_code=201)
709
+ async def create_document(
710
+ request: Request,
711
+ payload: V4DocumentRequest,
712
+ dao: MemoryDAO = Depends(get_dao),
713
+ access_control: AccessControl = Depends(get_access_control),
714
+ ) -> dict:
715
+ await _require_dataset_roles(
716
+ request,
717
+ access_control,
718
+ tenant_id=payload.tenant_id,
719
+ workspace_id=payload.workspace_id,
720
+ dataset_ids=[payload.dataset_id],
721
+ required_role="WRITER",
722
+ )
723
+ await _require_mutation_admission(dao)
724
+ try:
725
+ return await dao.create_v4_document(
726
+ tenant_id=payload.tenant_id,
727
+ dataset_id=payload.dataset_id,
728
+ document_id=payload.document_id,
729
+ title=payload.title,
730
+ external_ref=payload.external_ref,
731
+ )
732
+ except ValueError as exc:
733
+ raise HTTPException(status_code=409, detail=str(exc))
734
+
735
+ @router.get("/catalog/documents")
736
+ async def list_documents(
737
+ tenant_id: PublicIdentifier,
738
+ workspace_id: PublicIdentifier,
739
+ dataset_id: PublicIdentifier,
740
+ request: Request,
741
+ dao: MemoryDAO = Depends(get_dao),
742
+ access_control: AccessControl = Depends(get_access_control),
743
+ ) -> dict:
744
+ await _require_dataset_roles(
745
+ request,
746
+ access_control,
747
+ tenant_id=tenant_id,
748
+ workspace_id=workspace_id,
749
+ dataset_ids=[dataset_id],
750
+ required_role="READER",
751
+ )
752
+ return {
753
+ "documents": await dao.list_v4_documents(
754
+ tenant_id=tenant_id, dataset_id=dataset_id
755
+ )
756
+ }
757
+
758
+ @router.post("/catalog/revisions", status_code=201)
759
+ async def create_revision(
760
+ request: Request,
761
+ payload: V4RevisionRequest,
762
+ dao: MemoryDAO = Depends(get_dao),
763
+ access_control: AccessControl = Depends(get_access_control),
764
+ ) -> dict:
765
+ await _require_dataset_roles(
766
+ request,
767
+ access_control,
768
+ tenant_id=payload.tenant_id,
769
+ workspace_id=payload.workspace_id,
770
+ dataset_ids=[payload.dataset_id],
771
+ required_role="WRITER",
772
+ )
773
+ await _require_mutation_admission(dao)
774
+ try:
775
+ return await dao.create_v4_revision(
776
+ tenant_id=payload.tenant_id,
777
+ dataset_id=payload.dataset_id,
778
+ document_id=payload.document_id,
779
+ revision_id=payload.revision_id,
780
+ revision_number=payload.revision_number,
781
+ content_hash=payload.content_sha256,
782
+ supersedes_revision_id=payload.supersedes_revision_id,
783
+ )
784
+ except ValueError as exc:
785
+ raise HTTPException(status_code=409, detail=str(exc))
786
+
787
+ @router.get("/catalog/revisions")
788
+ async def list_revisions(
789
+ tenant_id: PublicIdentifier,
790
+ workspace_id: PublicIdentifier,
791
+ dataset_id: PublicIdentifier,
792
+ document_id: SourceIdentifier,
793
+ request: Request,
794
+ dao: MemoryDAO = Depends(get_dao),
795
+ access_control: AccessControl = Depends(get_access_control),
796
+ ) -> dict:
797
+ await _require_dataset_roles(
798
+ request,
799
+ access_control,
800
+ tenant_id=tenant_id,
801
+ workspace_id=workspace_id,
802
+ dataset_ids=[dataset_id],
803
+ required_role="READER",
804
+ )
805
+ try:
806
+ return {
807
+ "revisions": await dao.list_v4_revisions(
808
+ tenant_id=tenant_id,
809
+ dataset_id=dataset_id,
810
+ document_id=document_id,
811
+ )
812
+ }
813
+ except CatalogIdentityNotFoundError as exc:
814
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
815
+
816
+ @router.post("/catalog/source-chunks", status_code=201)
817
+ async def create_source_chunk(
818
+ request: Request,
819
+ payload: V4SourceChunkRequest,
820
+ dao: MemoryDAO = Depends(get_dao),
821
+ access_control: AccessControl = Depends(get_access_control),
822
+ ) -> dict:
823
+ await _require_dataset_roles(
824
+ request,
825
+ access_control,
826
+ tenant_id=payload.tenant_id,
827
+ workspace_id=payload.workspace_id,
828
+ dataset_ids=[payload.dataset_id],
829
+ required_role="WRITER",
830
+ )
831
+ await _require_mutation_admission(dao)
832
+ try:
833
+ return await dao.create_v4_source_chunk(
834
+ tenant_id=payload.tenant_id,
835
+ dataset_id=payload.dataset_id,
836
+ document_id=payload.document_id,
837
+ revision_id=payload.revision_id,
838
+ chunk_id=payload.chunk_id,
839
+ title=payload.title,
840
+ content_payload=payload.content,
841
+ source_ref=payload.source_ref,
842
+ revision_number=payload.revision_number,
843
+ chunk_ordinal=payload.chunk_ordinal,
844
+ finalize_revision=payload.finalize_revision,
845
+ external_ref=payload.external_ref,
846
+ supersedes_revision_id=payload.supersedes_revision_id,
847
+ )
848
+ except ValueError as exc:
849
+ msg = str(exc)
850
+ if any(
851
+ term in msg
852
+ for term in ("collides", "finalized", "immutable", "superseded", "belong")
853
+ ):
854
+ raise HTTPException(status_code=409, detail=msg) from exc
855
+ raise HTTPException(status_code=422, detail=msg) from exc
856
+
857
+ @router.delete("/catalog/documents/{document_id}", status_code=202)
858
+ async def purge_document(
859
+ document_id: SourceIdentifier,
860
+ tenant_id: PublicIdentifier,
861
+ workspace_id: PublicIdentifier,
862
+ dataset_id: PublicIdentifier,
863
+ request: Request,
864
+ dao: MemoryDAO = Depends(get_dao),
865
+ access_control: AccessControl = Depends(get_access_control),
866
+ ) -> dict:
867
+ await _require_dataset_roles(
868
+ request,
869
+ access_control,
870
+ tenant_id=tenant_id,
871
+ workspace_id=workspace_id,
872
+ dataset_ids=[dataset_id],
873
+ required_role="OWNER",
874
+ )
875
+ principal = _active_principal(request)
876
+ if not await access_control.check_dataset_permission(
877
+ principal.principal_id,
878
+ tenant_id=tenant_id,
879
+ dataset_id=dataset_id,
880
+ permission="PURGE",
881
+ ):
882
+ raise HTTPException(status_code=403, detail="PURGE permission required")
883
+ await _require_mutation_admission(dao)
884
+ try:
885
+ return await dao.purge_v4_document(
886
+ tenant_id=tenant_id,
887
+ dataset_id=dataset_id,
888
+ document_id=document_id,
889
+ )
890
+ except ValueError as exc:
891
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
892
+ except PurgeRetryPendingError as exc:
893
+ raise HTTPException(
894
+ status_code=503,
895
+ detail="purge_cleanup_pending",
896
+ headers={"Retry-After": "5"},
897
+ ) from exc
898
+
899
+ @router.post("/sessions/start", status_code=201)
900
+ async def start_session(
901
+ request: Request,
902
+ payload: V4SessionStartRequest,
903
+ dao: MemoryDAO = Depends(get_dao),
904
+ access_control: AccessControl = Depends(get_access_control),
905
+ ) -> dict:
906
+ principal = _active_principal(request)
907
+ if not await access_control.check_principal_permission(
908
+ principal.principal_id, payload.agent_id, "SESSION_CREATE"
909
+ ):
910
+ raise HTTPException(
911
+ status_code=403, detail="Principal lacks SESSION_CREATE permission"
912
+ )
913
+ await _require_dataset_roles(
914
+ request,
915
+ access_control,
916
+ tenant_id=payload.tenant_id,
917
+ workspace_id=payload.workspace_id,
918
+ dataset_ids=payload.dataset_ids,
919
+ required_role="WRITER",
920
+ )
921
+ await _require_mutation_admission(dao)
922
+ try:
923
+ session = await dao.create_v4_session(
924
+ tenant_id=payload.tenant_id,
925
+ workspace_id=payload.workspace_id,
926
+ dataset_ids=payload.dataset_ids,
927
+ agent_id=payload.agent_id,
928
+ principal_id=principal.principal_id,
929
+ )
930
+ except ValueError as exc:
931
+ raise HTTPException(status_code=400, detail=str(exc))
932
+ session_id = str(session["session_id"])
933
+ await access_control.grant_access(payload.agent_id, session_id, "WRITE")
934
+ await access_control.grant_principal_session_access(
935
+ principal.principal_id, payload.agent_id, session_id, "WRITE"
936
+ )
937
+ return {"status": "started", **session}
938
+
939
+ @router.get("/capability", status_code=200)
940
+ async def get_capability(
941
+ dao: MemoryDAO = Depends(get_dao),
942
+ ) -> V4CapabilityResponse:
943
+ """Return bounded capability truth without implying planned behaviour."""
944
+ vector_available = getattr(
945
+ getattr(dao, "_vec", None),
946
+ "semantic_operational",
947
+ getattr(getattr(dao, "_vec", None), "semantic_runtime_available", False),
948
+ )
949
+ vector_contract_available = True
950
+ if callable(getattr(type(dao), "vector_consumer_status", None)):
951
+ vector_consumer = await dao.vector_consumer_status()
952
+ vector_contract_available = (
953
+ vector_consumer.get("compatible_registry_artifacts", 0) > 0
954
+ )
955
+ graph_available = getattr(dao, "graph_operational", False)
956
+ canonical_writes_available = dao.canonical_v4_writes_enabled is not False
957
+ capabilities = V4CapabilityFlags(
958
+ projection_outbox=canonical_writes_available,
959
+ idempotent_ingestion=canonical_writes_available,
960
+ durable_rebuild=config.v4_rebuild_enabled,
961
+ vector_retrieval=(
962
+ vector_available and vector_contract_available
963
+ if isinstance(vector_available, bool)
964
+ else False
965
+ ),
966
+ graph_projection=canonical_writes_available
967
+ and dao.graph_enabled is not False,
968
+ graph_neighbor_retrieval=(
969
+ graph_available if isinstance(graph_available, bool) else False
970
+ ),
971
+ )
972
+ policy = current_validation_policy()
973
+ if policy is not None:
974
+ val_cap = V4ValidationCapability(
975
+ mode=policy.mode,
976
+ policy=policy.name,
977
+ llm_validation_enabled=policy.llm_validation_enabled,
978
+ validator_count=policy.validator_count,
979
+ )
980
+ elif config.effective_tier3_mode() == 0:
981
+ val_cap = V4ValidationCapability(
982
+ mode=0,
983
+ policy="deterministic_only",
984
+ llm_validation_enabled=False,
985
+ validator_count=0,
986
+ )
987
+ else:
988
+ val_cap = V4ValidationCapability(
989
+ mode=config.effective_tier3_mode(),
990
+ policy="not_composed",
991
+ llm_validation_enabled=False,
992
+ validator_count=0,
993
+ )
994
+
995
+ return V4CapabilityResponse(
996
+ features=[
997
+ name for name, enabled in capabilities.model_dump().items() if enabled
998
+ ],
999
+ capabilities=capabilities,
1000
+ validation=val_cap,
1001
+ )
1002
+
1003
+ async def submit_rebuild_operation(
1004
+ request: Request,
1005
+ idempotency_key: str,
1006
+ dao: MemoryDAO,
1007
+ access_control: AccessControl,
1008
+ ) -> V4OperationResponse:
1009
+ principal = await _require_control_admin(request, access_control)
1010
+ if (
1011
+ not idempotency_key
1012
+ or idempotency_key.strip() != idempotency_key
1013
+ or len(idempotency_key) > 128
1014
+ or any(ord(char) < 32 for char in idempotency_key)
1015
+ ):
1016
+ raise HTTPException(status_code=422, detail="Idempotency-Key is invalid")
1017
+ if not config.v4_rebuild_enabled:
1018
+ raise HTTPException(status_code=501, detail="Durable rebuild is disabled")
1019
+ payload_hash = hashlib.sha256(
1020
+ b'{"kind":"projection","scope":"storage_root","version":1}'
1021
+ ).hexdigest()
1022
+ try:
1023
+ operation = await dao.operations.submit(
1024
+ requested_by_principal_id=str(principal.principal_id),
1025
+ idempotency_key=idempotency_key,
1026
+ payload_hash=payload_hash,
1027
+ )
1028
+ except OperationIdempotencyConflictError as exc:
1029
+ raise HTTPException(
1030
+ status_code=409, detail="Idempotency key conflicts with request"
1031
+ ) from exc
1032
+ except OperationActiveConflictError as exc:
1033
+ raise HTTPException(
1034
+ status_code=409, detail="A projection rebuild is already active"
1035
+ ) from exc
1036
+ return _public_operation(operation)
1037
+
1038
+ @router.post(
1039
+ "/operations/rebuild",
1040
+ response_model=V4OperationResponse,
1041
+ status_code=202,
1042
+ )
1043
+ async def create_rebuild_operation(
1044
+ request: Request,
1045
+ idempotency_key: str = Header(
1046
+ ...,
1047
+ alias="Idempotency-Key",
1048
+ min_length=1,
1049
+ max_length=128,
1050
+ ),
1051
+ dao: MemoryDAO = Depends(get_dao),
1052
+ access_control: AccessControl = Depends(get_access_control),
1053
+ ) -> V4OperationResponse:
1054
+ return await submit_rebuild_operation(
1055
+ request, idempotency_key, dao, access_control
1056
+ )
1057
+
1058
+ @router.get(
1059
+ "/operations/{operation_id}",
1060
+ response_model=V4OperationResponse,
1061
+ status_code=200,
1062
+ )
1063
+ async def get_rebuild_operation(
1064
+ operation_id: PublicIdentifier,
1065
+ request: Request,
1066
+ dao: MemoryDAO = Depends(get_dao),
1067
+ access_control: AccessControl = Depends(get_access_control),
1068
+ ) -> V4OperationResponse:
1069
+ await _require_control_admin(request, access_control, hide_existence=True)
1070
+ operation = await dao.operations.get(operation_id)
1071
+ if operation is None:
1072
+ raise HTTPException(status_code=404, detail="Operation not found")
1073
+ return _public_operation(operation)
1074
+
1075
+ @router.post(
1076
+ "/operations/{operation_id}/cancel",
1077
+ response_model=V4OperationResponse,
1078
+ status_code=200,
1079
+ )
1080
+ async def cancel_rebuild_operation(
1081
+ operation_id: PublicIdentifier,
1082
+ request: Request,
1083
+ dao: MemoryDAO = Depends(get_dao),
1084
+ access_control: AccessControl = Depends(get_access_control),
1085
+ ) -> V4OperationResponse:
1086
+ await _require_control_admin(request, access_control, hide_existence=True)
1087
+ try:
1088
+ operation = await dao.operations.cancel(operation_id)
1089
+ except OperationNotFoundError as exc:
1090
+ raise HTTPException(status_code=404, detail="Operation not found") from exc
1091
+ except OperationStateError as exc:
1092
+ raise HTTPException(
1093
+ status_code=409, detail="Operation cannot be cancelled"
1094
+ ) from exc
1095
+ return _public_operation(operation)
1096
+
1097
+ @router.post(
1098
+ "/operations/{operation_id}/retry",
1099
+ response_model=V4OperationResponse,
1100
+ status_code=202,
1101
+ )
1102
+ async def retry_rebuild_operation(
1103
+ operation_id: PublicIdentifier,
1104
+ request: Request,
1105
+ dao: MemoryDAO = Depends(get_dao),
1106
+ access_control: AccessControl = Depends(get_access_control),
1107
+ ) -> V4OperationResponse:
1108
+ await _require_control_admin(request, access_control, hide_existence=True)
1109
+ if not config.v4_rebuild_enabled:
1110
+ raise HTTPException(status_code=501, detail="Durable rebuild is disabled")
1111
+ try:
1112
+ operation = await dao.operations.retry(operation_id)
1113
+ except OperationNotFoundError as exc:
1114
+ raise HTTPException(status_code=404, detail="Operation not found") from exc
1115
+ except OperationStateError as exc:
1116
+ raise HTTPException(
1117
+ status_code=409, detail="Operation cannot be retried"
1118
+ ) from exc
1119
+ return _public_operation(operation)
1120
+
1121
+ @router.post(
1122
+ "/rebuild",
1123
+ response_model=V4OperationResponse,
1124
+ status_code=202,
1125
+ deprecated=True,
1126
+ )
1127
+ async def rebuild_index(
1128
+ request: Request,
1129
+ tenant_id: PublicIdentifier | None = None,
1130
+ workspace_id: PublicIdentifier | None = None,
1131
+ dataset_id: PublicIdentifier | None = None,
1132
+ idempotency_key: str = Header(
1133
+ ...,
1134
+ alias="Idempotency-Key",
1135
+ min_length=1,
1136
+ max_length=128,
1137
+ ),
1138
+ dao: MemoryDAO = Depends(get_dao),
1139
+ access_control: AccessControl = Depends(get_access_control),
1140
+ ) -> V4OperationResponse:
1141
+ await _require_control_admin(request, access_control)
1142
+ if tenant_id is not None or workspace_id is not None or dataset_id is not None:
1143
+ raise HTTPException(
1144
+ status_code=409,
1145
+ detail="Scoped projection rebuild is not supported",
1146
+ )
1147
+ return await submit_rebuild_operation(
1148
+ request, idempotency_key, dao, access_control
1149
+ )
1150
+
1151
+ @router.post("/memory/insert", status_code=202)
1152
+ async def insert_memory(
1153
+ request: Request,
1154
+ payload: V4MemoryInsertRequest,
1155
+ dao: MemoryDAO = Depends(get_dao),
1156
+ access_control: AccessControl = Depends(get_access_control),
1157
+ ) -> dict:
1158
+ session = await _authorized_v4_session(
1159
+ request,
1160
+ dao,
1161
+ access_control,
1162
+ payload.session_id,
1163
+ level="WRITE",
1164
+ )
1165
+ if payload.dataset_id not in session["dataset_ids"]:
1166
+ raise HTTPException(
1167
+ status_code=403, detail="Dataset is outside session scope"
1168
+ )
1169
+ await _require_mutation_admission(dao, require_projection_consumer=True)
1170
+ payload_hash = hashlib.sha256(
1171
+ payload.model_dump_json(exclude={"session_id", "idempotency_key"}).encode()
1172
+ ).hexdigest()
1173
+ embedding_identity = configured_embedding_identity()
1174
+ validation_policy = current_validation_policy()
1175
+ try:
1176
+ admission = await dao.admit_v4_memory(
1177
+ tenant_id=str(session["tenant_id"]),
1178
+ workspace_id=str(session["workspace_id"]),
1179
+ dataset_id=payload.dataset_id,
1180
+ agent_id=str(session["agent_id"]),
1181
+ session_id=payload.session_id,
1182
+ document_id=payload.document_id,
1183
+ revision_id=payload.revision_id,
1184
+ chunk_id=payload.chunk_id,
1185
+ title=payload.title,
1186
+ content_payload=payload.content,
1187
+ source_ref=payload.source_ref,
1188
+ evidence_span=payload.evidence_span,
1189
+ revision_number=payload.revision_number,
1190
+ chunk_ordinal=payload.chunk_ordinal,
1191
+ supersedes_revision_id=payload.supersedes_revision_id,
1192
+ metadata=payload.metadata,
1193
+ legal_identity=(
1194
+ payload.legal_identity.model_dump(exclude_none=True)
1195
+ if payload.legal_identity is not None
1196
+ else None
1197
+ ),
1198
+ embedding_provider=embedding_identity.provider,
1199
+ embedding_model=embedding_identity.model,
1200
+ embedding_version=embedding_identity.version,
1201
+ embedding_dimension=embedding_identity.dimension,
1202
+ embedding_space_id=embedding_identity.embedding_space_id,
1203
+ embedding_model_revision=embedding_identity.model_revision,
1204
+ embedding_normalized=embedding_identity.normalized,
1205
+ policy=config.queue_admission_policy,
1206
+ validation_mode=(
1207
+ validation_policy.mode
1208
+ if validation_policy is not None
1209
+ else config.effective_tier3_mode()
1210
+ ),
1211
+ idempotency_key=payload.idempotency_key,
1212
+ payload_hash=payload_hash if payload.idempotency_key else None,
1213
+ finalize_revision=payload.finalize_revision,
1214
+ )
1215
+ except ValueError as exc:
1216
+ raise HTTPException(status_code=409, detail=str(exc))
1217
+ except QueueRecordTooLargeError:
1218
+ raise HTTPException(status_code=413, detail="queue_record_too_large")
1219
+ except QueueOverCapacityError:
1220
+ raise HTTPException(status_code=503, detail="queue_over_capacity")
1221
+ except QueueUnavailableError:
1222
+ raise HTTPException(status_code=503, detail="queue_unavailable")
1223
+ if admission["outcome"] == "IN_PROGRESS":
1224
+ raise HTTPException(
1225
+ status_code=409, detail="idempotency_key is in progress"
1226
+ )
1227
+ response = dict(admission["response"])
1228
+ if admission["outcome"] == "DUPLICATE":
1229
+ response["duplicate"] = True
1230
+ return response
1231
+
1232
+ @router.post("/documents", response_model=V4DocumentIngestResponse, status_code=202)
1233
+ async def ingest_document(
1234
+ request: Request,
1235
+ payload: V4DocumentIngestRequest,
1236
+ dao: MemoryDAO = Depends(get_dao),
1237
+ access_control: AccessControl = Depends(get_access_control),
1238
+ ) -> dict:
1239
+ session = await _authorized_v4_session(
1240
+ request, dao, access_control, payload.session_id, level="WRITE"
1241
+ )
1242
+ if payload.dataset_id not in session["dataset_ids"]:
1243
+ raise HTTPException(
1244
+ status_code=403, detail="Dataset is outside session scope"
1245
+ )
1246
+ await _require_mutation_admission(dao, require_projection_consumer=True)
1247
+ content_hash = hashlib.sha256(payload.content.encode("utf-8")).hexdigest()
1248
+ revision_id = (
1249
+ "rev-"
1250
+ + hashlib.sha256(
1251
+ (payload.document_id + "\n" + content_hash).encode("utf-8")
1252
+ ).hexdigest()[:32]
1253
+ )
1254
+ scope = {
1255
+ "tenant_id": str(session["tenant_id"]),
1256
+ "dataset_id": payload.dataset_id,
1257
+ }
1258
+ async with document_admission_lock:
1259
+ try:
1260
+ document = await dao.create_v4_document(
1261
+ **scope, document_id=payload.document_id, title=payload.title
1262
+ )
1263
+ if document["status"] == "PURGED":
1264
+ raise ValueError("document is purged")
1265
+ revisions = await dao.list_v4_revisions(
1266
+ **scope, document_id=payload.document_id
1267
+ )
1268
+ revision = next(
1269
+ (r for r in revisions if r["revision_id"] == revision_id), None
1270
+ )
1271
+ if revision is None:
1272
+ active = next(
1273
+ (r for r in revisions if r["status"] == "ACTIVE"), None
1274
+ )
1275
+ revision = await dao.create_v4_revision(
1276
+ **scope,
1277
+ document_id=payload.document_id,
1278
+ revision_id=revision_id,
1279
+ revision_number=max(
1280
+ (r["revision_number"] for r in revisions), default=0
1281
+ )
1282
+ + 1,
1283
+ content_hash=content_hash,
1284
+ supersedes_revision_id=active["revision_id"]
1285
+ if active
1286
+ else None,
1287
+ )
1288
+ if revision["status"] not in {"ACTIVE", "PENDING"}:
1289
+ raise ValueError(
1290
+ f"content equals revision {revision['revision_number']} "
1291
+ f"({revision['status']}); restoring an earlier revision "
1292
+ "is not supported in v0.9"
1293
+ )
1294
+ admitted = await dao.list_v4_revision_mutations(
1295
+ **scope, revision_id=revision_id
1296
+ )
1297
+ except ValueError as exc:
1298
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
1299
+ if revision["status"] == "ACTIVE":
1300
+ return {
1301
+ "document_id": payload.document_id,
1302
+ "revision_id": revision_id,
1303
+ "revision_number": revision["revision_number"],
1304
+ "chunk_count": len(admitted),
1305
+ "mutation_ids": [admitted[i] for i in sorted(admitted)],
1306
+ "duplicate": True,
1307
+ }
1308
+ chunks = chunk_document(payload.content)
1309
+ duplicate = len(admitted) == len(chunks)
1310
+ for ordinal, chunk in enumerate(chunks):
1311
+ if ordinal in admitted:
1312
+ continue
1313
+ chunk_id = f"{revision_id}-c{ordinal:05d}"
1314
+ response = await insert_memory(
1315
+ request,
1316
+ V4MemoryInsertRequest(
1317
+ **payload.model_dump(exclude={"content"}),
1318
+ content=chunk,
1319
+ revision_id=revision_id,
1320
+ chunk_id=chunk_id,
1321
+ revision_number=revision["revision_number"],
1322
+ chunk_ordinal=ordinal,
1323
+ supersedes_revision_id=revision["supersedes_revision_id"],
1324
+ finalize_revision=ordinal == len(chunks) - 1,
1325
+ idempotency_key=chunk_id,
1326
+ ),
1327
+ dao,
1328
+ access_control,
1329
+ )
1330
+ admitted[ordinal] = response["mutation_id"]
1331
+ return {
1332
+ "document_id": payload.document_id,
1333
+ "revision_id": revision_id,
1334
+ "revision_number": revision["revision_number"],
1335
+ "chunk_count": len(chunks),
1336
+ "mutation_ids": [admitted[i] for i in range(len(chunks))],
1337
+ "duplicate": duplicate,
1338
+ }
1339
+
1340
+ @router.post("/memory/search", response_model=V4SearchResponse)
1341
+ async def search_memory(
1342
+ request: Request,
1343
+ payload: V4SearchRequest,
1344
+ dao: MemoryDAO = Depends(get_dao),
1345
+ access_control: AccessControl = Depends(get_access_control),
1346
+ ) -> dict:
1347
+ session = await _authorized_v4_session(
1348
+ request, dao, access_control, payload.session_id, level="READ"
1349
+ )
1350
+ datasets = payload.dataset_ids or list(session["dataset_ids"])
1351
+ if not set(datasets).issubset(set(session["dataset_ids"])):
1352
+ raise HTTPException(
1353
+ status_code=403, detail="Dataset is outside session scope"
1354
+ )
1355
+ try:
1356
+ certification_metadata: dict[str, Any] = {}
1357
+ principal = _active_principal(request)
1358
+ results = await dao.search_v4_memory(
1359
+ tenant_id=str(session["tenant_id"]),
1360
+ agent_id=str(session["agent_id"]),
1361
+ dataset_ids=datasets,
1362
+ query=payload.query,
1363
+ limit=payload.limit,
1364
+ jurisdiction=payload.jurisdiction,
1365
+ valid_at=payload.valid_at.isoformat() if payload.valid_at else None,
1366
+ valid_from=(
1367
+ payload.valid_from.isoformat() if payload.valid_from else None
1368
+ ),
1369
+ valid_to=payload.valid_to.isoformat() if payload.valid_to else None,
1370
+ graph_enabled=payload.graph_mode == "enabled",
1371
+ request_principal_id=str(principal.principal_id),
1372
+ certification_metadata=certification_metadata,
1373
+ )
1374
+ except EmbeddingMigrationRequiredError:
1375
+ raise HTTPException(
1376
+ status_code=409,
1377
+ detail="embedding_migration_required",
1378
+ )
1379
+ except (
1380
+ VectorSearchError,
1381
+ EmbeddingUnavailableError,
1382
+ EmbeddingGenerationError,
1383
+ ExternalProviderForbiddenError,
1384
+ EmbeddingIdentityMismatchError,
1385
+ ):
1386
+ raise HTTPException(
1387
+ status_code=503,
1388
+ detail="vector_backend_unavailable",
1389
+ )
1390
+ except GraphSearchError:
1391
+ raise HTTPException(
1392
+ status_code=503,
1393
+ detail="graph_backend_unavailable",
1394
+ )
1395
+ return {
1396
+ "session_id": payload.session_id,
1397
+ "dataset_ids": datasets,
1398
+ "results": results,
1399
+ **certification_metadata,
1400
+ }
1401
+
1402
+ @router.get("/mutations/{mutation_id}", response_model=V4MutationStatusResponse)
1403
+ async def mutation_status(
1404
+ mutation_id: PublicIdentifier,
1405
+ request: Request,
1406
+ dao: MemoryDAO = Depends(get_dao),
1407
+ access_control: AccessControl = Depends(get_access_control),
1408
+ ) -> V4MutationStatusResponse:
1409
+ mutation = await dao.get_mutation_summary(mutation_id)
1410
+ if mutation is None:
1411
+ raise HTTPException(status_code=404, detail="Unknown mutation")
1412
+ await _authorized_v4_session(
1413
+ request,
1414
+ dao,
1415
+ access_control,
1416
+ str(mutation["session_id"]),
1417
+ level="READ",
1418
+ )
1419
+ pipeline = (
1420
+ await dao.get_pipeline_run(str(mutation["pipeline_run_id"]))
1421
+ if mutation.get("pipeline_run_id")
1422
+ else None
1423
+ )
1424
+ return V4MutationStatusResponse(
1425
+ mutation_id=str(mutation["mutation_id"]),
1426
+ candidate_id=str(mutation["candidate_id"]),
1427
+ state=str(mutation["state"]),
1428
+ failure_class=mutation.get("failure_class"),
1429
+ rejection_reason=mutation.get("rejection_reason"),
1430
+ tier3_audit=mutation.get("tier3_audit"),
1431
+ pipeline_run=pipeline,
1432
+ artifacts=mutation["artifacts"],
1433
+ projections=mutation["projections"],
1434
+ )
1435
+
1436
+ @router.post("/mutations/{mutation_id}/rollback", status_code=202)
1437
+ async def rollback_mutation(
1438
+ mutation_id: PublicIdentifier,
1439
+ request: Request,
1440
+ dao: MemoryDAO = Depends(get_dao),
1441
+ access_control: AccessControl = Depends(get_access_control),
1442
+ ) -> dict:
1443
+ mutation = await dao.get_mutation_summary(mutation_id)
1444
+ if mutation is None:
1445
+ raise HTTPException(status_code=404, detail="Unknown mutation")
1446
+ session = await _authorized_v4_session(
1447
+ request,
1448
+ dao,
1449
+ access_control,
1450
+ str(mutation["session_id"]),
1451
+ level="WRITE",
1452
+ allow_closed=True,
1453
+ )
1454
+ _require_historical_mutation_scope(mutation, session)
1455
+ principal = _active_principal(request)
1456
+ if not await access_control.check_dataset_permission(
1457
+ principal.principal_id,
1458
+ tenant_id=str(session["tenant_id"]),
1459
+ dataset_id=str(mutation["dataset_id"]),
1460
+ permission="ROLLBACK",
1461
+ ):
1462
+ raise HTTPException(status_code=403, detail="ROLLBACK permission required")
1463
+ await _require_mutation_admission(dao)
1464
+ try:
1465
+ return await dao.request_pipeline_rollback(str(mutation["pipeline_run_id"]))
1466
+ except NonHeadRollbackConflictError as exc:
1467
+ raise HTTPException(
1468
+ status_code=409,
1469
+ detail="NON_HEAD_ROLLBACK_CONFLICT",
1470
+ ) from exc
1471
+ except ValueError as exc:
1472
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
1473
+
1474
+ @router.post("/mutations/{mutation_id}/replay", status_code=202)
1475
+ async def replay_mutation(
1476
+ mutation_id: PublicIdentifier,
1477
+ request: Request,
1478
+ dao: MemoryDAO = Depends(get_dao),
1479
+ access_control: AccessControl = Depends(get_access_control),
1480
+ ) -> dict:
1481
+ mutation = await dao.get_mutation_summary(mutation_id)
1482
+ if mutation is None:
1483
+ raise HTTPException(status_code=404, detail="Unknown mutation")
1484
+ session = await _authorized_v4_session(
1485
+ request,
1486
+ dao,
1487
+ access_control,
1488
+ str(mutation["session_id"]),
1489
+ level="WRITE",
1490
+ allow_closed=True,
1491
+ )
1492
+ _require_historical_mutation_scope(mutation, session)
1493
+ principal = _active_principal(request)
1494
+ if not await access_control.check_dataset_permission(
1495
+ principal.principal_id,
1496
+ tenant_id=str(session["tenant_id"]),
1497
+ dataset_id=str(mutation["dataset_id"]),
1498
+ permission="ROLLBACK",
1499
+ ):
1500
+ raise HTTPException(status_code=403, detail="ROLLBACK permission required")
1501
+ await _require_mutation_admission(dao)
1502
+ try:
1503
+ return await dao.replay_pipeline_run(
1504
+ str(mutation["pipeline_run_id"]),
1505
+ target_mutation_id=mutation_id,
1506
+ )
1507
+ except NonReplayableMutationConflictError as exc:
1508
+ raise HTTPException(status_code=409, detail="NON_REPLAYABLE") from exc
1509
+ except ValueError as exc:
1510
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
1511
+
1512
+ @router.get("/sessions/{session_id}/context")
1513
+ async def get_context(
1514
+ session_id: PublicIdentifier,
1515
+ request: Request,
1516
+ query: str = "",
1517
+ token_budget: int = 2048,
1518
+ jurisdiction: str | None = None,
1519
+ valid_at: datetime | None = None,
1520
+ valid_from: datetime | None = None,
1521
+ valid_to: datetime | None = None,
1522
+ retrieval_mode: Literal["single", "adaptive"] = "single",
1523
+ dao: MemoryDAO = Depends(get_dao),
1524
+ access_control: AccessControl = Depends(get_access_control),
1525
+ ) -> dict:
1526
+ session = await _authorized_v4_session(
1527
+ request, dao, access_control, session_id, level="READ"
1528
+ )
1529
+ if token_budget <= 0:
1530
+ raise HTTPException(
1531
+ status_code=422, detail="token_budget must be positive"
1532
+ )
1533
+ if valid_from and valid_to and valid_from > valid_to:
1534
+ raise HTTPException(
1535
+ status_code=422, detail="valid_from must not be after valid_to"
1536
+ )
1537
+ agent_id = str(session["agent_id"])
1538
+ adaptive_retriever = None
1539
+ if retrieval_mode == "adaptive":
1540
+ planner = None
1541
+ if is_extraction_enabled():
1542
+ try:
1543
+ planner = BoundedQueryPlanner(AdapterFactory.get_adapter())
1544
+ except Exception as exc:
1545
+ logger.warning(
1546
+ "QUERY_PLANNER_COMPOSITION_UNAVAILABLE | exception_type=%s",
1547
+ type(exc).__name__,
1548
+ )
1549
+ adaptive_retriever = BoundedAdaptiveQueryRetriever(dao, planner)
1550
+ builder = ContextBuilder(dao, adaptive_retriever=adaptive_retriever)
1551
+ try:
1552
+ ctx = await builder.build_context(
1553
+ tenant_id=str(session["tenant_id"]),
1554
+ agent_id=agent_id,
1555
+ dataset_ids=list(session.get("dataset_ids", [])),
1556
+ query=query,
1557
+ session_id=session_id,
1558
+ token_budget=token_budget,
1559
+ jurisdiction=jurisdiction,
1560
+ valid_at=valid_at.isoformat() if valid_at else None,
1561
+ valid_from=valid_from.isoformat() if valid_from else None,
1562
+ valid_to=valid_to.isoformat() if valid_to else None,
1563
+ retrieval_mode=retrieval_mode,
1564
+ )
1565
+ except EmbeddingMigrationRequiredError:
1566
+ raise HTTPException(
1567
+ status_code=409,
1568
+ detail="embedding_migration_required",
1569
+ )
1570
+ except (
1571
+ VectorSearchError,
1572
+ EmbeddingUnavailableError,
1573
+ EmbeddingGenerationError,
1574
+ ExternalProviderForbiddenError,
1575
+ EmbeddingIdentityMismatchError,
1576
+ ):
1577
+ raise HTTPException(
1578
+ status_code=503,
1579
+ detail="vector_backend_unavailable",
1580
+ )
1581
+ except GraphSearchError:
1582
+ raise HTTPException(
1583
+ status_code=503,
1584
+ detail="graph_backend_unavailable",
1585
+ )
1586
+ mutations = await dao.list_session_mutation_summaries(
1587
+ agent_id, session_id, limit=20
1588
+ )
1589
+ return {
1590
+ "tenant_id": session["tenant_id"],
1591
+ "workspace_id": session["workspace_id"],
1592
+ "dataset_ids": session["dataset_ids"],
1593
+ "agent_id": agent_id,
1594
+ "session_id": session_id,
1595
+ "context": ctx["formatted_context"],
1596
+ "canonical_memories": ctx["canonical_memories"],
1597
+ "estimated_token_count": ctx["estimated_token_count"],
1598
+ "context_status": ctx["context_status"],
1599
+ "context_diagnostics": ctx["context_diagnostics"],
1600
+ "mutations": mutations,
1601
+ }
1602
+
1603
+ @router.post("/sessions/{session_id}/end")
1604
+ async def end_session(
1605
+ session_id: PublicIdentifier,
1606
+ request: Request,
1607
+ dao: MemoryDAO = Depends(get_dao),
1608
+ access_control: AccessControl = Depends(get_access_control),
1609
+ ) -> dict:
1610
+ session = await _authorized_v4_session(
1611
+ request, dao, access_control, session_id, level="WRITE"
1612
+ )
1613
+ await _require_mutation_admission(dao)
1614
+ finalization = await dao.request_session_finalization(
1615
+ str(session["agent_id"]), session_id
1616
+ )
1617
+ await dao.end_v4_session(session_id)
1618
+ return {
1619
+ "status": "ended" if finalization["state"] == "COMPLETED" else "pending",
1620
+ "session_id": session_id,
1621
+ "finalization_id": finalization["finalization_id"],
1622
+ }
1623
+
1624
+ return router