albert-memory 0.4.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 (74) hide show
  1. albert/__init__.py +3 -0
  2. albert/admin.py +532 -0
  3. albert/api.py +698 -0
  4. albert/automatic_memory_release.json +14 -0
  5. albert/benchmark.py +272 -0
  6. albert/benchmark_cli.py +37 -0
  7. albert/capture.py +1051 -0
  8. albert/capture_installer.py +1000 -0
  9. albert/capture_promotion.py +298 -0
  10. albert/capture_retention.py +72 -0
  11. albert/capture_sdk.py +558 -0
  12. albert/capture_security.py +79 -0
  13. albert/chunking.py +79 -0
  14. albert/classifier.py +263 -0
  15. albert/claude_adapter.py +357 -0
  16. albert/claude_lifecycle.py +418 -0
  17. albert/claude_package.json +19 -0
  18. albert/claude_plugin/.claude-plugin/plugin.json +11 -0
  19. albert/claude_plugin/.mcp.json +13 -0
  20. albert/claude_plugin/hooks/hooks.json +70 -0
  21. albert/clusters.py +140 -0
  22. albert/codex_adapter.py +308 -0
  23. albert/codex_hooks/hooks.json +49 -0
  24. albert/codex_lifecycle.py +485 -0
  25. albert/codex_package.json +19 -0
  26. albert/compatibility.py +121 -0
  27. albert/config.py +144 -0
  28. albert/console.py +508 -0
  29. albert/console_backends.py +236 -0
  30. albert/console_map.py +518 -0
  31. albert/console_ops.py +919 -0
  32. albert/credentials.py +616 -0
  33. albert/cursor_adapter.py +320 -0
  34. albert/cursor_hooks/hooks.json +11 -0
  35. albert/cursor_rules/albert-memory.mdc +11 -0
  36. albert/db.py +68 -0
  37. albert/embeddings.py +91 -0
  38. albert/graph.py +623 -0
  39. albert/graph_builders.py +879 -0
  40. albert/graph_cypher.py +669 -0
  41. albert/graph_store.py +376 -0
  42. albert/host_targets/claude-code.json +45 -0
  43. albert/host_targets/codex.json +45 -0
  44. albert/host_targets/openclaw.json +45 -0
  45. albert/host_targets.py +271 -0
  46. albert/links.py +428 -0
  47. albert/mcp_server.py +457 -0
  48. albert/memory_safety.py +111 -0
  49. albert/models.py +611 -0
  50. albert/openclaw_adapter.py +326 -0
  51. albert/openclaw_adapter_trust.json +5 -0
  52. albert/openclaw_package.json +22 -0
  53. albert/openclaw_plugin/index.js +135 -0
  54. albert/openclaw_plugin/openclaw.plugin.json +25 -0
  55. albert/openclaw_plugin/package.json +12 -0
  56. albert/portability.py +291 -0
  57. albert/provenance.py +105 -0
  58. albert/recorder.py +166 -0
  59. albert/release_gate.py +83 -0
  60. albert/schemas.py +617 -0
  61. albert/scope.py +26 -0
  62. albert/search.py +447 -0
  63. albert/security.py +150 -0
  64. albert/services.py +1316 -0
  65. albert/session_context.py +162 -0
  66. albert/trace_feed.py +165 -0
  67. albert/trace_writer.py +131 -0
  68. albert/tracing.py +75 -0
  69. albert/worker.py +202 -0
  70. albert/workspaces.py +256 -0
  71. albert_memory-0.4.0.dist-info/METADATA +554 -0
  72. albert_memory-0.4.0.dist-info/RECORD +74 -0
  73. albert_memory-0.4.0.dist-info/WHEEL +4 -0
  74. albert_memory-0.4.0.dist-info/entry_points.txt +14 -0
albert/capture.py ADDED
@@ -0,0 +1,1051 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from datetime import UTC, datetime
7
+ from uuid import UUID, uuid4
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Response, status
10
+ from sqlalchemy import select
11
+ from sqlalchemy.exc import IntegrityError
12
+ from sqlalchemy.orm import Session
13
+
14
+ from albert.capture_security import screen_capture
15
+ from albert.db import get_session
16
+ from albert.models import (
17
+ CaptureCheckpoint,
18
+ CaptureEvent,
19
+ CaptureProfile,
20
+ CaptureSession,
21
+ Episode,
22
+ Workspace,
23
+ utcnow,
24
+ )
25
+ from albert.provenance import merge_event_provenance, normalize_provenance
26
+ from albert.recorder import current_recorder
27
+ from albert.schemas import (
28
+ CAPTURE_CONTRACT_VERSION,
29
+ CaptureCheckpointRead,
30
+ CaptureCheckpointWrite,
31
+ CaptureEventBatch,
32
+ CaptureEventBatchRead,
33
+ CaptureEventRead,
34
+ CaptureEventWrite,
35
+ CaptureProfileRead,
36
+ CaptureProfileWrite,
37
+ CaptureSessionEnd,
38
+ CaptureSessionRead,
39
+ CaptureSessionStart,
40
+ )
41
+ from albert.security import AuthContext, authenticate
42
+ from albert.services import audit, enqueue
43
+
44
+ router = APIRouter(prefix="/v1/capture", tags=["capture"])
45
+ PRIVILEGED_EVENT_CLASSES = frozenset({"system", "instruction"})
46
+ SENSITIVITY_ORDER = ("public", "internal", "confidential", "restricted")
47
+ DEFAULT_EVENT_CLASSES = frozenset(
48
+ {
49
+ "session_started",
50
+ "user_goal",
51
+ "assistant_outcome",
52
+ "selected_tool_outcome",
53
+ "decision",
54
+ "correction",
55
+ "vcs_checkpoint",
56
+ "pre_compaction_checkpoint",
57
+ "session_ended",
58
+ "failure_summary",
59
+ }
60
+ )
61
+
62
+
63
+ def _capture_note(stage: str, **fields: object) -> None:
64
+ recorder = current_recorder()
65
+ if recorder is not None:
66
+ recorder.note(capture_stage=stage, **fields)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class CapturePolicy:
71
+ profile_key: str
72
+ version: int
73
+ allowed_event_classes: frozenset[str]
74
+ metadata_allowlists: dict[str, list[str]]
75
+ event_bounds: dict[str, dict[str, int]]
76
+ sensitivity_floor: str
77
+ source_trust: str
78
+ retention_days: int
79
+ raw_body_policy: str
80
+ secret_policy: str
81
+ derivative_policy: str
82
+ deny_reasons: dict[str, str]
83
+ event_source_trust: dict[str, str] = field(default_factory=dict)
84
+
85
+ def trust_for(self, event_class: str) -> str:
86
+ return self.event_source_trust.get(event_class, self.source_trust)
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class PolicyResult:
91
+ data: CaptureEventWrite
92
+ policy: CapturePolicy
93
+ provenance: dict[str, object]
94
+ dropped_fields: tuple[str, ...]
95
+ request_hash: str
96
+ screening_decision: str
97
+ screening_reasons: tuple[str, ...]
98
+
99
+
100
+ def _default_policy() -> CapturePolicy:
101
+ return CapturePolicy(
102
+ profile_key="safe-default",
103
+ version=1,
104
+ allowed_event_classes=DEFAULT_EVENT_CLASSES,
105
+ metadata_allowlists={event_class: [] for event_class in DEFAULT_EVENT_CLASSES},
106
+ event_bounds={
107
+ event_class: {"max_content_chars": 64_000, "max_metadata_chars": 64_000}
108
+ for event_class in DEFAULT_EVENT_CLASSES
109
+ },
110
+ sensitivity_floor="internal",
111
+ source_trust="untrusted",
112
+ retention_days=30,
113
+ raw_body_policy="deny",
114
+ secret_policy="reject",
115
+ derivative_policy="cascade",
116
+ deny_reasons={},
117
+ # What the user typed is the least likely place for injected text and
118
+ # is where the user's facts live; assistant replies and tool outcomes
119
+ # stay untrusted. Operators whose "users" may be third parties (for
120
+ # example public channel bots) can publish a profile without this.
121
+ event_source_trust={"user_goal": "verified"},
122
+ )
123
+
124
+
125
+ def _profile_policy(value: CaptureProfile) -> CapturePolicy:
126
+ return CapturePolicy(
127
+ profile_key=value.profile_key,
128
+ version=value.version,
129
+ allowed_event_classes=frozenset(value.allowed_event_classes),
130
+ metadata_allowlists=value.metadata_allowlists,
131
+ event_bounds=value.event_bounds,
132
+ sensitivity_floor=value.sensitivity_floor,
133
+ source_trust=value.source_trust,
134
+ retention_days=value.retention_days,
135
+ raw_body_policy=value.raw_body_policy,
136
+ secret_policy=value.secret_policy,
137
+ derivative_policy=value.derivative_policy,
138
+ deny_reasons=value.deny_reasons,
139
+ event_source_trust=dict(value.event_source_trust or {}),
140
+ )
141
+
142
+
143
+ def _as_utc(value: datetime | None) -> datetime | None:
144
+ if value is None:
145
+ return None
146
+ return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
147
+
148
+
149
+ def _hash(value: object) -> str:
150
+ encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
151
+ return hashlib.sha256(encoded.encode()).hexdigest()
152
+
153
+
154
+ def _reject_secret_identifiers(*values: str) -> None:
155
+ screening = screen_capture("", {"values": list(values)})
156
+ if screening.reasons:
157
+ raise HTTPException(
158
+ status_code=422,
159
+ detail={"error": "capture_identifier_rejected", "reasons": list(screening.reasons)},
160
+ )
161
+
162
+
163
+ def _require_version(version: str) -> None:
164
+ if version != CAPTURE_CONTRACT_VERSION:
165
+ raise HTTPException(
166
+ status_code=status.HTTP_426_UPGRADE_REQUIRED,
167
+ detail={
168
+ "error": "unsupported_capture_contract_version",
169
+ "supported_versions": [CAPTURE_CONTRACT_VERSION],
170
+ },
171
+ )
172
+
173
+
174
+ def _require_event_capability(auth: AuthContext, event_class: str) -> None:
175
+ auth.require("capture.write")
176
+ if event_class in PRIVILEGED_EVENT_CLASSES:
177
+ auth.require("capture.system")
178
+
179
+
180
+ def _workspace(session: Session, auth: AuthContext, requested: UUID | None) -> UUID | None:
181
+ workspace_id = requested if requested is not None else auth.workspace_id
182
+ if auth.workspace_id is not None and workspace_id != auth.workspace_id:
183
+ raise HTTPException(status_code=404, detail="Capture scope not found")
184
+ if workspace_id is not None:
185
+ found = session.scalar(
186
+ select(Workspace.id).where(
187
+ Workspace.id == workspace_id,
188
+ Workspace.organization_id == auth.organization_id,
189
+ )
190
+ )
191
+ if found is None:
192
+ raise HTTPException(status_code=404, detail="Capture scope not found")
193
+ return workspace_id
194
+
195
+
196
+ def _latest_policy(
197
+ session: Session, auth: AuthContext, workspace_id: UUID | None, profile_key: str
198
+ ) -> CapturePolicy:
199
+ if profile_key == "safe-default":
200
+ return _default_policy()
201
+ profile = session.scalar(
202
+ select(CaptureProfile)
203
+ .where(
204
+ CaptureProfile.organization_id == auth.organization_id,
205
+ CaptureProfile.workspace_id == workspace_id,
206
+ CaptureProfile.profile_key == profile_key,
207
+ )
208
+ .order_by(CaptureProfile.version.desc())
209
+ .limit(1)
210
+ )
211
+ if profile is None:
212
+ raise HTTPException(status_code=404, detail="Capture profile not found")
213
+ return _profile_policy(profile)
214
+
215
+
216
+ def _deny_policy(
217
+ session: Session,
218
+ auth: AuthContext,
219
+ capture_session: CaptureSession,
220
+ policy: CapturePolicy,
221
+ event_class: str,
222
+ reason: str,
223
+ ) -> None:
224
+ public_reason = policy.deny_reasons.get(event_class, policy.deny_reasons.get(reason, reason))
225
+ audit(
226
+ session,
227
+ auth,
228
+ "capture.policy.denied",
229
+ resource_type="capture_session",
230
+ resource_id=str(capture_session.id),
231
+ detail={
232
+ "event_class": event_class,
233
+ "profile_key": policy.profile_key,
234
+ "profile_version": policy.version,
235
+ "decision": "deny",
236
+ "reason": public_reason,
237
+ },
238
+ )
239
+ session.commit()
240
+ _capture_note("policy", decision="rejected", failure_reason="policy_denied")
241
+ raise HTTPException(
242
+ status_code=403,
243
+ detail={"error": "capture_policy_denied", "reason": public_reason},
244
+ )
245
+
246
+
247
+ def _apply_policy(
248
+ session: Session,
249
+ auth: AuthContext,
250
+ capture_session: CaptureSession,
251
+ data: CaptureEventWrite,
252
+ ) -> PolicyResult:
253
+ policy = _latest_policy(
254
+ session, auth, capture_session.workspace_id, capture_session.profile_key
255
+ )
256
+ allowed_classes = policy.allowed_event_classes
257
+ if capture_session.local_event_classes is not None:
258
+ allowed_classes &= frozenset(capture_session.local_event_classes)
259
+ if data.event_class not in allowed_classes:
260
+ _deny_policy(
261
+ session, auth, capture_session, policy, data.event_class, "event_class_not_allowed"
262
+ )
263
+ if data.body_kind == "raw" and policy.raw_body_policy != "allow":
264
+ _deny_policy(session, auth, capture_session, policy, data.event_class, "raw_body_denied")
265
+
266
+ observed = normalize_provenance(
267
+ data.provenance,
268
+ organization_id=auth.organization_id,
269
+ allow_absolute_path="admin" in auth.capabilities,
270
+ )
271
+ provenance = merge_event_provenance(capture_session.provenance, observed)
272
+ screening = screen_capture(
273
+ data.content,
274
+ {"metadata": data.metadata, "provenance": provenance},
275
+ )
276
+ if screening.reasons and policy.secret_policy == "reject":
277
+ audit(
278
+ session,
279
+ auth,
280
+ "capture.secret.rejected",
281
+ resource_type="capture_session",
282
+ resource_id=str(capture_session.id),
283
+ detail={
284
+ "event_class": data.event_class,
285
+ "profile_key": policy.profile_key,
286
+ "profile_version": policy.version,
287
+ "decision": "reject",
288
+ "reasons": list(screening.reasons),
289
+ },
290
+ )
291
+ session.commit()
292
+ _capture_note("policy", decision="rejected", failure_reason="secret_rejected")
293
+ raise HTTPException(
294
+ status_code=422,
295
+ detail={"error": "capture_secret_rejected", "reasons": list(screening.reasons)},
296
+ )
297
+ screened_data = data.model_copy(
298
+ update={"content": screening.content, "metadata": screening.metadata["metadata"]}
299
+ )
300
+ screened_provenance = screening.metadata["provenance"]
301
+
302
+ bounds = policy.event_bounds.get(data.event_class, {})
303
+ max_content = bounds.get("max_content_chars", 64_000)
304
+ if len(screened_data.content) > max_content:
305
+ _deny_policy(session, auth, capture_session, policy, data.event_class, "content_too_large")
306
+
307
+ allowed_metadata = set(policy.metadata_allowlists.get(data.event_class, []))
308
+ if capture_session.local_metadata_allowlists is not None:
309
+ allowed_metadata &= set(capture_session.local_metadata_allowlists.get(data.event_class, []))
310
+ filtered_metadata = {
311
+ key: value for key, value in screened_data.metadata.items() if key in allowed_metadata
312
+ }
313
+ max_metadata = bounds.get("max_metadata_chars", 64_000)
314
+ if len(json.dumps(filtered_metadata, default=str)) > max_metadata:
315
+ _deny_policy(session, auth, capture_session, policy, data.event_class, "metadata_too_large")
316
+
317
+ sensitivity = max(
318
+ (screened_data.sensitivity, policy.sensitivity_floor), key=SENSITIVITY_ORDER.index
319
+ )
320
+ if sensitivity not in auth.allowed_sensitivities():
321
+ _deny_policy(
322
+ session, auth, capture_session, policy, data.event_class, "credential_capability_denied"
323
+ )
324
+ dropped = tuple(sorted(set(screened_data.metadata) - set(filtered_metadata)))
325
+ effective = screened_data.model_copy(
326
+ update={"metadata": filtered_metadata, "sensitivity": sensitivity}
327
+ )
328
+ return PolicyResult(
329
+ data=effective,
330
+ policy=policy,
331
+ provenance=screened_provenance,
332
+ dropped_fields=dropped,
333
+ request_hash=_event_hash(data),
334
+ screening_decision="redacted" if screening.reasons else "clean",
335
+ screening_reasons=screening.reasons,
336
+ )
337
+
338
+
339
+ def _capture_session(
340
+ session: Session, auth: AuthContext, session_id: UUID, *, lock: bool = False
341
+ ) -> CaptureSession:
342
+ statement = select(CaptureSession).where(
343
+ CaptureSession.id == session_id,
344
+ CaptureSession.organization_id == auth.organization_id,
345
+ CaptureSession.owner_principal_id == auth.principal_id,
346
+ CaptureSession.deleted_at.is_(None),
347
+ )
348
+ if lock:
349
+ statement = statement.with_for_update()
350
+ capture_session = session.scalar(statement)
351
+ if capture_session is None:
352
+ raise HTTPException(status_code=404, detail="Capture session not found")
353
+ return capture_session
354
+
355
+
356
+ def _session_read(value: CaptureSession) -> CaptureSessionRead:
357
+ return CaptureSessionRead(
358
+ id=value.id,
359
+ version=value.contract_version,
360
+ client_session_id=value.client_session_id,
361
+ workspace_id=value.workspace_id,
362
+ profile_key=value.profile_key,
363
+ status=value.status,
364
+ last_sequence=value.last_sequence,
365
+ started_at=_as_utc(value.started_at),
366
+ ended_at=_as_utc(value.ended_at),
367
+ provenance=value.provenance,
368
+ )
369
+
370
+
371
+ def _event_read(value: CaptureEvent) -> CaptureEventRead:
372
+ if value.episode_id is None:
373
+ raise HTTPException(status_code=409, detail="Capture event episode was deleted")
374
+ return CaptureEventRead(
375
+ id=value.id,
376
+ session_id=value.session_id,
377
+ version=value.contract_version,
378
+ sequence=value.sequence,
379
+ event_class=value.event_class,
380
+ profile_key=value.profile_key,
381
+ profile_version=value.profile_version,
382
+ sensitivity=value.sensitivity,
383
+ source_trust=value.source_trust,
384
+ retention_days=value.retention_days,
385
+ screening_decision=value.screening_decision,
386
+ screening_reasons=value.screening_reasons,
387
+ occurred_at=_as_utc(value.occurred_at),
388
+ episode_id=value.episode_id,
389
+ derived_memory_id=value.derived_memory_id,
390
+ provenance=value.provenance,
391
+ )
392
+
393
+
394
+ def _checkpoint_read(value: CaptureCheckpoint) -> CaptureCheckpointRead:
395
+ if value.episode_id is None:
396
+ raise HTTPException(status_code=409, detail="Capture checkpoint episode was deleted")
397
+ return CaptureCheckpointRead(
398
+ id=value.id,
399
+ session_id=value.session_id,
400
+ version=value.contract_version,
401
+ through_sequence=value.through_sequence,
402
+ episode_id=value.episode_id,
403
+ derived_memory_id=value.derived_memory_id,
404
+ promotion_status=value.promotion_status,
405
+ promotion_error=value.promotion_error,
406
+ )
407
+
408
+
409
+ def _event_hash(data: CaptureEventWrite) -> str:
410
+ return _hash(data.model_dump(mode="json"))
411
+
412
+
413
+ def _existing_event(
414
+ session: Session, capture_session: CaptureSession, data: CaptureEventWrite
415
+ ) -> CaptureEvent | None:
416
+ existing = session.scalar(
417
+ select(CaptureEvent).where(
418
+ CaptureEvent.session_id == capture_session.id,
419
+ CaptureEvent.idempotency_key == data.idempotency_key,
420
+ )
421
+ )
422
+ if existing is not None and (
423
+ existing.request_hash != _event_hash(data) or existing.sequence != data.sequence
424
+ ):
425
+ raise HTTPException(status_code=409, detail="Idempotency key was reused with new input")
426
+ return existing
427
+
428
+
429
+ def _stage_event(
430
+ session: Session,
431
+ auth: AuthContext,
432
+ capture_session: CaptureSession,
433
+ result: PolicyResult,
434
+ ) -> CaptureEvent:
435
+ data = result.data
436
+ policy = result.policy
437
+ event_id = uuid4()
438
+ occurred_at = data.occurred_at or utcnow()
439
+ content_hash = hashlib.sha256(data.content.encode()).hexdigest()
440
+ provenance = result.provenance
441
+ project_identity = provenance.get("project_id")
442
+ source_commit = provenance.get("source_commit")
443
+ branch_ref = provenance.get("branch") or provenance.get("ref")
444
+ episode = Episode(
445
+ organization_id=auth.organization_id,
446
+ workspace_id=capture_session.workspace_id,
447
+ project_ref=str(project_identity) if project_identity else None,
448
+ task_ref=str(provenance["task"]) if provenance.get("task") else None,
449
+ run_ref=str(provenance["run"]) if provenance.get("run") else None,
450
+ owner_principal_id=auth.principal_id,
451
+ source_type="automatic_capture",
452
+ source_uri=f"capture://{capture_session.id}/events/{data.sequence}",
453
+ content=data.content,
454
+ content_hash=content_hash,
455
+ sensitivity=data.sensitivity,
456
+ occurred_at=occurred_at,
457
+ extra={
458
+ **data.metadata,
459
+ "capture_session_id": str(capture_session.id),
460
+ "capture_event_id": str(event_id),
461
+ "capture_event_class": data.event_class,
462
+ "capture_profile_key": policy.profile_key,
463
+ "capture_profile_version": policy.version,
464
+ "capture_source_trust": policy.trust_for(data.event_class),
465
+ "capture_retention_days": policy.retention_days,
466
+ "provenance": provenance,
467
+ },
468
+ )
469
+ session.add(episode)
470
+ session.flush()
471
+ job = enqueue(session, auth.organization_id, "enrich_episode", {"episode_id": str(episode.id)})
472
+ session.flush()
473
+ episode.enrichment_job_id = job.id
474
+ event = CaptureEvent(
475
+ id=event_id,
476
+ session_id=capture_session.id,
477
+ organization_id=auth.organization_id,
478
+ workspace_id=capture_session.workspace_id,
479
+ owner_principal_id=auth.principal_id,
480
+ sequence=data.sequence,
481
+ idempotency_key=data.idempotency_key,
482
+ request_hash=result.request_hash,
483
+ contract_version=data.version,
484
+ event_class=data.event_class,
485
+ profile_key=policy.profile_key,
486
+ profile_version=policy.version,
487
+ policy_decision="allow",
488
+ sensitivity=data.sensitivity,
489
+ source_trust=policy.trust_for(data.event_class),
490
+ retention_days=policy.retention_days,
491
+ body_kind=data.body_kind,
492
+ screening_decision=result.screening_decision,
493
+ screening_reasons=list(result.screening_reasons),
494
+ derivative_policy=policy.derivative_policy,
495
+ content=data.content,
496
+ metadata_=data.metadata,
497
+ provenance=provenance,
498
+ project_identity=str(project_identity) if project_identity else None,
499
+ source_commit=str(source_commit) if source_commit else None,
500
+ branch_ref=str(branch_ref) if branch_ref else None,
501
+ occurred_at=occurred_at,
502
+ episode_id=episode.id,
503
+ )
504
+ session.add(event)
505
+ return event
506
+
507
+
508
+ def _profile_read(value: CaptureProfile) -> CaptureProfileRead:
509
+ return CaptureProfileRead(
510
+ id=value.id,
511
+ profile_key=value.profile_key,
512
+ version=value.version,
513
+ workspace_id=value.workspace_id,
514
+ allowed_event_classes=value.allowed_event_classes,
515
+ metadata_allowlists=value.metadata_allowlists,
516
+ event_bounds=value.event_bounds,
517
+ sensitivity_floor=value.sensitivity_floor,
518
+ source_trust=value.source_trust,
519
+ retention_days=value.retention_days,
520
+ raw_body_policy=value.raw_body_policy,
521
+ secret_policy=value.secret_policy,
522
+ derivative_policy=value.derivative_policy,
523
+ deny_reasons=value.deny_reasons,
524
+ event_source_trust=dict(value.event_source_trust or {}),
525
+ created_at=_as_utc(value.created_at),
526
+ )
527
+
528
+
529
+ @router.post("/profiles", response_model=CaptureProfileRead, status_code=201)
530
+ def create_profile(
531
+ data: CaptureProfileWrite,
532
+ auth: AuthContext = Depends(authenticate),
533
+ session: Session = Depends(get_session),
534
+ ) -> CaptureProfileRead:
535
+ auth.require("capture.system")
536
+ if data.profile_key == "safe-default":
537
+ raise HTTPException(status_code=422, detail="safe-default is reserved")
538
+ workspace_id = _workspace(session, auth, data.workspace_id)
539
+ latest = session.scalar(
540
+ select(CaptureProfile)
541
+ .where(
542
+ CaptureProfile.organization_id == auth.organization_id,
543
+ CaptureProfile.workspace_id == workspace_id,
544
+ CaptureProfile.profile_key == data.profile_key,
545
+ )
546
+ .order_by(CaptureProfile.version.desc())
547
+ .limit(1)
548
+ )
549
+ expected_version = 1 if latest is None else latest.version + 1
550
+ if data.version != expected_version:
551
+ raise HTTPException(
552
+ status_code=409,
553
+ detail={
554
+ "error": "capture_profile_version_conflict",
555
+ "expected_version": expected_version,
556
+ },
557
+ )
558
+ profile = CaptureProfile(
559
+ organization_id=auth.organization_id,
560
+ workspace_id=workspace_id,
561
+ scope_key=str(workspace_id) if workspace_id is not None else "organization",
562
+ profile_key=data.profile_key,
563
+ version=data.version,
564
+ allowed_event_classes=data.allowed_event_classes,
565
+ metadata_allowlists=data.metadata_allowlists,
566
+ event_bounds=data.event_bounds,
567
+ sensitivity_floor=data.sensitivity_floor,
568
+ source_trust=data.source_trust,
569
+ retention_days=data.retention_days,
570
+ raw_body_policy=data.raw_body_policy,
571
+ secret_policy=data.secret_policy,
572
+ derivative_policy=data.derivative_policy,
573
+ deny_reasons=data.deny_reasons,
574
+ event_source_trust=data.event_source_trust,
575
+ )
576
+ session.add(profile)
577
+ try:
578
+ session.flush()
579
+ audit(
580
+ session,
581
+ auth,
582
+ "capture.profile.created",
583
+ resource_type="capture_profile",
584
+ resource_id=str(profile.id),
585
+ detail={"profile_key": data.profile_key, "profile_version": data.version},
586
+ )
587
+ session.commit()
588
+ except IntegrityError as exc:
589
+ session.rollback()
590
+ raise HTTPException(
591
+ status_code=409, detail="Capture profile version already exists"
592
+ ) from exc
593
+ session.refresh(profile)
594
+ return _profile_read(profile)
595
+
596
+
597
+ @router.get("/profiles/{profile_key}", response_model=CaptureProfileRead)
598
+ def get_profile(
599
+ profile_key: str,
600
+ workspace_id: UUID | None = None,
601
+ auth: AuthContext = Depends(authenticate),
602
+ session: Session = Depends(get_session),
603
+ ) -> CaptureProfileRead:
604
+ auth.require("capture.write")
605
+ resolved_workspace = _workspace(session, auth, workspace_id)
606
+ profile = session.scalar(
607
+ select(CaptureProfile)
608
+ .where(
609
+ CaptureProfile.organization_id == auth.organization_id,
610
+ CaptureProfile.workspace_id == resolved_workspace,
611
+ CaptureProfile.profile_key == profile_key,
612
+ )
613
+ .order_by(CaptureProfile.version.desc())
614
+ .limit(1)
615
+ )
616
+ if profile is None:
617
+ raise HTTPException(status_code=404, detail="Capture profile not found")
618
+ return _profile_read(profile)
619
+
620
+
621
+ @router.post("/sessions", response_model=CaptureSessionRead, status_code=201)
622
+ def start_session(
623
+ data: CaptureSessionStart,
624
+ response: Response,
625
+ auth: AuthContext = Depends(authenticate),
626
+ session: Session = Depends(get_session),
627
+ ) -> CaptureSessionRead:
628
+ auth.require("capture.write")
629
+ _require_version(data.version)
630
+ _reject_secret_identifiers(data.client_session_id, data.idempotency_key)
631
+ workspace_id = _workspace(session, auth, data.workspace_id)
632
+ policy = _latest_policy(session, auth, workspace_id, data.profile_key)
633
+ provenance = normalize_provenance(
634
+ data.provenance,
635
+ organization_id=auth.organization_id,
636
+ allow_absolute_path="admin" in auth.capabilities,
637
+ )
638
+ screening = screen_capture(
639
+ "",
640
+ {"metadata": data.metadata, "provenance": provenance},
641
+ )
642
+ if screening.reasons and policy.secret_policy == "reject":
643
+ raise HTTPException(
644
+ status_code=422,
645
+ detail={
646
+ "error": "capture_secret_rejected",
647
+ "reasons": list(screening.reasons),
648
+ },
649
+ )
650
+ if data.local_event_classes is not None:
651
+ local_classes = set(data.local_event_classes)
652
+ if len(local_classes) != len(data.local_event_classes):
653
+ raise HTTPException(status_code=422, detail="Local event classes must be unique")
654
+ if not local_classes.issubset(policy.allowed_event_classes):
655
+ raise HTTPException(status_code=422, detail="Local policy cannot widen Brain policy")
656
+ if data.local_metadata_allowlists is not None:
657
+ local_classes = (
658
+ set(data.local_event_classes)
659
+ if data.local_event_classes is not None
660
+ else policy.allowed_event_classes
661
+ )
662
+ for event_class, fields in data.local_metadata_allowlists.items():
663
+ if event_class not in local_classes or not set(fields).issubset(
664
+ policy.metadata_allowlists.get(event_class, [])
665
+ ):
666
+ raise HTTPException(
667
+ status_code=422, detail="Local policy cannot widen Brain policy"
668
+ )
669
+ screened_metadata = screening.metadata["metadata"]
670
+ provenance = screening.metadata["provenance"]
671
+ request_hash = _hash(data.model_dump(mode="json"))
672
+ existing = session.scalar(
673
+ select(CaptureSession).where(
674
+ CaptureSession.owner_principal_id == auth.principal_id,
675
+ CaptureSession.idempotency_key == data.idempotency_key,
676
+ )
677
+ )
678
+ if existing is not None:
679
+ if existing.request_hash != request_hash:
680
+ raise HTTPException(status_code=409, detail="Idempotency key was reused with new input")
681
+ response.status_code = status.HTTP_200_OK
682
+ return _session_read(existing)
683
+ capture_session = CaptureSession(
684
+ organization_id=auth.organization_id,
685
+ workspace_id=workspace_id,
686
+ owner_principal_id=auth.principal_id,
687
+ client_session_id=data.client_session_id,
688
+ idempotency_key=data.idempotency_key,
689
+ request_hash=request_hash,
690
+ contract_version=data.version,
691
+ profile_key=data.profile_key,
692
+ local_event_classes=data.local_event_classes,
693
+ local_metadata_allowlists=data.local_metadata_allowlists,
694
+ metadata_=screened_metadata,
695
+ provenance=provenance,
696
+ project_identity=provenance.get("project_id"),
697
+ source_commit=provenance.get("source_commit"),
698
+ branch_ref=provenance.get("branch") or provenance.get("ref"),
699
+ )
700
+ session.add(capture_session)
701
+ try:
702
+ session.flush()
703
+ except IntegrityError as exc:
704
+ session.rollback()
705
+ concurrent = session.scalar(
706
+ select(CaptureSession).where(
707
+ CaptureSession.owner_principal_id == auth.principal_id,
708
+ CaptureSession.idempotency_key == data.idempotency_key,
709
+ )
710
+ )
711
+ if concurrent is not None and concurrent.request_hash == request_hash:
712
+ response.status_code = status.HTTP_200_OK
713
+ return _session_read(concurrent)
714
+ raise HTTPException(status_code=409, detail="Capture session already exists") from exc
715
+ audit(
716
+ session,
717
+ auth,
718
+ "capture.session.started",
719
+ resource_type="capture_session",
720
+ resource_id=str(capture_session.id),
721
+ detail={"profile_key": policy.profile_key, "profile_version": policy.version},
722
+ )
723
+ session.commit()
724
+ _capture_note(
725
+ "adapter",
726
+ decision="accepted",
727
+ contract_version=data.version,
728
+ policy_version=policy.version,
729
+ )
730
+ return _session_read(capture_session)
731
+
732
+
733
+ @router.post("/sessions/{session_id}/events", response_model=CaptureEventRead, status_code=201)
734
+ def write_event(
735
+ session_id: UUID,
736
+ data: CaptureEventWrite,
737
+ response: Response,
738
+ auth: AuthContext = Depends(authenticate),
739
+ session: Session = Depends(get_session),
740
+ ) -> CaptureEventRead:
741
+ _require_version(data.version)
742
+ _require_event_capability(auth, data.event_class)
743
+ _reject_secret_identifiers(data.idempotency_key)
744
+ capture_session = _capture_session(session, auth, session_id, lock=True)
745
+ existing = _existing_event(session, capture_session, data)
746
+ if existing is not None:
747
+ response.status_code = status.HTTP_200_OK
748
+ return _event_read(existing)
749
+ if capture_session.status != "active":
750
+ raise HTTPException(status_code=409, detail="Capture session has ended")
751
+ expected = capture_session.last_sequence + 1
752
+ if data.sequence != expected:
753
+ raise HTTPException(
754
+ status_code=409,
755
+ detail={"error": "out_of_order_event", "expected_sequence": expected},
756
+ )
757
+ result = _apply_policy(session, auth, capture_session, data)
758
+ try:
759
+ event = _stage_event(session, auth, capture_session, result)
760
+ capture_session.last_sequence = data.sequence
761
+ audit(
762
+ session,
763
+ auth,
764
+ "capture.event.created",
765
+ resource_type="capture_event",
766
+ resource_id=str(event.id),
767
+ detail={
768
+ "sequence": data.sequence,
769
+ "event_class": data.event_class,
770
+ "profile_key": result.policy.profile_key,
771
+ "profile_version": result.policy.version,
772
+ "decision": "allow",
773
+ "dropped_fields": list(result.dropped_fields),
774
+ "screening_decision": result.screening_decision,
775
+ "screening_reasons": list(result.screening_reasons),
776
+ },
777
+ )
778
+ session.commit()
779
+ _capture_note(
780
+ "ingestion",
781
+ accepted=1,
782
+ redacted=int(result.screening_decision == "redacted"),
783
+ policy_version=result.policy.version,
784
+ )
785
+ except IntegrityError as exc:
786
+ session.rollback()
787
+ concurrent = session.scalar(
788
+ select(CaptureEvent).where(
789
+ CaptureEvent.session_id == session_id,
790
+ CaptureEvent.idempotency_key == data.idempotency_key,
791
+ )
792
+ )
793
+ if concurrent is not None and concurrent.request_hash == _event_hash(data):
794
+ response.status_code = status.HTTP_200_OK
795
+ return _event_read(concurrent)
796
+ raise HTTPException(status_code=409, detail="Event was concurrently ingested") from exc
797
+ return _event_read(event)
798
+
799
+
800
+ @router.post(
801
+ "/sessions/{session_id}/events/batch", response_model=CaptureEventBatchRead, status_code=201
802
+ )
803
+ def write_event_batch(
804
+ session_id: UUID,
805
+ data: CaptureEventBatch,
806
+ response: Response,
807
+ auth: AuthContext = Depends(authenticate),
808
+ session: Session = Depends(get_session),
809
+ ) -> CaptureEventBatchRead:
810
+ _require_version(data.version)
811
+ for item in data.events:
812
+ _require_version(item.version)
813
+ _require_event_capability(auth, item.event_class)
814
+ _reject_secret_identifiers(item.idempotency_key)
815
+ capture_session = _capture_session(session, auth, session_id, lock=True)
816
+ existing = [_existing_event(session, capture_session, item) for item in data.events]
817
+ if all(item is not None for item in existing):
818
+ response.status_code = status.HTTP_200_OK
819
+ return CaptureEventBatchRead(events=[_event_read(item) for item in existing if item])
820
+ if any(item is not None for item in existing):
821
+ raise HTTPException(status_code=409, detail="Batch overlaps previously stored events")
822
+ if capture_session.status != "active":
823
+ raise HTTPException(status_code=409, detail="Capture session has ended")
824
+ expected = list(
825
+ range(
826
+ capture_session.last_sequence + 1,
827
+ capture_session.last_sequence + len(data.events) + 1,
828
+ )
829
+ )
830
+ actual = [item.sequence for item in data.events]
831
+ if actual != expected:
832
+ raise HTTPException(
833
+ status_code=409,
834
+ detail={"error": "out_of_order_batch", "expected_sequences": expected},
835
+ )
836
+ results = [_apply_policy(session, auth, capture_session, item) for item in data.events]
837
+ try:
838
+ events = [_stage_event(session, auth, capture_session, item) for item in results]
839
+ capture_session.last_sequence = actual[-1]
840
+ audit(
841
+ session,
842
+ auth,
843
+ "capture.events.created",
844
+ resource_type="capture_session",
845
+ resource_id=str(capture_session.id),
846
+ detail={
847
+ "first_sequence": actual[0],
848
+ "last_sequence": actual[-1],
849
+ "count": len(actual),
850
+ "profile_key": results[0].policy.profile_key,
851
+ "profile_version": results[0].policy.version,
852
+ "decision": "allow",
853
+ "dropped_field_count": sum(len(item.dropped_fields) for item in results),
854
+ "redacted_count": sum(item.screening_decision == "redacted" for item in results),
855
+ "screening_reasons": sorted(
856
+ {reason for item in results for reason in item.screening_reasons}
857
+ ),
858
+ },
859
+ )
860
+ session.commit()
861
+ _capture_note(
862
+ "ingestion",
863
+ accepted=len(events),
864
+ redacted=sum(item.screening_decision == "redacted" for item in results),
865
+ policy_version=results[0].policy.version,
866
+ )
867
+ except IntegrityError as exc:
868
+ session.rollback()
869
+ concurrent = session.scalars(
870
+ select(CaptureEvent).where(
871
+ CaptureEvent.session_id == session_id,
872
+ CaptureEvent.idempotency_key.in_([item.idempotency_key for item in data.events]),
873
+ )
874
+ ).all()
875
+ by_key = {item.idempotency_key: item for item in concurrent}
876
+ if len(by_key) == len(data.events) and all(
877
+ by_key[item.idempotency_key].request_hash == _event_hash(item) for item in data.events
878
+ ):
879
+ response.status_code = status.HTTP_200_OK
880
+ return CaptureEventBatchRead(
881
+ events=[_event_read(by_key[item.idempotency_key]) for item in data.events]
882
+ )
883
+ raise HTTPException(status_code=409, detail="Batch was concurrently ingested") from exc
884
+ return CaptureEventBatchRead(events=[_event_read(item) for item in events])
885
+
886
+
887
+ @router.post(
888
+ "/sessions/{session_id}/checkpoints", response_model=CaptureCheckpointRead, status_code=201
889
+ )
890
+ def write_checkpoint(
891
+ session_id: UUID,
892
+ data: CaptureCheckpointWrite,
893
+ response: Response,
894
+ auth: AuthContext = Depends(authenticate),
895
+ session: Session = Depends(get_session),
896
+ ) -> CaptureCheckpointRead:
897
+ auth.require("capture.write")
898
+ _require_version(data.version)
899
+ _reject_secret_identifiers(data.idempotency_key)
900
+ capture_session = _capture_session(session, auth, session_id, lock=True)
901
+ policy = _latest_policy(
902
+ session, auth, capture_session.workspace_id, capture_session.profile_key
903
+ )
904
+ screened_metadata = screen_capture("", data.metadata)
905
+ if screened_metadata.reasons and policy.secret_policy == "reject":
906
+ raise HTTPException(
907
+ status_code=422,
908
+ detail={
909
+ "error": "capture_secret_rejected",
910
+ "reasons": list(screened_metadata.reasons),
911
+ },
912
+ )
913
+ request_hash = _hash(data.model_dump(mode="json"))
914
+ existing = session.scalar(
915
+ select(CaptureCheckpoint).where(
916
+ CaptureCheckpoint.session_id == capture_session.id,
917
+ CaptureCheckpoint.idempotency_key == data.idempotency_key,
918
+ )
919
+ )
920
+ if existing is not None:
921
+ if existing.request_hash != request_hash:
922
+ raise HTTPException(status_code=409, detail="Idempotency key was reused with new input")
923
+ response.status_code = status.HTTP_200_OK
924
+ return _checkpoint_read(existing)
925
+ if capture_session.status != "active":
926
+ raise HTTPException(status_code=409, detail="Capture session has ended")
927
+ if data.through_sequence > capture_session.last_sequence:
928
+ raise HTTPException(status_code=409, detail="Checkpoint exceeds the stored event sequence")
929
+ event = session.scalar(
930
+ select(CaptureEvent).where(
931
+ CaptureEvent.session_id == capture_session.id,
932
+ CaptureEvent.sequence == data.through_sequence,
933
+ CaptureEvent.deleted_at.is_(None),
934
+ )
935
+ )
936
+ if event is None or event.episode_id is None:
937
+ raise HTTPException(status_code=409, detail="Checkpoint event is unavailable")
938
+ checkpoint = CaptureCheckpoint(
939
+ session_id=capture_session.id,
940
+ organization_id=auth.organization_id,
941
+ workspace_id=capture_session.workspace_id,
942
+ owner_principal_id=auth.principal_id,
943
+ through_sequence=data.through_sequence,
944
+ idempotency_key=data.idempotency_key,
945
+ request_hash=request_hash,
946
+ contract_version=data.version,
947
+ episode_id=event.episode_id,
948
+ derived_memory_id=event.derived_memory_id,
949
+ metadata_=screened_metadata.metadata,
950
+ )
951
+ session.add(checkpoint)
952
+ try:
953
+ session.flush()
954
+ job = enqueue(
955
+ session,
956
+ auth.organization_id,
957
+ "promote_capture_checkpoint",
958
+ {"checkpoint_id": str(checkpoint.id)},
959
+ )
960
+ session.flush()
961
+ checkpoint.promotion_job_id = job.id
962
+ audit(
963
+ session,
964
+ auth,
965
+ "capture.checkpoint.created",
966
+ resource_type="capture_checkpoint",
967
+ resource_id=str(checkpoint.id),
968
+ detail={"through_sequence": data.through_sequence},
969
+ )
970
+ session.commit()
971
+ _capture_note(
972
+ "queue",
973
+ checkpoint_id=str(checkpoint.id),
974
+ checkpoint_status=checkpoint.promotion_status,
975
+ )
976
+ except IntegrityError as exc:
977
+ session.rollback()
978
+ concurrent = session.scalar(
979
+ select(CaptureCheckpoint).where(
980
+ CaptureCheckpoint.session_id == session_id,
981
+ CaptureCheckpoint.idempotency_key == data.idempotency_key,
982
+ )
983
+ )
984
+ if concurrent is not None and concurrent.request_hash == request_hash:
985
+ response.status_code = status.HTTP_200_OK
986
+ return _checkpoint_read(concurrent)
987
+ raise HTTPException(status_code=409, detail="Checkpoint was concurrently written") from exc
988
+ return _checkpoint_read(checkpoint)
989
+
990
+
991
+ @router.get("/checkpoints/{checkpoint_id}", response_model=CaptureCheckpointRead)
992
+ def get_checkpoint(
993
+ checkpoint_id: UUID,
994
+ auth: AuthContext = Depends(authenticate),
995
+ session: Session = Depends(get_session),
996
+ ) -> CaptureCheckpointRead:
997
+ auth.require("capture.write")
998
+ checkpoint = session.scalar(
999
+ select(CaptureCheckpoint).where(
1000
+ CaptureCheckpoint.id == checkpoint_id,
1001
+ CaptureCheckpoint.organization_id == auth.organization_id,
1002
+ CaptureCheckpoint.owner_principal_id == auth.principal_id,
1003
+ CaptureCheckpoint.workspace_id == auth.workspace_id,
1004
+ CaptureCheckpoint.deleted_at.is_(None),
1005
+ )
1006
+ )
1007
+ if checkpoint is None:
1008
+ raise HTTPException(status_code=404, detail="Capture checkpoint not found")
1009
+ return _checkpoint_read(checkpoint)
1010
+
1011
+
1012
+ @router.post("/sessions/{session_id}/end", response_model=CaptureSessionRead)
1013
+ def end_session(
1014
+ session_id: UUID,
1015
+ data: CaptureSessionEnd,
1016
+ auth: AuthContext = Depends(authenticate),
1017
+ session: Session = Depends(get_session),
1018
+ ) -> CaptureSessionRead:
1019
+ auth.require("capture.write")
1020
+ _require_version(data.version)
1021
+ _reject_secret_identifiers(data.idempotency_key)
1022
+ capture_session = _capture_session(session, auth, session_id, lock=True)
1023
+ if capture_session.status == "ended":
1024
+ if (
1025
+ capture_session.end_idempotency_key == data.idempotency_key
1026
+ and capture_session.last_sequence == data.final_sequence
1027
+ ):
1028
+ return _session_read(capture_session)
1029
+ raise HTTPException(status_code=409, detail="Capture session has already ended")
1030
+ if data.final_sequence != capture_session.last_sequence:
1031
+ raise HTTPException(
1032
+ status_code=409,
1033
+ detail={
1034
+ "error": "final_sequence_mismatch",
1035
+ "stored_sequence": capture_session.last_sequence,
1036
+ },
1037
+ )
1038
+ capture_session.status = "ended"
1039
+ capture_session.ended_at = utcnow()
1040
+ capture_session.end_idempotency_key = data.idempotency_key
1041
+ audit(
1042
+ session,
1043
+ auth,
1044
+ "capture.session.ended",
1045
+ resource_type="capture_session",
1046
+ resource_id=str(capture_session.id),
1047
+ detail={"final_sequence": data.final_sequence},
1048
+ )
1049
+ session.commit()
1050
+ _capture_note("adapter", decision="ended", final_sequence=data.final_sequence)
1051
+ return _session_read(capture_session)