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.
- albert/__init__.py +3 -0
- albert/admin.py +532 -0
- albert/api.py +698 -0
- albert/automatic_memory_release.json +14 -0
- albert/benchmark.py +272 -0
- albert/benchmark_cli.py +37 -0
- albert/capture.py +1051 -0
- albert/capture_installer.py +1000 -0
- albert/capture_promotion.py +298 -0
- albert/capture_retention.py +72 -0
- albert/capture_sdk.py +558 -0
- albert/capture_security.py +79 -0
- albert/chunking.py +79 -0
- albert/classifier.py +263 -0
- albert/claude_adapter.py +357 -0
- albert/claude_lifecycle.py +418 -0
- albert/claude_package.json +19 -0
- albert/claude_plugin/.claude-plugin/plugin.json +11 -0
- albert/claude_plugin/.mcp.json +13 -0
- albert/claude_plugin/hooks/hooks.json +70 -0
- albert/clusters.py +140 -0
- albert/codex_adapter.py +308 -0
- albert/codex_hooks/hooks.json +49 -0
- albert/codex_lifecycle.py +485 -0
- albert/codex_package.json +19 -0
- albert/compatibility.py +121 -0
- albert/config.py +144 -0
- albert/console.py +508 -0
- albert/console_backends.py +236 -0
- albert/console_map.py +518 -0
- albert/console_ops.py +919 -0
- albert/credentials.py +616 -0
- albert/cursor_adapter.py +320 -0
- albert/cursor_hooks/hooks.json +11 -0
- albert/cursor_rules/albert-memory.mdc +11 -0
- albert/db.py +68 -0
- albert/embeddings.py +91 -0
- albert/graph.py +623 -0
- albert/graph_builders.py +879 -0
- albert/graph_cypher.py +669 -0
- albert/graph_store.py +376 -0
- albert/host_targets/claude-code.json +45 -0
- albert/host_targets/codex.json +45 -0
- albert/host_targets/openclaw.json +45 -0
- albert/host_targets.py +271 -0
- albert/links.py +428 -0
- albert/mcp_server.py +457 -0
- albert/memory_safety.py +111 -0
- albert/models.py +611 -0
- albert/openclaw_adapter.py +326 -0
- albert/openclaw_adapter_trust.json +5 -0
- albert/openclaw_package.json +22 -0
- albert/openclaw_plugin/index.js +135 -0
- albert/openclaw_plugin/openclaw.plugin.json +25 -0
- albert/openclaw_plugin/package.json +12 -0
- albert/portability.py +291 -0
- albert/provenance.py +105 -0
- albert/recorder.py +166 -0
- albert/release_gate.py +83 -0
- albert/schemas.py +617 -0
- albert/scope.py +26 -0
- albert/search.py +447 -0
- albert/security.py +150 -0
- albert/services.py +1316 -0
- albert/session_context.py +162 -0
- albert/trace_feed.py +165 -0
- albert/trace_writer.py +131 -0
- albert/tracing.py +75 -0
- albert/worker.py +202 -0
- albert/workspaces.py +256 -0
- albert_memory-0.4.0.dist-info/METADATA +554 -0
- albert_memory-0.4.0.dist-info/RECORD +74 -0
- albert_memory-0.4.0.dist-info/WHEEL +4 -0
- 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)
|