Raxx · internal docs

internal · gated

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)

  1. 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 in AUDIT_ACTION_ALLOWLISTS (CI-enforced; hard-reject in GA).

  2. Deny-list stripping: before_state and after_state are stripped of any key in AUDIT_GLOBAL_DENY_LIST before INSERT. Presence of denied key → 422 + Sentry WARNING (not silent strip). See design.md §4.

  3. Action allowlist filtering: fields not in the action's registered allowlist are replaced with "<REDACTED>" before INSERT.

  4. Actor type validation: actor_id for operator_email type must be exactly 16 hex characters (truncated SHA-256 of operator email). Raw email rejected with 422.

  5. dimension = 'operator_interaction' requires ticket state lookup: the writer service queries freescout_ticket_cache and populates ticket_state_at_read synchronously. If cache miss or TTL expired → ticket_state_at_read = 'none' (fail-closed, triggers Path B notification). ticket_id should be non-null for support reads; if null and dimension is operator_interaction, assume ticket_state_at_read = 'none'.

  6. Customer ID validation: FK constraint enforces customer_id references users.id. Returns 422 on FK violation.

  7. 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.

  8. HMAC computation: after validation and stripping, the writer calls KMS.GenerateMac before 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:

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:

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

  1. Verify HMAC signature. 401 on failure.
  2. Upsert freescout_ticket_cache: INSERT ... ON CONFLICT (ticket_id) DO UPDATE SET status = $status, updated_at = NOW(), ttl_expires = NOW() + INTERVAL '24 hours'.
  3. 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

No customer PII in Sentry. customer_id as opaque integer tag. No JSONB field values.