Unified Customer Audit API Contract — v2
Status: Design v2 — pending implementation sub-cards
Owner: software-architect
Date: 2026-05-09 UTC
Supersedes: v1 (PR #1451)
Refs: design.md, workflow-uuid-tracing.md, ADR-0058, ADR-0060, ADR-0061, ADR-0062
1. Overview
Three endpoints form the unified audit API. All reader endpoints are gated behind FLAG_UNIFIED_AUDIT_READ until all Phase 3 prerequisites are simultaneously green (see design.md §12).
| Method | Path | Purpose | Auth |
|---|---|---|---|
POST |
/api/customer-audit/event |
Internal-only writer | Service token (AUDIT_INGEST_TOKEN) |
GET |
/api/customer-audit/<customer_id> |
Auth-gated reader with filter/pagination | Session JWT + RBAC |
GET |
/api/customer-audit/<customer_id>/by-replay/<replay_uuid> |
Cross-dimension pivot | Session JWT + RBAC |
POST |
/api/internal/freescout-webhook |
FreeScout ticket-state cache update | Webhook HMAC signature |
All responses JSON. All timestamps UTC ISO 8601 (YYYY-MM-DDTHH:MM:SSZ). All paths under Raptor (api.raxx.app).
2. POST /api/customer-audit/event — Internal Writer
Internal-only. Not callable by customer sessions or browser clients.
Auth
Authorization: Bearer <AUDIT_INGEST_TOKEN>. Token stored in AWS SSM at /raxx/prod/audit/AUDIT_INGEST_TOKEN (and /raxx/staging/...). 32-byte random hex, no embedded claims. Token-level rate limit: 300 req/min (token bucket). Per-customer rate limit: 100 writes/min/customer (in-process counter, backed by Redis). Excess writes above per-customer limit are dropped with a single aggregate row written: action=audit.rate_limited, after_state={"dropped_count": N}.
Request Body
{
"dimension": "customer_self | system_automated | operator_interaction",
"customer_id": 42,
"actor_id": "42",
"actor_type": "customer | system_actor | operator_email",
"action": "trade.submit",
"target_resource": {
"type": "trade",
"id": "99"
},
"before_state": null,
"after_state": {
"symbol": "SPY",
"quantity": 1,
"side": "buy",
"status": "submitted"
},
"ticket_id": null,
"replay_uuid": "550e8400-e29b-41d4-a716-446655440000"
}
Required fields: dimension, customer_id, actor_id, actor_type, action.
Optional fields: target_resource, before_state, after_state, ticket_id, replay_uuid.
Validation Rules (fail-closed)
-
Action namespace pattern:
[a-z][a-z0-9_]*\.[a-z][a-z0-9_.]*. Lowercase, dot-notation. No spaces, no credential-suggestive keywords. Must match a registered entry inAUDIT_ACTION_ALLOWLISTS(CI-enforced; hard-reject in GA). -
Deny-list stripping:
before_stateandafter_stateare stripped of any key inAUDIT_GLOBAL_DENY_LISTbefore INSERT. Presence of denied key → 422 + Sentry WARNING (not silent strip). Seedesign.md §4. -
Action allowlist filtering: fields not in the action's registered allowlist are replaced with
"<REDACTED>"before INSERT. -
Actor type validation:
actor_idforoperator_emailtype must be exactly 16 hex characters (truncated SHA-256 of operator email). Raw email rejected with 422. -
dimension = 'operator_interaction'requires ticket state lookup: the writer service queriesfreescout_ticket_cacheand populatesticket_state_at_readsynchronously. If cache miss or TTL expired →ticket_state_at_read = 'none'(fail-closed, triggers Path B notification).ticket_idshould be non-null for support reads; if null and dimension isoperator_interaction, assumeticket_state_at_read = 'none'. -
Customer ID validation: FK constraint enforces
customer_idreferencesusers.id. Returns 422 on FK violation. -
UUID v4 validation for
replay_uuid: must match[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}pattern. UUID v7 is rejected. -
HMAC computation: after validation and stripping, the writer calls
KMS.GenerateMacbefore INSERT. KMS failure is non-retryable at this layer — the row is not written, and the primary operation is not blocked (swallow + Sentry ERROR).
Responses
| Status | Body | Condition |
|---|---|---|
| 201 | {"id": "<uuid>", "event_hash": "<hmac_hex>"} |
Written successfully |
| 400 | {"error": "missing_required_fields", "fields": [...]} |
Required field absent |
| 401 | {"error": "unauthorized"} |
Missing or invalid bearer |
| 422 | {"error": "validation_failed", "detail": "..."} |
Validation rule violated (includes deny-list hit) |
| 429 | {"error": "rate_limited"} |
Token bucket or per-customer rate limit |
| 500 | {"error": "internal_error"} |
DB or KMS failure |
Idempotency: not supported. 500 → retry with exponential back-off, max 3 attempts. Duplicate detection is post-hoc via HMAC chain inspection.
Notification side-effects: for dimension = 'operator_interaction', the writer triggers the notification job (async, within 5 min SLA) based on ticket_state_at_read. The notification job is separate from the writer; the 201 response is returned before the email dispatches.
3. GET /api/customer-audit/<customer_id> — Auth-Gated Reader
Behind FLAG_UNIFIED_AUDIT_READ. Returns paginated, filterable events.
Auth
Session JWT in cookie. RBAC role checked at handler:
antlers-audit-self: owncustomer_idonly (enforced: JWT sub == pathcustomer_id).raptor-audit-support: any customer + active ticket required for Dim-3 events.raptor-audit-admin: full access + Path B notification triggers.raptor-audit-compliance: full read, no notification. SOC-2 auditor path.
Query Parameters
| Parameter | Type | Default | Constraint |
|---|---|---|---|
dimensions |
comma-separated | Role default (see below) | Role-allowed values only |
since |
ISO 8601 UTC | now() - 30d |
Must be within 90 days of until |
until |
ISO 8601 UTC | now() |
Must be >= since |
action_prefix |
string | — | Filters by action namespace prefix |
replay_uuid |
string | — | UUID v4 format enforced |
page |
integer | 1 | 1-indexed |
per_page |
integer | 25 | Max 100 (customer), 200 (operator) |
Mandatory date-range guard: if since + until span > 90 days, returns 400 with {"error": "date_range_too_wide", "max_days": 90}. Requests without since + until default to the last 30 days (not unbounded). This addresses T-SCALE-1 (audit volume DoS on reads).
Default dimensions by role:
| Role | Default dimensions |
|---|---|
antlers-audit-self |
customer_self,system_automated |
raptor-audit-support |
customer_self,system_automated,operator_interaction |
raptor-audit-admin |
all three |
raptor-audit-compliance |
all three |
Per-customer rate limit on reads: 20 req/min per customer session, 60 req/min per operator session. Applies independently of the write-path rate limit.
Response Shape
{
"customer_id": 42,
"page": 1,
"per_page": 25,
"total": 137,
"total_pages": 6,
"query_window": {
"since": "2026-04-09T00:00:00Z",
"until": "2026-05-09T00:00:00Z"
},
"events": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"dimension": "customer_self",
"actor_type": "customer",
"action": "trade.submit",
"target_resource": {"type": "trade", "id": "99"},
"after_state": {"status": "submitted", "symbol": "SPY", "quantity": 1, "side": "buy"},
"at_utc": "2026-05-09T14:32:00Z",
"ticket_id": null,
"replay_uuid": "550e8400-e29b-41d4-a716-446655440000"
}
]
}
Field masking:
antlers-audit-self:before/after_statestripped of any deny-list keys (should be clean at rest, but defense-in-depth).actor_idforoperator_emailtype rendered as resolved operator display name — the 16-hex hash is never exposed to customers. Vendor / broker names stripped perfeedback_no_backend_branding.md.raptor-audit-support/raptor-audit-admin: same deny-list stripping.actor_idforoperator_emailrendered as"<display_name> (<hex_prefix>)".raptor-audit-compliance: full row, same deny-list stripping, no masking of actor_id. No customer notification triggered.
Dim-3 enforcement for support role: before returning Dim-3 events, check freescout_ticket_cache for an active ticket. If none (cache miss or status in resolved/closed), Dim-3 events are excluded; response includes header X-Audit-Dim3-Excluded: ticket_required.
RLS enforcement: Raptor sets SET LOCAL app.current_customer_id = <customer_id> before the DB query. RLS policy on customer_audit_events enforces that only rows for that customer_id are returned, regardless of the WHERE clause. Post-query assertion: all rows in the response must have customer_id == requested_customer_id. If assertion fails → 500 + CRITICAL Sentry event.
Notification side-effect: for raptor-audit-admin requests, the reader endpoint triggers the Path B notification job for the customer being read. (Admin reads have no ticket; they always go to Path B.) For raptor-audit-compliance requests, no notification.
Error Responses
| Status | Condition |
|---|---|
| 400 | Invalid query parameter, date range > 90 days, missing since/until when required |
| 403 | Customer accessing another customer's events; support request without active ticket |
| 404 | customer_id not found |
| 429 | Read rate limit exceeded |
| 503 | Feature flag FLAG_UNIFIED_AUDIT_READ is off |
4. GET /api/customer-audit/<customer_id>/by-replay/<replay_uuid> — Pivot View
Returns all events for a customer sharing a given replay_uuid, sorted by at_utc. Same RBAC + RLS + notification side-effects as §3.
replay_uuid must be UUID v4 format. UUID v7 values are rejected with 400. This prevents enumeration (T-PEN-5).
Rate limit: max 10 by-replay lookups per minute per session (lower than the standard reader rate limit).
Response Shape
{
"customer_id": 42,
"replay_uuid": "550e8400-e29b-41d4-a716-446655440000",
"event_count": 3,
"query_window": {
"since": "2026-04-09T00:00:00Z",
"until": "2026-05-09T00:00:00Z"
},
"events": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"dimension": "customer_self",
"action": "trade.submit",
"at_utc": "2026-05-09T14:32:00Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"dimension": "system_automated",
"action": "system.paper_gate.pass",
"at_utc": "2026-05-09T14:32:01Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"dimension": "operator_interaction",
"action": "customer.data.read.in_ticket",
"at_utc": "2026-05-09T16:10:00Z",
"ticket_id": "T-88",
"ticket_state_at_read": "open"
}
]
}
Error Responses
| Status | Condition |
|---|---|
| 400 | replay_uuid not UUID v4 format; date range > 90 days |
| 403 | Caller cannot access this customer |
| 404 | customer_id not found, or no events for this replay_uuid |
| 503 | FLAG_UNIFIED_AUDIT_READ is off |
5. POST /api/internal/freescout-webhook — Ticket-State Cache
Internal endpoint. Called by FreeScout webhook on every conversation state change. Not customer-callable.
Auth
FreeScout HMAC-SHA-256 signature in X-FreeScout-Signature header. Secret stored in AWS SSM at /raxx/prod/freescout/FREESCOUT_WEBHOOK_SECRET. Signature verified before processing.
Request Body
{
"event": "conversation.status.changed",
"conversation": {
"id": "T-88",
"status": "resolved",
"customer_id": "42",
"updated_at": "2026-05-09T16:05:00Z"
}
}
Processing
- Verify HMAC signature. 401 on failure.
- Upsert
freescout_ticket_cache:INSERT ... ON CONFLICT (ticket_id) DO UPDATE SET status = $status, updated_at = NOW(), ttl_expires = NOW() + INTERVAL '24 hours'. - Return 200.
Unrecognized event types are silently accepted (200) and ignored. This allows FreeScout to send future event types without breaking the webhook.
6. Writer Registration and Token Management
All writer surfaces (Raptor middleware, Console, Reasonator, Velvet) use AUDIT_INGEST_TOKEN from AWS SSM. Rotation procedure: new token → write to SSM → rolling redeploy of all writers. No dual-token overlap window. Audit write is non-blocking (swallow on failure pattern).
AUDIT_KMS_KEY_ARN in Infisical (not SSM — it is a configuration value, not a workload secret) at /raxx/prod/audit/AUDIT_KMS_KEY_ARN. Rotatable without redeploy; KMS supports key rotation with version retention.
7. Sentry Instrumentation
- Writer 422 (validation failures):
sentry.capture_messageatWARNING. Includes field names, not values. - Writer 500 (DB or KMS failure):
sentry.capture_exception. - Writer deny-list hit:
sentry.capture_messageatWARNINGwith key name. - Hash chain integrity check failures:
sentry.capture_messageatFATAL. - Path B notification trigger:
sentry.capture_messageatCRITICALwith operator hash + customer ID + timestamp. - Post-query RLS assertion failure:
sentry.capture_messageatCRITICAL. - Per-customer rate limit aggregate drop:
sentry.capture_messageatINFOwith drop count. - p99 INSERT latency > 50ms custom metric:
sentry.set_measurement("audit.trigger.insert_p99_ms", value)weekly.
No customer PII in Sentry. customer_id as opaque integer tag. No JSONB field values.