synapse-memory-engine 1.3.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.
@@ -0,0 +1,1334 @@
1
+ import sys
2
+ import os
3
+ import time
4
+ import datetime
5
+ import hashlib
6
+ import logging
7
+ import threading
8
+ from typing import List, Dict, Any, Optional, Tuple
9
+ from pydantic import BaseModel, Field
10
+ import uvicorn
11
+ from fastapi import FastAPI, HTTPException, status, Header, Request, Depends
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+
14
+ # Adjust path to enable absolute imports when running as standalone script
15
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
16
+
17
+ from synapse_memory.core.sqlite_db import SQLiteMemoryStore
18
+ from synapse_memory.core.pgvector_db import PGVectorMemoryStore
19
+ from synapse_memory.core.embedder import SynapseEmbedder
20
+ from synapse_memory.core.memory_manager import DEFAULT_MAX_RECORD_LIMIT, MemoryManager
21
+ from synapse_memory.core.knapsack_packer import KnapsackPacker
22
+ from synapse_memory.core.decay_engine import DecayEngine
23
+ from synapse_memory.core.hybrid_search import HybridSearch
24
+ from synapse_memory.core.auth import (
25
+ SecurityManager,
26
+ APIKeyRecord,
27
+ can_create_api_key,
28
+ is_platform_admin,
29
+ tenant_selection_allowed,
30
+ )
31
+
32
+ logger = logging.getLogger("SynapseGateway")
33
+ logging.basicConfig(level=logging.INFO)
34
+
35
+ # Detect Canonical Database Storage Backend
36
+ DATABASE_URL = os.environ.get("DATABASE_URL") or os.environ.get("POSTGRES_URL")
37
+ STORAGE_BACKEND = os.environ.get("SYNAPSE_STORAGE_BACKEND", "auto").lower()
38
+ # SQLite file location. Configurable so tests and multi-instance dev setups can
39
+ # isolate their state instead of always colliding on ./synapse_memory.db.
40
+ SQLITE_DB_PATH = os.environ.get("SYNAPSE_SQLITE_DB_PATH", "synapse_memory.db")
41
+ IS_PRODUCTION = os.environ.get("NODE_ENV", "").lower() == "production"
42
+ # Fail-closed by default whenever a DATABASE_URL is configured: silent in-memory
43
+ # fallback causes data loss and is unacceptable for teams. Explicit opt-out via
44
+ # SYNAPSE_ALLOW_DB_FALLBACK=true (dev only).
45
+ ALLOW_DB_FALLBACK = os.environ.get("SYNAPSE_ALLOW_DB_FALLBACK", "").lower() == "true"
46
+ FAIL_CLOSED_DB = bool(DATABASE_URL) and not ALLOW_DB_FALLBACK
47
+ # Gate demo/simulated routes (infrastructure fantasy metrics, licensing toy).
48
+ # Production deployments MUST set ENABLE_DEMO_ROUTES=false.
49
+ ENABLE_DEMO_ROUTES = os.environ.get("ENABLE_DEMO_ROUTES", "false" if IS_PRODUCTION else "true").lower() == "true"
50
+ # Schema ownership topology. true (default) = the engine creates/upgrades its own
51
+ # schema (single-role local/dev). false = "migrate-then-run": DDL + migrations are
52
+ # performed ahead of start-up by the migration role, and the engine connects as a
53
+ # least-privilege DML-only runtime role (scripts/provision_db_roles.py). Required
54
+ # when the runtime role is a NON-owner without BYPASSRLS, because such a role
55
+ # cannot execute DDL.
56
+ APPLY_DB_SCHEMA = os.environ.get("SYNAPSE_APPLY_DB_SCHEMA", "true").lower() == "true"
57
+
58
+ # Security & Distributed Identity Engine
59
+ # When SYNAPSE_APPLY_DB_SCHEMA=false (least-privilege production topology), the
60
+ # engine must not issue DDL; it only validates that auth tables exist and are
61
+ # readable by the runtime role.
62
+ security_manager = SecurityManager(
63
+ db_path=SQLITE_DB_PATH,
64
+ connection_string=DATABASE_URL if (DATABASE_URL and STORAGE_BACKEND in ["pgvector", "auto"]) else None,
65
+ fail_closed=FAIL_CLOSED_DB or (IS_PRODUCTION and bool(DATABASE_URL)),
66
+ validate_schema_only=not APPLY_DB_SCHEMA,
67
+ )
68
+
69
+ # Instantiate active embedder to establish canonical vector dimension
70
+ embedder = SynapseEmbedder()
71
+ embedding_dimension = embedder.dimension
72
+
73
+ store: Any
74
+ if STORAGE_BACKEND == "pgvector":
75
+ try:
76
+ store = PGVectorMemoryStore(
77
+ connection_string=DATABASE_URL,
78
+ dimension=embedding_dimension,
79
+ fail_closed=True,
80
+ apply_schema=APPLY_DB_SCHEMA,
81
+ )
82
+ ACTIVE_BACKEND = "pgvector"
83
+ logger.info(f"Canonical Production Engine using PostgreSQL + pgvector ({embedding_dimension}-d HNSW) at {str(DATABASE_URL)[:20]}...")
84
+ except Exception as e:
85
+ logger.exception(f"PostgreSQL startup failed in pgvector mode: {e}")
86
+ raise
87
+ elif STORAGE_BACKEND == "auto" and DATABASE_URL:
88
+ try:
89
+ store = PGVectorMemoryStore(
90
+ connection_string=DATABASE_URL,
91
+ dimension=embedding_dimension,
92
+ fail_closed=FAIL_CLOSED_DB or IS_PRODUCTION,
93
+ apply_schema=APPLY_DB_SCHEMA,
94
+ )
95
+ ACTIVE_BACKEND = "pgvector"
96
+ logger.info(f"Canonical Production Engine using PostgreSQL + pgvector ({embedding_dimension}-d HNSW) at {str(DATABASE_URL)[:20]}...")
97
+ except Exception as e:
98
+ if FAIL_CLOSED_DB or IS_PRODUCTION:
99
+ logger.exception(f"PostgreSQL startup failed (fail-closed): {e}")
100
+ raise
101
+ logger.warning(f"Could not connect to PostgreSQL ({e}); initializing SQLiteMemoryStore.")
102
+ store = SQLiteMemoryStore(SQLITE_DB_PATH)
103
+ ACTIVE_BACKEND = "sqlite"
104
+ else:
105
+ store = SQLiteMemoryStore(SQLITE_DB_PATH)
106
+ ACTIVE_BACKEND = "sqlite"
107
+
108
+ manager = MemoryManager(store=store, embedder=embedder, max_record_limit=DEFAULT_MAX_RECORD_LIMIT)
109
+ logger.info(
110
+ f"MemoryManager capacity limit: {manager.max_record_limit} records "
111
+ f"(override with SYNAPSE_MAX_RECORD_LIMIT; historical default is 50)"
112
+ )
113
+ packer = KnapsackPacker(token_budget=min(int(os.environ.get("TOKEN_BUDGET_MAX", "4000")), KnapsackPacker.MAX_BUDGET))
114
+ decay = DecayEngine(category_configs={})
115
+ searcher = HybridSearch()
116
+
117
+ # --- Rate limiting: distributed (Redis) when configured, else in-process ---
118
+ # REDIS_URL enables a Redis sorted-set sliding window enforced across ALL
119
+ # replicas; without it (or when Redis is unreachable) we degrade to a
120
+ # thread-safe per-process window. See synapse_memory.core.rate_limiter.
121
+ from synapse_memory.core.rate_limiter import build_rate_limiter
122
+
123
+ RATE_LIMIT_PER_MIN = int(os.environ.get("RATE_LIMIT_PER_MIN", "120"))
124
+ RATE_LIMIT_WINDOW_SECONDS = float(os.environ.get("RATE_LIMIT_WINDOW_SECONDS", "60"))
125
+ rate_limiter = build_rate_limiter()
126
+
127
+ # --- Live operational metrics (no extra deps; scraped via /metrics) ---
128
+ _metrics_lock = threading.Lock()
129
+ # Request counts keyed by (route_template, status) with a max of 10000 distinct
130
+ # keys. Dynamic paths (e.g. /api/memories/<id>) are bucketed as "other" so
131
+ # request volume cannot exhaust memory (F-08).
132
+ _REQUEST_COUNTS: Dict[Tuple[str, str], int] = {}
133
+ # Latency samples keyed by route template (bounded by _REQUEST_COUNTS).
134
+ _LATENCY_SAMPLES: Dict[str, List[float]] = {}
135
+ # Key expiration for metrics: (timestamp, count) tuples; entries older than
136
+ # _METRICS_TTL_SECONDS are pruned on each scrape.
137
+ _METRICS_TTL: Dict[Tuple[str, str], Tuple[float, int]] = {}
138
+ _METRICS_TTL_SECONDS = 3600.0
139
+ # Max distinct route templates; excess paths get bucketed as "other".
140
+ _MAX_ROUTE_TEMPLATES = 10000
141
+ # Canonical route templates for dynamic paths (F-08)
142
+ _ROUTE_TEMPLATE_MAP = {
143
+ "/api/memories": "/api/memories",
144
+ "/api/memories/{memory_id}": "/api/memories/{memory_id}",
145
+ "/api/memories/list": "/api/memories",
146
+ "/api/memories/{memory_id}/feedback": "/api/memories/{memory_id}/feedback",
147
+ }
148
+ _rate_limit_rejections = 0
149
+ _rate_limit_checks = 0
150
+ _ingest_success = 0
151
+ _ingest_errors = 0
152
+ _retrieval_success = 0
153
+ _retrieval_errors = 0
154
+ # Auth/authorization failures. Exported as synapse_auth_failures_total in
155
+ # /metrics; incremented on the fail-closed path in _build_auth_dependency.
156
+ _auth_failures = 0
157
+ _last_candidate_count = 0
158
+ _last_packed_tokens = 0
159
+
160
+
161
+ def _record_request(path: str, status: int, latency_s: float) -> None:
162
+ """Record request metrics with bounded cardinality (F-08).
163
+
164
+ Route templates are used as labels (e.g. /api/memories/{memory_id});
165
+ dynamic paths are bucketed as 'other'. Keys are pruned after TTL.
166
+ """
167
+ with _metrics_lock:
168
+ # Prune expired keys
169
+ now = time.time()
170
+ expired_keys = [
171
+ k for k, (t, _) in _METRICS_TTL.items() if now - t > _METRICS_TTL_SECONDS
172
+ ]
173
+ for key in expired_keys:
174
+ _REQUEST_COUNTS.pop(key, None)
175
+ _METRICS_TTL.pop(key, None)
176
+
177
+ # Bucket path: use route template if known, else 'other'
178
+ route_template = _ROUTE_TEMPLATE_MAP.get(path, "other")
179
+
180
+ key = (route_template, str(status))
181
+ if key not in _REQUEST_COUNTS and len(_REQUEST_COUNTS) >= _MAX_ROUTE_TEMPLATES:
182
+ # Hard ceiling on distinct series: over-cap paths fold into 'other'
183
+ # instead of growing the counter dict without bound.
184
+ key = ("other", str(status))
185
+ _REQUEST_COUNTS[key] = _REQUEST_COUNTS.get(key, 0) + 1
186
+ # Record freshness so the prune above can actually expire this series.
187
+ _METRICS_TTL[key] = (now, _REQUEST_COUNTS[key])
188
+ samples = _LATENCY_SAMPLES.setdefault(route_template, [])
189
+ samples.append(latency_s)
190
+ if len(samples) > 500:
191
+ del samples[: len(samples) - 500]
192
+
193
+
194
+ def _check_rate_limit(key: str) -> None:
195
+ global _rate_limit_rejections, _rate_limit_checks
196
+ with _metrics_lock:
197
+ _rate_limit_checks += 1
198
+ should_prune = _rate_limit_checks % 256 == 0
199
+ # Opportunistically bound bucket growth: a spoofed/rotating key space must
200
+ # not accumulate buckets forever. No-op on the Redis limiter (keys PEXPIRE).
201
+ if should_prune:
202
+ prune = getattr(rate_limiter, "prune_idle", None)
203
+ if callable(prune):
204
+ try:
205
+ prune()
206
+ except Exception:
207
+ pass
208
+ if rate_limiter.check(key, RATE_LIMIT_PER_MIN, RATE_LIMIT_WINDOW_SECONDS):
209
+ return
210
+ with _metrics_lock:
211
+ _rate_limit_rejections += 1
212
+ raise HTTPException(status_code=429, detail="Rate limit exceeded. Retry after 60s.")
213
+
214
+
215
+ def _rate_limit_principal(request: Request) -> str:
216
+ """Stable, non-reversible bucket identity for a request.
217
+
218
+ Buckets key on a truncated SHA-256 of the presented API key — never the raw
219
+ secret — so every request from the same credential shares one bucket and the
220
+ secret is never stored in limiter state. The client IP is folded in so a
221
+ caller rotating spoofed API-key values cannot mint unlimited buckets from a
222
+ single source address. Anonymous traffic falls back to the client IP alone.
223
+ """
224
+ api_key = request.headers.get("X-Synapse-API-Key") or ""
225
+ client_ip = request.client.host if request.client else "anon"
226
+ if api_key:
227
+ digest = hashlib.sha256(api_key.encode("utf-8")).hexdigest()[:16]
228
+ return f"{client_ip}:{digest}"
229
+ return f"ip:{client_ip}"
230
+
231
+
232
+ # Initialize FastAPI application (MUST precede any @app.middleware / route registration)
233
+ app = FastAPI(
234
+ title="SynapseMemory Enterprise Cognitive Gateway",
235
+ description="Sovereign Active Long-Term Memory & Context Budgeting REST API with Multi-Tenancy and Database-Level RLS.",
236
+ version="1.2.0"
237
+ )
238
+
239
+
240
+ @app.middleware("http")
241
+ async def add_request_id_and_rate_limit(request: Request, call_next): # type: ignore
242
+ import uuid as _uuid
243
+
244
+ request_id = request.headers.get("X-Request-ID") or f"req_{_uuid.uuid4().hex[:12]}"
245
+ start = time.time()
246
+ # Rate-limit only mutating + heavy retrieval endpoints
247
+ if request.url.path.startswith(("/ingest", "/api/memories", "/query", "/api/retrieval")):
248
+ try:
249
+ _check_rate_limit(f"{_rate_limit_principal(request)}:{request.url.path}")
250
+ except HTTPException as e:
251
+ from fastapi.responses import JSONResponse
252
+
253
+ _record_request(request.url.path, e.status_code, time.time() - start)
254
+ return JSONResponse(
255
+ status_code=e.status_code,
256
+ content={"success": False, "error": e.detail, "request_id": request_id},
257
+ )
258
+ # HTTPException raised inside endpoints is shaped by the registered handler
259
+ # below; the middleware only stamps the request id on successful responses.
260
+ response = await call_next(request)
261
+ response.headers["X-Request-ID"] = request_id
262
+ try:
263
+ _record_request(request.url.path, response.status_code, time.time() - start)
264
+ except Exception:
265
+ pass
266
+ return response
267
+
268
+
269
+ @app.exception_handler(HTTPException)
270
+ async def http_exception_handler(request: Request, exc: HTTPException): # type: ignore
271
+ """Shape HTTPException into the canonical error envelope with request_id."""
272
+ import uuid as _uuid
273
+ from fastapi.responses import JSONResponse
274
+
275
+ request_id = request.headers.get("X-Request-ID") or f"req_{_uuid.uuid4().hex[:12]}"
276
+ return JSONResponse(
277
+ status_code=exc.status_code,
278
+ content={"success": False, "error": exc.detail, "request_id": request_id},
279
+ headers={"X-Request-ID": request_id},
280
+ )
281
+
282
+
283
+ # Tighten CORS to production-appropriate origins
284
+ ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")
285
+ app.add_middleware(
286
+ CORSMiddleware,
287
+ allow_origins=["*"] if os.environ.get("ALLOW_ALL_CORS") == "true" else ALLOWED_ORIGINS,
288
+ allow_credentials=True,
289
+ allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
290
+ allow_headers=["*"],
291
+ )
292
+
293
+
294
+ @app.on_event("shutdown")
295
+ def _flush_audit_on_shutdown() -> None:
296
+ """Flush buffered audit rows on graceful shutdown (F-13).
297
+
298
+ Without this, a clean container stop still loses the pending audit batch;
299
+ abrupt SIGKILL remains a documented residual risk (see SECURITY.md).
300
+ """
301
+ try:
302
+ security_manager.close()
303
+ except Exception: # pragma: no cover - shutdown must never raise
304
+ pass
305
+
306
+
307
+ # Security Authentication Dependency & Scope Enforcement
308
+ def require_scope(scope: str):
309
+ async def dependency(
310
+ request: Request,
311
+ x_synapse_api_key: Optional[str] = Header(None, alias="X-Synapse-API-Key"),
312
+ authorization: Optional[str] = Header(None)
313
+ ) -> APIKeyRecord:
314
+ token = x_synapse_api_key
315
+ if not token and authorization:
316
+ if authorization.lower().startswith("bearer "):
317
+ token = authorization[7:].strip()
318
+ else:
319
+ token = authorization.strip()
320
+
321
+ tenant_header = request.headers.get("X-Tenant-ID") or request.headers.get("x-tenant-id")
322
+ client_ip = request.client.host if request.client else None
323
+
324
+ # Dev-open bypass is ONLY for a completely unconfigured local setup:
325
+ # not in production AND no SYNAPSE_API_KEY env AND no keys provisioned.
326
+ # The moment any auth is configured, anonymous requests are denied (403).
327
+ # Short-circuited so authenticated requests never hit the keys table.
328
+ if not token:
329
+ auth_configured = bool(os.environ.get("SYNAPSE_API_KEY")) or len(security_manager.list_api_keys()) > 0
330
+ if not IS_PRODUCTION and not auth_configured:
331
+ return APIKeyRecord(
332
+ key_id="key_dev_open",
333
+ key_hash="",
334
+ name="Development Default",
335
+ organization_id="org_dev",
336
+ project_id="proj_dev",
337
+ tenant_id=tenant_header or "default",
338
+ scopes=["admin", "memories:read", "memories:write", "memories:delete"],
339
+ # Explicit platform_admin: this bypass exists only when NO
340
+ # auth is configured at all (local dev). It was previously
341
+ # role="admin", which now normalizes to workspace_admin.
342
+ role="platform_admin",
343
+ is_active=True,
344
+ expires_at=None,
345
+ created_at=time.time(),
346
+ last_used_at=time.time()
347
+ )
348
+
349
+ valid, record, reason = security_manager.validate_api_key(
350
+ token=token or "",
351
+ required_scope=scope,
352
+ requested_tenant_id=tenant_header,
353
+ route=request.url.path,
354
+ action=request.method,
355
+ client_ip=client_ip,
356
+ )
357
+
358
+ if not valid or record is None:
359
+ global _auth_failures
360
+ with _metrics_lock:
361
+ _auth_failures += 1
362
+ raise HTTPException(
363
+ status_code=status.HTTP_403_FORBIDDEN,
364
+ detail="Authentication failed.",
365
+ )
366
+
367
+ return record
368
+
369
+ return dependency
370
+
371
+
372
+ def resolve_tenant(
373
+ principal: APIKeyRecord,
374
+ requested_tenant: Optional[str]
375
+ ) -> str:
376
+ """Resolve the effective tenant for this request.
377
+
378
+ Cross-tenant selection is a platform_admin capability only (F-01). Neither
379
+ the raw stored role nor the legacy "admin" scope widens the boundary, so a
380
+ delegated workspace key can never operate on another tenant by asking.
381
+ """
382
+ if is_platform_admin(principal):
383
+ if requested_tenant:
384
+ return requested_tenant
385
+ if principal.tenant_id not in ("*", ""):
386
+ return principal.tenant_id
387
+ raise HTTPException(
388
+ status_code=status.HTTP_400_BAD_REQUEST,
389
+ detail="Explicit tenant_id is required for admin cross-tenant operations."
390
+ )
391
+
392
+ if not tenant_selection_allowed(principal, requested_tenant):
393
+ raise HTTPException(
394
+ status_code=status.HTTP_403_FORBIDDEN,
395
+ detail="Tenant mismatch."
396
+ )
397
+
398
+ return principal.tenant_id or "default"
399
+
400
+
401
+ def envelope(data: Any, request: Optional[Request] = None) -> Dict[str, Any]:
402
+ """Wrap a successful payload in the canonical response envelope.
403
+
404
+ {success: true, data: <payload>, request_id: <id>}
405
+
406
+ Every JSON endpoint returns this so clients (React dashboard, Python/JS SDKs,
407
+ curl) read `success`/`data` uniformly. request_id is echoed from the
408
+ inbound header when present so a caller can trace a request end-to-end.
409
+ """
410
+ rid = ""
411
+ if request is not None:
412
+ rid = request.headers.get("X-Request-ID", "")
413
+ return {"success": True, "data": data, "request_id": rid}
414
+
415
+
416
+ # --- PYDANTIC SCHEMAS ---
417
+
418
+ class IngestRequest(BaseModel):
419
+ content: str = Field(..., min_length=1, max_length=32000, description="Raw text representation of the memory segment")
420
+ category: str = Field("interaction", min_length=1, max_length=64, pattern=r"^[a-z0-9_\-]+$", description="Cognitive category (e.g., interaction, preference, system)")
421
+ confidence: float = Field(0.95, ge=0.0, le=1.0, description="Confidence alignment coefficient")
422
+ tenant_id: Optional[str] = Field(None, alias="tenantId", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_\-\.]+$", description="Target tenant ID for isolation")
423
+
424
+
425
+ class IngestResponse(BaseModel):
426
+ id: str
427
+ status: str
428
+ token_cost: int
429
+ tenant_id: str
430
+
431
+
432
+ class QueryRequest(BaseModel):
433
+ prompt: Optional[str] = Field(None, min_length=1, max_length=8000, description="Active user prompt")
434
+ query: Optional[str] = Field(None, min_length=1, max_length=8000, description="Alternative alias for prompt")
435
+ max_token_budget: int = Field(1500, ge=50, le=8000, description="Max token window allowed for context packing", alias="maxTokens")
436
+ category: Optional[str] = Field(None, min_length=1, max_length=64, pattern=r"^[a-z0-9_\-]+$", description="Optional category filter")
437
+ tenant_id: Optional[str] = Field(None, alias="tenantId", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_\-\.]+$", description="Target tenant ID")
438
+
439
+
440
+ class QueryResponse(BaseModel):
441
+ fused_context: str
442
+ injected_nodes_count: int
443
+ injected_nodes: List[Dict[str, Any]]
444
+ active_storage_backend: str
445
+
446
+
447
+ class FeedbackRequest(BaseModel):
448
+ memory_id: str = Field(..., min_length=1, max_length=256, description="Memory node ID")
449
+ feedback_type: str = Field(..., pattern=r"^(positive|negative)$", description="Must be either 'positive' (boost) or 'negative' (hallucination alert)")
450
+ tenant_id: Optional[str] = Field(None, alias="tenantId", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_\-\.]+$")
451
+
452
+
453
+ class CreateKeyRequest(BaseModel):
454
+ name: str = Field(..., min_length=1, max_length=128)
455
+ organization_id: str = Field("org_default", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_\-\.]+$")
456
+ project_id: str = Field("proj_default", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_\-\.]+$")
457
+ tenant_id: str = Field("default", min_length=1, max_length=128, pattern=r"^([A-Za-z0-9_\-\.]+|\*)$")
458
+ scopes: List[str] = Field(["memories:read", "memories:write"], min_length=1, max_length=16)
459
+ # Accepts the canonical roles plus the legacy "admin" string. The legacy
460
+ # string stays schema-valid so can_create_api_key() can reject it with a
461
+ # clear 403 ("not assignable") instead of a generic 422, and so the role
462
+ # hierarchy is actually reachable through the API (F-01).
463
+ role: str = Field(
464
+ "developer",
465
+ pattern=r"^(admin|platform_admin|organization_admin|workspace_admin|developer|service|viewer)$",
466
+ )
467
+ ttl_days: Optional[int] = Field(365, ge=1, le=3650)
468
+
469
+
470
+ class RevokeKeyRequest(BaseModel):
471
+ key_id: str = Field(..., min_length=1, max_length=128)
472
+
473
+
474
+ AUTH_KEYS_CREATE_SCOPE = "auth:keys:create"
475
+
476
+
477
+ # --- Telemetry (no auth) ---
478
+
479
+ @app.get("/liveness", tags=["Telemetry"])
480
+ def liveness():
481
+ """Returns simple process liveness state.
482
+
483
+ DB-independent by design: the engine can be running and the process alive
484
+ even when the database or auth backend has failed. K8s `livenessProbe`
485
+ depends on this route; failures restart the container but do not take it
486
+ out of rotation until /readiness fails.
487
+ """
488
+ return {"status": "OK", "timestamp": time.time()}
489
+
490
+
491
+ @app.get("/readiness", tags=["Telemetry"])
492
+ def readiness():
493
+ """Checks actual database and auth backend usability.
494
+
495
+ Performs a bounded live health check with a short timeout (F-07). This is
496
+ the Kubernetes readiness probe: it must fail closed (503) when the storage
497
+ backend becomes unreachable so traffic is shed before requests start
498
+ failing.
499
+ """
500
+ if ACTIVE_BACKEND == "pgvector":
501
+ # Bounded live check (F-07): trigger a real connection attempt with timeout.
502
+ # NOTE: psycopg2 connections have no execute() (that is a psycopg3 API), so
503
+ # the check must go through a cursor — otherwise readiness always 503s and
504
+ # the k8s probe keeps the engine out of rotation forever.
505
+ conn = None
506
+ try:
507
+ conn = store._get_connection()
508
+ with conn.cursor() as cur:
509
+ cur.execute("SELECT 1")
510
+ cur.fetchone()
511
+ conn.rollback() # end the probe's transaction before returning it
512
+ except Exception as e:
513
+ logger.warning("Readiness check failed: " + str(e))
514
+ raise HTTPException(
515
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
516
+ detail={
517
+ "status": "UNHEALTHY",
518
+ "storage_backend": ACTIVE_BACKEND,
519
+ "reason": "PostgreSQL connection unavailable."
520
+ },
521
+ )
522
+ finally:
523
+ if conn is not None:
524
+ store._return_connection(conn)
525
+
526
+ return {
527
+ "status": "READY",
528
+ "timestamp": time.time(),
529
+ "storage_backend": ACTIVE_BACKEND,
530
+ "embedding_provider": embedder.provider_name,
531
+ "embedding_is_semantic": embedder.is_semantic
532
+ }
533
+
534
+
535
+ @app.get("/health", tags=["Telemetry"])
536
+ def get_health():
537
+ """Returns active service telemetry, storage backend, and security status."""
538
+ db_ok = True
539
+ if ACTIVE_BACKEND == "pgvector":
540
+ db_ok = store.is_connected
541
+
542
+ if not db_ok:
543
+ raise HTTPException(
544
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
545
+ detail={
546
+ "status": "UNHEALTHY",
547
+ "storage_backend": ACTIVE_BACKEND,
548
+ "reason": "Database connection offline"
549
+ }
550
+ )
551
+
552
+ return {
553
+ "status": "HEALTHY",
554
+ "timestamp": time.time(),
555
+ "storage_backend": ACTIVE_BACKEND,
556
+ "hnsw_enabled": ACTIVE_BACKEND == "pgvector",
557
+ "row_level_security": "ENFORCED" if ACTIVE_BACKEND == "pgvector" else "TENANT_INDEXED",
558
+ "total_indexed_memories": store.count_memories(),
559
+ "embedding_provider": embedder.provider_name,
560
+ "engine_budget_solver": "Knapsack DP 0/1",
561
+ "auth_engine": "Hashed RBAC + Audit Log"
562
+ }
563
+
564
+
565
+ @app.get("/api/memories", tags=["Memory Operations"])
566
+ @app.get("/api/memories/list", tags=["Memory Operations"])
567
+ def list_memories(
568
+ request: Request,
569
+ tenant_id: Optional[str] = None,
570
+ category: Optional[str] = None,
571
+ limit: int = 50,
572
+ offset: int = 0,
573
+ principal: APIKeyRecord = Depends(require_scope("memories:read"))
574
+ ):
575
+ """Lists memories for the authenticated tenant with pagination."""
576
+ effective_tenant = resolve_tenant(principal, tenant_id)
577
+ limit = max(1, min(limit, 200))
578
+ offset = max(0, offset)
579
+ if category:
580
+ mems = store.get_memories_by_category(category, tenant_id=effective_tenant)
581
+ # Full category-filtered set: length is the accurate total.
582
+ total = len(mems)
583
+ else:
584
+ # Pagination total must be the REAL corpus size (via COUNT), not the
585
+ # length of the fetched page window.
586
+ if hasattr(store, "count_active_memories"):
587
+ total = store.count_active_memories(tenant_id=effective_tenant) # type: ignore
588
+ elif hasattr(store, "count_memories"):
589
+ total = store.count_memories(tenant_id=effective_tenant) # type: ignore
590
+ else:
591
+ total = None # type: ignore
592
+ # Prefer bounded candidate query when available to avoid full-table loads
593
+ if hasattr(store, "query_candidates"):
594
+ try:
595
+ mems = store.query_candidates(tenant_id=effective_tenant, limit=limit + offset) # type: ignore
596
+ except Exception:
597
+ mems = store.get_all_memories(tenant_id=effective_tenant)
598
+ else:
599
+ mems = store.get_all_memories(tenant_id=effective_tenant)
600
+ if total is None:
601
+ total = len(mems)
602
+
603
+ page = mems[offset : offset + limit]
604
+
605
+ return envelope(
606
+ {
607
+ "memories": page,
608
+ "tenant_id": effective_tenant or "all",
609
+ "total": total,
610
+ "limit": limit,
611
+ "offset": offset,
612
+ "storage_backend": ACTIVE_BACKEND,
613
+ },
614
+ request,
615
+ )
616
+
617
+
618
+ @app.post("/ingest", status_code=status.HTTP_201_CREATED, tags=["Ingestion"])
619
+ @app.post("/api/memories/create", status_code=status.HTTP_201_CREATED, tags=["Ingestion"])
620
+ @app.post("/api/memories/add", status_code=status.HTTP_201_CREATED, tags=["Ingestion"])
621
+ def ingest_memory(
622
+ request: Request,
623
+ payload: IngestRequest,
624
+ principal: APIKeyRecord = Depends(require_scope("memories:write"))
625
+ ):
626
+ """
627
+ Ingests memory segments with tenant isolation, cryptographic payload encryption,
628
+ semantic deduplication, and Knapsack token footprint calculation.
629
+ """
630
+ if not payload.content.strip():
631
+ raise HTTPException(status_code=400, detail="Content string cannot be empty.")
632
+
633
+ effective_tenant = resolve_tenant(principal, payload.tenant_id)
634
+
635
+ global _ingest_success, _ingest_errors
636
+ try:
637
+ memory_id, action, cost = manager.ingest_with_deduplication(
638
+ content=payload.content,
639
+ category=payload.category,
640
+ confidence=payload.confidence,
641
+ tenant_id=effective_tenant
642
+ )
643
+ except Exception:
644
+ with _metrics_lock:
645
+ _ingest_errors += 1
646
+ raise
647
+ with _metrics_lock:
648
+ _ingest_success += 1
649
+
650
+ return envelope(
651
+ {
652
+ "id": memory_id,
653
+ "status": f"SUCCESS_{action}",
654
+ "token_cost": cost,
655
+ "tenant_id": effective_tenant,
656
+ },
657
+ request,
658
+ )
659
+
660
+
661
+ @app.post("/query", tags=["Retrieval"])
662
+ @app.post("/api/memories/query", tags=["Retrieval"])
663
+ @app.post("/api/retrieval/knapsack-pack", tags=["Retrieval"])
664
+ def query_memory(
665
+ request: Request,
666
+ payload: QueryRequest,
667
+ principal: APIKeyRecord = Depends(require_scope("memories:read"))
668
+ ):
669
+ """
670
+ Performs reciprocal hybrid vector search with Ebbinghaus temporal decay
671
+ and packs candidate nodes optimally into context budget using 0/1 Knapsack DP.
672
+ """
673
+ query_text = payload.prompt or payload.query or ""
674
+ if not query_text.strip():
675
+ return envelope(QueryResponse(fused_context="", injected_nodes_count=0, injected_nodes=[], active_storage_backend=ACTIVE_BACKEND).model_dump(), request)
676
+
677
+ effective_tenant = resolve_tenant(principal, payload.tenant_id)
678
+ query_vector = embedder.embed_query(query_text)
679
+
680
+ # 1. Candidate retrieval. On the pgvector backend, hybrid search (dense HNSW
681
+ # + lexical tsvector) is fused inside a single SQL query (the scale path).
682
+ # On SQLite we load the tenant corpus and fuse in Python (dev/local only).
683
+ candidates = []
684
+ if ACTIVE_BACKEND == "pgvector" and hasattr(store, "hybrid_search_sql"):
685
+ candidates = store.hybrid_search_sql(
686
+ query=query_text,
687
+ query_vec=query_vector,
688
+ top_k=25,
689
+ category=payload.category,
690
+ tenant_id=effective_tenant,
691
+ )
692
+
693
+ if not candidates:
694
+ # Fallback / SQLite path: dense + lexical pools fused in Python.
695
+ dense_candidates = store.vector_search(
696
+ query_vec=query_vector,
697
+ top_k=100,
698
+ category=payload.category,
699
+ tenant_id=effective_tenant
700
+ )
701
+ lexical_candidates = store.lexical_search(
702
+ query=query_text,
703
+ top_k=100,
704
+ category=payload.category,
705
+ tenant_id=effective_tenant
706
+ )
707
+
708
+ candidate_map = {m["id"]: m for m in dense_candidates}
709
+ for m in lexical_candidates:
710
+ if m["id"] not in candidate_map:
711
+ candidate_map[m["id"]] = m
712
+
713
+ candidate_docs = list(candidate_map.values())
714
+ if not candidate_docs:
715
+ candidate_docs = store.get_all_memories(tenant_id=effective_tenant)
716
+
717
+ if not candidate_docs:
718
+ return envelope(QueryResponse(fused_context="", injected_nodes_count=0, injected_nodes=[], active_storage_backend=ACTIVE_BACKEND).model_dump(), request)
719
+
720
+ # 2. Reciprocal Rank Fusion (RRF) in Python for the non-pgvector backends.
721
+ candidates = searcher.fused_search(
722
+ query_text,
723
+ candidate_docs,
724
+ query_vector=query_vector,
725
+ top_k=25,
726
+ category_filter=payload.category,
727
+ tenant_filter=effective_tenant
728
+ )
729
+
730
+ if not candidates:
731
+ return envelope(QueryResponse(fused_context="", injected_nodes_count=0, injected_nodes=[], active_storage_backend=ACTIVE_BACKEND).model_dump(), request)
732
+
733
+ # 2. Dynamic temporal decay adjustment: S(m,q,t) = R(m,q) * D(m,t).
734
+ # Preserve retrieval relevance; decay only re-weights priority.
735
+ decayed_candidates = []
736
+ for m in candidates:
737
+ mem_node = {
738
+ "created_at": m["created_at"],
739
+ "last_accessed_at": m.get("last_accessed_at", m["created_at"]),
740
+ "access_count": m.get("access_count", 0),
741
+ "confidence": m.get("confidence", 0.5),
742
+ "category": m.get("category", "interaction")
743
+ }
744
+ retrieval_score = float(m.get("relevance_score", 0.0))
745
+ combined = decay.combine_scores(retrieval_score, mem_node)
746
+ m_copy = m.copy()
747
+ m_copy["retrieval_score"] = retrieval_score
748
+ m_copy["decay_multiplier"] = combined / retrieval_score if retrieval_score else decay.temporal_multiplier(mem_node)
749
+ m_copy["relevance_score"] = combined
750
+ decayed_candidates.append(m_copy)
751
+
752
+ # 3. Dynamic programming 0/1 Knapsack optimal budget packing
753
+ packed_nodes = packer.pack(decayed_candidates, payload.max_token_budget)
754
+
755
+ global _retrieval_success, _last_candidate_count, _last_packed_tokens
756
+ with _metrics_lock:
757
+ _retrieval_success += 1
758
+ _last_candidate_count = len(candidates)
759
+ _last_packed_tokens = sum(int(n.get("token_cost", 0) or 0) for n in packed_nodes)
760
+
761
+ # Increment access counts for Ebbinghaus stabilization
762
+ for node in packed_nodes:
763
+ store.update_access_count(node["id"], 1, tenant_id=effective_tenant)
764
+
765
+ # Formulate fused context block
766
+ context_lines = []
767
+ for n in packed_nodes:
768
+ context_lines.append(f"[{n.get('category', 'MEMORY').upper()}] (Confidence: {n.get('confidence', 0.9):.2f}): {n.get('content', '')}")
769
+
770
+ fused_context_block = "\n---\n".join(context_lines)
771
+
772
+ return envelope(
773
+ {
774
+ "fused_context": fused_context_block,
775
+ "injected_nodes_count": len(packed_nodes),
776
+ "injected_nodes": packed_nodes,
777
+ "active_storage_backend": ACTIVE_BACKEND,
778
+ },
779
+ request,
780
+ )
781
+
782
+
783
+ @app.post("/feedback", tags=["Reinforcement"])
784
+ def submit_feedback(
785
+ request: Request,
786
+ payload: FeedbackRequest,
787
+ principal: APIKeyRecord = Depends(require_scope("memories:write"))
788
+ ):
789
+ """Applies RLAIF reinforcement feedback to boost or demote memory relevance score."""
790
+ effective_tenant = resolve_tenant(principal, payload.tenant_id)
791
+ node = store.get_memory_by_id(payload.memory_id, tenant_id=effective_tenant)
792
+ if not node:
793
+ raise HTTPException(status_code=404, detail="Memory node ID not found in store.")
794
+
795
+ old_conf = node.get("confidence", 0.5)
796
+ new_conf = decay.compute_feedback_boost(old_conf, payload.feedback_type)
797
+ store.update_confidence(payload.memory_id, new_conf, tenant_id=effective_tenant)
798
+
799
+ return envelope(
800
+ {
801
+ "id": payload.memory_id,
802
+ "feedback_status": "PROCESSED",
803
+ "old_confidence": old_conf,
804
+ "new_confidence": new_conf,
805
+ "tenant_id": effective_tenant,
806
+ },
807
+ request,
808
+ )
809
+
810
+
811
+ @app.delete("/api/memories/{memory_id}", tags=["Memory Operations"])
812
+ def delete_memory(
813
+ request: Request,
814
+ memory_id: str,
815
+ tenant_id: Optional[str] = None,
816
+ principal: APIKeyRecord = Depends(require_scope("memories:delete"))
817
+ ):
818
+ """Deletes memory node by ID with tenant isolation."""
819
+ effective_tenant = resolve_tenant(principal, tenant_id)
820
+ store.delete_memory(memory_id, tenant_id=effective_tenant)
821
+ return envelope({"status": "DELETED", "id": memory_id}, request)
822
+
823
+
824
+ @app.post("/api/memories/clear", tags=["Memory Operations"])
825
+ def clear_memories(
826
+ request: Request,
827
+ payload: Optional[Dict[str, Any]] = None,
828
+ principal: APIKeyRecord = Depends(require_scope("memories:delete"))
829
+ ):
830
+ """Flushes memory corpus for the authenticated tenant."""
831
+ requested_tenant = payload.get("tenant_id") if payload else None
832
+ effective_tenant = resolve_tenant(principal, requested_tenant)
833
+ deleted_count = store.clear_memories(tenant_id=effective_tenant)
834
+ security_manager.log_audit_action(
835
+ actor_key_id=principal.key_id,
836
+ tenant_id=effective_tenant or "all",
837
+ action="MEMORIES_CLEAR",
838
+ resource="memories",
839
+ status="SUCCESS",
840
+ metadata={"deleted_count": deleted_count}
841
+ )
842
+ return envelope(
843
+ {
844
+ "status": "CLEARED",
845
+ "tenant_id": effective_tenant or "all",
846
+ "deleted_count": deleted_count,
847
+ },
848
+ request,
849
+ )
850
+
851
+
852
+ @app.post("/api/security/gdpr-delete", tags=["Security"])
853
+ def gdpr_delete(
854
+ request: Request,
855
+ payload: Dict[str, Any],
856
+ principal: APIKeyRecord = Depends(require_scope("memories:delete"))
857
+ ):
858
+ """
859
+ Executes an authenticated GDPR Right to be Forgotten deletion path.
860
+ Performs PostgreSQL transaction DELETE on tenant memories and returns actual deleted row count.
861
+ """
862
+ requested_tenant = payload.get("tenant_id") or payload.get("tenantId")
863
+ effective_tenant = resolve_tenant(principal, requested_tenant)
864
+ deleted_count = store.clear_memories(tenant_id=effective_tenant)
865
+ security_manager.log_audit_action(
866
+ actor_key_id=principal.key_id,
867
+ tenant_id=effective_tenant or "all",
868
+ action="GDPR_RIGHT_TO_BE_FORGOTTEN_DELETE",
869
+ resource="memories",
870
+ status="SUCCESS",
871
+ metadata={"deleted_count": deleted_count}
872
+ )
873
+ return envelope(
874
+ {
875
+ "status": "SUCCESS",
876
+ "tenant_id": effective_tenant or "all",
877
+ "deleted_count": deleted_count,
878
+ "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
879
+ "audit_logged": True,
880
+ },
881
+ request,
882
+ )
883
+
884
+
885
+ # --- ENTERPRISE SECURITY & API KEY ENDPOINTS ---
886
+
887
+ @app.post("/api/auth/keys/create", tags=["Enterprise Security"])
888
+ def create_api_key(
889
+ request: Request,
890
+ payload: CreateKeyRequest,
891
+ principal: APIKeyRecord = Depends(require_scope(AUTH_KEYS_CREATE_SCOPE)),
892
+ ):
893
+ """Generates a new cryptographic API Key with RBAC scopes.
894
+
895
+ P1.1/P1.2: key minting is authorization-aware. The authenticated principal may
896
+ only mint keys inside the workspaces they control, at a role that their own
897
+ role permits, and MUST NOT be allowed to elevate to platform-wide admin. The
898
+ 'admin' scope is never auto-promoted: requested role/scopes are authoritative
899
+ and validated before minting.
900
+ """
901
+ # Resolve the caller's effective workspace binding from their key.
902
+ caller_tenant = principal.tenant_id or "default"
903
+ caller_org = principal.organization_id or "org_default"
904
+
905
+ # Target workspace that the new key is bound to. Defaults to the caller's own
906
+ # binding so a caller cannot mint a key for an org/workspace they do not own.
907
+ target_org = (payload.organization_id or caller_org).strip() or caller_org
908
+ target_workspace = payload.tenant_id or caller_tenant
909
+ requested_role = payload.role or "developer"
910
+
911
+ allowed, reason = can_create_api_key(
912
+ principal,
913
+ target_org=target_org,
914
+ target_workspace=target_workspace,
915
+ requested_role=requested_role,
916
+ # F-01: the requested scopes MUST reach the delegation check, otherwise
917
+ # a workspace admin can mint scopes=["admin"]/"security:audit" that the
918
+ # guard was written to block.
919
+ requested_scopes=payload.scopes,
920
+ )
921
+ if not allowed:
922
+ raise HTTPException(status_code=403, detail=reason)
923
+
924
+ # Defense-in-depth: still re-validate the org/project/tenant bounding via the
925
+ # generic hierarchy check (system principals bypass it).
926
+ ok, reason = security_manager.check_hierarchy(
927
+ principal,
928
+ requested_org=target_org,
929
+ requested_project=payload.project_id,
930
+ requested_tenant=target_workspace,
931
+ )
932
+ if not ok:
933
+ raise HTTPException(status_code=403, detail=reason)
934
+
935
+ token, record = security_manager.generate_api_key(
936
+ name=payload.name,
937
+ organization_id=target_org,
938
+ project_id=payload.project_id,
939
+ tenant_id=target_workspace,
940
+ scopes=payload.scopes,
941
+ role=requested_role,
942
+ ttl_days=payload.ttl_days,
943
+ owner_id=principal.key_id,
944
+ )
945
+
946
+ return envelope(
947
+ {
948
+ "status": "CREATED",
949
+ "api_key": token,
950
+ "key_id": record.key_id,
951
+ "name": record.name,
952
+ "scopes": record.scopes,
953
+ "tenant_id": record.tenant_id,
954
+ "expires_at": record.expires_at,
955
+ },
956
+ request,
957
+ )
958
+
959
+
960
+ @app.get("/api/auth/keys", tags=["Enterprise Security"])
961
+ def list_api_keys(
962
+ request: Request,
963
+ principal: APIKeyRecord = Depends(require_scope("admin"))
964
+ ):
965
+ """Lists registered API keys without exposing secrets."""
966
+ scoped_org = (
967
+ principal.organization_id
968
+ if principal.organization_id not in ("org_root", "org_system")
969
+ else None
970
+ )
971
+ return envelope(
972
+ {"keys": security_manager.list_api_keys(organization_id=scoped_org)},
973
+ request,
974
+ )
975
+
976
+
977
+ @app.post("/api/auth/keys/revoke", tags=["Enterprise Security"])
978
+ def revoke_api_key(
979
+ request: Request,
980
+ payload: RevokeKeyRequest,
981
+ principal: APIKeyRecord = Depends(require_scope("admin"))
982
+ ):
983
+ """Revokes an API key immediately.
984
+
985
+ P1.1: an admin can only revoke keys it could have listed — i.e. keys inside
986
+ its own org boundary (system principals see and manage everything).
987
+ """
988
+ scoped_org = (
989
+ principal.organization_id
990
+ if principal.organization_id not in ("org_root", "org_system")
991
+ else None
992
+ )
993
+ target = next(
994
+ (k for k in security_manager.list_api_keys(organization_id=scoped_org)
995
+ if k.get("key_id") == payload.key_id),
996
+ None,
997
+ )
998
+ if target is None:
999
+ # Never leak whether a key exists outside the caller's boundary.
1000
+ return envelope({"status": "NOT_FOUND", "key_id": payload.key_id}, request)
1001
+ ok, reason = security_manager.check_hierarchy(
1002
+ principal,
1003
+ requested_org=target.get("organization_id") or None,
1004
+ requested_project=target.get("project_id") or None,
1005
+ requested_tenant=target.get("tenant_id") or None,
1006
+ )
1007
+ if not ok:
1008
+ raise HTTPException(status_code=403, detail=reason)
1009
+ success = security_manager.revoke_api_key(payload.key_id)
1010
+ return envelope({"status": "REVOKED" if success else "NOT_FOUND", "key_id": payload.key_id}, request)
1011
+
1012
+
1013
+ @app.get("/api/auth/audit", tags=["Enterprise Security"])
1014
+ def get_audit_trail(
1015
+ request: Request,
1016
+ limit: int = 50,
1017
+ principal: APIKeyRecord = Depends(require_scope("admin"))
1018
+ ):
1019
+ """Retrieves authorization audit logs."""
1020
+ logs = security_manager.get_audit_logs(limit=min(limit, 200), tenant_id=principal.tenant_id if principal.tenant_id != "*" else None)
1021
+ return envelope({"audit_logs": logs}, request)
1022
+
1023
+
1024
+ @app.get("/api/auth/audit/export", tags=["Enterprise Security"])
1025
+ def export_audit_trail(
1026
+ request: Request,
1027
+ since: Optional[float] = None,
1028
+ limit: int = 5000,
1029
+ principal: APIKeyRecord = Depends(require_scope("admin"))
1030
+ ):
1031
+ """Compliance export of audit records (tenant-scoped, bounded, time-ordered).
1032
+
1033
+ Intended for archival into SIEM/object storage. Non-admins export only
1034
+ their own tenant; admins export everything via tenant wildcard.
1035
+ """
1036
+ scoped_tenant = None if principal.tenant_id in ("*", "") else principal.tenant_id
1037
+ logs = security_manager.export_audit_logs(
1038
+ tenant_id=scoped_tenant,
1039
+ since=since,
1040
+ limit=min(limit, 20000),
1041
+ )
1042
+ return envelope({"audit_logs": logs, "exported": len(logs)}, request)
1043
+
1044
+
1045
+ @app.post("/api/auth/audit/prune", tags=["Enterprise Security"])
1046
+ def prune_audit_trail(
1047
+ request: Request,
1048
+ payload: Optional[Dict[str, Any]] = None,
1049
+ principal: APIKeyRecord = Depends(require_scope("admin"))
1050
+ ):
1051
+ """Delete audit records older than the retention window (default 90 days).
1052
+
1053
+ Body: {"older_than_days": <float>}. Returns the deleted row count.
1054
+ """
1055
+ days = 90.0
1056
+ if payload and payload.get("older_than_days") is not None:
1057
+ try:
1058
+ days = float(payload["older_than_days"])
1059
+ except (TypeError, ValueError):
1060
+ raise HTTPException(status_code=400, detail="older_than_days must be a number.")
1061
+ # P1.1: tenant-bound admins prune ONLY their own audit trail; only system
1062
+ # principals (env master / org_system) run a global retention pass.
1063
+ scoped_tenant = (
1064
+ None
1065
+ if security_manager.is_system_principal(principal) or principal.tenant_id in ("*", "")
1066
+ else principal.tenant_id
1067
+ )
1068
+ deleted = security_manager.prune_audit_logs(
1069
+ older_than_seconds=max(0.0, days) * 86400, tenant_id=scoped_tenant
1070
+ )
1071
+ security_manager.log_audit_action(
1072
+ actor_key_id=principal.key_id,
1073
+ tenant_id=principal.tenant_id,
1074
+ action="AUDIT_RETENTION_PRUNE",
1075
+ resource="auth_audit_logs",
1076
+ status="SUCCESS",
1077
+ metadata={"deleted_count": deleted, "older_than_days": days},
1078
+ )
1079
+ return envelope({"deleted": deleted, "older_than_days": days}, request)
1080
+
1081
+
1082
+ class RotateKeyRequest(BaseModel):
1083
+ key_id: str
1084
+
1085
+
1086
+ @app.post("/api/auth/keys/rotate", tags=["Enterprise Security"])
1087
+ def rotate_api_key(
1088
+ request: Request,
1089
+ payload: RotateKeyRequest,
1090
+ principal: APIKeyRecord = Depends(require_scope("admin"))
1091
+ ):
1092
+ """Rotates an API key: revokes old, issues new with same metadata. Zero-downtime rotation.
1093
+
1094
+ P1.1: the target must live inside the caller's org boundary (system
1095
+ principals manage everything) and inherit only hierarchy-compatible
1096
+ metadata.
1097
+ """
1098
+ scoped_org = (
1099
+ principal.organization_id
1100
+ if principal.organization_id not in ("org_root", "org_system")
1101
+ else None
1102
+ )
1103
+ keys = security_manager.list_api_keys(organization_id=scoped_org)
1104
+ old = next((k for k in keys if k.get("key_id") == payload.key_id), None)
1105
+ if not old:
1106
+ # 404 both for missing keys and keys outside the caller's boundary.
1107
+ raise HTTPException(status_code=404, detail="Key not found.")
1108
+ ok, reason = security_manager.check_hierarchy(
1109
+ principal,
1110
+ requested_org=old.get("organization_id") or None,
1111
+ requested_project=old.get("project_id") or None,
1112
+ requested_tenant=old.get("tenant_id") or None,
1113
+ )
1114
+ if not ok:
1115
+ raise HTTPException(status_code=403, detail=reason)
1116
+ token, record = security_manager.generate_api_key(
1117
+ name=old.get("name", "rotated"),
1118
+ organization_id=old.get("organization_id", principal.organization_id),
1119
+ project_id=old.get("project_id", "proj_default"),
1120
+ tenant_id=old.get("tenant_id", principal.tenant_id),
1121
+ scopes=old.get("scopes", ["memories:read", "memories:write"]),
1122
+ role=old.get("role", "developer"),
1123
+ ttl_days=365,
1124
+ )
1125
+ security_manager.revoke_api_key(payload.key_id)
1126
+ return envelope(
1127
+ {
1128
+ "status": "ROTATED",
1129
+ "old_key_id": payload.key_id,
1130
+ "api_key": token,
1131
+ "key_id": record.key_id,
1132
+ "expires_at": record.expires_at,
1133
+ },
1134
+ request,
1135
+ )
1136
+
1137
+
1138
+ class BulkIngestItem(BaseModel):
1139
+ content: str = Field(..., min_length=1, max_length=32000)
1140
+ category: str = Field("interaction", min_length=1, max_length=64, pattern=r"^[a-z0-9_\-]+$")
1141
+ confidence: float = Field(0.95, ge=0.0, le=1.0)
1142
+
1143
+
1144
+ class BulkIngestRequest(BaseModel):
1145
+ items: List[BulkIngestItem] = Field(..., min_length=1, max_length=100)
1146
+ tenant_id: Optional[str] = Field(None, alias="tenantId", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_\-\.]+$")
1147
+
1148
+
1149
+ @app.post("/api/memories/bulk", tags=["Ingestion"])
1150
+ def bulk_ingest(
1151
+ request: Request,
1152
+ payload: BulkIngestRequest,
1153
+ principal: APIKeyRecord = Depends(require_scope("memories:write"))
1154
+ ):
1155
+ """Bulk ingest up to 100 memories in one call (team backfills)."""
1156
+ total_chars = sum(len(i.content) for i in payload.items)
1157
+ if total_chars > 500_000:
1158
+ raise HTTPException(status_code=413, detail="Bulk payload too large (500KB text limit).")
1159
+ effective_tenant = resolve_tenant(principal, payload.tenant_id)
1160
+ results = []
1161
+ for item in payload.items:
1162
+ if not item.content.strip():
1163
+ continue
1164
+ mid, action, cost = manager.ingest_with_deduplication(
1165
+ content=item.content,
1166
+ category=item.category,
1167
+ confidence=max(0.0, min(1.0, item.confidence)),
1168
+ tenant_id=effective_tenant,
1169
+ )
1170
+ results.append({"id": mid, "status": action, "token_cost": cost})
1171
+ return envelope({"tenant_id": effective_tenant, "ingested": len(results), "results": results}, request)
1172
+
1173
+
1174
+ @app.get("/metrics", tags=["Telemetry"])
1175
+ def prometheus_metrics():
1176
+ """Prometheus-compatible metrics (no extra deps). Scrape with Prometheus/Grafana.
1177
+
1178
+ All values are live: request counters + latency samples by endpoint,
1179
+ auth failures, rate-limit rejections, ingestion/retrieval volumes and
1180
+ errors, pool utilization, token-budget usage. No hardcoded figures.
1181
+ """
1182
+ total = 0
1183
+ try:
1184
+ total = store.count_memories()
1185
+ except Exception:
1186
+ pass
1187
+ pool_stats: Dict[str, Any] = {}
1188
+ for candidate in (store, security_manager):
1189
+ stats_fn = getattr(candidate, "pool_stats", None)
1190
+ if not callable(stats_fn):
1191
+ continue
1192
+ try:
1193
+ candidate_stats = stats_fn() or {}
1194
+ except Exception:
1195
+ logger.debug("pool_stats() failed for %r", candidate)
1196
+ candidate_stats = {}
1197
+ if candidate_stats:
1198
+ pool_stats = candidate_stats
1199
+ break
1200
+ lines = [
1201
+ "# HELP synapse_memories_total Total indexed memories",
1202
+ "# TYPE synapse_memories_total gauge",
1203
+ f"synapse_memories_total{{backend=\"{ACTIVE_BACKEND}\"}} {total}",
1204
+ "# HELP synapse_up Service liveness",
1205
+ "# TYPE synapse_up gauge",
1206
+ "synapse_up 1",
1207
+ "# HELP synapse_embedding_dimension Active embedding dimension",
1208
+ "# TYPE synapse_embedding_dimension gauge",
1209
+ f"synapse_embedding_dimension {embedding_dimension}",
1210
+ "# HELP synapse_requests_total Requests by endpoint and status",
1211
+ "# TYPE synapse_requests_total counter",
1212
+ ]
1213
+ with _metrics_lock:
1214
+ for (route_template, status_code), count in sorted(_REQUEST_COUNTS.items()):
1215
+ lines.append(f"synapse_requests_total{{path=\"{route_template}\",status=\"{status_code}\"}} {count}")
1216
+ lines += [
1217
+ "# HELP synapse_auth_failures_total Authentication/authorization failures",
1218
+ "# TYPE synapse_auth_failures_total counter",
1219
+ f"synapse_auth_failures_total {_auth_failures}",
1220
+ "# HELP synapse_rate_limit_rejections_total Rate-limit 429 rejections",
1221
+ "# TYPE synapse_rate_limit_rejections_total counter",
1222
+ f"synapse_rate_limit_rejections_total {_rate_limit_rejections}",
1223
+ "# HELP synapse_ingest_total Ingested memories and failures",
1224
+ "# TYPE synapse_ingest_total counter",
1225
+ f"synapse_ingest_total{{outcome=\"success\"}} {_ingest_success}",
1226
+ f"synapse_ingest_total{{outcome=\"error\"}} {_ingest_errors}",
1227
+ "# HELP synapse_retrieval_total Retrieval requests and errors",
1228
+ "# TYPE synapse_retrieval_total counter",
1229
+ f"synapse_retrieval_total{{outcome=\"success\"}} {_retrieval_success}",
1230
+ f"synapse_retrieval_total{{outcome=\"error\"}} {_retrieval_errors}",
1231
+ "# HELP synapse_retrieval_candidates Candidate count of last retrieval",
1232
+ "# TYPE synapse_retrieval_candidates gauge",
1233
+ f"synapse_retrieval_candidates {_last_candidate_count}",
1234
+ "# HELP synapse_context_tokens_packed Tokens packed into last context",
1235
+ "# TYPE synapse_context_tokens_packed gauge",
1236
+ f"synapse_context_tokens_packed {_last_packed_tokens}",
1237
+ ]
1238
+ lines += [
1239
+ "# HELP synapse_db_pool_in_use Pooled DB connections currently checked out",
1240
+ "# TYPE synapse_db_pool_in_use gauge",
1241
+ f"synapse_db_pool_in_use {pool_stats.get('in_use', 0)}",
1242
+ "# HELP synapse_db_pool_size Configured DB pool size",
1243
+ "# TYPE synapse_db_pool_size gauge",
1244
+ f"synapse_db_pool_size {pool_stats.get('pool_size', 0)}",
1245
+ "# HELP synapse_rate_limit_backend Active rate-limiter backend (constant 1)",
1246
+ "# TYPE synapse_rate_limit_backend gauge",
1247
+ f'synapse_rate_limit_backend{{backend="{rate_limiter.backend}"}} 1',
1248
+ ]
1249
+ lat_lines = ["# HELP synapse_request_latency_seconds Request latency by endpoint", "# TYPE synapse_request_latency_seconds summary"]
1250
+ with _metrics_lock:
1251
+ for path, samples in sorted(_LATENCY_SAMPLES.items()):
1252
+ if not samples:
1253
+ continue
1254
+ ordered = sorted(samples)
1255
+ n = len(ordered)
1256
+ avg = sum(ordered) / n
1257
+ p50 = ordered[min(n - 1, n // 2)]
1258
+ p95 = ordered[min(n - 1, int(n * 0.95))]
1259
+ lat_lines.append(f"synapse_request_latency_seconds{{path=\"{path}\",quantile=\"0.5\"}} {p50:.6f}")
1260
+ lat_lines.append(f"synapse_request_latency_seconds{{path=\"{path}\",quantile=\"0.95\"}} {p95:.6f}")
1261
+ lat_lines.append(f"synapse_request_latency_seconds_sum{{path=\"{path}\"}} {sum(ordered):.6f}")
1262
+ lat_lines.append(f"synapse_request_latency_seconds_count{{path=\"{path}\"}} {n}")
1263
+ lat_lines.append(f"# avg {avg:.6f}s over {n} samples")
1264
+ from fastapi.responses import PlainTextResponse
1265
+
1266
+ return PlainTextResponse("\n".join(lines + lat_lines) + "\n", media_type="text/plain")
1267
+
1268
+
1269
+ @app.post("/api/security/scrub-pii", tags=["Security"])
1270
+ @app.post("/api/security/scrub", tags=["Security"])
1271
+ def scrub_pii(
1272
+ request: Request,
1273
+ payload: Dict[str, Any],
1274
+ principal: APIKeyRecord = Depends(require_scope("memories:read"))
1275
+ ):
1276
+ """
1277
+ Authenticated PII scrubbing endpoint — the single implementation.
1278
+
1279
+ The Node gateway proxies both of its aliases here instead of running its own
1280
+ weaker regex set (F-11). Ordering matters: high-entropy identifiers and
1281
+ structured digit runs (card / Aadhaar / PAN / SSN) are redacted BEFORE the
1282
+ generic phone matcher, which would otherwise swallow them and leave them
1283
+ only *counted* as detected rather than actually removed.
1284
+
1285
+ Nothing cryptographic happens in this handler, so the response carries no
1286
+ encryption/CMEK claim.
1287
+ """
1288
+ import re
1289
+
1290
+ text = payload.get("text", "")
1291
+ if not isinstance(text, str) or not text.strip():
1292
+ raise HTTPException(status_code=400, detail="Text field required.")
1293
+ if len(text) > 20000:
1294
+ raise HTTPException(status_code=413, detail="Text exceeds the 20000 character limit.")
1295
+
1296
+ redactions = [
1297
+ (
1298
+ r"(sk-proj-[A-Za-z0-9]{20,}|sk-ant-[A-Za-z0-9\-_]{20,}"
1299
+ r"|xox[bpas]-[A-Za-z0-9\-_]{10,}|gh[pousr]_[A-Za-z0-9]{20,}"
1300
+ r"|AKIA[0-9A-Z]{16})",
1301
+ "[REDACTED_API_KEY]",
1302
+ ),
1303
+ (
1304
+ r"\b(?:4[0-9]{3}|5[1-5][0-9]{2}|3[47][0-9]{2}|6(?:011|5[0-9]{2}))"
1305
+ r"[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{1,4}\b",
1306
+ "[REDACTED_CARD]",
1307
+ ),
1308
+ (r"\b[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}\b", "[REDACTED_AADHAAR]"),
1309
+ (r"\b[A-Z]{5}[0-9]{4}[A-Z]\b", "[REDACTED_PAN]"),
1310
+ (r"\b[0-9]{3}-[0-9]{2}-[0-9]{4}\b", "[REDACTED_SSN]"),
1311
+ (r"[\w\.-]+@[\w\.-]+\.\w+", "[REDACTED_EMAIL]"),
1312
+ (r"password\s*[:=]\s*\S+", "password=[REDACTED]"),
1313
+ (r"\+?[0-9][0-9\-.\s()]{7,}[0-9]", "[REDACTED_PHONE]"),
1314
+ ]
1315
+
1316
+ scrubbed = text
1317
+ redaction_count = 0
1318
+ for pattern, replacement in redactions:
1319
+ scrubbed, count = re.subn(pattern, replacement, scrubbed)
1320
+ redaction_count += count
1321
+
1322
+ return envelope(
1323
+ {
1324
+ "detectedPII": redaction_count > 0,
1325
+ "scrubbedText": scrubbed,
1326
+ "redactionCount": redaction_count,
1327
+ "scrubber": "synapse-engine/regex-v2",
1328
+ },
1329
+ request,
1330
+ )
1331
+
1332
+
1333
+ if __name__ == "__main__":
1334
+ uvicorn.run("fastapi_server:app", host="127.0.0.1", port=int(os.environ.get("PORT", 8008)), reload=True) # nosec B104