causal-memory-layer 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.
- api/__init__.py +1 -0
- api/server.py +515 -0
- api/store.py +189 -0
- causal_memory_layer-0.4.0.dist-info/METADATA +303 -0
- causal_memory_layer-0.4.0.dist-info/RECORD +29 -0
- causal_memory_layer-0.4.0.dist-info/WHEEL +5 -0
- causal_memory_layer-0.4.0.dist-info/entry_points.txt +3 -0
- causal_memory_layer-0.4.0.dist-info/licenses/LICENSE +21 -0
- causal_memory_layer-0.4.0.dist-info/licenses/LICENSE_COMMERCIAL.md +74 -0
- causal_memory_layer-0.4.0.dist-info/top_level.txt +3 -0
- cli/__init__.py +1 -0
- cli/audit.py +83 -0
- cli/chain.py +113 -0
- cli/main.py +141 -0
- cml/__init__.py +50 -0
- cml/audit.py +440 -0
- cml/chain.py +115 -0
- cml/ctag.py +274 -0
- cml/experimental/__init__.py +4 -0
- cml/experimental/cause_band.py +177 -0
- cml/experimental/cause_band_payload.py +16 -0
- cml/experimental/cause_band_trajectory.py +47 -0
- cml/integrations/__init__.py +1 -0
- cml/integrations/mcp/__init__.py +1 -0
- cml/integrations/mcp/core.py +55 -0
- cml/integrations/mcp/server.py +57 -0
- cml/record.py +196 -0
- cml/report.py +132 -0
- cml/safety_eval.py +189 -0
api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# api package
|
api/server.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CML Audit API Server (FastAPI)
|
|
3
|
+
|
|
4
|
+
Endpoints:
|
|
5
|
+
POST /audit — Audit a JSONL log
|
|
6
|
+
Body: {"log": "<JSONL>", "config": "<YAML>", "format": "json|markdown|text"}
|
|
7
|
+
POST /audit/file — Audit an uploaded JSONL file (multipart/form-data)
|
|
8
|
+
POST /ingest — Append records to a named log store
|
|
9
|
+
Body: {"log_name": "...", "records": [...]}
|
|
10
|
+
GET /records/{log_name} — List records in a named log
|
|
11
|
+
GET /records/{log_name}/audit — Run audit on a stored log
|
|
12
|
+
GET /chain/{log_name}/{id} — Reconstruct causal chain for a record
|
|
13
|
+
POST /ctag/decode — Decode a 16-bit CTAG value
|
|
14
|
+
Body: {"ctag": <int|hex_string>}
|
|
15
|
+
GET /health — Health check
|
|
16
|
+
|
|
17
|
+
Authentication:
|
|
18
|
+
Set CML_API_TOKEN env var to enable Bearer-token auth on all endpoints
|
|
19
|
+
except /health, /docs*, /redoc*. Community tier: unset = no auth.
|
|
20
|
+
Token comparison is constant-time (hmac.compare_digest).
|
|
21
|
+
|
|
22
|
+
Store backend:
|
|
23
|
+
Set CML_STORE_PATH to a .db file path to enable SQLite persistence.
|
|
24
|
+
Unset = in-memory store (ephemeral, community tier).
|
|
25
|
+
|
|
26
|
+
Hardening env vars:
|
|
27
|
+
CML_CORS_ORIGINS — comma-separated allowed origins. With auth enabled the
|
|
28
|
+
default is empty (deny). Without auth the default is
|
|
29
|
+
"*". Use "*" explicitly to opt into permissive CORS.
|
|
30
|
+
CML_DISABLE_DOCS — when truthy, hides /docs and /redoc.
|
|
31
|
+
CML_STORE_TTL — SQLite TTL in seconds (1..31_536_000, default 86_400).
|
|
32
|
+
|
|
33
|
+
Rate limiting (slowapi):
|
|
34
|
+
CML_RATE_LIMIT_ENABLED — master switch (default: on).
|
|
35
|
+
CML_RATE_LIMIT_DEFAULT — default per-key budget (default: "60/minute").
|
|
36
|
+
CML_RATE_LIMIT_INGEST — override for /ingest (default: "30/minute").
|
|
37
|
+
CML_RATE_LIMIT_AUDIT — override for /audit, /audit/file, and
|
|
38
|
+
/records/{log_name}/audit (default: "30/minute").
|
|
39
|
+
CML_RATE_LIMIT_RECORDS — override for /records/{log_name}
|
|
40
|
+
(default: "120/minute").
|
|
41
|
+
CML_RATE_LIMIT_CHAIN — override for /chain/{log_name}/{id}
|
|
42
|
+
(default: "120/minute").
|
|
43
|
+
CML_RATE_LIMIT_CTAG — override for /ctag/decode (default: "300/minute").
|
|
44
|
+
CML_RATE_LIMIT_BACKEND — slowapi storage URI (default: "memory://"; e.g.
|
|
45
|
+
"redis://host:6379" for multi-replica deploys).
|
|
46
|
+
CML_TRUST_PROXY — when truthy, key by X-Forwarded-For. Off by
|
|
47
|
+
default — trusting it blindly enables bypass.
|
|
48
|
+
|
|
49
|
+
Run:
|
|
50
|
+
uvicorn api.server:app --reload --port 8080
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import hashlib
|
|
56
|
+
import hmac
|
|
57
|
+
import json
|
|
58
|
+
import logging
|
|
59
|
+
import os
|
|
60
|
+
import re
|
|
61
|
+
import sys
|
|
62
|
+
from typing import Optional
|
|
63
|
+
|
|
64
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
65
|
+
|
|
66
|
+
from fastapi import FastAPI, HTTPException, Header, Request, UploadFile, File, Body
|
|
67
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
68
|
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
69
|
+
from pydantic import BaseModel
|
|
70
|
+
from slowapi import Limiter
|
|
71
|
+
from slowapi.errors import RateLimitExceeded
|
|
72
|
+
|
|
73
|
+
import cml
|
|
74
|
+
from cml import (
|
|
75
|
+
CausalRecord, load_jsonl, records_to_index,
|
|
76
|
+
AuditEngine, AuditConfig, AuditResult,
|
|
77
|
+
reconstruct_chain,
|
|
78
|
+
to_markdown, to_json, to_text,
|
|
79
|
+
decode_ctag,
|
|
80
|
+
)
|
|
81
|
+
from api.store import InMemoryStore, SQLiteStore, StoreLimitError
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
logger = logging.getLogger("cml.api")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _env_bool(name: str, default: bool = False) -> bool:
|
|
88
|
+
val = os.environ.get(name)
|
|
89
|
+
if val is None:
|
|
90
|
+
return default
|
|
91
|
+
return val.strip().lower() in {"1", "true", "yes", "on"}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _env_int(name: str, default: int, *, minimum: int = 0, maximum: int = 2**31 - 1) -> int:
|
|
95
|
+
raw = os.environ.get(name)
|
|
96
|
+
if raw is None or raw == "":
|
|
97
|
+
return default
|
|
98
|
+
try:
|
|
99
|
+
val = int(raw)
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
logger.warning("Invalid %s=%r — falling back to default %d", name, raw, default)
|
|
102
|
+
return default
|
|
103
|
+
if val < minimum or val > maximum:
|
|
104
|
+
logger.warning(
|
|
105
|
+
"%s=%d out of range [%d, %d] — clamping to default %d",
|
|
106
|
+
name, val, minimum, maximum, default,
|
|
107
|
+
)
|
|
108
|
+
return default
|
|
109
|
+
return val
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _env_csv(name: str) -> list[str]:
|
|
113
|
+
raw = os.environ.get(name, "")
|
|
114
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Configuration
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
#
|
|
121
|
+
# CML_API_TOKEN — Bearer token; unset disables auth (community tier)
|
|
122
|
+
# CML_CORS_ORIGINS — comma-separated allowed origins. Required when token
|
|
123
|
+
# auth is enabled; otherwise defaults to "*".
|
|
124
|
+
# Use "*" explicitly to opt into permissive CORS.
|
|
125
|
+
# CML_DISABLE_DOCS — when truthy, disables /docs and /redoc (recommended
|
|
126
|
+
# in production deployments behind authentication).
|
|
127
|
+
# CML_STORE_PATH — SQLite DB path; empty/unset → in-memory ephemeral
|
|
128
|
+
# CML_STORE_TTL — TTL in seconds (default 86400 = 24h, 0 < ttl ≤ 1y)
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
_API_TOKEN = os.environ.get("CML_API_TOKEN") or None
|
|
132
|
+
_DISABLE_DOCS = _env_bool("CML_DISABLE_DOCS", default=False)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_cors_origins() -> list[str]:
|
|
136
|
+
"""Pick safe CORS defaults.
|
|
137
|
+
|
|
138
|
+
With auth enabled, default-deny (empty list) unless explicitly configured.
|
|
139
|
+
Without auth, "*" is acceptable since there are no credentials to leak.
|
|
140
|
+
Explicit CML_CORS_ORIGINS=* opts in to permissive CORS.
|
|
141
|
+
"""
|
|
142
|
+
configured = _env_csv("CML_CORS_ORIGINS")
|
|
143
|
+
if configured:
|
|
144
|
+
return configured
|
|
145
|
+
if _API_TOKEN:
|
|
146
|
+
# Default-deny: do not allow arbitrary cross-origin browsers to reach
|
|
147
|
+
# an authenticated API. Operators must opt in explicitly.
|
|
148
|
+
return []
|
|
149
|
+
return ["*"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_CORS_ORIGINS = _resolve_cors_origins()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Rate limiting configuration
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
_RATE_LIMIT_ENABLED = _env_bool("CML_RATE_LIMIT_ENABLED", default=True)
|
|
160
|
+
_RATE_LIMIT_DEFAULT = os.environ.get("CML_RATE_LIMIT_DEFAULT") or "60/minute"
|
|
161
|
+
_RATE_LIMIT_INGEST = os.environ.get("CML_RATE_LIMIT_INGEST") or "30/minute"
|
|
162
|
+
_RATE_LIMIT_AUDIT = os.environ.get("CML_RATE_LIMIT_AUDIT") or "30/minute"
|
|
163
|
+
_RATE_LIMIT_RECORDS = os.environ.get("CML_RATE_LIMIT_RECORDS") or "120/minute"
|
|
164
|
+
_RATE_LIMIT_CHAIN = os.environ.get("CML_RATE_LIMIT_CHAIN") or "120/minute"
|
|
165
|
+
_RATE_LIMIT_CTAG = os.environ.get("CML_RATE_LIMIT_CTAG") or "300/minute"
|
|
166
|
+
_RATE_LIMIT_BACKEND = os.environ.get("CML_RATE_LIMIT_BACKEND") or "memory://"
|
|
167
|
+
_TRUST_PROXY = _env_bool("CML_TRUST_PROXY", default=False)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _rate_limit_key(request: "Request") -> str:
|
|
171
|
+
"""Bucket per Bearer token, fall back to client IP.
|
|
172
|
+
|
|
173
|
+
The token is hashed so log lines and slowapi keys never carry the raw
|
|
174
|
+
secret. ``X-Forwarded-For`` is honoured only when ``CML_TRUST_PROXY``
|
|
175
|
+
is set — trusting it by default would let any client spoof their key.
|
|
176
|
+
"""
|
|
177
|
+
auth = request.headers.get("authorization", "")
|
|
178
|
+
if auth.startswith("Bearer "):
|
|
179
|
+
token = auth[7:].strip()
|
|
180
|
+
if token:
|
|
181
|
+
digest = hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
|
|
182
|
+
return f"tok:{digest}"
|
|
183
|
+
if _TRUST_PROXY:
|
|
184
|
+
forwarded = request.headers.get("x-forwarded-for", "")
|
|
185
|
+
if forwarded:
|
|
186
|
+
# Use the leftmost (original client) entry. Subsequent entries
|
|
187
|
+
# are upstream proxies we control under CML_TRUST_PROXY.
|
|
188
|
+
return f"ip:{forwarded.split(',')[0].strip()}"
|
|
189
|
+
client = request.client.host if request.client else "unknown"
|
|
190
|
+
return f"ip:{client}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
limiter = Limiter(
|
|
194
|
+
key_func=_rate_limit_key,
|
|
195
|
+
default_limits=[_RATE_LIMIT_DEFAULT],
|
|
196
|
+
storage_uri=_RATE_LIMIT_BACKEND,
|
|
197
|
+
enabled=_RATE_LIMIT_ENABLED,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# App setup
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
app = FastAPI(
|
|
206
|
+
title="CML Audit API",
|
|
207
|
+
description=(
|
|
208
|
+
"Causal Memory Layer — REST API for causal log ingestion, "
|
|
209
|
+
"audit, and chain reconstruction."
|
|
210
|
+
),
|
|
211
|
+
version=cml.__version__,
|
|
212
|
+
docs_url=None if _DISABLE_DOCS else "/docs",
|
|
213
|
+
redoc_url=None if _DISABLE_DOCS else "/redoc",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def _rate_limit_handler(request: "Request", exc: RateLimitExceeded) -> JSONResponse:
|
|
217
|
+
"""Build a 429 response that always carries ``Retry-After``.
|
|
218
|
+
|
|
219
|
+
slowapi's default handler omits ``Retry-After`` unless ``headers_enabled``
|
|
220
|
+
is set on the limiter, but enabling that flag conflicts with routes that
|
|
221
|
+
return ``Response`` objects directly. We compute the retry window from
|
|
222
|
+
the underlying ``RateLimitItem`` so callers always know how long to back
|
|
223
|
+
off, regardless of which route was throttled.
|
|
224
|
+
"""
|
|
225
|
+
retry_after = 60
|
|
226
|
+
try:
|
|
227
|
+
retry_after = int(exc.limit.limit.get_expiry())
|
|
228
|
+
except (AttributeError, TypeError, ValueError):
|
|
229
|
+
pass
|
|
230
|
+
response = JSONResponse(
|
|
231
|
+
{"detail": f"Rate limit exceeded: {exc.detail}"},
|
|
232
|
+
status_code=429,
|
|
233
|
+
)
|
|
234
|
+
response.headers["Retry-After"] = str(retry_after)
|
|
235
|
+
return response
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
app.state.limiter = limiter
|
|
239
|
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_handler)
|
|
240
|
+
|
|
241
|
+
# Methods are restricted to those the API actually serves. Allowed headers are
|
|
242
|
+
# limited to what the API consumes — anything else gets blocked at the browser.
|
|
243
|
+
app.add_middleware(
|
|
244
|
+
CORSMiddleware,
|
|
245
|
+
allow_origins=_CORS_ORIGINS,
|
|
246
|
+
allow_methods=["GET", "POST"],
|
|
247
|
+
allow_headers=["Authorization", "Content-Type"],
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
# Bearer-token auth (enabled when CML_API_TOKEN env var is set)
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
if _API_TOKEN:
|
|
255
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
256
|
+
from starlette.requests import Request
|
|
257
|
+
from starlette.responses import JSONResponse as _AuthJSONResponse
|
|
258
|
+
|
|
259
|
+
_TOKEN_BYTES = _API_TOKEN.encode("utf-8")
|
|
260
|
+
|
|
261
|
+
class _BearerAuthMiddleware(BaseHTTPMiddleware):
|
|
262
|
+
_PUBLIC_EXACT = {"/health", "/openapi.json"}
|
|
263
|
+
_PUBLIC_PREFIX = ("/docs", "/redoc")
|
|
264
|
+
|
|
265
|
+
async def dispatch(self, request: Request, call_next):
|
|
266
|
+
path = request.url.path
|
|
267
|
+
if path in self._PUBLIC_EXACT or path.startswith(self._PUBLIC_PREFIX):
|
|
268
|
+
return await call_next(request)
|
|
269
|
+
auth = request.headers.get("authorization", "")
|
|
270
|
+
presented = auth[7:].encode("utf-8") if auth.startswith("Bearer ") else b""
|
|
271
|
+
# Constant-time compare to prevent token-recovery via timing.
|
|
272
|
+
if not hmac.compare_digest(presented, _TOKEN_BYTES):
|
|
273
|
+
client = request.client.host if request.client else "unknown"
|
|
274
|
+
logger.warning("Auth failure on %s from %s", path, client)
|
|
275
|
+
return _AuthJSONResponse(
|
|
276
|
+
{"detail": "Invalid or missing Bearer token."},
|
|
277
|
+
status_code=401,
|
|
278
|
+
)
|
|
279
|
+
return await call_next(request)
|
|
280
|
+
|
|
281
|
+
app.add_middleware(_BearerAuthMiddleware)
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# Log store (pluggable backend)
|
|
285
|
+
#
|
|
286
|
+
# CML_STORE_PATH → SQLiteStore with TTL eviction (persistent)
|
|
287
|
+
# unset → InMemoryStore (community tier, ephemeral)
|
|
288
|
+
# CML_STORE_TTL → TTL in seconds (default: 86400 = 24h)
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
_store_path = os.environ.get("CML_STORE_PATH", "")
|
|
292
|
+
# TTL: clamp to (0, 1 year]. Non-positive or non-integer values fall back to
|
|
293
|
+
# 24h so a malformed env var cannot disable eviction or crash startup.
|
|
294
|
+
_store_ttl = _env_int("CML_STORE_TTL", default=86_400, minimum=1, maximum=31_536_000)
|
|
295
|
+
|
|
296
|
+
_store = (
|
|
297
|
+
SQLiteStore(_store_path, ttl_seconds=_store_ttl)
|
|
298
|
+
if _store_path
|
|
299
|
+
else InMemoryStore()
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# Input validation helpers
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
# log_name flows into SQLite parameters and HTTP path segments. Parameterized
|
|
308
|
+
# queries already prevent SQL injection, but unconstrained names enable
|
|
309
|
+
# resource exhaustion (millions of distinct logs) and confusing path routing.
|
|
310
|
+
_LOG_NAME_RE = re.compile(r"^[A-Za-z0-9._\-]{1,128}$")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _validate_log_name(log_name: str) -> str:
|
|
314
|
+
if not isinstance(log_name, str) or not _LOG_NAME_RE.match(log_name):
|
|
315
|
+
raise HTTPException(
|
|
316
|
+
status_code=422,
|
|
317
|
+
detail=(
|
|
318
|
+
"Invalid log_name: must be 1-128 chars, "
|
|
319
|
+
"alphanumeric plus '.', '_', '-'."
|
|
320
|
+
),
|
|
321
|
+
)
|
|
322
|
+
return log_name
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _get_log(log_name: str) -> list[CausalRecord]:
|
|
326
|
+
return _store.get(log_name)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _store_records(log_name: str, records: list[CausalRecord]):
|
|
330
|
+
try:
|
|
331
|
+
_store.store(log_name, records)
|
|
332
|
+
except StoreLimitError as e:
|
|
333
|
+
raise HTTPException(status_code=429, detail=str(e))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
# Helpers
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def _parse_jsonl(text: str) -> list[CausalRecord]:
|
|
341
|
+
records = []
|
|
342
|
+
for line in text.splitlines():
|
|
343
|
+
line = line.strip()
|
|
344
|
+
if line:
|
|
345
|
+
try:
|
|
346
|
+
records.append(CausalRecord.from_json(line))
|
|
347
|
+
except Exception as e:
|
|
348
|
+
logger.exception("Failed to parse JSONL record")
|
|
349
|
+
raise HTTPException(
|
|
350
|
+
status_code=422,
|
|
351
|
+
detail=f"Failed to parse record: {e}\nLine: {line[:80]}"
|
|
352
|
+
)
|
|
353
|
+
return records
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _run_audit(records: list[CausalRecord], config_yaml: Optional[str] = None) -> AuditResult:
|
|
357
|
+
cfg = AuditConfig.from_yaml_string(config_yaml) if config_yaml else AuditConfig()
|
|
358
|
+
return AuditEngine(cfg).run(records)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
# Models
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
class AuditTextRequest(BaseModel):
|
|
366
|
+
log: str # Raw JSONL content
|
|
367
|
+
config: Optional[str] = None # Optional YAML config text
|
|
368
|
+
format: str = "json" # json | markdown | text
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class IngestRequest(BaseModel):
|
|
372
|
+
log_name: str
|
|
373
|
+
records: list[dict] # List of raw record dicts
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
# Routes
|
|
378
|
+
#
|
|
379
|
+
# /health is intentionally undecorated so liveness probes from load balancers
|
|
380
|
+
# and uptime checks cannot be rate limited. All other routes are bucketed via
|
|
381
|
+
# the slowapi limiter; per-route limits are configurable through env vars.
|
|
382
|
+
# ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
@app.get("/health")
|
|
385
|
+
def health():
|
|
386
|
+
return {"status": "ok", "version": cml.__version__}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@app.post("/audit")
|
|
390
|
+
@limiter.limit(_RATE_LIMIT_AUDIT)
|
|
391
|
+
def audit_text(request: Request, req: AuditTextRequest):
|
|
392
|
+
"""
|
|
393
|
+
Audit a JSONL log provided as a string.
|
|
394
|
+
|
|
395
|
+
Returns audit result in json, markdown, or text format.
|
|
396
|
+
"""
|
|
397
|
+
records = _parse_jsonl(req.log)
|
|
398
|
+
result = _run_audit(records, req.config)
|
|
399
|
+
|
|
400
|
+
if req.format == "markdown":
|
|
401
|
+
index = records_to_index(records)
|
|
402
|
+
return PlainTextResponse(
|
|
403
|
+
to_markdown(result, index=index),
|
|
404
|
+
media_type="text/markdown"
|
|
405
|
+
)
|
|
406
|
+
elif req.format == "text":
|
|
407
|
+
return PlainTextResponse(to_text(result))
|
|
408
|
+
else:
|
|
409
|
+
return JSONResponse(result.to_dict())
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@app.post("/audit/file")
|
|
413
|
+
@limiter.limit(_RATE_LIMIT_AUDIT)
|
|
414
|
+
async def audit_file(request: Request, file: UploadFile = File(...)):
|
|
415
|
+
"""
|
|
416
|
+
Audit an uploaded JSONL file.
|
|
417
|
+
"""
|
|
418
|
+
content = await file.read()
|
|
419
|
+
text = content.decode("utf-8")
|
|
420
|
+
records = _parse_jsonl(text)
|
|
421
|
+
result = _run_audit(records)
|
|
422
|
+
return JSONResponse(result.to_dict())
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@app.post("/ingest")
|
|
426
|
+
@limiter.limit(_RATE_LIMIT_INGEST)
|
|
427
|
+
def ingest(request: Request, req: IngestRequest):
|
|
428
|
+
"""
|
|
429
|
+
Append records to a named in-memory log.
|
|
430
|
+
"""
|
|
431
|
+
log_name = _validate_log_name(req.log_name)
|
|
432
|
+
records = []
|
|
433
|
+
for raw in req.records:
|
|
434
|
+
try:
|
|
435
|
+
records.append(CausalRecord.from_dict(raw))
|
|
436
|
+
except Exception as e:
|
|
437
|
+
logger.exception("Failed to ingest record")
|
|
438
|
+
raise HTTPException(status_code=422, detail=f"Invalid record: {e}")
|
|
439
|
+
_store_records(log_name, records)
|
|
440
|
+
return {"log_name": log_name, "ingested": len(records)}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@app.get("/records/{log_name}")
|
|
444
|
+
@limiter.limit(_RATE_LIMIT_RECORDS)
|
|
445
|
+
def list_records(request: Request, log_name: str):
|
|
446
|
+
"""List all records in a named log."""
|
|
447
|
+
log_name = _validate_log_name(log_name)
|
|
448
|
+
records = _get_log(log_name)
|
|
449
|
+
if not records:
|
|
450
|
+
raise HTTPException(status_code=404, detail=f"Log '{log_name}' not found.")
|
|
451
|
+
return {"log_name": log_name, "count": len(records),
|
|
452
|
+
"records": [r.to_dict() for r in records]}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@app.get("/records/{log_name}/audit")
|
|
456
|
+
@limiter.limit(_RATE_LIMIT_AUDIT)
|
|
457
|
+
def audit_stored_log(request: Request, log_name: str):
|
|
458
|
+
"""Run audit on a stored log."""
|
|
459
|
+
log_name = _validate_log_name(log_name)
|
|
460
|
+
records = _get_log(log_name)
|
|
461
|
+
if not records:
|
|
462
|
+
raise HTTPException(status_code=404, detail=f"Log '{log_name}' not found.")
|
|
463
|
+
result = _run_audit(records)
|
|
464
|
+
return JSONResponse(result.to_dict())
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@app.get("/chain/{log_name}/{record_id}")
|
|
468
|
+
@limiter.limit(_RATE_LIMIT_CHAIN)
|
|
469
|
+
def get_chain(request: Request, log_name: str, record_id: str):
|
|
470
|
+
"""
|
|
471
|
+
Reconstruct the causal chain for a record in a stored log.
|
|
472
|
+
"""
|
|
473
|
+
log_name = _validate_log_name(log_name)
|
|
474
|
+
records = _get_log(log_name)
|
|
475
|
+
if not records:
|
|
476
|
+
raise HTTPException(status_code=404, detail=f"Log '{log_name}' not found.")
|
|
477
|
+
index = records_to_index(records)
|
|
478
|
+
if record_id not in index:
|
|
479
|
+
raise HTTPException(status_code=404, detail=f"Record '{record_id}' not found.")
|
|
480
|
+
chain = reconstruct_chain(record_id, index)
|
|
481
|
+
return {
|
|
482
|
+
"record_id": record_id,
|
|
483
|
+
"chain_length": len(chain),
|
|
484
|
+
"chain": [r.to_dict() for r in chain],
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@app.post("/ctag/decode")
|
|
489
|
+
@limiter.limit(_RATE_LIMIT_CTAG)
|
|
490
|
+
def api_decode_ctag(request: Request, body: dict = Body(...)):
|
|
491
|
+
"""Decode a 16-bit CTAG value."""
|
|
492
|
+
raw = body.get("ctag")
|
|
493
|
+
if raw is None:
|
|
494
|
+
raise HTTPException(status_code=422, detail="Field 'ctag' required.")
|
|
495
|
+
try:
|
|
496
|
+
if isinstance(raw, bool):
|
|
497
|
+
# bool is a subclass of int; reject explicitly to avoid surprises.
|
|
498
|
+
raise ValueError("ctag must be an integer or hex/decimal string")
|
|
499
|
+
if isinstance(raw, str):
|
|
500
|
+
s = raw.strip()
|
|
501
|
+
if not s:
|
|
502
|
+
raise ValueError("ctag string is empty")
|
|
503
|
+
val = int(s, 16) if s.lower().startswith("0x") else int(s)
|
|
504
|
+
elif isinstance(raw, int):
|
|
505
|
+
val = raw
|
|
506
|
+
else:
|
|
507
|
+
raise ValueError(f"unsupported ctag type: {type(raw).__name__}")
|
|
508
|
+
except (TypeError, ValueError) as e:
|
|
509
|
+
raise HTTPException(status_code=422, detail=f"Invalid ctag: {e}")
|
|
510
|
+
if not (0 <= val <= 0xFFFF):
|
|
511
|
+
raise HTTPException(
|
|
512
|
+
status_code=422,
|
|
513
|
+
detail="ctag out of range: must be a 16-bit value in [0, 65535].",
|
|
514
|
+
)
|
|
515
|
+
return decode_ctag(val)
|