ProjexCloud — API Reference
Every documented API by SDK: URI, method, auth, payload, parameters, expected response, the enumerated error responses (code + when it happens) and any status transitions. Generated from tests/api_definitions/.
api-gateway
POST/admin/active-active/profilespublic
Activates an active-active multi-region profile for a tenant (P8 Variant D): home region, the list of paired regions, the contractual addendum that authorizes it, optional RPO/RTO targets and optional per-stream replication overrides (sync | async | single-region). Returns 201 { success: true, data: <profile> }. QA edge cases: tenant_id, home_region, contract_addendum_ref are falsy-checked and paired_regions must pass Array.isArray — note an EMPTY array passes the guard, so [] is accepted and produces a profile with no pairs; there is no check that home_region is excluded from paired_regions or that any region actually exists, so nonsense regions are only caught (if at all) at the DB layer as a 500; rpo_target_seconds/rto_target_seconds are unvalidated (negative and zero accepted); replication_overrides values are not enum-checked in the route; re-activating an existing tenant profile has no conflict branch and may raise a unique violation as a 500 rather than 409.
[ "POST /api/auth/signup-tenant" ]
home_region: us-east-1, us-west-2, eu-west-1, ap-south-1replication_overrides: sync, async, single-region| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | tenant_id, home_region, paired_regions[], contract_addendum_ref required | tenant_id, home_region or contract_addendum_ref is missing/empty, or paired_regions is not an array (an empty array passes this guard) |
| 500 | InternalServerError | <activation error message> | activateActiveActiveProfile() throws — tenant_id not a valid UUID or not an existing tenant, an unknown region, replication_overrides values outside sync|async|single-region hitting a check constraint, a unique violation on an existing profile, or Postgres unavailable |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"home_region": "us-east-1",
"paired_regions": [
"us-west-2",
"eu-west-1"
],
"contract_addendum_ref": "addendum-2026-DR-001",
"rpo_target_seconds": 5,
"rto_target_seconds": 60,
"replication_overrides": {
"sdk-billing": "sync",
"sdk-analytics": "async"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"home_region": "us-east-1",
"paired_regions": [
"us-west-2",
"eu-west-1"
],
"contract_addendum_ref": "addendum-2026-DR-001",
"rpo_target_seconds": 5,
"rto_target_seconds": 60,
"replication_overrides": {
"sdk-billing": "sync",
"sdk-analytics": "async"
}
}{
"success": true,
"data": {
"profile_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"home_region": "us-east-1",
"paired_regions": [
"us-west-2",
"eu-west-1"
],
"contract_addendum_ref": "addendum-2026-DR-001",
"rpo_target_seconds": 5,
"rto_target_seconds": 60,
"replication_overrides": {
"sdk-billing": "sync",
"sdk-analytics": "async"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"profile_id": "string",
"tenant_id": "string",
"tier": "string",
"home_region": "string",
"paired_regions": "array",
"rpo_target_seconds": "number",
"rto_target_seconds": "number",
"contract_addendum_ref": "string",
"activated_at": "string"
}
}POST/admin/active-active/profiles/:profile_id/drillspublic
Runs a failover drill against an active-active profile, moving traffic to to_region (optionally declaring from_region) and recording the measured result; returns 201 { success: true, data: <drill> }. QA edge cases: only to_region is required — from_region is optional and inferred when omitted, so a minimal body is just { to_region }; there is no validation that to_region is one of the profile's paired_regions, nor that it differs from from_region, so a drill to an unpaired region is not a 400 and either succeeds meaninglessly or fails as a 500; an unknown profile_id has no 404 branch and surfaces as a 500 with the raw message; the drill is a real state-changing operation and is NOT idempotent — every call records a new drill row and may actually move traffic, so it should not be replayed casually in a shared environment.
[ "POST /api/auth/signup-tenant", "POST /admin/active-active/profiles" ]
to_region: us-east-1, us-west-2, eu-west-1, ap-south-1from_region: us-east-1, us-west-2, eu-west-1, ap-south-1| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | to_region required | to_region is missing, null, or an empty string in the body (from_region is optional and never required) |
| 500 | InternalServerError | <drill error message> | runFailoverDrill() throws — unknown profile_id (there is no 404 branch), to_region not paired with the profile, the target region being unreachable, or the drill record write failing |
{
"profile_id": "{{cache:active-active-profiles.create.response.data.profile_id}}"
}{
"to_region": "us-west-2",
"from_region": "us-east-1"
}{
"to_region": "us-west-2",
"from_region": "us-east-1"
}{
"success": true,
"data": {
"status": "completed",
"to_region": "us-west-2",
"from_region": "us-east-1",
"drill_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"drill_id": "string",
"profile_id": "string",
"from_region": "string",
"to_region": "string",
"started_at": "string",
"rpo_observed_seconds": "number",
"rto_observed_seconds": "number",
"passed": "boolean",
"audit_entry_id": "string",
"tier_downgrade_triggered": "boolean"
}
}GET/admin/active-active/profiles/:tenant_idpublic
Reads a tenant's active-active profile together with its replication streams, returning { success: true, data: { profile, replication_streams } }. QA edge cases: a tenant with no profile is a clean 404 "no profile for tenant" — the null check on the profile happens before the stream lookup, so streams are only queried for a real profile; a profile that exists with zero replication streams is a 200 with an empty replication_streams array, not a 404; neither call is wrapped in try/catch, so a tenant_id that fails the Postgres uuid cast escapes to the Fastify default 500 body ({ statusCode, error, message }) rather than a 400 or 404; the route is cross-tenant and ops-token gated, so the tenant_id path param is trusted with no ownership check against a caller JWT.
[ "POST /api/auth/signup-tenant", "POST /admin/active-active/profiles" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 404 | NotFound | no profile for tenant | getActiveActiveProfile(tenant_id) returns null/undefined — that tenant has no active-active profile |
| 500 | InternalServerError | Internal Server Error | getActiveActiveProfile() or listReplicationStreams() throws — most commonly tenant_id failing a uuid cast, or Postgres unavailable; unhandled by the route, so Fastify default error serialization applies |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"success": true,
"data": {
"profile_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "profile not found"
}{
"data": {
"profile": {
"profile_id": "string",
"tenant_id": "string",
"tier": "string",
"home_region": "string",
"paired_regions": "array"
},
"replication_streams": "array"
}
}GET/admin/approvals/breachespublic
Operator SLA-breach report: pending approval requests whose elapsed time since requested_at exceeds their route's SLA, exposing request_id, tenant_id, route_id, a subject_ref built as subject_kind:subject_id, requested_at, status, elapsed_minutes and sla_minutes, worst-breach-first and capped at 100 rows. QA edge cases: only requests with status = "pending" are considered, so a request that breached its SLA and was later approved or rejected disappears from this report entirely; routes whose computed sla_minutes is 0 (no per-step SLA set, since the value is COALESCE'd to 0) are excluded by the sla_minutes > 0 filter and can never breach; the comparison is strictly greater-than, so a request exactly at its SLA is not yet a breach; elapsed_minutes is computed from now() at query time and therefore changes between calls; cross-tenant with no tenant filter and no pagination past 100 rows.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 500 | InternalServerError | <database error message> | the breach query throws — jsonb_array_elements failing on a malformed route steps column, or Postgres unavailable |
{
"success": true,
"data": [
{
"breach_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/approvals/requests/:request_id/operator-overridepublic
Operator break-glass override of a pending approval request: forces the decision to approved or rejected, stamps resolved_at, and rewrites the reason as "[operator-override by <operator_id>] <reason>" so the override is self-evident in the audit trail. Returns a bare { success: true } with no data. QA edge cases: the UPDATE is guarded by "WHERE request_id = $1 AND status = 'pending'", but the handler NEVER inspects the affected row count — so overriding an unknown request_id, or one that is already approved/rejected, still returns 200 { success: true } while changing nothing. There is no 404 and no 409; QA must verify the effect by re-reading the request rather than trusting the status code. decision, reason and operator_id are all required by a falsy check, and decision is not enum-validated in the route, so a value other than approved/rejected is written straight into status.
[ "POST /api/auth/signup-tenant", "POST /api/approvals/routes", "POST /api/approvals/requests" ]
decision: approved, rejected| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | decision + reason + operator_id required | any of decision, reason or operator_id is missing, null, or an empty string |
| 500 | InternalServerError | <database error message> | the UPDATE on approval.request throws — request_id not castable to the column type, a decision value rejected by the status check constraint, or Postgres unavailable |
{
"entity": "approval.request",
"field": "status",
"flow": [
"pending",
"approved",
"rejected"
],
"transitions": [
{
"from": "pending",
"to": "approved",
"via": "POST /admin/approvals/requests/:request_id/operator-override"
},
{
"from": "pending",
"to": "rejected",
"via": "POST /admin/approvals/requests/:request_id/operator-override"
}
]
}{
"request_id": "{{cache:approvals.requests.create.response.data.request.request_id}}"
}{
"decision": "approved",
"reason": "Operator override — SLA breach escalation resolved out-of-band",
"operator_id": "{{var:operator_id}}"
}{
"decision": "approved",
"reason": "Operator override — SLA breach escalation resolved out-of-band",
"operator_id": "{{var:operator_id}}"
}{
"success": true,
"data": {
"operator_override_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"decision": "approved",
"reason": "Operator override — SLA breach escalation resolved out-of-band",
"operator_id": "{{var:operator_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/admin/approvals/routespublic
Operator cross-tenant listing of approval routes — route_id, tenant_id, name, status, created_at and a derived sla_minutes computed as the MAX sla_minutes across the route's steps JSON array — newest-first and capped at 200 rows. QA edge cases: sla_minutes is COALESCE'd to 0, so a route whose steps carry no sla_minutes reports 0 rather than null, and 0 is also what the breaches query treats as "no SLA" (such routes can never appear as breached); the derived value is the maximum across steps, not per-step, so per-step SLAs are not visible here; no filtering by tenant or status and no pagination past the 200-row cap; an empty result is a 200 with data: [], never 404; a malformed steps JSON in a row can fail jsonb_array_elements and turn the whole request into a 500.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 500 | InternalServerError | <database error message> | the SELECT against approval.route throws — jsonb_array_elements failing on a steps column that is not a JSON array, or Postgres unavailable |
{
"success": true,
"data": [
{
"route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/appspublic
Provisions the parent app (and its owning org) that a tenant references via tenant.app_id, returning 201 { data: { app } }. Deliberately idempotent: appEnsure() returns the existing app unchanged when app_id already exists, so replaying the same body is safe and still answers 201 (not 409). This exists because without it there is no admin path to create the first app and POST /admin/tenants always fails its FK. QA edge cases: app_id and display_name are required and empty strings are rejected as missing; org_name is optional and only used on first creation — re-posting with a different org_name does NOT re-parent an existing app; changing display_name on a replay may or may not update depending on appEnsure semantics, so assert against the returned row rather than the request body.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 400 | ValidationError | app_id, display_name are required | app_id or display_name is missing, null, or an empty string |
| 500 | InternalServerError | <database error message> | appEnsure() throws — e.g. Postgres unavailable or a constraint violation on the app/org insert |
{
"app_id": "{{dynamic:name}}",
"display_name": "QA Admin App",
"org_name": "QA Admin Org"
}{
"app_id": "Acme QA Sample",
"display_name": "QA Admin App",
"org_name": "QA Admin Org"
}{
"success": true,
"data": {
"app_id": "Acme QA Sample",
"display_name": "QA Admin App",
"org_name": "QA Admin Org",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"app": {
"app_id": "string",
"org_id": "string",
"display_name": "string",
"status": "string",
"created_at": "string"
}
}
}GET/admin/audit/entriespublic
Browses the audit chain across tenants with optional tenant_id, actor_id, from and to filters, returning entry_id, tenant_id, actor_kind, actor_id, event_type, occurred_at and seq, newest-first. limit defaults to 100 and is clamped to a maximum of 500 — a larger value is silently reduced rather than rejected. QA edge cases: a non-numeric limit makes parseInt return NaN, and Math.min(NaN, 500) is NaN, which is passed as the LIMIT parameter and fails in Postgres as a 500 — so limit=abc is a server error, not a 400; there is no offset or cursor, so paging deeper than the limit is impossible; tenant_id must be a valid UUID or the ::uuid cast fails as a 500, and from/to must be castable to timestamptz for the same reason; with no filters the route returns entries across ALL tenants, which is deliberate for an operator surface.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 500 | InternalServerError | <database error message> | the SELECT against audit.entry throws — a non-numeric limit producing NaN, tenant_id not castable to uuid, from/to not castable to timestamptz, or Postgres unavailable |
{
"success": true,
"data": [
{
"entry_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": "array"
}GET/admin/audit/entries/:entry_idpublic
Fetches a single audit entry by entry_id with SELECT *, so the response includes the full row — payload plus the chain fields seq, prev_hash and entry_hash that POST /admin/audit/verify walks. Returns { success: true, data: <entry> }. QA edge cases: an unknown entry_id is a clean 404 "entry not found" from an explicit row-count check; an entry_id that is not castable to the column type (e.g. a non-UUID when the column is a UUID) fails in Postgres and returns 500 rather than 400 or 404, so malformed and missing ids behave differently here — unlike GET /admin/invoices/:invoice_id which regex-checks first and 404s; the hash columns come back as bytea and serialize as buffer-shaped JSON, not hex strings; the route is cross-tenant and ops-token gated, so no tenant ownership check is applied.
[ "POST /api/auth/signup-tenant", "POST /api/audit/append" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 404 | NotFound | entry not found | no row in audit.entry matches the entry_id path param |
| 500 | InternalServerError | <database error message> | the SELECT throws — entry_id not castable to the column type, or Postgres unavailable |
{
"entry_id": "{{cache:audit.append.response.data.entry_id}}"
}{
"success": true,
"data": {
"entry_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "entry not found"
}{
"data": {
"entry_id": "string",
"tenant_id": "string",
"actor_kind": "string",
"actor_id": "string",
"action": "string",
"occurred_at": "string",
"seq": "number"
}
}POST/admin/audit/verifypublic
Verifies the integrity of a tenant's audit hash chain: loads every entry for the tenant ordered by seq and walks it, requiring seq to be contiguous from 0 and each entry's prev_hash to equal the previous entry's entry_hash (the first entry must link to 32 zero bytes). tenant_id is a QUERY parameter, not a body field, and is mandatory. QA edge cases: a FAILED verification is NOT an error status — it returns 200 with { verified: false, failed_seq, reason: "gap" | "wrong-prev" }, so tests must assert on the payload, not the HTTP code; a tenant with zero entries verifies as true with entry_count 0; the walk loads the entire chain with no LIMIT, so a large tenant makes this a slow, memory-heavy call; a tenant_id that is not a valid UUID fails the ::uuid cast and returns 500 rather than 400.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | tenant_id query param required | the tenant_id query-string parameter is missing or empty — note it is read from the query string, not the request body |
| 500 | InternalServerError | <database error message> | the chain query throws — tenant_id not castable to uuid, prev_hash/entry_hash not being bytea buffers as expected, or Postgres unavailable |
{}{
"success": true,
"data": {
"status": "completed",
"verify_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"verified": "boolean",
"entry_count": "number"
}
}POST/admin/byok/bindingspublic
Binds a tenant to a customer-managed KMS key (P8 Variant A · BYOK), recording provider, customer_kms_key_arn, tenant_key_id, the revoke-propagation SLA and an optional SIEM forwarder endpoint, and returns 200 { success: true, data: <binding> }. QA edge cases: tenant_id, provider, customer_kms_key_arn, tenant_key_id and operator_id are all required with a falsy check (empty strings rejected); provider is typed as aws-kms|gcp-kms|hsm-pkcs11 but NOT validated at runtime by the route, so an unknown provider reaches bindCmk and fails as a 500 rather than a 400; the ARN is not format-checked and the key is not proven reachable at bind time; binding a tenant that already has a binding is not given a 409 branch here — whatever bindCmk does (replace or unique violation) determines whether you see a 200 or a 500; sla_revoke_propagation_seconds and siem_forwarder_endpoint are optional and unbounded.
[ "POST /api/auth/signup-tenant" ]
provider: aws-kms, gcp-kms, hsm-pkcs11| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | tenant_id, provider, customer_kms_key_arn, tenant_key_id, operator_id all required | any of tenant_id, provider, customer_kms_key_arn, tenant_key_id or operator_id is missing, null, or an empty string |
| 500 | InternalServerError | <bind error message> | bindCmk() throws — unknown/unsupported provider value, tenant_id not a valid UUID or not an existing tenant, a unique violation on an already-bound tenant, or the KMS/DB call failing |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"provider": "aws-kms",
"customer_kms_key_arn": "{{var:customer_kms_key_arn}}",
"tenant_key_id": "{{var:tenant_key_id}}",
"sla_revoke_propagation_seconds": 30,
"siem_forwarder_endpoint": "https://siem.qa.example.com/ingest",
"operator_id": "{{var:operator_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider": "aws-kms",
"customer_kms_key_arn": "{{var:customer_kms_key_arn}}",
"tenant_key_id": "{{var:tenant_key_id}}",
"sla_revoke_propagation_seconds": 30,
"siem_forwarder_endpoint": "https://siem.qa.example.com/ingest",
"operator_id": "{{var:operator_id}}"
}{
"success": true,
"data": {
"binding_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider": "aws-kms",
"customer_kms_key_arn": "{{var:customer_kms_key_arn}}",
"tenant_key_id": "{{var:tenant_key_id}}",
"sla_revoke_propagation_seconds": 30,
"siem_forwarder_endpoint": "https://siem.qa.example.com/ingest",
"operator_id": "{{var:operator_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"binding_id": "string",
"tenant_id": "string",
"provider": "string",
"customer_kms_key_arn": "string",
"tenant_key_id": "string",
"grant_status": "string",
"bound_at": "string",
"revoked_at": "string",
"sla_revoke_propagation_seconds": "number",
"siem_forwarder_endpoint": "string"
}
}POST/admin/byok/bindings/:binding_id/revokepublic
Revokes a tenant CMK binding, recording the mandatory reason and operator_id for the audit trail, and returns 200 { success: true, data: <binding> } with the now-revoked binding. QA edge cases: reason and operator_id are both required with a falsy check (empty strings rejected) — revocation is never allowed to be unattributed; an unknown binding_id is a clean 404 "binding not found" because revokeCmk() returning null is handled explicitly, unlike the sibling rotate route which 500s in the same situation; revoking an already-revoked binding depends on revokeCmk returning the row (200) versus null (404), so assert on the returned status rather than assuming idempotency; propagation to readers is bounded by the binding's sla_revoke_propagation_seconds, so a revoked key can still decrypt for a short window after this call returns 200.
[ "POST /api/auth/signup-tenant", "POST /admin/byok/bindings" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | reason + operator_id required | reason or operator_id is missing, null, or an empty string in the body |
| 404 | NotFound | binding not found | revokeCmk() returns null/undefined — no binding exists with that binding_id (or it is no longer revocable) |
| 500 | InternalServerError | <revoke error message> | revokeCmk() throws — KMS unreachable, the audit/DB write failing, or Postgres unavailable |
{
"entity": "byok_binding",
"field": "grant_status",
"flow": [
"active",
"revoking",
"revoked"
],
"transitions": [
{
"from": "active",
"to": "revoking",
"via": "POST /admin/byok/bindings/:binding_id/revoke"
},
{
"from": "revoking",
"to": "revoked",
"via": "POST /admin/byok/bindings/:binding_id/revoke"
}
]
}{
"binding_id": "{{cache:byok-bindings.create.response.data.binding_id}}"
}{
"reason": "QA automated revoke drill",
"operator_id": "{{var:operator_id}}"
}{
"reason": "QA automated revoke drill",
"operator_id": "{{var:operator_id}}"
}{
"success": true,
"data": {
"status": "completed",
"reason": "QA automated revoke drill",
"operator_id": "{{var:operator_id}}",
"revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"binding_id": "string",
"tenant_id": "string",
"grant_status": "string",
"revoked_at": "string"
}
}POST/admin/byok/bindings/:binding_id/rotatepublic
Rotates a tenant CMK: re-wraps material from previous_tenant_key_id to new_tenant_key_id for the binding named in the path, attributing the action to operator_id, and returns 200 { success: true, data: <rotation> }. QA edge cases: all three body fields are required with a falsy check; the distinctive failure is UndecryptableError — raised when data cannot be re-wrapped under the new key (wrong previous key, revoked/deleted customer key, KMS denying access) — which the handler maps to the error's own status_code or 409 when it has none, so QA must expect a 409-class conflict there rather than a 500; every other throw (unknown binding_id, KMS outage, DB failure) collapses into a 500 with the raw message and there is no 404 branch, so rotating a non-existent binding_id is a 500; a repeated rotation with the same previous/new pair is not idempotent and will typically fail undecryptable on the second run because the previous key no longer wraps anything.
[ "POST /api/auth/signup-tenant", "POST /admin/byok/bindings" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | previous_tenant_key_id, new_tenant_key_id, operator_id required | any of previous_tenant_key_id, new_tenant_key_id or operator_id is missing, null, or an empty string |
| 409 | UndecryptableError | <undecryptable error message> | rotateCmk() throws UndecryptableError — material cannot be re-wrapped under the new key (previous_tenant_key_id does not match, the customer key was revoked/deleted, or KMS denied the decrypt); the handler replies with the error's status_code when present, otherwise 409 |
| 500 | InternalServerError | <rotation error message> | rotateCmk() throws anything other than UndecryptableError — unknown binding_id (there is no 404 branch), KMS unreachable, or the DB write failing |
{
"binding_id": "{{cache:byok-bindings.create.response.data.binding_id}}"
}{
"previous_tenant_key_id": "{{var:tenant_key_id}}",
"new_tenant_key_id": "{{var:new_tenant_key_id}}",
"operator_id": "{{var:operator_id}}"
}{
"previous_tenant_key_id": "{{var:tenant_key_id}}",
"new_tenant_key_id": "{{var:new_tenant_key_id}}",
"operator_id": "{{var:operator_id}}"
}{
"success": true,
"data": {
"status": "completed",
"previous_tenant_key_id": "{{var:tenant_key_id}}",
"new_tenant_key_id": "{{var:new_tenant_key_id}}",
"operator_id": "{{var:operator_id}}",
"rotate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"rotation_id": "string",
"binding_id": "string",
"started_at": "string",
"completed_at": "string",
"previous_tenant_key_id": "string",
"new_tenant_key_id": "string",
"leaf_reencryption_needed": "boolean"
}
}GET/admin/byok/bindings/tenant/:tenant_idpublic
Reads the current BYOK binding for a single tenant, returning { success: true, data: <binding> } or 404 when the tenant has never been bound. QA edge cases: the "no binding" case is an explicit 404 with error "no binding for tenant", so absence is distinguishable from a bad request; the lookup is NOT wrapped in try/catch, so a tenant_id that is not a well-formed UUID fails the Postgres uuid cast and escapes to the Fastify default 500 body ({ statusCode, error, message }) instead of a 400 or the { success: false, error } envelope — this is the most common surprise when testing with a non-UUID path param; the route is ops-token gated and cross-tenant, so the tenant_id in the path is trusted with no ownership check against a caller JWT; a revoked binding is still returned as data (with its revoked state) rather than 404.
[ "POST /api/auth/signup-tenant", "POST /admin/byok/bindings" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 404 | NotFound | no binding for tenant | getByokBindingForTenant(tenant_id) returns null/undefined — the tenant exists or not, but has no BYOK binding row |
| 500 | InternalServerError | Internal Server Error | the lookup throws — most commonly tenant_id is not a valid UUID and fails the Postgres cast, or Postgres is unavailable; unhandled by the route, so Fastify default error serialization applies |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"success": true,
"data": {
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "tenant not found"
}{
"data": {
"binding_id": "string",
"tenant_id": "string",
"provider": "string",
"grant_status": "string"
}
}POST/admin/federation/chaos-drillpublic
Runs an operator-triggered federation chaos drill (P7 FR-FED-3 / AC-6): performs a failover from from_region to to_region for the given federation and records a failover_event with trigger="chaos-drill" so monthly RPO/RTO drills produce auditable, production-condition numbers. Returns 200 { success: true, data: <failover event> }. QA edge cases: all three of federation_id, from_region and to_region are required and empty strings count as missing; there is no check that from_region differs from to_region, nor that either region is a member of the federation — an unknown federation_id or region surfaces from the orchestrator as a 500 with the raw message rather than a 404; each call writes a new failover_event, so it is NOT idempotent and repeated drills accumulate rows; this route depends on the gateway-internal orchestrator started at boot (FEDERATION_ORCHESTRATOR_ENABLED), unlike POST /admin/chaos-drill which has no orchestrator in the gateway mount.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 400 | ValidationError | federation_id, from_region, to_region are required | any of federation_id, from_region or to_region is missing, null, or an empty string |
| 500 | InternalServerError | <drill error message> | federationOrchestrator.runChaosDrill() throws — unknown federation_id or region, the target pool being unreachable, or the failover_event insert failing |
{
"federation_id": "{{var:federation_id}}",
"from_region": "us-east-1",
"to_region": "us-west-2"
}{
"federation_id": "{{var:federation_id}}",
"from_region": "us-east-1",
"to_region": "us-west-2"
}{
"success": true,
"data": {
"chaos_drill_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"federation_id": "{{var:federation_id}}",
"from_region": "us-east-1",
"to_region": "us-west-2",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/federation/iceberg-backendpublic
Hot-reloads the active Iceberg backend driver in the running gateway without a restart, so ops can flip drivers (nessie ↔ glue) or refresh credentials in place. Returns { success: true, data: { driver } } echoing the now-active driver. QA edge cases: the change is process-local and in-memory — in a multi-replica deployment only the replica that served the request is reconfigured, and the setting does not survive a restart; driver is the only required field and is not checked against an enum by the route, so an unknown/misconfigured driver is rejected by bootstrapIcebergBackend() and mapped to 400 (not 500) along with any credential/connection setup error it throws; a successful call has no 201 semantics and returns plain 200.
driver: nessie, glue, none| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 400 | ValidationError | driver is required | the driver field is missing, null, or an empty string in the body |
| 400 | ValidationError | <bootstrap error message> | bootstrapIcebergBackend() throws — unknown driver name, missing or invalid credentials, or malformed backend configuration; every throw from bootstrap is mapped to 400, never 500 |
{
"driver": "none",
"base_url": "https://nessie.example.com/api/v1",
"bearer_token": "{{static:test-bearer-token}}"
}{
"driver": "none",
"base_url": "https://nessie.example.com/api/v1",
"bearer_token": "test-bearer-token"
}{
"success": true,
"data": {
"iceberg_backend_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"driver": "none",
"base_url": "https://nessie.example.com/api/v1",
"bearer_token": "test-bearer-token",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/federation/iceberg-bindingspublic
Registers (or updates) a federation.iceberg_table_binding row mapping a ClickHouse source table onto an Iceberg table_ref within a catalog. Upsert on binding_id: ON CONFLICT DO UPDATE rewrites catalog_id, table_ref, source_clickhouse_table, partition_strategy and z_order_cols, so a repeat post is an update and still returns 201. partition_strategy defaults to {} and is stored as jsonb; z_order_cols defaults to []. QA edge cases: binding_id, catalog_id and table_ref are required (empty strings count as missing); catalog_id is a foreign key onto federation.iceberg_catalog, so binding to a catalog that was never registered surfaces as a 500 with the raw FK message, NOT a 400 or 404 — register the catalog first; partition_strategy is JSON.stringify-ed without shape validation, so any object is accepted; source_clickhouse_table is optional and stored as NULL when omitted.
[ "POST /admin/federation/iceberg-catalogs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 400 | ValidationError | binding_id, catalog_id, table_ref are required | any of binding_id, catalog_id or table_ref is missing, null, or an empty string |
| 500 | InternalServerError | <database error message> | the INSERT ... ON CONFLICT into federation.iceberg_table_binding throws — most commonly a foreign-key violation because catalog_id does not exist, or Postgres unavailable |
{
"binding_id": "qa-iceberg-binding-1",
"catalog_id": "{{cache:federation-iceberg-catalogs.create.response.data.catalog_id}}",
"table_ref": "warehouse.usage_events",
"source_clickhouse_table": "meter.usage_ledger",
"partition_strategy": {
"region": "identity",
"tenant": "identity",
"time": "day"
},
"z_order_cols": [
"tenant_id",
"sku"
]
}{
"binding_id": "qa-iceberg-binding-1",
"catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"table_ref": "warehouse.usage_events",
"source_clickhouse_table": "meter.usage_ledger",
"partition_strategy": {
"region": "identity",
"tenant": "identity",
"time": "day"
},
"z_order_cols": [
"tenant_id",
"sku"
]
}{
"success": true,
"data": {
"iceberg_binding_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"binding_id": "qa-iceberg-binding-1",
"catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"table_ref": "warehouse.usage_events",
"source_clickhouse_table": "meter.usage_ledger",
"partition_strategy": {
"region": "identity",
"tenant": "identity",
"time": "day"
},
"z_order_cols": [
"tenant_id",
"sku"
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/admin/federation/iceberg-catalogspublic
Lists every registered Iceberg catalog (catalog_id, region, backend, root_url, capacity_tier, status, created_at) newest-first as { success: true, data: rows }. Cross-tenant operator view with no filtering, no pagination and no row limit — the full federation.iceberg_catalog table is returned, so response size grows unbounded with catalog count. QA edge cases: the query is NOT wrapped in try/catch, so a database failure escapes to the Fastify default error handler and returns the framework-shaped 500 body ({ statusCode, error, message }) rather than the { success: false, error } shape the sibling routes use — assert accordingly; an empty table is a 200 with data: [], not a 404.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 500 | InternalServerError | Internal Server Error | the SELECT against federation.iceberg_catalog throws (Postgres unavailable, missing schema/table) — unhandled by the route, so Fastify default error serialization applies, not the { success: false } envelope |
{
"success": true,
"data": [
{
"iceberg_catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/federation/iceberg-catalogspublic
Registers (or updates) a federation.iceberg_catalog row so the lineage-projector worker can resolve a target table_ref, giving ops an alternative to raw SQL and preserving the auto-migrate doctrine. Upsert semantics: ON CONFLICT (catalog_id) DO UPDATE overwrites region, backend, root_url, capacity_tier and status, so re-posting an existing catalog_id is an update and still returns 201, never 409. capacity_tier defaults to "standard" and status to "active". QA edge cases: backend is validated against the closed set glue|nessie|hive and anything else is a 400; catalog_id, region, backend and root_url are all required and empty strings count as missing; root_url is not URL-validated; status is NOT validated in the handler, so an out-of-enum status reaches the DB check constraint and returns 500 rather than 400.
backend: glue, nessie, hivestatus: active, degraded, retired| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 400 | ValidationError | catalog_id, region, backend, root_url are required | any of catalog_id, region, backend or root_url is missing, null, or an empty string |
| 400 | ValidationError | backend must be one of: glue, nessie, hive | backend is present but not one of glue, nessie, hive |
| 500 | InternalServerError | <database error message> | the INSERT ... ON CONFLICT into federation.iceberg_catalog throws — e.g. status outside the allowed enum hitting a check constraint, or Postgres unavailable |
{
"catalog_id": "qa-iceberg-catalog-useast",
"region": "us-east-1",
"backend": "nessie",
"root_url": "https://nessie.example.com/api/v1",
"capacity_tier": "standard",
"status": "active"
}{
"catalog_id": "qa-iceberg-catalog-useast",
"region": "us-east-1",
"backend": "nessie",
"root_url": "https://nessie.example.com/api/v1",
"capacity_tier": "standard",
"status": "active"
}{
"success": true,
"data": {
"iceberg_catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"catalog_id": "qa-iceberg-catalog-useast",
"region": "us-east-1",
"backend": "nessie",
"root_url": "https://nessie.example.com/api/v1",
"capacity_tier": "standard",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/admin/federation/orchestrator-statspublic
Read-only probe counter for the federation failover orchestrator: returns { success: true, data: <stats> } straight from the in-memory handle (probe counts, failure streaks, last-run bookkeeping) with no database access. QA edge cases: the numbers are per-replica and per-process — they reset to zero on every gateway restart and differ between replicas behind the load balancer, so tests must not assert absolute counts across calls; with FEDERATION_ORCHESTRATOR_ENABLED=false the orchestrator is constructed but idle and stats stay at their initial values rather than the route 404-ing or erroring; the handler has no try/catch and no failure branch beyond the auth guard, so 401 and 200 are the only realistic outcomes.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
{
"success": true,
"data": [
{
"orchestrator_stat_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/admin/invoicespublic
Cross-tenant operator listing of billing invoices with optional tenant_id, from and to filters, newest-first by generated_at and hard-capped at 200 rows. The date filters are inclusive-overlap, not containment: from matches invoices whose period_end >= from and to matches invoices whose period_start <= to, so a range returns every invoice overlapping it. QA edge cases: there is no pagination or cursor, so beyond 200 invoices the tail is invisible; omitting tenant_id returns invoices for ALL tenants — this route is deliberately not tenant-scoped and is ops-token gated only; a tenant_id that is not a valid UUID fails the ::uuid cast and returns 500 (not 400), and an unparseable from/to fails the ::date cast the same way; filters that match nothing are a 200 with an empty array, never 404.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 500 | InternalServerError | <database error message> | the SELECT against billing.invoice throws — tenant_id not castable to uuid, from/to not castable to date, or Postgres unavailable |
{
"success": true,
"data": [
{
"invoice_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/admin/invoices/:invoice_idpublic
Returns one invoice with all of its line items, as { success: true, data: { invoice, line_items } }, line items ordered by sku. QA edge cases: invoice_id is checked against a strict UUID regex BEFORE any query runs and a non-UUID is answered 404 "invoice not found" rather than 400 — so malformed and non-existent ids are indistinguishable by design (this deliberately prevents id-format probing); a well-formed UUID with no matching row gets the same 404; an invoice that exists with zero line items is a 200 with an empty line_items array, not a 404; the invoice row is selected with SELECT *, so the payload includes every column including internal billing state; the route is cross-tenant and ops-token gated, so no tenant ownership check is applied to the requested invoice.
[ "POST /api/billing/invoices/generate" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 404 | NotFound | invoice not found | invoice_id fails the UUID regex (checked before any DB access), or it is a valid UUID with no matching row in billing.invoice |
| 500 | InternalServerError | <database error message> | the invoice or line_item SELECT throws — Postgres unavailable or the billing schema missing |
{
"invoice_id": "{{cache:billing-invoices.generate.response.data.invoice.invoice_id}}"
}{
"success": true,
"data": {
"invoice_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "invoice not found"
}POST/admin/lineage/backfillpublic
Operator-triggered lineage backfill (P6B FR-LIN-5 / TK-3380). Resumable via a per-(pool_index, event_type) checkpoint so an interrupted run continues where it stopped rather than restarting; dry_run (default false) reports the counts it would write without writing anything. All body fields are optional — with an empty body the backfill runs across every pool and event_type from the stored checkpoint. QA edge cases: there is NO field validation, so an unknown pool_index or event_type is not a 400 — it simply matches nothing and returns a zero-count success; from/to are passed through new Date() unchecked, so an unparseable string becomes Invalid Date and surfaces as a 500 from the query layer; batch_size is unbounded, so a very large value can hold a connection long enough to exhaust the pool; a non-dry-run backfill advances the checkpoint, which makes a second identical call return far smaller counts — assert relative, not absolute.
event_type: parsing.field.extracted.v1, recommendation.suggestion.generated.v1, semantic.intent.planned.v1, ai-gateway.complete.v1| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 500 | InternalServerError | <backfill error message> | runLineageBackfill() throws — unparseable from/to yielding an Invalid Date, the checkpoint read/write failing, Postgres unavailable, or the batch query timing out |
{
"pool_index": "{{var:pool_index}}",
"event_type": "ai-gateway.complete.v1",
"batch_size": 500,
"dry_run": true,
"from": "{{dynamic:pastdatetime}}",
"to": "{{dynamic:futuredatetime}}"
}{
"pool_index": "{{var:pool_index}}",
"event_type": "ai-gateway.complete.v1",
"batch_size": 500,
"dry_run": true,
"from": "2026-01-15T10:30:00Z",
"to": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"backfill_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"pool_index": "{{var:pool_index}}",
"event_type": "ai-gateway.complete.v1",
"batch_size": 500,
"dry_run": true,
"from": "2026-01-15T10:30:00Z",
"to": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/meter/hardcap/overridepublic
Grants a time-boxed operator override of a metering hard cap for one tenant + SKU, lifting the block until the "until" timestamp with a mandatory operator_id and reason for the audit trail; the x-trace-id request header is threaded through when present. Returns 200 { success: true, data: <override> }. QA edge cases: all five of tenant_id, sku, until, operator_id and reason are required with a falsy check — an override can never be unattributed or open-ended; "until" is passed through as a string with no parsing or future-dating check in the route, so an unparseable timestamp or one already in the past is not a 400 and either fails the timestamptz cast as a 500 or is stored as an immediately-expired override that silently does nothing; sku is not validated against the known SKU set; re-issuing an override for the same tenant+sku typically supersedes or duplicates depending on applyHardCapOverride, with no 409 branch in the route.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 400 | ValidationError | tenant_id, sku, until, operator_id, reason are all required | any of tenant_id, sku, until, operator_id or reason is missing, null, or an empty string |
| 500 | InternalServerError | <override error message> | applyHardCapOverride() throws — "until" not castable to a timestamp, tenant_id not a valid UUID or not an existing tenant, an unknown sku failing a constraint, or Postgres unavailable |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"sku": "ai.gateway.tokens.input",
"until": "{{dynamic:futuredatetime}}",
"operator_id": "{{var:operator_id}}",
"reason": "Temporary lift while billing negotiates an upgrade"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"sku": "ai.gateway.tokens.input",
"until": "2026-01-15T10:30:00Z",
"operator_id": "{{var:operator_id}}",
"reason": "Temporary lift while billing negotiates an upgrade"
}{
"success": true,
"data": {
"override_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"sku": "ai.gateway.tokens.input",
"until": "2026-01-15T10:30:00Z",
"operator_id": "{{var:operator_id}}",
"reason": "Temporary lift while billing negotiates an upgrade",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/admin/meter/pricing-catalogspublic
Lists every metering pricing catalog (P7 Y-11) backing the Admin UI as { success: true, data: catalogs }. Read-only operator surface with no filtering, no pagination and no limit, and gated by the requireAdmin ops-token helper rather than a tenant JWT. QA edge cases: the listPricingCatalogs() call is NOT wrapped in try/catch, so a DB failure escapes to the Fastify default error handler and produces the framework 500 body ({ statusCode, error, message }) instead of the { success: false, error } envelope used elsewhere; catalogs of every status (draft, active, retired) are returned together with no status filter, so tests that expect only active catalogs must filter client-side; an empty result is a 200 with an empty array, never 404.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 500 | InternalServerError | Internal Server Error | listPricingCatalogs() throws (Postgres unavailable or missing pricing tables) — unhandled by the route, so Fastify default error serialization applies |
{
"success": true,
"data": [
{
"pricing_catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/meter/pricing-catalogspublic
Creates a new version of a pricing catalog, stamping created_by from operator_id, and returns 200 { success: true, data: <created version> }. QA edge cases: catalog_id, version and operator_id are all required and validated with a falsy check — which means version: 0 is rejected as missing even though it is a valid-looking integer, and empty strings are likewise rejected; version is not type-checked, so a string version is passed straight to the DB; re-creating an existing (catalog_id, version) pair violates the uniqueness constraint and surfaces as a 500 with the raw driver message rather than a 409 — there is no conflict branch in this handler; nothing verifies operator_id corresponds to a real operator, it is recorded as free text.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | catalog_id, version, operator_id required | catalog_id, version or operator_id is missing, null, an empty string, or (for version) the number 0 — the guard is a falsy check |
| 500 | InternalServerError | <database error message> | createCatalogVersion() throws — most commonly a unique-constraint violation because that catalog_id/version already exists, or Postgres unavailable |
{
"catalog_id": "qa-pricing-catalog-{{dynamic:slug}}",
"version": 1,
"operator_id": "{{var:operator_id}}"
}{
"catalog_id": "qa-pricing-catalog-{{dynamic:slug}}",
"version": 1,
"operator_id": "{{var:operator_id}}"
}{
"success": true,
"data": {
"pricing_catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"catalog_id": "qa-pricing-catalog-{{dynamic:slug}}",
"version": 1,
"operator_id": "{{var:operator_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/admin/meter/pricing-catalogs/:catalog_idpublic
Fetches one pricing catalog plus its associated rates by catalog_id, returning { success: true, data: { catalog, ... } }. QA edge cases: a catalog_id that does not exist yields 404 "catalog not found" — the handler explicitly checks result.catalog for null, so a catalog row that exists with zero rates is still a 200 (empty rate collection), not a 404; catalog_id is taken from the path verbatim with no format validation, so arbitrary strings are looked up and simply miss; getPricingCatalog() itself is NOT inside a try/catch, so a database error escapes to the Fastify default 500 shape rather than the { success: false, error } envelope; the route is ops-token gated and returns 401 before ever touching the DB.
[ "POST /admin/meter/pricing-catalogs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 404 | NotFound | catalog not found | getPricingCatalog(catalog_id) returns a result whose catalog is null/undefined — no row with that catalog_id exists |
| 500 | InternalServerError | Internal Server Error | getPricingCatalog() throws (Postgres unavailable, missing pricing tables) — unhandled by the route, so Fastify default error serialization applies |
{
"catalog_id": "{{cache:meter-pricing-catalogs.create.response.data.catalog_id}}"
}{
"success": true,
"data": {
"pricing_catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "pricing_catalog not found"
}PUT/admin/meter/pricing-catalogs/:catalog_id/rates/:skupublic
Upserts the price rate for a single SKU inside a pricing catalog (P7 Y-11), keyed by the catalog_id and sku path params, returning 200 { success: true, data: <upserted rate> }. Because it is an upsert it is idempotent: repeating the same PUT overwrites rather than conflicting. price, margin_pct and tiers are optional and coerced to null when omitted, so a body with only unit/mode/operator_id clears any previously set price and margin. QA edge cases: unit, mode and operator_id are required and empty strings count as missing; mode is not validated against an enum in the route, so an unsupported mode reaches the DB check constraint and returns 500 rather than 400; a catalog_id that does not exist is NOT a 404 — the FK violation surfaces as a 500; tiers is accepted as arbitrary unvalidated JSON; writing to a retired or non-draft catalog is not blocked by this handler.
[ "POST /admin/meter/pricing-catalogs" ]
mode: flat_per_call, tiered_per_call, passthrough_plus_margin, per_unit, bundled_subscription, free_internal| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | unit, mode, operator_id required | unit, mode or operator_id is missing, null, or an empty string in the body |
| 500 | InternalServerError | <database error message> | upsertPricingRate() throws — catalog_id does not exist (foreign-key violation), mode/unit violates a check constraint, tiers is not storable as jsonb, or Postgres unavailable |
{
"catalog_id": "{{cache:meter-pricing-catalogs.create.response.data.catalog_id}}",
"sku": "ai.gateway.tokens.input"
}{
"unit": "token",
"mode": "per_unit",
"price": 0.0025,
"margin_pct": 15,
"tiers": [
{
"up_to": 100000,
"unit_price": 0.0025
},
{
"up_to": null,
"unit_price": 0.002
}
],
"operator_id": "{{var:operator_id}}"
}{
"unit": "token",
"mode": "per_unit",
"price": 0.0025,
"margin_pct": 15,
"tiers": [
{
"up_to": 100000,
"unit_price": 0.0025
},
{
"up_to": null,
"unit_price": 0.002
}
],
"operator_id": "{{var:operator_id}}"
}{
"success": true,
"data": {
"rate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"unit": "token",
"mode": "per_unit",
"price": 0.0025,
"margin_pct": 15,
"tiers": [
{
"up_to": 100000,
"unit_price": 0.0025
},
{
"up_to": null,
"unit_price": 0.002
}
],
"operator_id": "{{var:operator_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}PATCH/admin/meter/pricing-catalogs/:catalog_id/statuspublic
Transitions a pricing catalog between draft, active and retired, returning a bare { success: true } with no data payload. QA edge cases: this handler does NOT verify the catalog exists first — setCatalogStatus() against an unknown catalog_id updates zero rows and still answers 200 { success: true }, so QA cannot use this route to probe existence and must confirm the change with GET /admin/meter/pricing-catalogs/:catalog_id; status and operator_id are required by a falsy check but status is not validated against the draft|active|retired enum in the route, so an out-of-enum value reaches the DB check constraint and returns 500 rather than 400; there is no state-machine guard, so retired → active is accepted; the transition is idempotent — setting the status a catalog already has succeeds.
[ "POST /admin/meter/pricing-catalogs" ]
status: draft, active, retired| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | status + operator_id required | status or operator_id is missing, null, or an empty string in the body |
| 500 | InternalServerError | <database error message> | setCatalogStatus() throws — status outside draft|active|retired hitting the DB check constraint, or Postgres unavailable |
{
"entity": "pricing_catalog",
"field": "status",
"flow": [
"draft",
"active",
"retired"
],
"transitions": [
{
"from": "draft",
"to": "active",
"via": "PATCH /admin/meter/pricing-catalogs/:catalog_id/status"
},
{
"from": "active",
"to": "retired",
"via": "PATCH /admin/meter/pricing-catalogs/:catalog_id/status"
}
]
}{
"catalog_id": "{{cache:meter-pricing-catalogs.create.response.data.catalog_id}}"
}{
"status": "active",
"operator_id": "{{var:operator_id}}"
}{
"status": "active",
"operator_id": "{{var:operator_id}}"
}{
"success": true,
"data": {
"statu_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"operator_id": "{{var:operator_id}}",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/onprem/installspublic
Registers a customer on-prem install (P8 Variant C) with its cluster name, Kubernetes distribution and installed version, returning 201 { success: true, data: <install> }. air_gap_mode (strict | diode-in | diode-bidi) and billing_mode (internal-report-only | flat-fee | per-incident) are optional and default inside registerOnpremInstall. QA edge cases: customer_id, cluster_name, k8s_distribution and installed_version are required with a falsy check (empty strings rejected); k8s_distribution, air_gap_mode and billing_mode are typed unions but are NOT runtime-validated by the route, so out-of-enum values reach the DB check constraints and return 500 rather than 400; the route is not idempotent — registering the same customer_id + cluster_name twice either creates a second install or raises a unique violation as a 500, since there is no conflict branch; installed_version is free text and not semver-validated.
k8s_distribution: vanilla, openshift, rancher, tanzuair_gap_mode: strict, diode-in, diode-bidibilling_mode: internal-report-only, flat-fee, per-incident| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | customer_id, cluster_name, k8s_distribution, installed_version required | any of customer_id, cluster_name, k8s_distribution or installed_version is missing, null, or an empty string |
| 500 | InternalServerError | <registration error message> | registerOnpremInstall() throws — k8s_distribution/air_gap_mode/billing_mode outside their allowed enums hitting a DB check constraint, a unique violation on a duplicate install, or Postgres unavailable |
{
"customer_id": "{{var:customer_id}}",
"cluster_name": "{{dynamic:name}}",
"k8s_distribution": "openshift",
"installed_version": "2026.3.0",
"air_gap_mode": "diode-in",
"billing_mode": "internal-report-only"
}{
"customer_id": "{{var:customer_id}}",
"cluster_name": "Acme QA Sample",
"k8s_distribution": "openshift",
"installed_version": "2026.3.0",
"air_gap_mode": "diode-in",
"billing_mode": "internal-report-only"
}{
"success": true,
"data": {
"install_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"customer_id": "{{var:customer_id}}",
"cluster_name": "Acme QA Sample",
"k8s_distribution": "openshift",
"installed_version": "2026.3.0",
"air_gap_mode": "diode-in",
"billing_mode": "internal-report-only",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/admin/onprem/installs/:install_idpublic
Reads a single on-prem install record by install_id, returning { success: true, data: <install> }. QA edge cases: an unknown install_id is a clean 404 "install not found" because getOnpremInstall() returning null is handled explicitly; the lookup is NOT wrapped in try/catch, so an install_id that fails a Postgres uuid cast (any non-UUID path segment, when the column is a UUID) escapes to the Fastify default 500 body ({ statusCode, error, message }) rather than a 400 or 404 — a common surprise in negative tests; the response is the install row only and does not include its bundles, local LLMs or billing reports, which have their own sub-resources; the route is cross-customer and ops-token gated, so no ownership check is applied to install_id.
[ "POST /admin/onprem/installs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 404 | NotFound | install not found | getOnpremInstall(install_id) returns null/undefined — no install row matches that install_id |
| 500 | InternalServerError | Internal Server Error | the lookup throws — most commonly install_id failing a uuid cast, or Postgres unavailable; unhandled by the route, so Fastify default error serialization applies |
{
"install_id": "{{cache:onprem.register-install.response.data.install_id}}"
}{
"success": true,
"data": {
"install_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "install not found"
}POST/admin/onprem/installs/:install_id/billing-reportspublic
Generates a billing report for an on-prem install over the [period_start, period_end] window and records the local artifact path where the report was written, returning 201 { success: true, data: <report> }. Air-gapped installs report billing by producing a local artifact rather than calling home, which is why the path is supplied by the operator. QA edge cases: period_start, period_end and artifact_local_path are all required with a falsy check; the dates are NOT parsed or ordered by the route, so an unparseable timestamp or a period_end before period_start is not a 400 — it either produces an empty report or fails the timestamptz cast as a 500; artifact_local_path is not existence- or writability-checked by the route, so a bad path surfaces as a 500 from the generator; an unknown install_id is an FK violation and therefore a 500, not a 404; regenerating the same period appends another report row rather than replacing.
[ "POST /admin/onprem/installs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | period_start, period_end, artifact_local_path required | any of period_start, period_end or artifact_local_path is missing, null, or an empty string |
| 500 | InternalServerError | <report error message> | generateOnpremBillingReport() throws — period_start/period_end not castable to a timestamp, artifact_local_path not writable, install_id not matching a registered install (FK violation), or Postgres unavailable |
{
"install_id": "{{cache:onprem.register-install.response.data.install_id}}"
}{
"period_start": "2026-04-01",
"period_end": "2026-06-30",
"artifact_local_path": "/var/onprem/reports/2026-q2.pdf"
}{
"period_start": "2026-04-01",
"period_end": "2026-06-30",
"artifact_local_path": "/var/onprem/reports/2026-q2.pdf"
}{
"success": true,
"data": {
"billing_report_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"period_start": "2026-04-01",
"period_end": "2026-06-30",
"artifact_local_path": "/var/onprem/reports/2026-q2.pdf",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/onprem/installs/:install_id/bundlespublic
Records that a signed release bundle was applied to an on-prem install, capturing bundle_version, whether its signature verified, and the list of migrations applied per SDK; returns 201 { success: true, data: <application record> }. QA edge cases: bundle_version is required and signature_verified must be a real boolean — the guard is typeof !== "boolean", so the strings "true"/"false" and the numbers 1/0 are all rejected as 400, which is the most common false failure when posting from a shell; signature_verified: false is a legitimate accepted value (it records a failed verification) and is NOT rejected; migrations_applied is optional and its [{ sdk, filename }] entries are not shape-validated; an unknown install_id is an FK violation and therefore a 500, not a 404; re-posting the same bundle_version has no conflict branch and may raise a unique violation as a 500.
[ "POST /admin/onprem/installs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | bundle_version + signature_verified required | bundle_version is missing/empty, or signature_verified is absent or not a JSON boolean (strings "true"/"false" and numbers are rejected) |
| 500 | InternalServerError | <apply error message> | applyOnpremBundle() throws — install_id not matching a registered install (FK violation), a duplicate bundle_version, malformed migrations_applied failing to store, or Postgres unavailable |
{
"install_id": "{{cache:onprem.register-install.response.data.install_id}}"
}{
"bundle_version": "2026.3.0",
"signature_verified": true,
"migrations_applied": [
{
"sdk": "sdk-identity",
"filename": "001_init_identity.sql"
},
{
"sdk": "sdk-onprem",
"filename": "001_init_onprem.sql"
}
]
}{
"bundle_version": "2026.3.0",
"signature_verified": true,
"migrations_applied": [
{
"sdk": "sdk-identity",
"filename": "001_init_identity.sql"
},
{
"sdk": "sdk-onprem",
"filename": "001_init_onprem.sql"
}
]
}{
"success": true,
"data": {
"bundle_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"bundle_version": "2026.3.0",
"signature_verified": true,
"migrations_applied": [
{
"sdk": "sdk-identity",
"filename": "001_init_identity.sql"
},
{
"sdk": "sdk-onprem",
"filename": "001_init_onprem.sql"
}
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/onprem/installs/:install_id/local-llmspublic
Registers a locally hosted LLM against an on-prem install — model id, serving backend (ollama | vllm | text-generation-inference), endpoint URL and quantization (fp16 | int8 | int4 | awq) — returning 201 { success: true, data: <model> }. status is optional (ready | loading | disabled) and defaults inside registerOnpremLocalLlm. QA edge cases: model_id, backend, endpoint_url and quantization are required with a falsy check; backend, quantization and status are typed unions but are NOT runtime-validated by the route, so out-of-enum values only fail at the DB check constraint and return 500 rather than 400; endpoint_url is not URL-validated and is never probed, so registering an unreachable endpoint succeeds; an unknown install_id is an FK violation and therefore a 500, not a 404; re-registering the same model_id on the same install has no conflict branch and may raise a unique violation as a 500.
[ "POST /admin/onprem/installs" ]
backend: ollama, vllm, text-generation-inferencequantization: fp16, int8, int4, awqstatus: ready, loading, disabled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | model_id, backend, endpoint_url, quantization required | any of model_id, backend, endpoint_url or quantization is missing, null, or an empty string |
| 500 | InternalServerError | <registration error message> | registerOnpremLocalLlm() throws — backend/quantization/status outside their allowed enums hitting a DB check constraint, install_id not matching a registered install (FK violation), a duplicate model_id, or Postgres unavailable |
{
"install_id": "{{cache:onprem.register-install.response.data.install_id}}"
}{
"model_id": "llama-3.1-8b-instruct",
"backend": "vllm",
"endpoint_url": "http://vllm.onprem.svc.cluster.local:8000/v1",
"quantization": "int8",
"status": "loading"
}{
"model_id": "llama-3.1-8b-instruct",
"backend": "vllm",
"endpoint_url": "http://vllm.onprem.svc.cluster.local:8000/v1",
"quantization": "int8",
"status": "loading"
}{
"success": true,
"data": {
"local_llm_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"model_id": "llama-3.1-8b-instruct",
"backend": "vllm",
"endpoint_url": "http://vllm.onprem.svc.cluster.local:8000/v1",
"quantization": "int8",
"status": "loading",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/admin/poolspublic
Lists every routing pool for the projexcloud-admin /pools page — pool_index, region, isolation_class, status, replication_role, replicates_from_pool_index and timestamps — ordered by region then pool_index, as { success: true, data: rows }. QA edge cases: no filtering, no pagination and no LIMIT, so the whole routing.pool table comes back and response size grows with the fleet; pools in every status (ACTIVE, MIGRATING, DRAINING, MAINTENANCE, RETIRED, QUARANTINE) are returned together with no status filter, so tests expecting only live pools must filter client-side; an empty table is a 200 with data: [], never 404; the route is ops-token gated rather than JWT gated and is fully cross-tenant.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 500 | InternalServerError | <database error message> | the SELECT against routing.pool throws — Postgres unavailable, pool exhausted, or the routing schema missing |
{
"success": true,
"data": [
{
"pool_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/pools🔒 auth
Registers a routing.pool row (Pool Registry, P1 §8.1) — the create producer for the previously read-only /admin/pools surface, so tests and operators provision pools through the API instead of a SQL fixture. Admin-ops-token gated (x-admin-ops-token, non-JWT). Idempotent on pool_index (ON CONFLICT DO UPDATE). Edge cases: pool_index/pool_family/region/primary_endpoint are required; pool_family must be admin|app|evidence|warehouse|vector; app_id is required only when pool_family='app' (ignored otherwise, per the routing.pool CHECK constraints); status must be a valid pool status; isolation_class is shared|dedicated.
pool_family: admin, app, evidence, warehouse, vectorstatus: ACTIVE, MIGRATING, DRAINING, MAINTENANCE, RETIRED, QUARANTINEisolation_class: shared, dedicated| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | pool_index, pool_family, region, primary_endpoint are required | any of the four required fields is missing |
| 400 | ValidationError | invalid pool_family | pool_family is not in admin|app|evidence|warehouse|vector |
| 400 | ValidationError | app_id is required when pool_family='app' | pool_family='app' but no app_id |
| 401 | Unauthorized | admin token required | missing or invalid x-admin-ops-token (requireAdmin) |
{
"pool_index": "qa-pool-{{dynamic:slug}}",
"pool_family": "admin",
"region": "us-east-1",
"primary_endpoint": "https://qa-pool.internal",
"status": "ACTIVE",
"capacity_tenants": 100,
"capacity_bytes": 1000000000,
"isolation_class": "shared"
}{
"pool_index": "qa-pool-{{dynamic:slug}}",
"pool_family": "admin",
"region": "us-east-1",
"primary_endpoint": "https://qa-pool.internal",
"status": "ACTIVE",
"capacity_tenants": 100,
"capacity_bytes": 1000000000,
"isolation_class": "shared"
}{
"success": true,
"data": {
"pool_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"pool_index": "qa-pool-{{dynamic:slug}}",
"pool_family": "admin",
"region": "us-east-1",
"primary_endpoint": "https://qa-pool.internal",
"status": "ACTIVE",
"capacity_tenants": 100,
"capacity_bytes": 1000000000,
"isolation_class": "shared",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"pool_index": "string",
"pool_family": "string",
"region": "string",
"status": "string",
"primary_endpoint": "string",
"isolation_class": "string"
}
}GET/admin/pools/:pool_indexpublic
Returns one routing pool plus its derived tenant_count and the 25 most recent lifecycle events, as { success: true, data: { pool, tenant_count, lifecycle_history } }. tenant_count counts rows in routing.tenant_pool_map where the pool is the admin or evidence pool, or where the pool_index appears inside app_pool_index — note that last clause is a LIKE substring match, so a short pool_index can over-count by matching longer indexes that contain it. QA edge cases: an unknown pool_index is a clean 404 "pool not found" checked before the count and history queries run; lifecycle_history is capped at 25 rows newest-first with no way to page further back; a pool with no lifecycle events returns an empty array, not a 404; pool_index is a text column so any string is a valid lookup and simply misses rather than erroring.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 404 | NotFound | pool not found | no row in routing.pool matches the pool_index path param |
| 500 | InternalServerError | <database error message> | any of the pool, tenant-count or lifecycle-history queries throws — Postgres unavailable or the routing schema missing |
{
"pool_index": "{{var:pool_index}}"
}{
"success": true,
"data": {
"pool_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "pool not found"
}PATCH/admin/pools/:pool_index/statuspublic
Transitions a routing pool to a new lifecycle status, recording from_status (read live from routing.pool), to_status, the mandatory reason and operator_id, and broadcasting a pool-flip to route caches. Returns a bare { success: true } with no data. to_status is upper-cased before use, so "draining" and "DRAINING" both work. QA edge cases: to_status, reason and operator_id are all required with a falsy check; the pool is verified to exist first, so an unknown pool_index is a clean 404; there is NO state-machine validation — any status is accepted from any status, including RETIRED → ACTIVE — but a to_status outside ACTIVE|MIGRATING|DRAINING|MAINTENANCE|RETIRED|QUARANTINE is caught only by the DB check constraint and returns 500 rather than 400; re-applying the pool's current status succeeds and still appends a lifecycle event, so the event log is not deduplicated.
to_status: ACTIVE, MIGRATING, DRAINING, MAINTENANCE, RETIRED, QUARANTINE| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | to_status + reason + operator_id required | any of to_status, reason or operator_id is missing, null, or an empty string |
| 404 | NotFound | pool not found | no row in routing.pool matches pool_index — checked after validation and before the transition is recorded |
| 500 | InternalServerError | <database error message> | recordPoolTransition() throws — to_status (upper-cased) outside the allowed pool-status enum and rejected by the DB check constraint, the lifecycle-event insert failing, the sdk-pool-router dynamic import failing, or Postgres unavailable |
{
"entity": "pool",
"field": "status",
"flow": [
"ACTIVE",
"MAINTENANCE",
"DRAINING",
"RETIRED"
],
"transitions": [
{
"from": "ACTIVE",
"to": "MAINTENANCE",
"via": "PATCH /admin/pools/:pool_index/status"
},
{
"from": "MAINTENANCE",
"to": "ACTIVE",
"via": "PATCH /admin/pools/:pool_index/status"
},
{
"from": "ACTIVE",
"to": "DRAINING",
"via": "PATCH /admin/pools/:pool_index/status"
},
{
"from": "DRAINING",
"to": "RETIRED",
"via": "PATCH /admin/pools/:pool_index/status"
}
]
}{
"pool_index": "{{var:pool_index}}"
}{
"to_status": "MAINTENANCE",
"reason": "Scheduled maintenance window for QA",
"operator_id": "{{var:operator_id}}"
}{
"to_status": "MAINTENANCE",
"reason": "Scheduled maintenance window for QA",
"operator_id": "{{var:operator_id}}"
}{
"success": true,
"data": {
"statu_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"to_status": "MAINTENANCE",
"reason": "Scheduled maintenance window for QA",
"operator_id": "{{var:operator_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/admin/security/ops-tokenspublic
Lists admin ops token metadata — id, label, expiry, creation and revocation bookkeeping — and NEVER returns secrets or hashes usable to reconstruct a token, since only SHA-256 hashes are stored. Returns { success: true, data: [...] }. QA edge cases: this is the only way to recover a token's id for revocation, because the plaintext is shown exactly once at mint time; the env ADMIN_OPS_TOKEN break-glass secret is NOT a DB row and therefore never appears in this list, so an empty list does not mean admin access is impossible; the listing has no filtering or pagination and typically includes expired and revoked tokens alongside active ones, so callers must filter on expiry/revocation themselves rather than assuming everything listed still authenticates.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 500 | InternalServerError | <listing error message> | listOpsTokens() throws — Postgres unavailable or the admin.ops_token table missing |
{
"success": true,
"data": [
{
"ops_token_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": "array"
}POST/admin/security/ops-tokenspublic
Mints a new DB-backed admin ops token so an operator can grant scoped, optionally time-boxed access (e.g. a short-lived QA token) without rotating the shared env secret or redeploying the gateway. Returns 201 with the issued record — the plaintext token is returned EXACTLY ONCE and only its SHA-256 hash is stored, so it is unrecoverable afterwards. On success the local cache is wiped and a Redis invalidation is broadcast to peer replicas, and a security.admin_ops_token.issued.v1 audit event is emitted. QA edge cases: label is the only required field (trimmed, so whitespace-only is rejected as 400); ttl_seconds is optional and null means a non-expiring token — it is not range-checked, so 0 or a negative value is accepted and yields an already-expired token; the route is itself admin-gated, so minting requires an existing valid token (env break-glass or another DB token); labels are not unique, so duplicate labels are allowed.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 400 | ValidationError | label is required | the label field is missing, null, empty, or whitespace-only (it is trimmed before the check) |
| 500 | InternalServerError | <issue error message> | issueOpsToken() throws, or the post-issue cache invalidation/audit-event emission fails — e.g. Postgres unavailable or the admin.ops_token insert failing |
{
"label": "{{dynamic:name}}",
"ttl_seconds": 3600,
"reason": "automated qa mint",
"created_by": "api-test-runner"
}{
"label": "Acme QA Sample",
"ttl_seconds": 3600,
"reason": "automated qa mint",
"created_by": "api-test-runner"
}{
"success": true,
"data": {
"ops_token_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"label": "Acme QA Sample",
"ttl_seconds": 3600,
"reason": "automated qa mint",
"created_by": "api-test-runner",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"id": "string",
"label": "string",
"token": "string",
"expires_at": "string"
}
}DELETE/admin/security/ops-tokens/:idpublic
Revokes a DB-backed admin ops token immediately, returning { success: true, data: { id, status: "revoked" } }. On success it wipes the local token cache, broadcasts a Redis invalidation so peer replicas drop the token too, and emits a security.admin_ops_token.revoked.v1 audit event. QA edge cases: revocation is NOT instantaneous fleet-wide — a replica that misses the Redis broadcast keeps honouring the token until its cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s), so a negative test immediately after revoking can still see 200s; revoking an unknown id, or one already revoked, returns 404 "token not found or already revoked", making the operation non-idempotent in its status code even though the end state is the same; the env ADMIN_OPS_TOKEN break-glass secret is not a DB row and CANNOT be revoked here — that is deliberate, so operators can never lock themselves out.
[ "POST /admin/security/ops-tokens" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 404 | NotFound | token not found or already revoked | revokeOpsToken(id) returns falsy — no admin.ops_token row with that id, or it was already revoked |
| 500 | InternalServerError | <revoke error message> | revokeOpsToken() throws, or the post-revoke cache invalidation/audit-event emission fails — e.g. Postgres unavailable |
{
"entity": "ops_token",
"field": "status",
"flow": [
"active",
"revoked"
],
"transitions": [
{
"from": "active",
"to": "revoked",
"via": "DELETE /admin/security/ops-tokens/:id"
}
]
}{
"id": "{{cache:ops-tokens.create.response.data.id}}"
}{
"success": true
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"id": "string",
"status": "string"
}
}POST/admin/security/rotate-signing-keypublic
P6A emergency rotation of the principal-token signing key, performed immediately rather than on the scheduled interval and always flagged emergency: true. The retired key is kept for a TTL-overlap window so short-lived tokens already in flight stay verifiable, and the rotation emits an audited event. Returns 200 { success: true, data: <rotation result> }. QA edge cases: BOTH body fields are optional with defaults — reason falls back to "manual emergency rotation" and actor_id to "ops-emergency" — so an empty body {} is a valid successful request and there is NO 400 branch on this route at all; whitespace-only values are trimmed to empty and therefore also fall back to the defaults; the call is genuinely state-changing and not idempotent (each invocation rotates again), so repeated calls in a shared environment churn the key and shorten the overlap window for previously issued tokens.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 500 | InternalServerError | <rotation error message> | signingKeyRotationHandle.rotateNow() throws — the vault-sourced PRINCIPAL_TOKEN_WRAP_KEY being unavailable, the key-material write failing, or the audit event emission failing |
{
"reason": "QA emergency rotation drill",
"actor_id": "{{var:operator_id}}"
}{
"reason": "QA emergency rotation drill",
"actor_id": "{{var:operator_id}}"
}{
"success": true,
"data": {
"rotate_signing_key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "QA emergency rotation drill",
"actor_id": "{{var:operator_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"rotated_at": "string",
"fingerprint_current": "string",
"fingerprint_previous": "string",
"grace_window_ms": "number",
"emergency": "boolean"
}
}POST/admin/sovereign/bundles/:release_id/appliedpublic
Marks a previously shipped sovereign bundle release as applied in the target region, closing the ship→apply loop, and returns { success: true, data: <release> }. Takes no request body — the release_id path param is the entire input. QA edge cases: an unknown release_id is a clean 404 "release not found" because markSovereignBundleApplied() returning null is handled explicitly; release_id is not format-validated, so arbitrary strings are looked up and simply miss (or fail a uuid cast and become a 500 if the column is a UUID); re-marking an already-applied release depends on whether markSovereignBundleApplied still returns the row — assert the returned status rather than assuming clean idempotency; sending a body is harmless and ignored.
[ "POST /admin/sovereign/regions", "POST /admin/sovereign/regions/:region_id/bundles" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 404 | NotFound | release not found | markSovereignBundleApplied(release_id) returns null/undefined — no release row matches that release_id |
| 500 | InternalServerError | <update error message> | markSovereignBundleApplied() throws — release_id failing a uuid cast, or Postgres unavailable |
{
"release_id": "{{cache:sovereign.ship-bundle.response.data.release_id}}"
}{}{
"success": true,
"data": {
"applied_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/admin/sovereign/regionspublic
Lists every registered sovereign-cloud region (P8 Variant B) as { success: true, data: [...] }, covering all regimes (fedramp-high, il5, pipl, eu-sovereign, uae-trd). Read-only operator surface with no filtering, no pagination and no limit. QA edge cases: listSovereignRegions() is NOT wrapped in try/catch, so a DB failure escapes to the Fastify default error handler and returns the framework 500 body ({ statusCode, error, message }) rather than the { success: false, error } envelope the POST sibling uses — assert the shape accordingly; an empty registry is a 200 with an empty array, never a 404; the route is ops-token gated only, so a valid tenant JWT without x-admin-ops-token is 401.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 500 | InternalServerError | Internal Server Error | listSovereignRegions() throws (Postgres unavailable or missing sovereign schema) — unhandled by the route, so Fastify default error serialization applies |
{
"success": true,
"data": [
{
"region_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/sovereign/regionspublic
Registers a sovereign-cloud region under a compliance regime with its operator partner and KMS provider, returning 200 { success: true, data: <region> }. terminal_federation is optional and marks a region that may not federate onward. QA edge cases: region_id, regime, operator_partner, kms_provider and operator_id are required by a falsy check (empty strings rejected); regime is typed as fedramp-high|il5|pipl|eu-sovereign|uae-trd but is NOT validated at runtime by the route, so an out-of-enum regime is only caught by the DB check constraint and comes back as a 500 rather than a 400; re-posting an existing region_id has no conflict branch, so it either updates or raises a unique violation as a 500 — never a 409; terminal_federation is optional and defaults inside registerSovereignRegion, so omitting it does not clear a previously set value in a predictable way.
regime: fedramp-high, il5, pipl, eu-sovereign, uae-trd| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | region_id, regime, operator_partner, kms_provider, operator_id required | any of region_id, regime, operator_partner, kms_provider or operator_id is missing, null, or an empty string |
| 500 | InternalServerError | <registration error message> | registerSovereignRegion() throws — regime outside the allowed enum hitting a DB check constraint, a unique violation on an already-registered region_id, or Postgres unavailable |
{
"region_id": "reg-eu-sov-fra",
"regime": "eu-sovereign",
"operator_partner": "T-Systems Sovereign Cloud",
"terminal_federation": true,
"kms_provider": "aws-cloudhsm-eu-central-1",
"operator_id": "{{var:operator_id}}"
}{
"region_id": "reg-eu-sov-fra",
"regime": "eu-sovereign",
"operator_partner": "T-Systems Sovereign Cloud",
"terminal_federation": true,
"kms_provider": "aws-cloudhsm-eu-central-1",
"operator_id": "{{var:operator_id}}"
}{
"success": true,
"data": {
"region_id": "reg-eu-sov-fra",
"regime": "eu-sovereign",
"operator_partner": "T-Systems Sovereign Cloud",
"terminal_federation": true,
"kms_provider": "aws-cloudhsm-eu-central-1",
"operator_id": "{{var:operator_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/sovereign/regions/:region_id/attestationspublic
Records a compliance attestation for a sovereign region — the regime attested to, the auditor, its validity window and the artifact reference — and returns 201 { success: true, data: <attestation> }. QA edge cases: regime, auditor_id, issued_at, expires_at and artifact_ref are all required with a falsy check; issued_at/expires_at are passed through as strings with NO date parsing or ordering check in the route, so an unparseable timestamp or an expires_at earlier than issued_at is not a 400 — it either lands in the DB as-is or fails the timestamptz cast as a 500; regime is typed but not runtime-validated, so an out-of-enum regime surfaces from the DB check constraint as a 500; an unknown region_id is an FK violation and therefore a 500, not a 404; attestations accumulate — posting twice records two rows rather than replacing.
[ "POST /admin/sovereign/regions" ]
regime: fedramp-high, il5, pipl, eu-sovereign, uae-trd| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | regime, auditor_id, issued_at, expires_at, artifact_ref required | any of regime, auditor_id, issued_at, expires_at or artifact_ref is missing, null, or an empty string |
| 500 | InternalServerError | <attestation error message> | recordSovereignAttestation() throws — issued_at/expires_at not castable to timestamptz, regime outside the allowed enum, region_id not matching a registered region (FK violation), or Postgres unavailable |
{
"entity": "sovereign.region_config",
"field": "attestation_state",
"flow": [
"in-progress",
"attested",
"expired"
],
"transitions": [
{
"from": "in-progress",
"to": "attested",
"via": "POST /admin/sovereign/regions/:region_id/attestations"
}
]
}{
"region_id": "{{cache:sovereign.register.response.data.region_id}}"
}{
"regime": "eu-sovereign",
"auditor_id": "{{var:auditor_id}}",
"issued_at": "{{dynamic:pastdatetime}}",
"expires_at": "{{dynamic:futuredatetime}}",
"artifact_ref": "s3://sovereign-attestations/eu-sov-fra-2026Q3.pdf"
}{
"regime": "eu-sovereign",
"auditor_id": "{{var:auditor_id}}",
"issued_at": "2026-01-15T10:30:00Z",
"expires_at": "2026-01-15T10:30:00Z",
"artifact_ref": "s3://sovereign-attestations/eu-sov-fra-2026Q3.pdf"
}{
"success": true,
"data": {
"attestation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"regime": "eu-sovereign",
"auditor_id": "{{var:auditor_id}}",
"issued_at": "2026-01-15T10:30:00Z",
"expires_at": "2026-01-15T10:30:00Z",
"artifact_ref": "s3://sovereign-attestations/eu-sov-fra-2026Q3.pdf",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/sovereign/regions/:region_id/bundlespublic
Ships a signed release bundle to a sovereign region, recording the version, the artifact reference and the detached signature, and returns 201 { success: true, data: <release> }. signature_hex is decoded with Buffer.from(hex, "hex") before being handed to shipSovereignBundle. QA edge cases: version, bundle_artifact_ref and signature_hex are all required with a falsy check; signature_hex is NOT validated as hex — Buffer.from silently truncates at the first invalid character and an odd-length or non-hex string yields a short/empty buffer that fails signature verification downstream as a 500, not a 400, so a malformed signature looks like a server error; an unknown region_id in the path is a foreign-key violation surfacing as a 500 rather than a 404; re-shipping the same version has no conflict branch and will typically raise a unique violation as a 500.
[ "POST /admin/sovereign/regions" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | version, bundle_artifact_ref, signature_hex required | any of version, bundle_artifact_ref or signature_hex is missing, null, or an empty string |
| 500 | InternalServerError | <ship error message> | shipSovereignBundle() throws — signature verification failing (including a non-hex or odd-length signature_hex silently truncated by Buffer.from), region_id not matching a registered region (FK violation), a duplicate version, or Postgres unavailable |
{
"region_id": "{{cache:sovereign.register.response.data.region_id}}"
}{
"version": "2026.3.0-{{dynamic:slug}}",
"bundle_artifact_ref": "oci://registry.projexlight.com/sovereign/eu-sov-fra:2026.3.0",
"signature_hex": "deadbeefcafef00dba5eba11c0ffee00"
}{
"version": "2026.3.0-{{dynamic:slug}}",
"bundle_artifact_ref": "oci://registry.projexlight.com/sovereign/eu-sov-fra:2026.3.0",
"signature_hex": "deadbeefcafef00dba5eba11c0ffee00"
}{
"success": true,
"data": {
"bundle_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"version": "2026.3.0-{{dynamic:slug}}",
"bundle_artifact_ref": "oci://registry.projexlight.com/sovereign/eu-sov-fra:2026.3.0",
"signature_hex": "deadbeefcafef00dba5eba11c0ffee00",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/sovereign/regions/:region_id/leakspublic
Ingests a data-sovereignty leak alert for a region — an egress attempt, a cross-region route, or a policy violation — with a severity and an optional incident_ref, returning 201 { success: true, data: <alert> }. QA edge cases: only kind and severity are required (falsy check); incident_ref is optional and explicitly allowed to be null; neither kind nor severity is validated against its enum in the route despite the TypeScript union, so an out-of-enum kind or severity is caught only by the DB check constraint and returns 500 rather than 400; an unknown region_id is an FK violation and therefore a 500, not a 404; every call appends a new alert row — there is no dedup on (region, kind, incident_ref), so replaying the same alert produces duplicates.
[ "POST /admin/sovereign/regions" ]
kind: egress-attempt, cross-region-route, policy-violationseverity: info, warn, critical| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 400 | ValidationError | kind + severity required | kind or severity is missing, null, or an empty string in the body |
| 500 | InternalServerError | <ingest error message> | ingestSovereignLeakAlert() throws — kind or severity outside the allowed enum hitting a DB check constraint, region_id not matching a registered region (FK violation), or Postgres unavailable |
{
"region_id": "{{cache:sovereign.register.response.data.region_id}}"
}{
"kind": "egress-attempt",
"severity": "critical",
"incident_ref": "INC-2026-0714-eu-sov-fra"
}{
"kind": "egress-attempt",
"severity": "critical",
"incident_ref": "INC-2026-0714-eu-sov-fra"
}{
"success": true,
"data": {
"leak_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "egress-attempt",
"severity": "critical",
"incident_ref": "INC-2026-0714-eu-sov-fra",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/storm/ingest-nowpublic
Forces an immediate out-of-band run of the storm-overlay ingestor instead of waiting for its hourly cadence, walking the provider fallback chain (NOAA → DTN → Weather Underground → synthetic) and upserting storm.event / storm.intensity_cell rows for the trailing window. lookback_hours defaults to 24 and defines the [now - lookback_hours, now] range. QA edge cases: no validation on lookback_hours — a negative value inverts the window (since > until) and simply ingests nothing, a huge value asks the upstream provider for a very wide range and can time out into a 500; the upsert is idempotent so re-running the same window does not duplicate rows; success returns HTTP 200 with { success: true, data: <ingest result> }, and a provider outage is NOT a 4xx because the synthetic provider is the last fallback.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 500 | InternalServerError | <ingest error message> | ingestStormOnce() throws — every provider in the fallback chain failed, the upstream call timed out, or the storm.* upsert failed |
{
"lookback_hours": 1
}{
"lookback_hours": 1
}{
"success": true,
"data": {
"ingest_now_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"lookback_hours": 1,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"success": "boolean",
"data": {
"events_from": "string",
"events_ingested": "number",
"cells_ingested": "number",
"providers_tried": "array"
}
}GET/admin/tenantspublic
Operator listing of platform tenants. Returns at most 200 tenants (hard-coded tenantList(200) cap) as { data: { tenants } } — there is no pagination, no cursor and no filtering, so on a platform with more than 200 tenants the tail is silently invisible. QA edge cases: this is a cross-tenant operator surface, so it is gated only by the x-admin-ops-token header and is NOT tenant-scoped by a JWT; a request with a valid tenant JWT but no ops token still gets 401. Extra query params are ignored rather than rejected. Revoking the ops token used here keeps working for up to the 30s admin-ops cache TTL on replicas that did not receive the Redis invalidation broadcast.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 500 | InternalServerError | <database error message> | tenantList() throws — e.g. Postgres unavailable, pool exhausted, or the tenant table is missing; the raw driver message is echoed back as { error } |
{
"success": true,
"data": [
{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"tenants": "array"
}
}POST/admin/tenantspublic
Provisions a new tenant under an already-existing app and returns 201 { data: { tenant } }. app_id, display_name and region are mandatory; isolation_tier defaults to "S" and module_subscriptions defaults to []. QA edge cases: this is NOT idempotent — calling it twice creates two tenants; app_id must reference a row created by POST /admin/apps first or the tenant_app_id_fkey foreign key fires and the handler translates any error message containing "foreign key" into a 400 ValidationError rather than a 500; empty-string values for the three required fields are falsy and rejected as missing; isolation_tier outside S|P|G is not validated in the handler and reaches the DB check constraint (500); the route is ops-token gated, not JWT gated, so tenant callers cannot self-provision.
[ "POST /admin/apps" ]
isolation_tier: S, P, G| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 400 | ValidationError | app_id, display_name, region are required | any of app_id, display_name or region is missing, null, or an empty string (falsy check on the parsed body; a missing body is treated as {}) |
| 400 | ValidationError | <foreign key violation message> | tenantCreate() fails and the error message contains "foreign key" — typically app_id does not exist in the app table (tenant_app_id_fkey) |
| 500 | InternalServerError | <database error message> | tenantCreate() throws for any reason that is not a foreign-key violation — e.g. isolation_tier/region check-constraint violation, unique violation, or Postgres unavailable |
{
"app_id": "{{cache:apps.create.response.data.app_id}}",
"display_name": "QA Tenant",
"region": "us-east-1",
"isolation_tier": "S",
"brand_domain": "qa-tenant.example.com",
"module_subscriptions": [
"sdk-billing",
"sdk-analytics"
]
}{
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"display_name": "QA Tenant",
"region": "us-east-1",
"isolation_tier": "S",
"brand_domain": "qa-tenant.example.com",
"module_subscriptions": [
"sdk-billing",
"sdk-analytics"
]
}{
"success": true,
"data": {
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"display_name": "QA Tenant",
"region": "us-east-1",
"isolation_tier": "S",
"brand_domain": "qa-tenant.example.com",
"module_subscriptions": [
"sdk-billing",
"sdk-analytics"
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"tenant": {
"tenant_id": "string",
"app_id": "string",
"display_name": "string",
"region": "string",
"isolation_tier": "string",
"status": "string"
}
}
}POST/admin/tenants/:tenant_id/pool-map🔒 auth
Creates/updates a routing.tenant_pool_map row assigning a tenant to pools (P1 §8.1) — producer for the tenant->pool mapping so GET /api/router/resolve resolves without a SQL fixture. Admin-ops-token gated. Idempotent on tenant_id (ON CONFLICT DO UPDATE). admin_pool_index (and evidence_pool_index, if set) must reference an existing routing.pool (a bad FK returns 400, not 500). app_pool_index is a jsonb map of app_id->pool_index. Edge cases: :tenant_id must be a UUID; admin_pool_index + region are required; status ACTIVE|MIGRATING|QUARANTINED.
[ "POST /api/auth/signup-tenant", "POST /admin/pools" ]
status: ACTIVE, MIGRATING, QUARANTINED| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id must be a UUID | the :tenant_id path param is not a valid UUID |
| 400 | ValidationError | admin_pool_index and region are required | either required body field is missing |
| 400 | ForeignKeyViolation | violates foreign key constraint | admin_pool_index (or evidence_pool_index) does not reference an existing routing.pool |
| 401 | Unauthorized | admin token required | missing or invalid x-admin-ops-token (requireAdmin) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"admin_pool_index": "{{cache:admin.pools.create.response.data.pool_index}}",
"app_pool_index": {},
"region": "us-east-1",
"status": "ACTIVE"
}{
"admin_pool_index": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_pool_index": {},
"region": "us-east-1",
"status": "ACTIVE"
}{
"success": true,
"data": {
"pool_map_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"admin_pool_index": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_pool_index": {},
"region": "us-east-1",
"status": "ACTIVE",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"tenant_id": "string",
"admin_pool_index": "string",
"region": "string",
"status": "string"
}
}GET/admin/webhookspublic
Operator cross-tenant listing of webhook endpoints — endpoint_id, tenant_id, url, status, failure_streak, last_success_at, last_failure_at, created_at — newest-first and capped at 200 rows, with an optional tenant_id query filter. QA edge cases: omitting tenant_id returns endpoints across ALL tenants, which is the point of this route; the 200-row cap has no pagination or cursor, so the tail is invisible on a busy platform; a tenant_id that is not a valid UUID fails the ::uuid cast and returns 500 with the raw driver message rather than a 400; a tenant_id that is valid but unknown is a 200 with an empty array, never 404; disabled and healthy endpoints are returned together with no status filter, and failure_streak is the field to watch for endpoints heading toward the DLQ.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 500 | InternalServerError | <database error message> | the SELECT against webhook.endpoint throws — the tenant_id query param not castable to uuid, or Postgres unavailable |
{
"success": true,
"data": [
{
"webhook_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/admin/webhooks/dlqpublic
Operator cross-tenant dead-letter queue view: deliveries in status "dlq" whose dlq_until is NULL or still in the future, joined to their subscription and endpoint to expose delivery_id, endpoint_id, event_type, attempts, failed_at and tenant_id. Ordered by last_attempt_at descending (NULLS LAST) and capped at 100 rows. QA edge cases: entries whose dlq_until has already passed are filtered OUT of this list — a delivery can therefore vanish from the DLQ view purely by aging, and replaying it afterwards returns 409 from the replay route, so QA should read this list and replay promptly; the query is deliberately not tenant-filtered, so it spans all tenants; the 100-row cap has no pagination; an empty DLQ is a 200 with data: [], never a 404.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 500 | InternalServerError | <database error message> | the DLQ join across webhook.delivery/subscription/endpoint throws — Postgres unavailable or the webhook schema missing |
{
"success": true,
"data": [
{
"dlq_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/webhooks/dlq/:delivery_id/replaypublic
Re-enqueues a dead-lettered webhook delivery for another attempt, returning { success: true, data: <replay result> }. Takes no request body — the delivery_id path param is the whole input. QA edge cases: two distinct typed failures are mapped explicitly — DeliveryNotInDlqError becomes 404 (the delivery does not exist, or exists but is not in dlq status, so a successfully delivered id also 404s) and DlqWindowExpiredError becomes 409 (the delivery is in the DLQ but its dlq_until retention window has passed, making it permanently unreplayable). That 409 is the key edge case: an entry can be visible in GET /admin/webhooks/dlq at one moment and 409 shortly after, since the list filters on the same dlq_until. Replay is not idempotent — a successful replay moves the delivery out of dlq, so an immediate second call returns 404.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash (30s cache TTL applies to revocations) |
| 404 | DeliveryNotInDlq | <DeliveryNotInDlqError message> | replayDelivery() throws DeliveryNotInDlqError — no delivery with that delivery_id, or it exists but is not in status "dlq" (already delivered, still retrying, or already replayed) |
| 409 | DlqWindowExpired | <DlqWindowExpiredError message> | replayDelivery() throws DlqWindowExpiredError — the delivery is in the DLQ but its dlq_until retention window has already elapsed, so it can no longer be replayed |
| 500 | InternalServerError | <replay error message> | replayDelivery() throws anything else, or the dynamic import of @projexlight/sdk-webhook fails — e.g. Postgres unavailable or the re-enqueue write failing |
{
"entity": "webhook.delivery",
"field": "status",
"flow": [
"pending",
"dlq",
"pending"
],
"transitions": [
{
"from": "dlq",
"to": "pending",
"via": "POST /admin/webhooks/dlq/:delivery_id/replay"
}
]
}{
"delivery_id": "{{var:dlq_delivery_id}}"
}{}{
"success": true,
"data": {
"status": "completed",
"replay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/api/admin/asset/rollup/backfillpublic
Operator-triggered sensor rollup backfill (P12·E1) that recomputes the 1-minute and 1-hour ClickHouse rollups for a trailing window, off the hot path. The window is [from, to]; when omitted, to defaults to now and from to now - lookback_hours (default 24). The recompute is delete-then-reinsert, so it is idempotent and safe to re-run over the same range. QA edge cases: the route hard-requires ClickHouse — with config.clickhouse.enabled false every authenticated call returns 409 before any work happens, which is the single most common false failure in a Postgres-only test environment; from/to are passed to new Date() with no validation, so an unparseable string yields "Invalid Date" and surfaces as a 500 from the query rather than a 400; from later than to produces an empty window and a zero-count success; very wide windows are not capped and can exhaust the ClickHouse container.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 409 | Conflict | ClickHouse not enabled | config.clickhouse.enabled is false — the deployment has no ClickHouse, checked immediately after auth and before the body is read |
| 500 | InternalServerError | <rollup error message> | runSensorRollup() throws — unparseable from/to producing an Invalid Date, ClickHouse unreachable, or the delete/insert of the rollup tables failing |
{
"lookback_hours": 24,
"from": "{{dynamic:pastdatetime}}",
"to": "{{dynamic:futuredatetime}}"
}{
"lookback_hours": 24,
"from": "2026-01-15T10:30:00Z",
"to": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"backfill_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"lookback_hours": 24,
"from": "2026-01-15T10:30:00Z",
"to": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/healthpublic
Liveness probe for the api-gateway. Returns 200 {status:'ok', service:<config.appName>, timestamp:<ISO 8601>} from a handler that only reads process config and the clock — it performs no database, Redis or ClickHouse check, so it stays 200 even when a downstream dependency is degraded (it is a liveness, not a readiness, signal). QA edge cases: '/health' is on the authGate.ts PUBLIC_EXACT allowlist (and the isHealth() suffix rule additionally exempts every '*/health' path), so it is intentionally unauthenticated — sending no token, a garbage token or an expired token all still return 200, and asserting a 401 here would be wrong. There are no path params, query params or body, and unknown query strings are ignored rather than rejected, so there is no 400 branch; the response is fully idempotent apart from `timestamp`, which advances on every call. Only GET is routed — other verbs fall through to Fastify's default 404 for the method/URL pair. Not tenant-scoped and not paginated. The handler contains no throw and no reply.code() other than the implicit 200, so it has no client-error surface at all.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | Not Found | Route <METHOD>:/health not found | A verb other than GET is used — Fastify's default not-found handler responds; the handler itself has no error path and cannot return 4xx or 5xx |
{
"success": true,
"data": [
{
"health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}connector-twilio-voice
GET/api/voice/calls🔒 auth
List the tenant's call legs, newest first, optionally filtered by status, direction, or is_voicemail. is_voicemail=true isolates the calls that answering-machine detection classified as reaching voicemail rather than a person. Edge cases: 400 when the tenant_id query param is missing; a tenant with no calls returns an empty array rather than 404; is_voicemail is only applied when the param is present (absent means 'either'), and any value other than the literal 'true' reads as false; limit defaults to 50 and offset to 0.
[ "POST /api/auth/signup-tenant", "POST /api/voice/tracking-numbers", "POST /api/voice/calls" ]
status: queued, initiated, ringing, in-progress, completed, busy, no-answer, canceled, faileddirection: inbound, outbound| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
| 401 | Unauthorized | Unauthorized | Authorization bearer token missing or invalid |
{
"success": true,
"data": [
{
"call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"calls": [
{
"voice_call_id": "string",
"external_id": "string",
"status": "string"
}
]
}
}POST/api/voice/calls🔒 auth
Place an outbound call. The request always sets a statusCallback URL and, by default, requests recording plus answering-machine detection (AMD) — recording because the SOP requires it, AMD because it is what lets the status webhook tell a human answer from a voicemail. RECORDING CONSENT GATE (TK-3654): when record is requested the call is checked against sdk-consent FIRST, and recording is withheld AT SOURCE — Twilio is never asked to record — unless there is an affirmative grant. Denial and the absence of any decision both withhold (recording_withheld_reason = consent_denied / consent_unknown), so an unconfigured environment never silently records anyone; the decision is persisted on the row as recording_consent (true/false/null) with its receipt id. Response field payload.record therefore reflects what was ACTUALLY requested upstream, which may be false even when the caller sent record:true. A mirror row is written in status 'queued' before Twilio's callbacks begin arriving; the status/recording webhooks then complete it. Caller-id resolution order: explicit from_number, else the referenced tracking_number_id (must be active), else the tenant's most recently provisioned ACTIVE tracking number. Edge cases: 400 NoCallerIdAvailable when none of those resolve — the call is rejected rather than dialled from an arbitrary number; 400 when tenant_id, install_id or to_number is missing; 422 with remediation when the upstream call request fails; repeat callbacks for the same Call SID are idempotent via UNIQUE(install_id, external_id).
[ "POST /api/auth/signup-tenant", "POST /api/voice/tracking-numbers" ]
status: queued, initiated, ringing, in-progress, completed, busy, no-answer, canceled, faileddirection: inbound, outboundrecording_withheld_reason: consent_denied, consent_unknown, not_requested| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, install_id and to_number are required | tenant_id, install_id or to_number missing from body |
| 400 | NoCallerIdAvailable | no from_number and no active tracking number to call from | no from_number given and the tenant has no active tracking number |
| 401 | Unauthorized | Unauthorized | Authorization bearer token missing or invalid |
| 422 | ProviderError | call placement failed: <upstream message> | the injected Twilio provider rejects the call (bad credentials, non-voice from number, geo-permission block) |
| Transition | Triggered by |
|---|---|
queued -> initiated -> ringing -> in-progress | Twilio call progress, delivered to POST /api/voice/webhooks/twilio/status |
in-progress -> completed | call ended normally (status webhook stamps ended_at + duration) |
queued -> busy|no-answer|canceled|failed | call never connected (terminal, delivered by the status webhook) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"install_id": "{{dynamic:uuid}}",
"to_number": "+14155550123",
"from_number": "+14155550100",
"tracking_number_id": "{{cache:voice.tracking-numbers.create.response.data.tracking_number.tracking_number_id}}",
"subject_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"initiated_by_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"record": true,
"machine_detection": true,
"metadata": {
"campaign": "google-ads-q3"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"install_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"to_number": "+14155550123",
"from_number": "+14155550100",
"tracking_number_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"initiated_by_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"record": true,
"machine_detection": true,
"metadata": {
"campaign": "google-ads-q3"
}
}{
"success": true,
"data": {
"call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"install_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"to_number": "+14155550123",
"from_number": "+14155550100",
"tracking_number_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"initiated_by_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"record": true,
"machine_detection": true,
"metadata": {
"campaign": "google-ads-q3"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"call": {
"voice_call_id": "string",
"external_id": "string",
"status": "string",
"from_number": "string",
"to_number": "string"
}
}
}GET/api/voice/calls/:voice_call_id🔒 auth
Fetch a single call leg by id, including its recording fields, AMD outcome (answered_by), is_voicemail classification and any voicemail transcript. Tenant-scoped: another tenant's call reads as not-found rather than leaking its existence. Edge cases: 400 when the tenant_id query param is missing; 404 for an unknown id or one belonging to a different tenant; recording_url/recording_sid stay null until the recording webhook delivers them, and answered_by stays null until the status callback carrying AMD arrives.
[ "POST /api/auth/signup-tenant", "POST /api/voice/tracking-numbers", "POST /api/voice/calls" ]
status: queued, initiated, ringing, in-progress, completed, busy, no-answer, canceled, failedanswered_by: human, machine_start, machine_end_beep, machine_end_silence, machine_end_other, fax, unknown| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
| 401 | Unauthorized | Unauthorized | Authorization bearer token missing or invalid |
| 404 | NotFound | NotFound | voice_call_id unknown or belongs to another tenant |
{
"voice_call_id": "{{cache:voice.calls.create.response.data.call.voice_call_id}}"
}{
"success": true,
"data": {
"call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"call": {
"voice_call_id": "string",
"external_id": "string",
"status": "string",
"is_voicemail": "boolean"
}
}
}GET/api/voice/tracking-numbers🔒 auth
List the tenant's Twilio tracking numbers, newest first, optionally filtered by status. Released numbers are retained and still listed (filter status=active to exclude them) so historical call attribution survives a release. Edge cases: 400 when the tenant_id query param is missing; a tenant with no numbers returns an empty array rather than 404; limit defaults to 50 and offset to 0.
[ "POST /api/auth/signup-tenant", "POST /api/voice/tracking-numbers" ]
status: active, released, deleted-upstream| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
| 401 | Unauthorized | Unauthorized | Authorization bearer token missing or invalid |
{
"success": true,
"data": [
{
"tracking_number_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"tracking_numbers": [
{
"tracking_number_id": "string",
"phone_number": "string",
"status": "string"
}
]
}
}POST/api/voice/tracking-numbers🔒 auth
Provision a Twilio tracking number and pin it to the tenant so inbound calls can be attributed to the campaign/source that owns the number. The upstream purchase goes through a pluggable provider (setTwilioVoiceProvider); when no live Twilio client is injected the built-in stub mints a synthetic SID and E.164 number, so this endpoint works without Twilio credentials. Supply phone_number to claim a specific number, or area_code to let the provider pick one. Edge cases: re-provisioning the SAME upstream SID for the same install is idempotent via UNIQUE(install_id, external_id) and returns the existing row (friendly_name/purpose/assigned_persona_id are refreshed); a DIFFERENT SID resolving to a number the tenant already holds ACTIVE returns 409, because a partial unique index allows only one active claim per (tenant, phone_number); released rows are retained so historical calls stay attributable; 400 when tenant_id or install_id is missing; 422 with a remediation hint when the upstream provisioning call fails.
[ "POST /api/auth/signup-tenant" ]
status: active, released, deleted-upstream| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and install_id are required | tenant_id or install_id missing from body |
| 401 | Unauthorized | Unauthorized | Authorization bearer token missing or invalid |
| 409 | NumberAlreadyProvisioned | <number> is already provisioned and active for this tenant | the tenant already holds an active claim on that phone number under a different upstream SID |
| 422 | ProviderError | number provisioning failed: <upstream message> | the injected Twilio provider rejects the purchase (bad credentials, region not permitted) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"install_id": "{{dynamic:uuid}}",
"friendly_name": "Inbound campaign line",
"purpose": "google-ads-q3",
"area_code": "415",
"assigned_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"install_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"friendly_name": "Inbound campaign line",
"purpose": "google-ads-q3",
"area_code": "415",
"assigned_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"tracking_number_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"install_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"friendly_name": "Inbound campaign line",
"purpose": "google-ads-q3",
"area_code": "415",
"assigned_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"tracking_number": {
"tracking_number_id": "string",
"phone_number": "string",
"external_id": "string",
"status": "string"
}
}
}POST/api/voice/tracking-numbers/:tracking_number_id/release🔒 auth
Release a tracking number back to Twilio and mark the mirror row 'released' with released_at stamped. The row is deliberately KEPT rather than deleted so historical calls placed from that number stay attributable, and releasing frees the (tenant, phone_number) active-claim slot so the number can be re-provisioned later. Edge cases: releasing an ALREADY-released number is an idempotent no-op that returns the current row (not a 409); 400 when tenant_id is missing from the body; 404 when the id is unknown or belongs to another tenant; 422 with remediation when the upstream release fails — retry reconciles the mirror if the number was already released in the Twilio console. After release the tenant may have no active number, in which case a later POST /api/voice/calls without an explicit from_number returns 400 NoCallerIdAvailable.
[ "POST /api/auth/signup-tenant", "POST /api/voice/tracking-numbers", "POST /api/voice/calls" ]
status: active, released, deleted-upstream| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing from body |
| 401 | Unauthorized | Unauthorized | Authorization bearer token missing or invalid |
| 404 | NotFound | NotFound | tracking_number_id unknown or belongs to another tenant |
| 422 | ProviderError | number release failed: <upstream message> | the injected Twilio provider rejects the release |
| Transition | Triggered by |
|---|---|
active -> released | POST /api/voice/tracking-numbers/:tracking_number_id/release (stamps released_at; row retained for attribution) |
{
"tracking_number_id": "{{cache:voice.tracking-numbers.create.response.data.tracking_number.tracking_number_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"release_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"tracking_number": {
"tracking_number_id": "string",
"status": "string",
"released_at": "string"
}
}
}POST/api/voice/webhooks/twilio/recordingpublic
PUBLIC signed webhook: Twilio posts here once a call recording is ready, carrying RecordingSid, RecordingUrl and RecordingDuration. Authentication is by X-Twilio-Signature exactly as for the status callback (fail-closed once TWILIO_AUTH_TOKEN is set, permissive when unconfigured so local/test runs work unsigned); the gateway allowlists the /api/voice/webhooks/ prefix past the default-deny authGate. CONSENT GATE (TK-3654): recording_url is the pointer to the actual audio, so it is stored ONLY when the call carries an affirmative sdk-consent grant. A denial AND the absence of any decision both withhold it and stamp recording_withheld_reason (consent_denied / consent_unknown) — 'no recording stored without a consent decision' means missing consent must fail closed. RecordingSid and RecordingDuration ARE retained even when withheld: knowing a recording exists upstream is what makes a later deletion request actionable, and that metadata is not itself the recording. With no consent seeded (the default in a fresh environment) the happy path therefore returns recording_stored=false and recording_withheld_reason='consent_unknown'. Other edge cases: recording fields merge with COALESCE so re-delivery is idempotent and a callback missing a field never nulls a stored value; an unrecognised or absent CallSid is acknowledged with 202 rather than 404 so Twilio stops retrying; recordings normally arrive AFTER the call completed and do not alter call status; 401 InvalidSignature only when a configured signature fails to verify.
[ "POST /api/auth/signup-tenant", "POST /api/voice/tracking-numbers", "POST /api/voice/calls" ]
recording_withheld_reason: consent_denied, consent_unknown, not_requested| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 202 | Accepted | unknown CallSid <sid> | the CallSid is not in the mirror, or the payload has no CallSid — acknowledged so Twilio stops retrying |
| 401 | InvalidSignature | InvalidSignature | TWILIO_AUTH_TOKEN is configured and X-Twilio-Signature is missing, wrong, or computed over different params |
{
"CallSid": "{{cache:voice.calls.create.response.data.call.external_id}}",
"RecordingSid": "RE{{dynamic:slug}}",
"RecordingUrl": "https://api.twilio.com/2010-04-01/Recordings/RE{{dynamic:slug}}",
"RecordingDuration": "40"
}{
"CallSid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"RecordingSid": "RE{{dynamic:slug}}",
"RecordingUrl": "https://api.twilio.com/2010-04-01/Recordings/RE{{dynamic:slug}}",
"RecordingDuration": "40"
}{
"success": true,
"data": {
"recording_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"CallSid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"RecordingSid": "RE{{dynamic:slug}}",
"RecordingUrl": "https://api.twilio.com/2010-04-01/Recordings/RE{{dynamic:slug}}",
"RecordingDuration": "40",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"matched": "boolean",
"voice_call_id": "string",
"recording_sid": "string",
"recording_duration_seconds": "number",
"recording_stored": "boolean",
"recording_withheld_reason": "string"
}
}POST/api/voice/webhooks/twilio/statuspublic
PUBLIC signed webhook: Twilio posts call-progress here (queued -> initiated -> ringing -> in-progress -> completed, or a terminal busy/no-answer/canceled/failed). Authentication is by X-Twilio-Signature (HMAC-SHA1 over the request URL plus every parameter appended as key+value in sorted order, base64), NOT a tenant JWT — so the gateway's default-deny authGate allowlists the /api/voice/webhooks/ prefix. Verification is fail-closed once TWILIO_AUTH_TOKEN is configured and permissive when it is not, so local and test runs work unsigned. AnsweredBy carries answering-machine detection: machine_start / machine_end_* mark the call is_voicemail, while 'unknown' and 'fax' deliberately do NOT (unknown means detection was inconclusive and treating it as voicemail would log phantom voicemails). Edge cases: callbacks are RETRIED and can arrive OUT OF ORDER, so a status is never allowed to regress out of a terminal state and is_voicemail is sticky once set; an unrecognised CallSid is acknowledged with 202 rather than 404, because retrying would never make the call known; a missing CallSid is likewise 202; 401 InvalidSignature only when a token is configured and the signature is absent/wrong/over tampered params.
[ "POST /api/auth/signup-tenant", "POST /api/voice/tracking-numbers", "POST /api/voice/calls" ]
CallStatus: queued, initiated, ringing, in-progress, completed, busy, no-answer, canceled, failedAnsweredBy: human, machine_start, machine_end_beep, machine_end_silence, machine_end_other, fax, unknown| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 202 | Accepted | unknown CallSid <sid> | the CallSid is not in the mirror, or the payload has no CallSid — acknowledged so Twilio stops retrying |
| 401 | InvalidSignature | InvalidSignature | TWILIO_AUTH_TOKEN is configured and X-Twilio-Signature is missing, wrong, or computed over different params |
| Transition | Triggered by |
|---|---|
queued -> ringing -> in-progress | successive status callbacks as the call progresses |
in-progress -> completed | final status callback; stamps ended_at and duration_seconds |
any -> is_voicemail=true | a callback whose AnsweredBy is machine_start/machine_end_* (AMD classification, sticky) |
{
"CallSid": "{{cache:voice.calls.create.response.data.call.external_id}}",
"CallStatus": "completed",
"AnsweredBy": "machine_start",
"CallDuration": "42",
"ErrorCode": ""
}{
"CallSid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"CallStatus": "completed",
"AnsweredBy": "machine_start",
"CallDuration": "42",
"ErrorCode": ""
}{
"success": true,
"data": {
"statu_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"CallSid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"CallStatus": "completed",
"AnsweredBy": "machine_start",
"CallDuration": "42",
"ErrorCode": "",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"matched": "boolean",
"voice_call_id": "string",
"status": "string",
"is_voicemail": "boolean",
"voicemail_detected": "boolean"
}
}contracts
POSTinternal://contracts/event-type-registry/p6apublic · manual
G-1 contracts-only task: adds 27 P6A event types to EVENT_TYPE_REGISTRY in @projexlight/contracts plus cross-SDK types (AgentContext, CompletionRequest/Response, StreamChunk, CapabilityToken, ExecutionLogEntry, ToolManifest, TraceSpan/Timeline, MCP types). No HTTP surface — the 'endpoint' is a synthetic in-process registry mutation. Enforcement is the existing assertRegisteredEventType() guard plus the producer-side schema validator (OC-2 doctrine).
{
"success": true,
"data": {
"p6a_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"success": true
}POSTinternal://contracts/event-type-registry/p6bpublic · manual
G-1 contracts-only task: adds 27 P6B event types to EVENT_TYPE_REGISTRY in @projexlight/contracts plus cross-SDK types (RagCorpus/Hit/Retrieval, ParseJob/Stage/ExtractedField, ConversationSession/Turn/Handoff, RecommendationModel/Suggestion/Feedback, RollupSpec/QueryResult/IcebergTableRef, LineageNode/Edge/Chain (G8), Ontology/SemanticObject/Relation/CapabilityGraph/Intent/Plan/Policy/Bridge (G9, 6 types), Snowflake Install/Binding/SyncRun/Query). No HTTP surface — the 'endpoint' is a synthetic in-process registry mutation. Enforcement is the existing assertRegisteredEventType() guard plus the producer-side schema validator (OC-2 doctrine).
{
"success": true,
"data": {
"p6b_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"success": true
}hdk-camera
GET/api/hdk/camera/capabilities🔒 auth
Returns the hdk-camera native capability manifest — supported photo formats (jpeg/heic/raw_dng), video codecs (h264/hevc/av1), max photo (48MP) and video (4K@60) resolution, flash/depth-sensor/OCR-passthrough flags, and the iOS/Android native module names the JS bridge must load. The handler is a pure static-metadata read: it takes no body, params or query, touches no database, and always returns HTTP 200 with the same JSON for every caller. QA edge cases: the route is behind requireAuth, so a missing/malformed Authorization header or an expired JWT returns 401 before the handler runs; there is no tenant scoping, so two different tenants receive byte-identical payloads (do not assert per-tenant differences); unknown query params and request bodies are ignored rather than rejected; there is no pagination, no idempotency concern (GET is naturally idempotent), and no 404/409 path because nothing is looked up.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"success": true,
"data": [
{
"capability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/hdk/camera/recording-presets🔒 auth
Returns the four hdk-camera video recording presets (standard 1080p30 h264, high 4K30 hevc, slow-mo 1080p240 h264, time-lapse 4K 1fps hevc) so the client can offer a preset picker without hard-coding resolutions. The handler is a pure static-metadata read: it takes no body, params or query, touches no database, and always returns HTTP 200 with the same JSON for every caller. QA edge cases: the route is behind requireAuth, so a missing/malformed Authorization header or an expired JWT returns 401 before the handler runs; there is no tenant scoping, so two different tenants receive byte-identical payloads (do not assert per-tenant differences); unknown query params and request bodies are ignored rather than rejected; there is no pagination, no idempotency concern (GET is naturally idempotent), and no 404/409 path because nothing is looked up.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"success": true,
"data": [
{
"recording_preset_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}hdk-diagnostic
POST/api/hdk-diagnostic/drain🔒 auth
Drains the persistent hdk-diagnostic outbox: claims up to `limit` rows from hdk_diagnostic.event where drained_at IS NULL (oldest received_at first, FOR UPDATE SKIP LOCKED), stamps drained_at = now(), and returns the claimed rows plus their count with 200. The body is optional - an absent body or absent `limit` defaults to 1000. Edge cases: draining an empty outbox returns 200 with events: [] and count: 0, never 404; the drain is destructive and NOT idempotent - an immediate second call returns the next batch rather than the same one, because the first call already stamped drained_at; concurrent callers never receive overlapping rows (SKIP LOCKED); a non-numeric or negative `limit` is passed straight into SQL LIMIT and fails at the database rather than in a validation branch; there is no tenant scoping on this route, so it drains the outbox across all devices and tenants.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in "Bearer <token>" form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler) |
| 400 | FST_ERR_CTP_EMPTY_JSON_BODY | Body cannot be empty when content-type is set to application/json | Content-Type: application/json declared but the request body is empty or unparseable (Fastify JSON body parser, before the handler runs) |
| 500 | InternalServerError | Internal Server Error | drainQueue SQL fails - e.g. limit is a non-numeric string or a negative number (invalid LIMIT), or the Postgres pool is unavailable; the route has no try/catch so the rejection becomes Fastify default 500 |
{
"limit": 50
}{
"limit": 50
}{
"success": true,
"data": {
"drain_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"limit": 50,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/hdk-diagnostic/events🔒 auth
Captures a single device diagnostic event into the persistent hdk_diagnostic.event outbox (device_uuid, category, JSONB payload, occurred_at) and returns 202 with the generated event_id; a downstream drain worker later ships it to long-term storage. device_uuid and category are required; payload defaults to {} and occurred_at defaults to now() when omitted. Edge cases: an empty-string device_uuid or category is rejected by the falsy missing-fields check; duplicate submissions are NOT deduplicated - each call inserts a new row with a fresh event_id, so client retries create duplicates; an unparseable occurred_at becomes an Invalid Date and fails at the INSERT rather than in validation; payload is stringified into jsonb so oversized payloads are bounded only by the Fastify body limit; there is no FK on device_uuid and no tenant scoping, so events for an unknown device are accepted.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in "Bearer <token>" form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler) |
| 400 | ValidationError | missing fields | device_uuid or category missing/empty in the body |
| 400 | FST_ERR_CTP_EMPTY_JSON_BODY | Body cannot be empty when content-type is set to application/json | Content-Type: application/json declared but the request body is empty or unparseable (Fastify JSON body parser, before the handler runs) |
| 500 | InternalServerError | Internal Server Error | captureEvent INSERT fails - e.g. occurred_at is not a parseable date (Invalid Date) or the Postgres pool is unavailable; insertion errors deliberately propagate rather than silently dropping telemetry |
{
"device_uuid": "{{static:test-device-uuid-001}}",
"category": "battery",
"payload": {
"level": 0.42,
"charging": false
},
"occurred_at": "{{dynamic:pastdatetime}}"
}{
"device_uuid": "test-device-uuid-001",
"category": "battery",
"payload": {
"level": 0.42,
"charging": false
},
"occurred_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"status": "accepted",
"job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 202.{
"success": false,
"error": "unauthorized: missing or invalid token"
}hdk-idp
POST/api/hdk-idp/claims🔒 auth
Registers (upserts) a device identity claim binding a device_uuid to a person_id, optionally storing a biometric template envelope and/or PIN envelope (base64 blobs). Requires device_uuid and person_id; envelopes are optional (a claim with neither credential is accepted). Idempotent upsert on (device_uuid, person_id): a repeat call COALESCE-updates envelopes (a null envelope does NOT overwrite an existing one) and returns 201 — no duplicate/conflict error. Edge cases: empty-string vs missing fields, non-base64 envelopes, repeat registrations.
[ "POST /api/auth/register", "POST /scim/v2/Users" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | missing fields | device_uuid or person_id missing/falsy in body |
{
"device_uuid": "device-hdk-idp-e2e-001",
"person_id": "{{cache:scim.create.response.data.person_id}}",
"biometric_template_envelope": "dGVzdC1iaW9tZXRyaWMtdGVtcGxhdGU=",
"pin_envelope": "dGVzdC1waW4tZW52ZWxvcGU="
}{
"device_uuid": "device-hdk-idp-e2e-001",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"biometric_template_envelope": "dGVzdC1iaW9tZXRyaWMtdGVtcGxhdGU=",
"pin_envelope": "dGVzdC1waW4tZW52ZWxvcGU="
}{
"success": true,
"data": {
"claim_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_uuid": "device-hdk-idp-e2e-001",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"biometric_template_envelope": "dGVzdC1iaW9tZXRyaWMtdGVtcGxhdGU=",
"pin_envelope": "dGVzdC1waW4tZW52ZWxvcGU=",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/hdk-idp/devices/:device_uuid/claims🔒 auth
Lists every device identity claim registered for one device_uuid, returning claim_id, device_uuid, person_id, the biometric_template_envelope and pin_envelope blobs, last_used_at and created_at. A device may carry several claims, one per bound person. Edge cases: an unknown or never-registered device_uuid returns 200 with claims: [] and never 404; device_uuid is a TEXT column, so any string including a non-UUID is a legal lookup key that simply matches nothing (no cast error); the result set is unpaginated and unordered, so a device with many bound persons returns every row in whatever order Postgres yields; the query filters on device_uuid alone with no tenant or persona predicate, so the caller's JWT tenant does not narrow the results.
[ "POST /api/auth/register", "POST /api/hdk-idp/claims" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in "Bearer <token>" form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler) |
{
"device_uuid": "{{cache:claims.register.response.data.claim.device_uuid}}"
}{
"success": true,
"data": {
"claim_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/hdk-idp/offline-auth/log🔒 auth
Records an authentication a device performed while offline, writing it to hdk_idp.offline_auth_log on the next sync and touching last_used_at on the matching (device_uuid, person_id) device_claim. All four fields - device_uuid, person_id, method and occurred_at - are required, and method must be one of biometric, pin or passkey (also enforced by a CHECK constraint on the table). Edge cases: an unrecognised method has its own distinct 400 branch separate from the missing-fields branch; there is no FK and no uniqueness constraint, so an unknown device/person is accepted and replaying the same event inserts a duplicate log row (not idempotent); when no claim matches, the last_used_at UPDATE affects zero rows silently and the call still returns 201; person_id must be a UUID and occurred_at must be a parseable timestamp - both are handled at the database and fail there, not in a validation branch.
[ "POST /api/auth/register", "POST /api/hdk-idp/claims" ]
method: biometric, pin, passkey| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in "Bearer <token>" form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler) |
| 400 | ValidationError | missing fields | device_uuid, person_id, method or occurred_at missing/empty in the body |
| 400 | ValidationError | invalid method | method is present but not one of 'biometric', 'pin', 'passkey' |
| 400 | FST_ERR_CTP_EMPTY_JSON_BODY | Body cannot be empty when content-type is set to application/json | Content-Type: application/json declared but the request body is empty or unparseable (Fastify JSON body parser, before the handler runs) |
| 500 | InternalServerError | Internal Server Error | logOfflineAuth INSERT fails - person_id is not a valid UUID (hdk_idp.offline_auth_log.person_id is UUID NOT NULL), occurred_at is not a parseable date (Invalid Date), or the Postgres pool is unavailable |
{
"device_uuid": "{{cache:claims.register.response.data.claim.device_uuid}}",
"person_id": "{{cache:claims.register.response.data.claim.person_id}}",
"method": "biometric",
"occurred_at": "{{dynamic:pastdatetime}}"
}{
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"method": "biometric",
"occurred_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"log_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"method": "biometric",
"occurred_at": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}hdk-image-editor
GET/api/hdk/image-editor/capabilities🔒 auth
Returns the hdk-image-editor capability manifest — available tools (crop/rotate/flip/markup/text/blur/sharpen/filter), the five named filters, supported output formats (jpeg/png/heic), and the iOS/Android native module names. The handler is a pure static-metadata read: it takes no body, params or query, touches no database, and always returns HTTP 200 with the same JSON for every caller. QA edge cases: the route is behind requireAuth, so a missing/malformed Authorization header or an expired JWT returns 401 before the handler runs; there is no tenant scoping, so two different tenants receive byte-identical payloads (do not assert per-tenant differences); unknown query params and request bodies are ignored rather than rejected; there is no pagination, no idempotency concern (GET is naturally idempotent), and no 404/409 path because nothing is looked up.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"success": true,
"data": [
{
"capability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}hdk-map
GET/api/hdk/map/capabilities🔒 auth
Returns the hdk-map capability manifest — supported overlay types (pin/polyline/polygon/heatmap/cluster), gestures, offline-caching and geofencing flags, routing modes (driving/walking/cycling/transit), and the iOS/Android native module names. The handler is a pure static-metadata read: it takes no body, params or query, touches no database, and always returns HTTP 200 with the same JSON for every caller. QA edge cases: the route is behind requireAuth, so a missing/malformed Authorization header or an expired JWT returns 401 before the handler runs; there is no tenant scoping, so two different tenants receive byte-identical payloads (do not assert per-tenant differences); unknown query params and request bodies are ignored rather than rejected; there is no pagination, no idempotency concern (GET is naturally idempotent), and no 404/409 path because nothing is looked up.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"success": true,
"data": [
{
"capability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/hdk/map/tile-providers🔒 auth
Returns the four configured map tile providers (mapbox-streets, mapbox-satellite, apple-standard, osm) with their {z}/{x}/{y} URL patterns and a requires_token flag. Security-relevant assertion for QA: this endpoint must NEVER return an actual tile access token — clients fetch those separately via sdk-secrets, so a response containing a token-like value is a defect. The handler is a pure static-metadata read: it takes no body, params or query, touches no database, and always returns HTTP 200 with the same JSON for every caller. QA edge cases: the route is behind requireAuth, so a missing/malformed Authorization header or an expired JWT returns 401 before the handler runs; there is no tenant scoping, so two different tenants receive byte-identical payloads (do not assert per-tenant differences); unknown query params and request bodies are ignored rather than rejected; there is no pagination, no idempotency concern (GET is naturally idempotent), and no 404/409 path because nothing is looked up.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"success": true,
"data": [
{
"tile_provider_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}hdk-measure
GET/api/hdk/measure🔒 auth
Lists every measurement recorded for one capture, filtered by the required capture_id query param and returned as { success: true, data: [...] }. QA edge cases: omitting capture_id is a 400, but an unknown or non-existent capture_id is NOT — it returns 200 with an empty array, so "no such capture" and "capture with zero measurements" are indistinguishable; there is no limit/offset parameter, so the endpoint returns the full unpaginated set for a capture; there is no tenant scoping on the query, so results are keyed purely by capture_id.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/hdk/measure" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | capture_id query param required | the capture_id query param is absent or an empty string |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"success": true,
"data": [
{
"measure_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/hdk/measure🔒 auth
Records one HDK device measurement (area / distance / volume) against a capture and persists it to hdk_measure.measurement. The route validates capture_id, kind, value, unit and device_uuid at the edge, then measurementService re-validates and inserts, returning 201 with the stored row. QA edge cases: EVERY validation failure — route-level missing field, unknown kind, non-finite or negative value, a captured_at that is not valid ISO 8601, or a database insert failure — collapses to HTTP 400, so assert on the error string, not just the status; value === 0 is accepted (only negative is rejected) while value omitted entirely is rejected, so a 0-value test must not be treated as a missing-field test; tenant_id is optional and defaults to null, meaning this endpoint does NOT enforce tenant scoping on write; the call is not idempotent — POSTing the same body twice creates two rows with different ids; the route has no requireAuth preHandler of its own but is still covered by the gateway default-deny authGate, so an anonymous call returns 401 before reaching the handler.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant" ]
kind: area, distance, volumeaccuracy_class: high, medium, low| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | capture_id, kind, value, unit, device_uuid are required | any of capture_id, kind, value, unit or device_uuid is missing from the body (route-level guard; value == null triggers this but value === 0 does not) |
| 400 | ValidationError | [hdk-measure] invalid kind '<kind>' | kind is present but not one of 'area', 'distance', 'volume' |
| 400 | ValidationError | [hdk-measure] value must be a finite number | value is NaN, Infinity, or a non-numeric type |
| 400 | ValidationError | [hdk-measure] value must be non-negative | value is a finite number below zero |
| 400 | ValidationError | [hdk-measure] captured_at is not a valid ISO 8601 timestamp | captured_at is supplied but does not parse as an ISO 8601 date |
| 400 | ValidationError | [hdk-measure] insert failed | the INSERT into hdk_measure.measurement returns no row (e.g. capture_id violates a foreign key) — the thrown error is caught and reported as 400 |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"capture_id": "{{var:capture_id}}",
"kind": "distance",
"value": 3.5,
"unit": "m",
"accuracy_class": "high",
"device_uuid": "{{dynamic:uuid}}",
"captured_at": "{{dynamic:pastdatetime}}",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"capture_id": "{{var:capture_id}}",
"kind": "distance",
"value": 3.5,
"unit": "m",
"accuracy_class": "high",
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"captured_at": "2026-01-15T10:30:00Z",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"measure_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"capture_id": "{{var:capture_id}}",
"kind": "distance",
"value": 3.5,
"unit": "m",
"accuracy_class": "high",
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"captured_at": "2026-01-15T10:30:00Z",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/hdk/measure/:id🔒 auth
Fetches a single measurement from hdk_measure.measurement by its measurement id and returns it as { success: true, data }. QA edge cases: an id that does not exist returns 404 with error "not found" — the same 404 is returned for a well-formed-but-unknown UUID and for a syntactically invalid id, so a malformed-id test cannot be distinguished from a missing-record test by status alone; there is no tenant filter on the lookup, so any authenticated caller who knows an id can read that row (do not assert cross-tenant isolation here); the read is idempotent and safe to repeat.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/hdk/measure" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | NotFound | not found | no hdk_measure.measurement row matches the :id path param (unknown, deleted, or malformed id) |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"id": "{{cache:measure.create.response.data.measurement_id}}"
}{
"success": true,
"data": {
"measure_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}hdk-permissions
GET/api/hdk-permissions/devices/:device_uuid/latest🔒 auth
Returns the most recent permission-surface snapshot captured for a device - the newest hdk_permissions.surface_snapshot row for that device_uuid by taken_at DESC - including snapshot_id, tenant_id, persona_id, the permission_set JSONB and taken_at. Edge cases: unlike the sibling claims list, a device with no snapshot yet returns 404 NotFound rather than an empty payload, so this must be exercised after a POST /api/hdk-permissions/snapshots for the same device_uuid; device_uuid is a TEXT column, so any non-matching string including a non-UUID yields a clean 404 rather than a cast error; only one row is ever returned no matter how many snapshots exist, and ties on taken_at are broken arbitrarily; the lookup is keyed solely on device_uuid - the row's tenant_id is returned but is never checked against the caller's JWT tenant.
[ "POST /api/auth/signup-tenant", "POST /api/hdk-permissions/snapshots" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in "Bearer <token>" form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler) |
| 404 | NotFound | NotFound | no surface_snapshot row exists for the given device_uuid (latestSnapshot returns null) |
{
"device_uuid": "{{cache:hdk-permissions.snapshot.response.data.snapshot.device_uuid}}"
}{
"success": true,
"data": {
"latest_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/hdk-permissions/snapshots🔒 auth
Captures the current permission-surface state of a device: the permission_set map (permission name to boolean or string, stored as JSONB) for a device_uuid within a tenant, optionally scoped to a persona_id, returning 201 with the stored snapshot including its generated snapshot_id and taken_at. device_uuid, tenant_id and permission_set are required; persona_id is optional and stored as NULL when omitted. Edge cases: there is no uniqueness constraint, so every call appends a new historical row - repeat posts are intentionally NOT idempotent and build the timeline that GET /devices/:device_uuid/latest reads; an empty permission_set object {} passes the presence check and is accepted, while a missing key is rejected; tenant_id and persona_id must be valid UUIDs and are enforced by the column types, not by handler validation; tenant_id comes from the body and is never cross-checked against the caller's JWT tenant.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in "Bearer <token>" form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler) |
| 400 | ValidationError | missing fields | device_uuid, tenant_id or permission_set missing/empty in the body (persona_id is optional) |
| 400 | FST_ERR_CTP_EMPTY_JSON_BODY | Body cannot be empty when content-type is set to application/json | Content-Type: application/json declared but the request body is empty or unparseable (Fastify JSON body parser, before the handler runs) |
| 500 | InternalServerError | Internal Server Error | snapshotSurface INSERT fails - tenant_id or persona_id is not a valid UUID (both are UUID columns), permission_set is not JSON-serialisable, or the Postgres pool is unavailable |
{
"device_uuid": "test-device-uuid-001",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"persona_id": "{{var:persona_id}}",
"permission_set": {
"camera": true,
"location": "when-in-use"
}
}{
"device_uuid": "test-device-uuid-001",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "{{var:persona_id}}",
"permission_set": {
"camera": true,
"location": "when-in-use"
}
}{
"success": true,
"data": {
"snapshot_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_uuid": "test-device-uuid-001",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "{{var:persona_id}}",
"permission_set": {
"camera": true,
"location": "when-in-use"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}hdk-scanner
GET/api/hdk/scanner/capabilities🔒 auth
Returns the hdk-scanner capability manifest — supported barcode symbologies (qr_code, pdf_417, code_128, ean_13, data_matrix), document-detection and OCR flags, and the iOS/Android native module names, so JS can negotiate scanner availability without calling into native code. The handler is a pure static-metadata read: it takes no body, params or query, touches no database, and always returns HTTP 200 with the same JSON for every caller. QA edge cases: the route is behind requireAuth, so a missing/malformed Authorization header or an expired JWT returns 401 before the handler runs; there is no tenant scoping, so two different tenants receive byte-identical payloads (do not assert per-tenant differences); unknown query params and request bodies are ignored rather than rejected; there is no pagination, no idempotency concern (GET is naturally idempotent), and no 404/409 path because nothing is looked up.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"success": true,
"data": [
{
"capability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}hdk-sync
POST/api/hdk-sync/conflicts/resolve🔒 auth
Resolves a two-sided conflict for an event_type by applying that type's registered policy (crdt / lww / merge / event-sourcing / human-review) to input_a and input_b, optionally linking the outcome to a batch_id and an audit_entry_id. Edge cases: the event_type MUST already be registered via PUT /api/hdk-sync/event-type-policies — an unregistered type returns 409 UnregisteredEventType, not 404; input_a and input_b must both be present objects (a falsy or missing side is a 400); the catch block maps ANY resolver throw to 409, so unrelated resolver failures also present as UnregisteredEventType; a 'human-review' policy does not resolve inline, it enqueues a human-review task; batch_id and audit_entry_id are optional linkage only.
[ "POST /api/auth/register", "PUT /api/hdk-sync/event-type-policies" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | missing fields | event_type, input_a or input_b is absent/falsy |
| 409 | UnregisteredEventType | event_type <type> has no registered conflict_policy | resolveConflict threw — chiefly because no policy is registered for the event_type; the catch maps every resolver throw to this 409 |
{
"event_type": "{{cache:hdk-sync.event-type-policies.create.response.data.policy.event_type}}",
"input_a": {
"ops": [
{
"position": 0,
"char": "A",
"ts": 1,
"replica_id": "d1"
}
]
},
"input_b": {
"ops": [
{
"position": 0,
"char": "B",
"ts": 2,
"replica_id": "d2"
}
]
}
}{
"event_type": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"input_a": {
"ops": [
{
"position": 0,
"char": "A",
"ts": 1,
"replica_id": "d1"
}
]
},
"input_b": {
"ops": [
{
"position": 0,
"char": "B",
"ts": 2,
"replica_id": "d2"
}
]
}
}{
"success": true,
"data": {
"status": "completed",
"event_type": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"input_a": {
"ops": [
{
"position": 0,
"char": "A",
"ts": 1,
"replica_id": "d1"
}
]
},
"input_b": {
"ops": [
{
"position": 0,
"char": "B",
"ts": 2,
"replica_id": "d2"
}
]
},
"resolve_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/hdk-sync/event-type-policies🔒 auth
Lists every registered event-type conflict policy. Takes no query parameters — there is no paging, filtering or tenant scoping, so the full registry is returned on each call and an empty registry yields data.policies = [] with 200, not 404. Read-only and safe to poll; clients use it to learn which event types may be replayed before starting a batch.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
{
"success": true,
"data": [
{
"event_type_policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}PUT/api/hdk-sync/event-type-policies🔒 auth
Registers (upserts) the conflict-resolution policy for a sync event_type: which of crdt | lww | merge | event-sourcing | human-review applies when two device replays disagree, plus an optional free-text strategy_detail and a retention_class. Idempotent — re-PUTting the same event_type overwrites the policy and returns 200 (never 201), so it is safe to replay. Edge cases: an unknown conflict_policy or retention_class is rejected 400 by an explicit whitelist in the route; an empty-string event_type is treated as missing; strategy_detail and retention_class are optional; the registry is global (not tenant-scoped), so a policy registered by one tenant's operator governs every tenant's replays for that event_type.
[ "POST /api/auth/register" ]
conflict_policy: crdt, lww, merge, event-sourcing, human-reviewretention_class: transient, operational, regulated| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | missing fields | event_type or conflict_policy is absent/empty |
| 400 | ValidationError | invalid conflict_policy | conflict_policy is not one of crdt, lww, merge, event-sourcing, human-review |
| 400 | ValidationError | invalid retention_class | retention_class was supplied but is not one of transient, operational, regulated |
{
"event_type": "clinical.note.edit.v1",
"conflict_policy": "human-review",
"strategy_detail": "human-review:dual-control",
"retention_class": "regulated"
}{
"event_type": "clinical.note.edit.v1",
"conflict_policy": "human-review",
"strategy_detail": "human-review:dual-control",
"retention_class": "regulated"
}{
"success": true,
"data": {
"event_type_policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"event_type": "clinical.note.edit.v1",
"conflict_policy": "human-review",
"strategy_detail": "human-review:dual-control",
"retention_class": "regulated",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/hdk-sync/event-type-policies/:event_type🔒 auth
Fetches the conflict policy registered for a single event_type given as a path segment. Edge cases: the lookup is an exact, case-sensitive match on the event_type string, so a differently-cased or escaped value that does not decode to the exact registered string returns 404; an unregistered event_type also returns 404 (the route never lazily creates a policy). Global registry — no tenant scoping.
[ "POST /api/auth/register", "PUT /api/hdk-sync/event-type-policies" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 404 | NotFound | NotFound | no policy row exists for the supplied :event_type |
{
"event_type": "{{cache:hdk-sync.event-type-policies.create.response.data.policy.event_type}}"
}{
"success": true,
"data": {
"event_type_policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/hdk-sync/human-review/:task_id/resolve🔒 auth
Resolves a queued human-review task by setting its status (open | in-review | resolved | rejected) and recording the reviewer's chosen resolved_value. resolved_value is optional and defaults to NULL, so a task may be rejected with no payload. Edge cases: status is checked against an explicit whitelist, so a missing OR unrecognised status is a 400; an unknown :task_id returns 404; there is no state-machine guard — re-resolving an already-resolved task is accepted as long as the row still matches, so idempotency must be enforced by the caller.
[ "POST /api/auth/register", "PUT /api/hdk-sync/event-type-policies", "POST /api/hdk-sync/conflicts/resolve" ]
status: open, in-review, resolved, rejected| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | invalid status | status is absent or is not one of open, in-review, resolved, rejected |
| 404 | NotFound | NotFound | no human-review task matches :task_id |
{
"entity": "hdk_sync.human_review_task",
"field": "status",
"flow": [
"open",
"in-review",
"resolved",
"rejected"
],
"transitions": [
{
"from": "open",
"to": "resolved",
"via": "POST /api/hdk-sync/human-review/:task_id/resolve"
},
{
"from": "open",
"to": "rejected",
"via": "POST /api/hdk-sync/human-review/:task_id/resolve"
},
{
"from": "in-review",
"to": "resolved",
"via": "POST /api/hdk-sync/human-review/:task_id/resolve"
}
]
}{
"task_id": "{{cache:hdk-sync.conflicts-resolve.response.data.human_review_task.task_id}}"
}{
"status": "resolved",
"resolved_value": {
"decision": "merge"
}
}{
"status": "resolved",
"resolved_value": {
"decision": "merge"
}
}{
"success": true,
"data": {
"status": "resolved",
"resolved_value": {
"decision": "merge"
},
"resolve_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/hdk-sync/human-review/open🔒 auth
Lists the open human-review tasks produced when a conflict's policy is 'human-review'. Optional ?assignee_persona_id= narrows the queue to one reviewer; omitting it returns every open task. Edge cases: an empty queue is 200 with data.tasks = [], never 404; an assignee_persona_id matching nobody also yields an empty list rather than an error; there is no paging or limit parameter, so a large backlog is returned in full.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
{
"success": true,
"data": [
{
"open_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/hdk-sync/replay/:batch_id/complete🔒 auth
Closes a replay batch, stamping it complete with the conflict_count the caller observed. The body is optional — an absent conflict_count defaults to 0. Edge cases: an unknown :batch_id returns 404; a non-UUID :batch_id is not validated by the route, reaches Postgres and surfaces as a 500 rather than a 400; there is no already-completed guard, so behaviour on a double-complete depends on whether the update still matches the row.
[ "POST /api/auth/register", "POST /api/hdk-sync/replay/start" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 404 | NotFound | NotFound | no replay batch matches :batch_id (unknown id, or the update matched no row) |
| 500 | Internal Server Error | Internal Server Error | :batch_id is not a valid UUID so the query throws, or the DB update fails — the route has no try/catch, so the rejection surfaces as Fastify's default 500 |
{
"batch_id": "{{cache:hdk-sync.replay-start.response.data.batch.batch_id}}"
}{
"conflict_count": 0
}{
"conflict_count": 0
}{
"success": true,
"data": {
"complete_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"conflict_count": 0,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/hdk-sync/replay/start🔒 auth
Opens a replay batch for one device: accepts the offline-queued envelopes a device accumulated and returns the created batch plus the subset that was rejected. Returns 201 even when EVERY envelope is rejected — inspect data.rejected rather than the status code. Edge cases: envelopes must be an array, but an EMPTY array is accepted and creates a zero-item batch; envelopes whose event_type has no registered policy come back in data.rejected instead of failing the request; batch size is not capped by the route, so payload size is bounded only by the gateway body limit; device_uuid and tenant_id come from the body, not the JWT, so tenant scoping is caller-asserted.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "PUT /api/hdk-sync/event-type-policies" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | missing fields | device_uuid or tenant_id is absent/empty, or envelopes is not an array |
{
"device_uuid": "test-device-uuid-001",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"envelopes": []
}{
"device_uuid": "test-device-uuid-001",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"envelopes": []
}{
"success": true,
"data": {
"start_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_uuid": "test-device-uuid-001",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"envelopes": [],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}hdk-video-editor
GET/api/hdk/video-editor/capabilities🔒 auth
Returns the hdk-video-editor capability manifest — available tools (trim/merge/caption/mute/speed/overlay), output formats (mp4/hevc/webm), max resolution (4K), and the iOS/Android native module names. The handler is a pure static-metadata read: it takes no body, params or query, touches no database, and always returns HTTP 200 with the same JSON for every caller. QA edge cases: the route is behind requireAuth, so a missing/malformed Authorization header or an expired JWT returns 401 before the handler runs; there is no tenant scoping, so two different tenants receive byte-identical payloads (do not assert per-tenant differences); unknown query params and request bodies are ignored rather than rejected; there is no pagination, no idempotency concern (GET is naturally idempotent), and no 404/409 path because nothing is looked up.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"success": true,
"data": [
{
"capability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}hdk-watermark
GET/api/hdk/watermark🔒 auth
Lists every watermark application recorded against one media variant, filtered by the required variant_id query param. QA edge cases: a missing variant_id is a 400, but an unknown variant_id returns 200 with an empty array rather than a 404 — "no such variant" and "variant with no watermarks" are indistinguishable; the listing is unpaginated (no limit/offset), so a variant with many applications returns them all in one response; the query is keyed only by variant_id with no tenant predicate.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/hdk/watermark" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | variant_id query param required | the variant_id query param is absent or an empty string |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"success": true,
"data": [
{
"watermark_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/hdk/watermark🔒 auth
Records a watermark application (visible / invisible / cryptographic) against a media variant, persisting the payload envelope as bytes in hdk_watermark.application and returning 201. payload_envelope is accepted as either base64 or plain utf8 — the service sniffs the string and decodes base64 only when it matches the base64 alphabet AND its length is a multiple of 4, otherwise it stores the raw utf8 bytes. QA edge cases: that sniffing is the subtle one — a short plain-text value that happens to be pure base64 characters with length %4 === 0 will be silently base64-decoded, so byte-length assertions must account for it; an envelope that decodes to zero bytes is rejected as empty even though the field was non-empty; oversized envelopes are rejected above HDK_WATERMARK_MAX_PAYLOAD_BYTES (default 16384 bytes, measured AFTER decoding, not on the string length); every failure — missing field, bad scheme, empty envelope, oversize envelope, insert failure — returns 400, so assert the message; the write is not idempotent (repeat POSTs create additional application rows); tenant_id is optional and defaults to null, so no tenant scoping is enforced on write; the route relies on the gateway default-deny authGate for authentication.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant" ]
scheme: visible, invisible, cryptographic| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | variant_id, scheme, payload_envelope are required | any of variant_id, scheme or payload_envelope is missing from the body (route-level guard) |
| 400 | ValidationError | [hdk-watermark] invalid scheme '<scheme>' | scheme is present but not one of 'visible', 'invisible', 'cryptographic' |
| 400 | ValidationError | [hdk-watermark] payload_envelope must be Buffer or string | payload_envelope is supplied as a non-string, non-Buffer type (number, object, array) |
| 400 | ValidationError | [hdk-watermark] payload_envelope is empty | payload_envelope decodes to zero bytes (e.g. an empty base64 string or whitespace only) |
| 400 | ValidationError | [hdk-watermark] payload_envelope size <n> exceeds limit <MAX_PAYLOAD_BYTES> | the decoded envelope exceeds HDK_WATERMARK_MAX_PAYLOAD_BYTES (default 16384 bytes) |
| 400 | ValidationError | [hdk-watermark] insert failed | the INSERT into hdk_watermark.application returns no row (e.g. variant_id violates a foreign key) — the thrown error is caught and reported as 400 |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"variant_id": "{{var:variant_id}}",
"scheme": "visible",
"payload_envelope": "eyJ3IjoxfQ==",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"variant_id": "{{var:variant_id}}",
"scheme": "visible",
"payload_envelope": "eyJ3IjoxfQ==",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"watermark_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"variant_id": "{{var:variant_id}}",
"scheme": "visible",
"payload_envelope": "eyJ3IjoxfQ==",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/hdk/watermark/:id🔒 auth
Fetches a single watermark application from hdk_watermark.application by its application id and returns it as { success: true, data }. QA edge cases: an unknown id returns 404 "not found", and a malformed id returns the same 404, so status alone does not distinguish them; the lookup is not tenant-filtered, so any authenticated caller holding an id can read the record; the response carries the stored payload_envelope bytes, so tests should assert the round-trip of the exact bytes POSTed (remembering the base64-vs-utf8 sniffing performed on write).
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/hdk/watermark" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | NotFound | not found | no hdk_watermark.application row matches the :id path param (unknown, deleted, or malformed id) |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"id": "{{cache:watermark.create.response.data.application_id}}"
}{
"success": true,
"data": {
"watermark_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}pool-federation-runtime
POST/admin/chaos-drillpublic
Standalone pool-federation-runtime chaos-drill endpoint (P7 AC-6) that records a chaos-drill failover_event with measured RPO/RTO, returning 201 with the event. IMPORTANT for QA: this route plugin is also mounted inside the api-gateway with mountHealth:false and NO orchestrator threaded in — because the gateway runs its own orchestrator on /admin/federation/chaos-drill — so against the gateway every authenticated request returns 503 by design, before body validation. Only the standalone binary on :8083 can actually run the drill. Auth differs from the rest of /admin: it is a constant-time compare against the env ADMIN_OPS_TOKEN only, with a length pre-check, so DB-issued admin.ops_token values are NOT accepted here and an unset ADMIN_OPS_TOKEN makes every call 401. Guard order is auth → orchestrator presence → field validation.
from_region: us-east-1, us-west-2, eu-west-1, ap-south-1to_region: us-east-1, us-west-2, eu-west-1, ap-south-1| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token is missing, is not a string, has a different length than env ADMIN_OPS_TOKEN, fails the constant-time compare, or ADMIN_OPS_TOKEN itself is unset; DB-backed ops tokens are not consulted by this route |
| 503 | ServiceUnavailable | orchestrator not available in this deployment | the route plugin was mounted without an orchestrator — always the case for the api-gateway mount; checked after auth and before body validation, so it masks any 400 |
| 400 | ValidationError | federation_id, from_region, to_region required | any of federation_id, from_region or to_region is missing, null, or an empty string (only reachable in the standalone deployment where an orchestrator exists) |
| 500 | InternalServerError | <drill error message> | orchestrator.runChaosDrill() throws — unknown federation or region, or the failover_event write failing |
{
"federation_id": "{{var:federation_id}}",
"from_region": "us-east-1",
"to_region": "us-west-2"
}{
"federation_id": "{{var:federation_id}}",
"from_region": "us-east-1",
"to_region": "us-west-2"
}{
"success": true,
"data": {
"chaos_drill_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"federation_id": "{{var:federation_id}}",
"from_region": "us-east-1",
"to_region": "us-west-2",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"event_id": "string",
"federation_id": "string",
"from_region": "string",
"to_region": "string",
"trigger": "string",
"rpo_observed": "number",
"rto_observed": "number"
}POST/failoverspublic
Record a failover event (chaos drill, real production failover, or operator-initiated) against federation.failover_event. Body maps 1:1 to recordFailover(); returns the persisted event including server-generated occurred_at.
trigger: chaos-drill, production-failover, operator-initiated| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | When the plugin is mounted inside the api-gateway: /failovers is not on the authGate.ts public allowlist, so the default-deny gate rejects a request with no Authorization: Bearer header. Not produced by the standalone :8083 binary, which has no auth gate |
| 401 | Unauthorized | Invalid or expired token | Gateway-mounted only: the bearer token fails verifyJwt (bad signature, malformed, or expired) |
| 400 | invalid_trigger | trigger must be one of chaos-drill,production-failover,operator-initiated | body.trigger is missing, not a string, or outside the sanctioned FAILOVER_TRIGGERS set — the only field-level validation in the handler |
| 409 | duplicate_event | failover event_id '<event_id>' already recorded | The INSERT into federation.failover_event violates the event_id primary key (Postgres 23505) — replaying the same event_id |
| 500 | InternalError | <Postgres error message from the INSERT, e.g. null value in column violates not-null constraint / violates foreign key constraint on federation_id / invalid input syntax> | recordFailover throws for any non-23505 reason — omitted event_id/federation_id/from_region/to_region/rpo_observed/rto_observed (none are pre-validated), an unknown federation_id, wrong types for the RPO/RTO numerics, or the DB being unavailable |
| 500 | Internal Server Error | Internal Server Error | No JSON body is sent at all — the handler dereferences body.trigger without a null guard, so a missing body throws before validation and Fastify's default error handler responds |
{
"event_id": "fov-{{dynamic:slug}}",
"federation_id": "{{var:federation_id}}",
"from_region": "us-east-1",
"to_region": "us-west-2",
"trigger": "chaos-drill",
"rpo_observed": 30,
"rto_observed": 120
}{
"event_id": "fov-{{dynamic:slug}}",
"federation_id": "{{var:federation_id}}",
"from_region": "us-east-1",
"to_region": "us-west-2",
"trigger": "chaos-drill",
"rpo_observed": 30,
"rto_observed": 120
}{
"success": true,
"data": {
"failover_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"event_id": "fov-{{dynamic:slug}}",
"federation_id": "{{var:federation_id}}",
"from_region": "us-east-1",
"to_region": "us-west-2",
"trigger": "chaos-drill",
"rpo_observed": 30,
"rto_observed": 120,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"trigger": "chaos-drill"
}GET/routes/:federation_id/:query_class🔒 auth
Resolves a sanctioned cross-pool federation route: given a federation_id and one of the four sanctioned query classes, returns the federation.route row as {route_id, federation_id, query_class, target_pool_indexes[], execution_plan, created_at, last_used_at}. Reads are cache-first (60s TTL) when a route cache is wired in, falling back to Postgres on miss and writing through; pass ?bypass_cache=true to force the DB read. QA edge cases: query_class is whitelist-validated against exactly ['resolver','dsar','analytics','lineage'] — anything else, including correct-looking values in the wrong case ('Resolver') or a typo, is 400 invalid_query_class, and that check runs BEFORE any lookup. federation_id is not validated at all, so an unknown or malformed federation_id falls through to a 404 route_not_found rather than a 400 — that is the missing-FK signal for this endpoint. bypass_cache is compared strictly to the string 'true', so 'false', '1', 'TRUE' and an omitted param all mean cache-enabled; a stale cache entry can therefore serve a route that was just deleted until the TTL expires, which is why the bypass flag exists. The call is idempotent for the caller but does write asynchronously — it fire-and-forgets an UPDATE of last_used_at, so consecutive reads return a changing last_used_at. Single-row lookup (LIMIT 1), no pagination, no tenant scoping. When mounted standalone on :8083 the route is unauthenticated, but inside the api-gateway /routes/* is not on the authGate.ts public allowlist, so the default-deny gate requires a valid tenant JWT — hence requiresAuth:true. The handler has no try/catch, so a datastore failure surfaces as Fastify's default 500 envelope.
[ "POST /api/auth/signup-tenant" ]
query_class: resolver, dsar, analytics, lineage| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Gateway-mounted deployment: no Authorization: Bearer header — /routes/* is not on the authGate.ts public allowlist. Not produced by the standalone :8083 binary |
| 401 | Unauthorized | Invalid or expired token | Gateway-mounted deployment: the bearer token fails verifyJwt (bad signature, malformed, or expired) |
| 400 | invalid_query_class | query_class must be one of resolver,dsar,analytics,lineage | :query_class is not one of the four SANCTIONED_CLASSES (checked before any lookup; case-sensitive) |
| 404 | route_not_found | route_not_found | resolveRoute returns null — no federation.route row exists for the (federation_id, query_class) pair, including an unknown or malformed federation_id |
| 500 | Internal Server Error | Internal Server Error | resolveRoute throws — the federation.route query fails, the route cache backend errors, or the DB pool is unavailable; the handler has no catch so Fastify's default error handler responds |
{
"federation_id": "{{var:federation_id}}",
"query_class": "resolver"
}{
"success": true,
"data": {
"route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}registry-mcp
POST/mcp/v1/call🔒 auth
Single dispatch entry point for the registry MCP service: the caller names a tool and passes its arguments, and the service routes to either a read dispatcher or a write dispatcher depending on whether the name is in the write-tool set. Authentication accepts EITHER an Authorization bearer token or an x-projex-api-key header; tenant context is derived from whichever is present, and the resolved tenant is also the rate-limit key. Note that a failing TOOL still returns HTTP 200 — the failure is carried in the body as isError=true with the detail in content[0].text, because the HTTP call itself succeeded. Only transport-level problems (auth, rate limit, malformed request) produce non-2xx. Every invocation is audited with the tool name, duration and error code.
[ "POST /api/auth/register" ]
isError: True, False| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | name is required | The request body omits name, or it is empty |
| 401 | Unauthorized | missing Authorization header or x-projex-api-key | Neither an Authorization bearer token nor an x-projex-api-key header is present |
| 429 | RateLimited | rate limited | The resolved tenant has exceeded its allowance for this window |
{
"name": "list_sdks",
"arguments": {}
}{
"name": "list_sdks",
"arguments": {}
}{
"success": true,
"data": {
"call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "list_sdks",
"arguments": {},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"content": "array",
"isError": "boolean"
}sdk-agent-runtime
GET/api/agent-runtime/agents🔒 auth
Lists agent_definition rows, optionally filtered by tenant_id and tier, with limit/offset paging. Requires a valid tenant JWT (requireAuth). Edge cases: tenant_id is read from the query string, not the JWT, so an absent tenant_id lists across tenants and a foreign tenant_id is NOT rejected - verify tenant scoping deliberately; limit/offset go through parseInt so a non-numeric or empty value yields NaN and surfaces as a 500 "List failed" rather than a 400; an unknown tier value or a tenant with no definitions returns 200 with an empty array, not a 404; a malformed non-UUID tenant_id fails the Postgres uuid cast and becomes a 500.
[ "POST /api/auth/register" ]
tier: sync, orchestration, batch| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 500 | ListFailed | List failed | listAgentDefinitions throws - NaN limit/offset from a non-numeric query value, a non-UUID tenant_id failing the uuid cast, or any DB error |
| 500 | InternalError | InternalError | the handler throws outside its own try/catch and the route wrapper catch fires |
{
"success": true,
"data": [
{
"agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/agent-runtime/agents🔒 auth
Create an agent_definition. Required: name, acting_persona_id, tier, vector_namespace, created_by. Returns the row including server-generated agent_id + timestamps.
[ "POST /api/auth/register" ]
tier: sync, orchestration, batch| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization header, or it does not match /^Bearer\s+(.+)$/. Enforced twice: the gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) and the route's own requireAuth preHandler. |
| 401 | Unauthorized | Invalid or expired token | Bearer token present but verifyJwt() throws — bad signature, malformed JWT, or exp in the past. |
| 400 | ValidationError | Required: name, acting_persona_id, tier, vector_namespace, created_by | createAgentDefinitionHandler falsy-checks the body: any of name, acting_persona_id, tier, vector_namespace or created_by missing, empty string, or the body absent entirely. Optional fields (tenant_id, description, agent_scope, default_ttl_seconds, kill_switch_flag_id, tool_manifest) are not checked here. |
| 500 | CreateFailed | Create failed | The INSERT into agents.agent_definition rejects. The handler catches every DB error and flattens it to 500 — there is no 400/409 mapping in this route. Real triggers: tier outside CHECK (sync|orchestration|batch); default_ttl_seconds <= 0 or > 3600 (CHECK); acting_persona_id, tenant_id or kill_switch_flag_id not a valid UUID literal (acting_persona_id is a bare UUID column with no FK, so a non-existent persona inserts fine — only bad syntax fails); name/vector_namespace/created_by NULL. Note: a duplicate name is NOT an error — there is no unique index on agent_definition, so repeated POSTs create distinct agent_id rows (endpoint is non-idempotent). |
| 500 | InternalError | InternalError | The route-level try/catch in sdk-agent-runtime registerRoutes fires — an error escaping createAgentDefinitionHandler itself (e.g. pool acquisition failure) before a reply was sent. |
{
"tenant_id": null,
"name": "{{dynamic:name}}",
"description": "QA automation agent definition",
"acting_persona_id": "{{var:acting_persona_id}}",
"agent_scope": [],
"default_ttl_seconds": 30,
"tier": "sync",
"kill_switch_flag_id": null,
"vector_namespace": "agents-qa-ns-default",
"tool_manifest": [
"crm.contact.create"
],
"created_by": "qa-automation"
}{
"tenant_id": null,
"name": "Acme QA Sample",
"description": "QA automation agent definition",
"acting_persona_id": "{{var:acting_persona_id}}",
"agent_scope": [],
"default_ttl_seconds": 30,
"tier": "sync",
"kill_switch_flag_id": null,
"vector_namespace": "agents-qa-ns-default",
"tool_manifest": [
"crm.contact.create"
],
"created_by": "qa-automation"
}{
"success": true,
"data": {
"agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": null,
"name": "Acme QA Sample",
"description": "QA automation agent definition",
"acting_persona_id": "{{var:acting_persona_id}}",
"agent_scope": [],
"default_ttl_seconds": 30,
"tier": "sync",
"kill_switch_flag_id": null,
"vector_namespace": "agents-qa-ns-default",
"tool_manifest": [
"crm.contact.create"
],
"created_by": "qa-automation",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}GET/api/agent-runtime/agents/:id🔒 auth
Fetch an agent_definition by id.
[ "POST /api/auth/register", "POST /api/agent-runtime/agents" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization header or a non-Bearer scheme. Applied by the gateway default-deny authGate and again by the route's requireAuth preHandler. |
| 401 | Unauthorized | Invalid or expired token | verifyJwt() rejects the bearer token — tampered signature, wrong secret, or expired exp claim. |
| 400 | ValidationError | Missing path param: id | req.params.id is falsy. Practically unreachable through the router (an empty :id segment matches the sibling GET /api/agent-runtime/agents collection route instead); reachable when the handler is called directly. |
| 404 | NotFound | agent_definition not found | The id is a syntactically valid UUID but no row exists in agents.agent_definition. Note this lookup is by agent_id ONLY — it is not tenant-scoped, so a caller with any valid tenant JWT can read another tenant's agent definition; the 404 is purely existence-based. |
| 500 | LookupFailed | Lookup failed | The SELECT throws. Dominant real case: :id is not a valid UUID (e.g. 'abc' or a truncated id) — Postgres raises 'invalid input syntax for type uuid', which surfaces as 500 rather than 400/404. |
| 500 | InternalError | InternalError | Route-level try/catch in registerRoutes — an error thrown outside the handler's own catch, with no reply already sent. |
{
"id": "{{cache:agent-runtime-agents.create.response.data.agent_id}}"
}{
"success": true,
"data": {
"agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}GET/api/agent-runtime/health🔒 auth
Static liveness probe for sdk-agent-runtime; returns {sdk:"sdk-agent-runtime", status:"ok"} with no DB or downstream call. Public: the gateway authGate allowlists any path ending in /health, so it answers 200 with no Authorization header, with a malformed or expired bearer token, and regardless of tenant. Edge cases: the body is a constant so no data path can 404 or 500; query strings and extra headers are ignored; a non-GET verb on this path is a Fastify 404 route-miss rather than a handler error.
[ "POST /api/auth/register" ]
{
"success": true,
"data": [
{
"health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/agent-runtime/runs🔒 auth
Lists agent_run rows, optionally filtered by tenant_id, agent_id and status, with limit/offset paging. Requires a valid tenant JWT (requireAuth). Edge cases: filters come from the query string, not the JWT, so omitting tenant_id lists across tenants and a foreign tenant_id is not rejected - tenant scoping must be tested explicitly; limit/offset go through parseInt, so a non-numeric value becomes NaN and surfaces as a 500 "List failed" instead of a 400; an unknown status value or an agent_id with no runs returns 200 with an empty array rather than a 404; a non-UUID tenant_id or agent_id fails the uuid cast and yields a 500.
[ "POST /api/auth/register" ]
status: running, completed, failed, terminated_ttl_expired, terminated_kill_switch, terminated_quota| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 500 | ListFailed | List failed | listAgentRuns throws - NaN limit/offset, a non-UUID tenant_id or agent_id failing the uuid cast, or any DB error |
| 500 | InternalError | InternalError | the handler throws outside its own try/catch and the route wrapper catch fires |
{
"success": true,
"data": [
{
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/agent-runtime/runs🔒 auth
Start an agent_run. Computes ttl_deadline from agent's default_ttl_seconds, materialises agent_chain from parent_run_id, emits agent.run.started.v1 (regulated retention) with chain as actor provenance.
[ "POST /api/auth/register", "POST /api/personas", "POST /api/agent-runtime/agents" ]
actor_kind: human, service, agent| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | Required: agent_id, persona_id, trace_id, actor_id, actor_kind | The body is missing any one of agent_id, persona_id, trace_id, actor_id or actor_kind |
| 404 | NotFound | <entity> not found | startAgentRun throws an error whose message contains "not found" — e.g. the referenced agent_id has no agent_definition row |
| 500 | InternalError | Start run failed | startAgentRun throws for any other reason (constraint violation, model snapshot resolution failure, database unreachable) |
{
"agent_id": "{{cache:agent-runtime-agents.create.response.data.agent_id}}",
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"trace_id": "{{var:trace_id}}",
"parent_run_id": null,
"ttl_seconds": 300,
"actor_id": "{{var:operator_id}}",
"actor_kind": "human"
}{
"agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trace_id": "{{var:trace_id}}",
"parent_run_id": null,
"ttl_seconds": 300,
"actor_id": "{{var:operator_id}}",
"actor_kind": "human"
}{
"success": true,
"data": {
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trace_id": "{{var:trace_id}}",
"parent_run_id": null,
"ttl_seconds": 300,
"actor_id": "{{var:operator_id}}",
"actor_kind": "human",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}GET/api/agent-runtime/runs/:id🔒 auth
Fetch a single agent_run including agent_chain + execution_log_ref.
[ "POST /api/auth/register", "POST /api/agent-runtime/runs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | Missing path param: id | The :id path segment resolves to an empty value |
| 404 | NotFound | agent_run not found | getAgentRun returns no row for the supplied id |
| 500 | InternalError | Lookup failed | getAgentRun throws — e.g. id is not a valid UUID and the query cast fails, or the database is unreachable |
{
"id": "{{cache:agent-runtime-runs.create.response.data.run_id}}"
}{
"success": true,
"data": {
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}POST/api/agent-runtime/runs/:run_id/replay🔒 auth
Replay an agent run against the recorded model snapshot. Returns {kind:'matched'|'snapshot-drift'|'diverged', ...}. AC-5. A freshly-started run has no execution_log_entry rows yet (those are appended only by the internal runtime engine, no HTTP producer), so replaying it returns 404 'no execution log entries'.
[ "POST /api/auth/register", "POST /api/personas", "POST /api/agent-runtime/agents", "POST /api/agent-runtime/runs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | Missing path param: run_id | The :run_id path segment resolves to an empty value |
| 404 | NotFound | <run> not found | no execution log entries | replayRun throws an error whose message contains "not found" or "no execution log entries" — the run does not exist, or it exists but recorded no replayable steps |
| 500 | InternalError | Replay failed | replayRun throws for any other reason (model snapshot unavailable, deterministic re-execution error, database unreachable) |
{
"run_id": "{{cache:agent-runtime-runs.create.response.data.run_id}}"
}{
"current_model_snapshot_id": null,
"dryRun": true
}{
"current_model_snapshot_id": null,
"dryRun": true
}{
"success": true,
"data": {
"status": "completed",
"current_model_snapshot_id": null,
"dryRun": true,
"replay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 404.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"success": false
}POST/api/agent-runtime/runs/:run_id/rollback🔒 auth
Roll back an agent run by replaying agents.action_journal in reverse from latest step down to ?to_seq=N. Invokes per-action_type compensation handlers. Returns RollbackSummary. AC-8. A freshly-started run has an empty action_journal, so rollback returns 200 with attempted=0.
[ "POST /api/auth/register", "POST /api/personas", "POST /api/agent-runtime/agents", "POST /api/agent-runtime/runs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | Missing path param: run_id | The :run_id path segment resolves to an empty value |
| 400 | ValidationError | to_seq must be an integer >= -1 | The to_seq query param is present but is not parseable as an integer, or parses to a value below -1 |
| 500 | InternalError | Rollback failed | rollbackRun throws — the run does not exist, a compensation handler fails, or the database is unreachable. Note this handler has no 404 branch, so an unknown run_id surfaces as 500 |
{
"run_id": "{{cache:agent-runtime-runs.create.response.data.run_id}}"
}{
"reason": "Operator-initiated rollback for testing",
"actor_id": "{{var:operator_id}}"
}{
"reason": "Operator-initiated rollback for testing",
"actor_id": "{{var:operator_id}}"
}{
"success": true,
"data": {
"status": "completed",
"reason": "Operator-initiated rollback for testing",
"actor_id": "{{var:operator_id}}",
"rollback_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}POST/api/agent-runtime/tokens🔒 auth
Mint a capability token for one tool invocation. HMAC-SHA256 signed, scope-limited to (agent_id, persona, tool_sku, args_hash, tenant_scope, expires_at), single-use. FR-ART-1..3. tool_sku MUST be in the agent's tool_manifest or the mint is denied with 403 scope_violation.
[ "POST /api/auth/register", "POST /api/agent-runtime/agents", "POST /api/agent-runtime/runs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | MissingRequiredField | Missing required field: run_id, agent_id, acting_persona_id, tool_sku, args, tenant_scope are required | any of run_id, agent_id, acting_persona_id, tool_sku or tenant_scope is falsy, or args is undefined (args:null and args:{} are accepted - only undefined fails) |
| 403 | scope_violation | scope_violation | mintToken raises ScopeViolationError because the requested tool_sku is outside the agent definition allowed scope; the body also carries requested_sku, agent_id, exception_id and approval_request_id |
| 500 | MintFailed | Mint failed | mintToken throws anything other than ScopeViolationError - unknown agent_id or run_id foreign key, non-UUID identifiers failing the uuid cast, signing-key failure, or any DB error |
| 500 | InternalError | InternalError | the handler throws outside its own try/catch and the route wrapper catch fires |
{
"run_id": "{{cache:agent-runtime-runs.create.response.data.run_id}}",
"agent_id": "{{cache:agent-runtime-agents.create.response.data.agent_id}}",
"acting_persona_id": "{{var:acting_persona_id}}",
"tool_sku": "crm.contact.create",
"args": {
"first_name": "Ada"
},
"tenant_scope": "tenant-default",
"ttl_seconds": 60,
"actor_id": "{{var:operator_id}}"
}{
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"acting_persona_id": "{{var:acting_persona_id}}",
"tool_sku": "crm.contact.create",
"args": {
"first_name": "Ada"
},
"tenant_scope": "tenant-default",
"ttl_seconds": 60,
"actor_id": "{{var:operator_id}}"
}{
"success": true,
"data": {
"token_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"acting_persona_id": "{{var:acting_persona_id}}",
"tool_sku": "crm.contact.create",
"args": {
"first_name": "Ada"
},
"tenant_scope": "tenant-default",
"ttl_seconds": 60,
"actor_id": "{{var:operator_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}POST/api/agent-runtime/tokens/:token_id/revoke🔒 auth
Revoke a capability token. Idempotent. Mid-flight tools polling isRevoked() observe the new state and self-cancel. FR-ART-4 / AC-3.
[ "POST /api/auth/register", "POST /api/agent-runtime/agents", "POST /api/agent-runtime/runs", "POST /api/agent-runtime/tokens" ]
actor_kind: human, service, agent| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | MissingPathParam | Missing path param: token_id | token_id resolves falsy (defensive - an empty path segment normally route-misses to 404 first) |
| 400 | MissingRequiredField | Missing required field: reason | body.reason is absent, an empty string, or not a string |
| 500 | RevokeFailed | Revoke failed | revokeToken throws - a non-UUID token_id failing the uuid cast, audit-emit failure, or any DB error |
| 500 | InternalError | InternalError | the handler throws outside its own try/catch and the route wrapper catch fires |
{
"entity": "agents.capability_token",
"field": "status",
"flow": [
"active",
"revoked"
],
"transitions": [
{
"from": "active",
"to": "revoked",
"via": "POST /api/agent-runtime/tokens/:token_id/revoke"
}
]
}{
"token_id": "{{cache:agent-runtime-tokens.mint.response.data.token_id}}"
}{
"reason": "Operator-initiated revoke for testing",
"actor_id": "{{var:operator_id}}",
"actor_kind": "human"
}{
"reason": "Operator-initiated revoke for testing",
"actor_id": "{{var:operator_id}}",
"actor_kind": "human"
}{
"success": true,
"data": {
"status": "completed",
"reason": "Operator-initiated revoke for testing",
"actor_id": "{{var:operator_id}}",
"actor_kind": "human",
"revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}POST/api/agent-runtime/tokens/:token_id/validate🔒 auth
Validate a capability token against the args the caller intends to invoke. Checks expiry, single-use, revocation, args binding, signature. Returns valid:true|false with reason. Always HTTP 200 (validity is in the body).
[ "POST /api/auth/register", "POST /api/agent-runtime/agents", "POST /api/agent-runtime/runs", "POST /api/agent-runtime/tokens" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | MissingPathParam | Missing path param: token_id | token_id resolves falsy (defensive - an empty path segment normally route-misses to 404 first) |
| 400 | MissingRequiredField | Missing required field: args | body.args is undefined (absent body, or a body with no args key); args:null passes the check |
| 500 | ValidateFailed | Validate failed | validateToken throws - a non-UUID token_id failing the uuid cast, or any DB error |
| 500 | InternalError | InternalError | the handler throws outside its own try/catch and the route wrapper catch fires |
{
"token_id": "{{cache:agent-runtime-tokens.mint.response.data.token_id}}"
}{
"args": {
"first_name": "Ada"
}
}{
"args": {
"first_name": "Ada"
}
}{
"success": true,
"data": {
"status": "completed",
"args": {
"first_name": "Ada"
},
"validate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}sdk-ai-gateway
POST/api/ai-gateway/complete🔒 auth
Non-streaming LLM completion. Pipeline: route resolution -> PII redaction -> credential unwrap -> retry-wrapped provider call -> ai_gateway.completion insert + ai-gateway.complete.v1 audit. Closes AC-1.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/agent-runtime/agents", "POST /api/agent-runtime/runs" ]
request.provider_hint: anthropic, openai, gemini, bedrock, local-llama, local-mistral| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | Missing required: request, context | body.request or body.context absent |
| 400 | ValidationError | request.model and request.prompt are required | request.model or request.prompt missing |
| 400 | ValidationError | context.agent_id, context.run_id, and context.trace_id are required | any of context.agent_id/run_id/trace_id missing |
| 503 | ProviderUnavailable | provider not available / no route matches and no provider_hint supplied | complete() throws an Error whose message includes 'not available' or 'no route matches' |
| 500 | CompletionFailed | Completion failed | any other error thrown by complete() (kill-switch, provider adapter failure, DB insert failure) |
{
"request": {
"model": "gpt-4o-mini",
"prompt": "Summarize the onboarding checklist in one sentence.",
"max_tokens": 100,
"temperature": 0.7,
"top_p": 1,
"stop_sequences": [],
"task_tag": "test",
"provider_hint": "openai",
"tools": [],
"stream": false
},
"context": {
"agent_id": "{{cache:agent-runtime-agents.create.response.data.agent_id}}",
"run_id": "{{cache:agent-runtime-runs.create.response.data.run_id}}",
"acting_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"trace_id": "{{dynamic:uuid}}",
"span_id": "{{dynamic:uuid}}",
"ttl_deadline": "{{dynamic:futuredatetime}}",
"agent_chain": []
}
}{
"request": {
"model": "gpt-4o-mini",
"prompt": "Summarize the onboarding checklist in one sentence.",
"max_tokens": 100,
"temperature": 0.7,
"top_p": 1,
"stop_sequences": [],
"task_tag": "test",
"provider_hint": "openai",
"tools": [],
"stream": false
},
"context": {
"agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"acting_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trace_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"span_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ttl_deadline": "2026-01-15T10:30:00Z",
"agent_chain": []
}
}{
"success": true,
"data": {
"complete_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"request": {
"model": "gpt-4o-mini",
"prompt": "Summarize the onboarding checklist in one sentence.",
"max_tokens": 100,
"temperature": 0.7,
"top_p": 1,
"stop_sequences": [],
"task_tag": "test",
"provider_hint": "openai",
"tools": [],
"stream": false
},
"context": {
"agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"acting_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trace_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"span_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ttl_deadline": "2026-01-15T10:30:00Z",
"agent_chain": []
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}GET/api/ai-gateway/healthpublic
Liveness probe for sdk-ai-gateway. Returns 200 with the constant body { sdk: 'sdk-ai-gateway', status: 'ok' }. QA edge cases: this path is on the api-gateway public allowlist (isHealth matches any path ending in /health), so it is reachable with NO Authorization header and must NOT return 401 — a 401 here means the authGate allowlist regressed, which is the one regression worth asserting; the handler has no requireAuth preHandler, touches no database, provider or credential store, and takes no parameters, so it reports only that the route is mounted and cannot detect a degraded provider or an unreachable Postgres — 'ok' is not a dependency health signal; it therefore has no error responses of its own.
{
"success": true,
"data": [
{
"health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"sdk": "sdk-ai-gateway",
"status": "ok"
}POST/api/ai-gateway/stream🔒 auth · manual
Streaming LLM completion (SSE). Yields StreamChunk frames as data: <json>\n\n; closes with data: [DONE]\n\n. Persists ai_gateway.completion on stream close.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/agent-runtime/agents", "POST /api/agent-runtime/runs" ]
request.provider_hint: anthropic, openai, gemini, bedrock, local-llama, local-mistral| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | Missing required: request, context | the body is absent, or either the request or the context object is missing |
| 400 | ValidationError | request.model and request.prompt are required | request is present but request.model or request.prompt is missing or empty |
| 400 | ValidationError | context.agent_id, context.run_id, and context.trace_id are required | context is present but any of agent_id, run_id or trace_id is missing or empty |
| 200 | StreamError | event: error / data: {"message":"<error>"} | the generator throws AFTER the SSE headers have been flushed (provider unavailable, no matching route, kill-switch tripped, provider mid-stream failure). The HTTP status is already committed as 200, so the failure arrives as an SSE error frame followed by stream close — it can never be a 4xx/5xx status, which is the key difference from /complete |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"request": {
"model": "claude-opus-4-7",
"prompt": "Stream a short greeting.",
"max_tokens": 100,
"temperature": 0.7,
"top_p": 1,
"stop_sequences": [],
"task_tag": "test",
"provider_hint": "anthropic",
"tools": [],
"stream": true
},
"context": {
"agent_id": "{{cache:agent-runtime-agents.create.response.data.agent_id}}",
"run_id": "{{cache:agent-runtime-runs.create.response.data.run_id}}",
"acting_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"trace_id": "{{dynamic:uuid}}",
"span_id": "{{dynamic:uuid}}",
"ttl_deadline": "{{dynamic:futuredatetime}}",
"agent_chain": []
}
}{
"request": {
"model": "claude-opus-4-7",
"prompt": "Stream a short greeting.",
"max_tokens": 100,
"temperature": 0.7,
"top_p": 1,
"stop_sequences": [],
"task_tag": "test",
"provider_hint": "anthropic",
"tools": [],
"stream": true
},
"context": {
"agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"acting_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trace_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"span_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ttl_deadline": "2026-01-15T10:30:00Z",
"agent_chain": []
}
}{
"success": true,
"data": {
"stream_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"request": {
"model": "claude-opus-4-7",
"prompt": "Stream a short greeting.",
"max_tokens": 100,
"temperature": 0.7,
"top_p": 1,
"stop_sequences": [],
"task_tag": "test",
"provider_hint": "anthropic",
"tools": [],
"stream": true
},
"context": {
"agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"acting_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trace_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"span_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ttl_deadline": "2026-01-15T10:30:00Z",
"agent_chain": []
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}GET/api/ai-gateway/tenant-credentials🔒 auth
Lists a tenant's AI provider credential bindings for the tenant-admin UI. Each entry carries the binding lifecycle metadata plus last_4 of the key; credential_envelope is never returned, which is the primary security assertion — a response containing envelope bytes or a full key is a defect. QA edge cases: tenant_id is a REQUIRED query param and is read straight from the URL rather than from the caller's JWT claims, so an authenticated user of tenant A can list tenant B's bindings by changing the query string — that cross-tenant isolation gap is the most valuable test here; an unknown tenant_id returns 200 with an empty bindings array rather than a 404, so 'no such tenant' and 'tenant with no bindings' are indistinguishable; the listing is unpaginated with no limit/offset and no status filter, so revoked bindings are returned alongside active ones and must be filtered client-side; any database failure is flattened to a generic 500 'list failed' with the real cause only in the server log.
[ "POST /api/auth/signup-tenant", "POST /api/ai-gateway/tenant-credentials" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param is required | the tenant_id query param is absent or empty |
| 500 | ListFailed | list failed | listTenantCredentials throws — malformed tenant UUID or a database error; the underlying message is logged but never returned |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"success": true,
"data": [
{
"tenant_credential_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"bindings": "array"
}
}POST/api/ai-gateway/tenant-credentials🔒 auth
Binds a tenant's own AI provider API key (BYOK, FR-BYOK-3). The raw key is enveloped by the credential service and the response deliberately contains only the binding metadata plus a last_4 — never raw_key and never credential_envelope, which is the primary security assertion for this endpoint. Optional model_allowlist restricts which models may use the tenant key, and fallback_on_error controls whether a failing tenant key falls back to the platform credential. QA edge cases: provider_id must be one of exactly anthropic, openai, bedrock or gemini — anything else is a 400 naming the unsupported value; raw_key must be a string of at least 8 characters, so a short or trimmed-to-empty key is a 400 rather than a provider-side failure at call time; every non-validation failure, including a duplicate active binding for the same (tenant_id, provider_id), surfaces as a 500 rather than a 409, so a re-bind test must expect 500 with the constraint message; the actor recorded on the binding falls back to the literal 'tenant-admin-ui' when the JWT carries no persona_id or sub; a model_allowlist that omits the model actually requested later does not fail here — it silently causes fall-through to the platform credential at completion time.
[ "POST /api/auth/signup-tenant" ]
provider_id: anthropic, openai, bedrock, gemini| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, provider_id, raw_key are required | the body is absent or any of tenant_id, provider_id or raw_key is missing or empty |
| 400 | ValidationError | unsupported provider_id: <provider_id> | provider_id is not one of 'anthropic', 'openai', 'bedrock', 'gemini' |
| 400 | ValidationError | raw_key must be a non-trivial string | raw_key is not a string or is shorter than 8 characters |
| 500 | BindFailed | <underlying error message> | bind failed | bindTenantCredential throws — duplicate active binding for the (tenant_id, provider_id) pair, envelope/KMS failure, invalid tenant UUID, or a database error. Note a duplicate binding is a 500, not a 409 |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"entity": "ai_gateway.tenant_provider_credential",
"field": "status",
"flow": [
"active",
"revoked"
],
"transitions": [
{
"from": "",
"to": "active",
"via": "POST /api/ai-gateway/tenant-credentials"
},
{
"from": "active",
"to": "revoked",
"via": "DELETE /api/ai-gateway/tenant-credentials/:binding_id"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"provider_id": "openai",
"raw_key": "{{static:sk-test-DUMMYKEYFORTESTING}}",
"model_allowlist": [
"gpt-4o",
"gpt-4o-mini"
],
"fallback_on_error": true
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider_id": "openai",
"raw_key": "sk-test-DUMMYKEYFORTESTING",
"model_allowlist": [
"gpt-4o",
"gpt-4o-mini"
],
"fallback_on_error": true
}{
"success": true,
"data": {
"tenant_credential_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider_id": "openai",
"raw_key": "sk-test-DUMMYKEYFORTESTING",
"model_allowlist": [
"gpt-4o",
"gpt-4o-mini"
],
"fallback_on_error": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"binding": {
"binding_id": "string",
"tenant_id": "string",
"provider_id": "string",
"status": "string",
"last_4": "string",
"bound_at": "string"
}
}
}DELETE/api/ai-gateway/tenant-credentials/:binding_id🔒 auth
Revokes an active tenant credential binding (FR-BYOK-6), requiring a human-readable reason of at least 6 characters after trimming — mirroring the CMEK BYOK revoke pattern so the audit ledger always carries a justification. QA edge cases: the reason guard trims first, so a body of six spaces is rejected as a 400 while 'typo!!' is accepted — assert the trim, not just the length; as with rotate, the 404-vs-500 split is decided by testing the thrown error message for the substring 'not found', so an unknown binding_id is a 404 and everything else is a 500; revoking an ALREADY-revoked binding is not idempotent-safe — the second call no longer matches an active row and returns 404 rather than 200, which is the repeat-call case to cover; revocation is a soft state change, so the binding row remains listable with a revoked status rather than disappearing; the important downstream effect is that completions for that (tenant, provider) fall back to the platform credential and start billing the markup SKU again, which is worth asserting end-to-end.
[ "POST /api/auth/signup-tenant", "POST /api/ai-gateway/tenant-credentials" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | binding_id path param is required | the :binding_id path segment is empty |
| 400 | ValidationError | reason must be at least 6 characters | body.reason is absent, or has fewer than 6 characters after trimming (whitespace-only reasons are rejected) |
| 404 | NotFound | <error message containing "not found"> | no active binding matches the binding_id — unknown id, or the binding was already revoked by an earlier call |
| 500 | RevokeFailed | <underlying error message> | revoke failed | revokeTenantCredential throws for any reason other than not-found — malformed binding_id UUID, audit/KMS failure, or a database error |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"entity": "ai_gateway.tenant_provider_credential",
"field": "status",
"flow": [
"active",
"revoked"
],
"transitions": [
{
"from": "active",
"to": "revoked",
"via": "DELETE /api/ai-gateway/tenant-credentials/:binding_id"
}
]
}{
"binding_id": "{{cache:ai-gateway-tenant-credentials.create.response.data.binding.binding_id}}"
}{
"reason": "{{static:rotating to a new provider account}}"
}{
"reason": "rotating to a new provider account"
}{
"success": true
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"binding": {
"binding_id": "string",
"status": "string",
"revoked_at": "string"
}
}
}PATCH/api/ai-gateway/tenant-credentials/:binding_id🔒 auth
Rotates the raw API key on an existing active tenant credential binding (FR-BYOK-5). binding_id and bound_at are preserved across the rotation so downstream references stay valid; only the enveloped key material and the rotation metadata change, and the response again never exposes raw_key or credential_envelope. QA edge cases: the 404-vs-500 split is message-driven, not type-driven — the handler inspects the thrown error string for the substring 'not found', so an unknown or already-revoked binding_id returns 404 while every other failure returns 500; that also means a future error message that happens to contain 'not found' would be misclassified; raw_key must be at least 8 characters (the same non-trivial-string guard as bind), so a rotation to an empty or short key is a 400; the endpoint does NOT verify the new key against the provider, so rotating to a syntactically valid but revoked upstream key succeeds here and only fails later at completion time; rotation is not idempotent in effect — each call re-envelopes and re-stamps, and the previous key material is not recoverable afterwards.
[ "POST /api/auth/signup-tenant", "POST /api/ai-gateway/tenant-credentials" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | binding_id path param is required | the :binding_id path segment is empty |
| 400 | ValidationError | raw_key must be a non-trivial string | body.raw_key is absent or shorter than 8 characters |
| 404 | NotFound | <error message containing "not found"> | no active ai_gateway.tenant_provider_credential row matches the binding_id (unknown id, or the binding was already revoked) |
| 500 | RotateFailed | <underlying error message> | rotate failed | rotateTenantCredential throws for any reason other than not-found — envelope/KMS failure, malformed binding_id UUID, or a database error |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"binding_id": "{{cache:ai-gateway-tenant-credentials.create.response.data.binding.binding_id}}"
}{
"raw_key": "{{static:sk-test-NEWDUMMYKEYAFTERROTATE}}"
}{
"raw_key": "sk-test-NEWDUMMYKEYAFTERROTATE"
}{
"success": true,
"data": {
"tenant_credential_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"raw_key": "sk-test-NEWDUMMYKEYAFTERROTATE",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"binding": {
"binding_id": "string",
"status": "string",
"last_4": "string"
}
}
}EVENT_CONTRACTevents://ai_gateway.tenant_credentialpublic · manual
Contract-only anchor for the three ai_gateway.tenant_credential.{bound,rotated,revoked}.v1 event types declared in packages/contracts/src/events.ts, all registered with regulated retention and an event-sourcing conflict policy. They are emitted as a side effect of the bind (POST), rotate (PATCH) and revoke (DELETE) /api/ai-gateway/tenant-credentials endpoints. QA edge cases: this definition declares no runtime surface — there is no route, no handler and therefore no error response of its own, so producer-side correctness must be asserted through those three endpoints' integration tests rather than here; the security-critical property to verify on the producer side is that no event payload ever carries raw_key or credential_envelope (last_4 and binding metadata only); because retention is 'regulated' these events are subject to the longer retention class, and because emission is best-effort inside the credential service a successful 2xx from bind/rotate/revoke does not by itself prove the event was appended.
{
"event_types": [
"ai_gateway.tenant_credential.bound.v1",
"ai_gateway.tenant_credential.rotated.v1",
"ai_gateway.tenant_credential.revoked.v1"
],
"retention_class": "regulated",
"conflict_policy": "event-sourcing"
}{
"event_types": [
"ai_gateway.tenant_credential.bound.v1",
"ai_gateway.tenant_credential.rotated.v1",
"ai_gateway.tenant_credential.revoked.v1"
],
"retention_class": "regulated",
"conflict_policy": "event-sourcing"
}{
"success": true,
"data": {
"ai_gateway.tenant_credential_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"success": true
}INTERNAL_FUNCTIONinternal://sdk-ai-gateway/completionService.emitCompletionEventpublic · manual
Internal (non-HTTP) contract for completionService.emitCompletionEvent, which stamps credential_source ('tenant' or 'platform') into the ai-gateway.complete.v1 / ai-gateway.stream.v1 audit payload so the meter ingest worker knows which SKUs to emit (FR-BYOK-9 / AC-4). On a BYOK call the tenant pays the provider directly, so billed_cost is 0, the ai-gateway.tokens.* markup SKU is suppressed, and only the governance SKU is emitted; on a platform-credential call both SKUs are emitted and billed_cost carries the margin. QA edge cases: the entire emit is wrapped in try/catch and only console.errors on failure, so an audit-ledger outage NEVER fails or degrades the completion — the completion still returns 200 while the billing event is silently lost, which is the revenue-leak scenario to watch for; consequently this function has no error return path of its own; the effect is observable only in the audit ledger payload (credential_source) and the persisted ai_gateway.completion.billed_cost, not in any HTTP response body.
{
"scenario": "tenant_credential",
"expected_audit_payload_field": "credential_source=tenant",
"expected_billed_cost": 0,
"expected_token_sku_emitted": false,
"expected_governance_sku_emitted": true
}{
"scenario": "tenant_credential",
"expected_audit_payload_field": "credential_source=tenant",
"expected_billed_cost": 0,
"expected_token_sku_emitted": false,
"expected_governance_sku_emitted": true
}{
"success": true,
"data": {
"completionService.emitCompletionEvent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"success": true
}{
"scenario": "platform_credential",
"expected_audit_payload_field": "credential_source=platform",
"expected_billed_cost_gt": 0,
"expected_token_sku_emitted": true,
"expected_governance_sku_emitted": true
}{
"scenario": "platform_credential",
"expected_audit_payload_field": "credential_source=platform",
"expected_billed_cost_gt": 0,
"expected_token_sku_emitted": true,
"expected_governance_sku_emitted": true
}{
"success": true,
"data": {
"completionService.emitCompletionEvent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"success": true
}INTERNAL_FUNCTIONinternal://sdk-ai-gateway/completionService.loadProviderRowpublic · manual
Internal (non-HTTP) contract for completionService.loadProviderRow, the BYOK credential resolver behind /api/ai-gateway/complete and /stream. Resolution order per FR-BYOK-2: (1) an active row in ai_gateway.tenant_provider_credential for (tenant, provider) — but if its model_allowlist is non-null and the requested model is absent from it, treat as no tenant credential and fall through (FR-BYOK-6); (2) the platform row in ai_gateway.provider. QA edge cases: the function NEVER throws — it returns null when neither row exists, and the caller (complete/stream) is what raises '[ai-gateway] provider <id> not available', which the controller maps to HTTP 503; the caching behaviour is the subtle part — results are cached per (tenant, provider) with no model in the key, so a model-allowlist fall-through deliberately bypasses and does not poison the cache, and a test that binds an allowlisted credential then immediately requests a non-allowlisted model must still see the platform credential; a disabled platform row is still returned (status is carried on the row, not filtered in the query), so 'disabled provider' is enforced downstream rather than here; a null/undefined tenant_id skips step 1 entirely and resolves platform-only.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 503 | ProviderNotAvailable | [ai-gateway] provider <provider_id> not available | loadProviderRow returns null (no active tenant binding and no platform ai_gateway.provider row) — the resolver itself does not throw; complete()/stream() raise this and completionController maps messages containing "not available" to HTTP 503 |
{
"tenant_id": "{{static:00000000-0000-0000-0000-000000000001}}",
"provider_id": "openai",
"model": "gpt-4o",
"tenant_binding": "active",
"expected_credential_source": "tenant"
}{
"tenant_id": "00000000-0000-0000-0000-000000000001",
"provider_id": "openai",
"model": "gpt-4o",
"tenant_binding": "active",
"expected_credential_source": "tenant"
}{
"success": true,
"data": {
"completionService.loadProviderRow_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"success": true
}{
"tenant_id": "{{static:00000000-0000-0000-0000-000000000002}}",
"provider_id": "openai",
"model": "gpt-4o",
"tenant_binding": "absent",
"expected_credential_source": "platform"
}{
"tenant_id": "00000000-0000-0000-0000-000000000002",
"provider_id": "openai",
"model": "gpt-4o",
"tenant_binding": "absent",
"expected_credential_source": "platform"
}{
"success": true,
"data": {
"completionService.loadProviderRow_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"success": true
}{
"tenant_id": "{{static:00000000-0000-0000-0000-000000000003}}",
"provider_id": "openai",
"model": "gpt-4o",
"tenant_binding": "active",
"model_allowlist": [
"gpt-4o-mini"
],
"expected_credential_source": "platform"
}{
"tenant_id": "00000000-0000-0000-0000-000000000003",
"provider_id": "openai",
"model": "gpt-4o",
"tenant_binding": "active",
"model_allowlist": [
"gpt-4o-mini"
],
"expected_credential_source": "platform"
}{
"success": true,
"data": {
"completionService.loadProviderRow_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"success": true
}sdk-analytics
POST/api/analytics/builds/:build_id/export🔒 auth
Exports an already-materialized dataset build to the warehouse / object store, optionally targeting a named destination via body.target (omitting it uses the configured default). Edge cases: an unknown build_id or a build owned by another tenant returns 404; the body is entirely optional; re-exporting the same build is allowed and simply overwrites/re-emits the artifact; large builds and object-store failures surface as 500.
[ "POST /api/auth/signup-tenant", "POST /api/assets", "POST /api/analytics/datasets", "POST /api/analytics/datasets/:spec_id/build" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | BadRequest | tenant context required | the verified JWT carries no tenant_id claim |
| 404 | NotFound | dataset build not found | build_id does not exist or belongs to a different tenant |
| 500 | InternalError | <error message from exportDatasetBuild> | the service/DB call throws (bad asset/sensor reference, ClickHouse or Postgres failure) |
{
"build_id": "{{cache:analytics.build.response.data.build_id}}"
}{
"target": "iceberg://warehouse/datasets"
}{
"target": "iceberg://warehouse/datasets"
}{
"success": true,
"data": {
"export_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target": "iceberg://warehouse/datasets",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/analytics/datasets🔒 auth
Lists every dataset spec belonging to the caller's tenant. Tenant scoping comes from the JWT claim, so a caller can never see another tenant's specs and a token with no tenant claim is rejected 400. Edge cases: a tenant with no specs returns an empty array with 200 (not 404); the endpoint takes no filter/pagination params, so the full spec list is returned each call.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | BadRequest | tenant context required | the verified JWT carries no tenant_id claim |
| 500 | InternalError | <error message from listDatasetSpecs> | the service/DB call throws (bad asset/sensor reference, ClickHouse or Postgres failure) |
{
"success": true,
"data": [
{
"dataset_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/analytics/datasets🔒 auth
Registers an ML feature/training-dataset spec (name + asset_id required; optional sensor_ids, grain, aggregations, label_source). tenant_id is taken from the JWT claim and never from the body, so specs are strictly tenant-scoped and a token without a tenant claim is rejected 400. Edge cases: missing name or asset_id -> 400; an empty or omitted sensor_ids means the spec covers every sensor on the asset; the route does not pre-validate that asset_id/sensor_ids exist, so a bad reference surfaces as a 500 from the insert; the call is NOT idempotent - repeating it with the same name creates another spec.
[ "POST /api/auth/signup-tenant", "POST /api/assets" ]
grain: minute, hour, dayaggregations: avg, min, max, last, count| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | BadRequest | tenant context required | the verified JWT carries no tenant_id claim |
| 400 | BadRequest | name and asset_id are required | body omits name or asset_id (or sends them empty) |
| 500 | InternalError | <error message from createDatasetSpec> | the service/DB call throws (bad asset/sensor reference, ClickHouse or Postgres failure) |
{
"name": "{{dynamic:name}}",
"asset_id": "{{cache:assets.create.response.data.asset_id}}",
"sensor_ids": [],
"grain": "minute",
"aggregations": [
"avg",
"min",
"max",
"last",
"count"
],
"label_source": {
"kind": "intervals",
"default_label": 0,
"intervals": []
}
}{
"name": "Acme QA Sample",
"asset_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"sensor_ids": [],
"grain": "minute",
"aggregations": [
"avg",
"min",
"max",
"last",
"count"
],
"label_source": {
"kind": "intervals",
"default_label": 0,
"intervals": []
}
}{
"success": true,
"data": {
"dataset_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"asset_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"sensor_ids": [],
"grain": "minute",
"aggregations": [
"avg",
"min",
"max",
"last",
"count"
],
"label_source": {
"kind": "intervals",
"default_label": 0,
"intervals": []
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/analytics/datasets/:spec_id/build🔒 auth
Materializes a feature window for a dataset spec over the [from, to] time range, producing a build row with lineage. Both from and to are required. Edge cases: a spec_id that does not exist or belongs to another tenant returns 404 (tenant scoping is applied inside the lookup); an inverted or zero-width from/to window is not rejected at the route and yields an empty/zero-row build; oversized windows and downstream warehouse failures surface as 500; builds are not idempotent - repeating the same window creates another build row in the reproducibility ledger.
[ "POST /api/auth/signup-tenant", "POST /api/assets", "POST /api/analytics/datasets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | BadRequest | tenant context required | the verified JWT carries no tenant_id claim |
| 400 | BadRequest | from and to are required | body omits either the from or the to window bound |
| 404 | NotFound | dataset spec not found | spec_id does not exist or belongs to a different tenant |
| 500 | InternalError | <error message from buildDatasetFromSpec> | the service/DB call throws (bad asset/sensor reference, ClickHouse or Postgres failure) |
{
"spec_id": "{{cache:analytics.dataset.response.data.spec_id}}"
}{
"from": "{{dynamic:pastdatetime}}",
"to": "{{dynamic:datetime}}"
}{
"from": "2026-01-15T10:30:00Z",
"to": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"build_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"from": "2026-01-15T10:30:00Z",
"to": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/analytics/datasets/:spec_id/builds🔒 auth
Returns the reproducibility ledger for a dataset spec - every build with its time window and lineage_ref - scoped to the caller's tenant. Edge cases: an unknown spec_id or one owned by another tenant is NOT a 404 here; the query simply returns an empty array with 200. No pagination params are accepted, so specs with a long build history return the whole list.
[ "POST /api/auth/signup-tenant", "POST /api/assets", "POST /api/analytics/datasets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | BadRequest | tenant context required | the verified JWT carries no tenant_id claim |
| 500 | InternalError | <error message from listDatasetBuilds> | the service/DB call throws (bad asset/sensor reference, ClickHouse or Postgres failure) |
{
"spec_id": "{{cache:analytics.dataset.response.data.spec_id}}"
}{
"success": true,
"data": {
"build_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}PUT/api/analytics/datasets/:spec_id/label-source🔒 auth
Sets or replaces the labeling source on a dataset spec for supervised training: either kind='intervals' (inline labeled time ranges plus an optional default_label) or kind='provider' (a provider joined against events/evidence, configured via provider_args). Edge cases: kind is strictly validated and any other value (including omitting it) is 400; an unknown spec_id or one owned by another tenant is 404; intervals are not validated for overlap, ordering, or emptiness at the route; the PUT fully replaces the previous label source, so it is idempotent for a given body.
[ "POST /api/auth/signup-tenant", "POST /api/assets", "POST /api/analytics/datasets" ]
kind: intervals, provider| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | BadRequest | tenant context required | the verified JWT carries no tenant_id claim |
| 400 | BadRequest | kind must be 'intervals' or 'provider' | body.kind is missing or is any value other than 'intervals' / 'provider' |
| 404 | NotFound | dataset spec not found | spec_id does not exist or belongs to a different tenant |
| 500 | InternalError | <error message from updateDatasetLabelSource> | the service/DB call throws (bad asset/sensor reference, ClickHouse or Postgres failure) |
{
"spec_id": "{{cache:analytics.dataset.response.data.spec_id}}"
}{
"kind": "intervals",
"default_label": 0,
"intervals": [
{
"from": "{{dynamic:pastdatetime}}",
"to": "{{dynamic:datetime}}",
"label": 1
}
],
"provider_args": {}
}{
"kind": "intervals",
"default_label": 0,
"intervals": [
{
"from": "2026-01-15T10:30:00Z",
"to": "2026-01-15T10:30:00Z",
"label": 1
}
],
"provider_args": {}
}{
"success": true,
"data": {
"label_source_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "intervals",
"default_label": 0,
"intervals": [
{
"from": "2026-01-15T10:30:00Z",
"to": "2026-01-15T10:30:00Z",
"label": 1
}
],
"provider_args": {},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-api-keys
GET/api/api-keys🔒 auth
Lists the API keys belonging to a tenant, identified by the required tenant_id query param, returning metadata only (the plaintext secret is never re-exposed after issue). Edge cases: tenant_id is mandatory and is trimmed, so a whitespace-only value is treated as missing and returns 400; a tenant with no keys returns 200 with an empty keys array rather than 404; tenant_id is read from the query string and not cross-checked against the JWT tenant_id, so scoping is by the supplied parameter — any authenticated caller who knows a tenant_id can enumerate that tenant's key metadata; the listing has no pagination or limit, so tenants with many keys return the full set in one response, and revoked/rotated keys remain in the listing with their status rather than being filtered out.
[ "POST /api/auth/signup-tenant", "POST /api/api-keys" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | tenant_id query param is required | The tenant_id query param is missing or trims to an empty string |
| 500 | InternalError | InternalError | listKeys throws — e.g. tenant_id is not a valid UUID and the query cast fails, or the database is unreachable |
{
"success": true,
"data": [
{
"api_key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"keys": "array"
}
}POST/api/api-keys🔒 auth
Issues a new API key for a tenant with an explicit scope list and optional rate limit and expiry, returning 201 with the key record plus the one-time plaintext secret. Edge cases: tenant_id is required and scopes must be a non-empty array of strings — an empty array, a non-array, or an array containing a non-string all fail as "scopes must be a non-empty string array"; rate_limit_rpm, when supplied, must be a finite positive number so 0 and negatives are rejected; expires_at must parse as ISO-8601 and an unparseable value is a 400, but a past expiry is not rejected by the validator; all validation failures are accumulated and returned together in one details array; tenant_id comes from the body rather than the JWT, and a tenant_id with no matching tenant row fails at insert and surfaces as a 500.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | tenant_id is required | tenant_id is missing or blank after trimming |
| 400 | ValidationError | scopes must be a non-empty string array | scopes is absent, not an array, empty, or contains a non-string element |
| 400 | ValidationError | rate_limit_rpm must be a positive number | rate_limit_rpm is supplied but is not finite or is <= 0 |
| 400 | ValidationError | expires_at must be ISO-8601 | expires_at is supplied but Date.parse cannot parse it |
| 500 | InternalError | InternalError | issueKey throws — e.g. tenant_id violates a foreign key, key hashing fails, or the database is unreachable |
{
"entity": "api-key",
"field": "status",
"flow": [
"active",
"rotating",
"revoked",
"expired"
],
"transitions": [
{
"from": "none",
"to": "active",
"via": "POST /api/api-keys"
},
{
"from": "active",
"to": "rotating",
"via": "POST /api/api-keys/:key_id/rotate"
},
{
"from": "active",
"to": "revoked",
"via": "POST /api/api-keys/:key_id/revoke"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"scopes": [
"crm.contact.read",
"engagement.encounter.create"
],
"rate_limit_rpm": 600,
"expires_at": "{{dynamic:futuredatetime}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"scopes": [
"crm.contact.read",
"engagement.encounter.create"
],
"rate_limit_rpm": 600,
"expires_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"api_key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"scopes": [
"crm.contact.read",
"engagement.encounter.create"
],
"rate_limit_rpm": 600,
"expires_at": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"key": {
"key_id": "string",
"prefix": "string",
"scopes": "array",
"status": "string"
},
"plaintext": "string"
}
}POST/api/api-keys/:key_id/revoke🔒 auth
Immediately revokes an API key by id so it stops authenticating, returning the updated key record. Edge cases: revokeKey only matches an ACTIVE key, so revoking a key that is already revoked returns 404 NotFound ("No active key with id ...") rather than a 200 no-op — the call is therefore not idempotent and a retry after a successful revoke will 404; an unknown key_id is likewise 404; a malformed (non-UUID) key_id fails the query cast and surfaces as a 500, since the route performs no id-format validation; there is no tenant ownership check against the JWT, so authorization rests on key ids being unguessable; revocation is immediate with no grace period, unlike rotate.
[ "POST /api/auth/signup-tenant", "POST /api/api-keys" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 404 | NotFound | No active key with id <key_id> | No active key matches key_id — it does not exist, or it was already revoked |
| 500 | InternalError | InternalError | revokeKey throws — e.g. key_id is not a valid UUID and the query cast fails, or the database is unreachable |
{
"entity": "api-key",
"field": "status",
"flow": [
"active",
"rotating",
"revoked",
"expired"
],
"transitions": [
{
"from": "active",
"to": "revoked",
"via": "POST /api/api-keys/:key_id/revoke"
},
{
"from": "rotating",
"to": "revoked",
"via": "POST /api/api-keys/:key_id/revoke"
}
]
}{
"key_id": "{{cache:api-keys.create.response.data.key.key_id}}"
}{}{
"success": true,
"data": {
"status": "completed",
"revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"key": {
"key_id": "string",
"status": "string"
}
}
}POST/api/api-keys/:key_id/rotate🔒 auth
Rotates an API key: mints a replacement secret and returns it with 201 while leaving the old key valid for a 24-hour grace window (FR-APK-4) so callers can cut over without downtime. Edge cases: only a rotatable (active, non-revoked) key matches, so rotating an already-revoked key returns 404 "No rotatable key with id ..."; an unknown key_id is also 404; rotation is not idempotent — each call mints a new secret and restarts the grace window, so a retried request produces a second replacement key rather than returning the first; the plaintext replacement secret appears only in this response and cannot be re-read afterwards; a malformed key_id fails the query cast and surfaces as 500.
[ "POST /api/auth/signup-tenant", "POST /api/api-keys" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 404 | NotFound | No rotatable key with id <key_id> | No rotatable key matches key_id — it does not exist, or it is already revoked |
| 500 | InternalError | InternalError | rotateKey throws — e.g. key_id is not a valid UUID, the replacement insert violates a constraint, or the database is unreachable |
{
"entity": "api-key",
"field": "status",
"flow": [
"active",
"rotating",
"revoked",
"expired"
],
"transitions": [
{
"from": "active",
"to": "rotating",
"via": "POST /api/api-keys/:key_id/rotate"
}
]
}{
"key_id": "{{cache:api-keys.create.response.data.key.key_id}}"
}{}{
"success": true,
"data": {
"status": "completed",
"rotate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"key": {
"key_id": "string",
"status": "string",
"rotated_from_key_id": "string"
},
"plaintext": "string"
}
}GET/api/applications🔒 auth
Every application belonging to the calling tenant, newest first. Scoped from the caller claims, so there is no tenant_id parameter to get wrong and no way to enumerate the applications of another tenant. Returns 200 with applications.
[ "POST /api/auth/signup-tenant", "POST /api/applications" ]
environment: live, teststatus: active, disabled| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — |
{
"success": true,
"data": [
{
"application_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"applications": "array"
}
}POST/api/applications🔒 auth
Creates the application a credential belongs to. One per thing that calls the platform - a backend, a scheduled job, a staging copy - so a leak, a rotation and a usage figure are each scoped to one integration rather than to the whole tenant. environment is a property of the APPLICATION, not of an individual key: a test application mints pk_test_ credentials and a live one mints pk_live_, so the two can never be confused by inspection. The tenant is taken from the caller JWT and never from the payload. slug is derived from the name, is unique within the tenant, and is the client_id used by the client_credentials grant. Answers 201 with the application, or 409 when that slug is already taken in this tenant.
[ "POST /api/auth/signup-tenant" ]
environment: live, teststatus: active, disabled| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — | |
— | — | — |
{
"name": "Journey backend",
"environment": "live",
"description": "Server-to-server calls from our backend"
}{
"name": "Journey backend",
"environment": "live",
"description": "Server-to-server calls from our backend"
}{
"success": true,
"data": {
"application_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Journey backend",
"environment": "live",
"description": "Server-to-server calls from our backend",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"application": {
"application_id": "string",
"slug": "string",
"environment": "string",
"status": "string"
}
}
}GET/api/applications/:application_id🔒 auth
One application and every key issued under it, revoked ones included so the history stays legible. Constrained to the tenant of the caller: an application_id belonging to somebody else answers 404 rather than 403, because a 403 confirms the id is real - exactly the fact an attacker enumerating ids is trying to establish. Returns 200 with application and keys.
[ "POST /api/auth/signup-tenant", "POST /api/applications" ]
status: active, rotating, revoked, expiredenvironment: live, test| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — |
{
"application_id": "{{cache:applications.create.response.data.application.application_id}}"
}{
"success": true,
"data": {
"application_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"application": {
"application_id": "string"
},
"keys": "array"
}
}PATCH/api/applications/:application_id🔒 auth
Updates an application's mutable metadata and returns 200 with data.application. ONLY name and description can be changed - every other field, including slug and status, is ignored rather than rejected, so a caller sending {slug:'new'} gets a 200 and no change. Use POST /api/applications/:application_id/disable to change status; slug is immutable because it is the client_id half of the client_credentials grant and rewriting it would silently break every integration holding a key. Both fields are optional and read only when they are strings, so a PATCH with an empty body is a valid no-op that returns the unchanged record. The tenant is taken from the verified claim via tenantOf(); an application_id belonging to another tenant is 404, not 403, so the route never confirms another tenant's applications exist. Unlike createApplicationHandler this route does NOT call tenantMismatch(), so a body tenant_id is simply ignored rather than refused.
[ "POST /api/auth/signup-tenant", "POST /api/applications" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header - the gateway default-deny authGate |
| 400 | ValidationError | This credential carries no tenant context | tenantOf() cannot resolve a tenant - the JWT carries no tenant_id claim, e.g. a bare /api/auth/register token |
| 404 | NotFound | No such application | No application matches (application_id, tenant_id) - including one owned by a DIFFERENT tenant, which answers 404 rather than 403 |
| 500 | InternalError | InternalError | updateApplication throws - a non-UUID application_id that fails the Postgres uuid cast, or any database error |
{
"application_id": "{{cache:applications.create.response.data.application.application_id}}"
}{
"name": "renamed-integration-{{dynamic:slug}}",
"description": "Updated by the api_definition regression suite"
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"application": {
"application_id": "string",
"name": "string"
}
}
}POST/api/applications/:application_id/disable🔒 auth
Switches an application off and revokes every key it owns in the SAME transaction, so there is no window - and on a failure no permanent state - where an application an operator believes is off still has live credentials calling the platform. The response names the revoked key ids so the operator sees what just stopped rather than discovering it when an integration starts failing. This is the switch to reach for when a credential has leaked. Answers 200; a second call answers 404 because the application is no longer active.
[ "POST /api/auth/signup-tenant", "POST /api/applications" ]
status: active, disabled| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — |
{
"application_id": "{{cache:applications.create.response.data.application.application_id}}"
}{}{
"success": true,
"data": {
"disable_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"application": {
"status": "string"
},
"revoked_key_ids": "array"
}
}POST/api/applications/:application_id/keys🔒 auth
Mints a credential under an application and returns the plaintext EXACTLY ONCE - only a keyed one-way hash and a display prefix are stored, so a lost value can only be replaced by rotating. The prefix follows the environment of the application (pk_test_ or pk_live_) rather than being chosen by the caller. scopes follow domain.resource.action and may use a tail wildcard such as sla.* so a key does not silently stop working when a new resource ships. rate_limit_rpm is enforced per key with 429 and the RateLimit headers; expires_at must be in the future. Answers 201, 404 for an application belonging to another tenant, and 409 when the application is disabled.
[ "POST /api/auth/signup-tenant", "POST /api/applications" ]
scopes: sla.clock.read, sla.clock.write, sla.policy.read, crm.contact.read, notification.send.writeenvironment: live, teststatus: active, rotating, revoked, expired| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — | |
— | — | — |
{
"application_id": "{{cache:applications.create.response.data.application.application_id}}"
}{
"name": "nightly sync",
"scopes": [
"sla.clock.read",
"sla.clock.write"
],
"rate_limit_rpm": 600,
"expires_at": "{{dynamic:futuredatetime+30d}}"
}{
"name": "nightly sync",
"scopes": [
"sla.clock.read",
"sla.clock.write"
],
"rate_limit_rpm": 600,
"expires_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "nightly sync",
"scopes": [
"sla.clock.read",
"sla.clock.write"
],
"rate_limit_rpm": 600,
"expires_at": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"key": {
"key_id": "string",
"prefix": "string",
"environment": "string"
},
"plaintext": "string"
}
}POST/api/auth/tokenpublic
RFC 6749 section 4.4. Exchanges an application credential (client_id is the application slug or id, client_secret is the pk_live_ or pk_test_ key) for a short-lived service JWT carrying the scopes, tenant and synthetic persona of the key, with actor.kind service so machine traffic stays distinguishable from a human in the audit trail. Public by necessity - the credential IS the request body - and never cached (Cache-Control no-store). The optional scope parameter may NARROW what the key holds and can never widen it. Invalid, revoked and expired credentials all answer invalid_client with identical wording, so probing cannot confirm that a client exists. Because a minted token cannot be revoked before it expires, its lifetime is the revocation delay and is capped at one hour.
[
"POST /api/auth/signup-tenant",
"POST /api/applications",
"POST /api/applications/{application_id}/keys"
]grant_type: client_credentialstoken_type: Bearererror: invalid_request, invalid_client, invalid_scope, unsupported_grant_type| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — | |
— | — | — | |
— | — | — | |
— | — | — |
{
"grant_type": "client_credentials",
"client_id": "{{cache:applications.create.response.data.application.slug}}",
"client_secret": "{{cache:applications.id-keys.response.data.plaintext}}"
}{
"grant_type": "client_credentials",
"client_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"client_secret": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"token_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"grant_type": "client_credentials",
"client_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"client_secret": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"access_token": "string",
"token_type": "string",
"expires_in": "number",
"scope": "string"
}GET/api/keys🔒 auth
Lists the API keys issued to the tenant named in the required tenant_id query param, delegating to sdk-api-keys so the canonical schema (prefix, key_hash BYTEA, scopes[], synthetic_persona_id) is honoured. Never returns key plaintext - only metadata. Gated by the gateway default-deny authGate, so a valid tenant JWT is required. Edge cases: tenant_id comes from the query string rather than the JWT, so a caller may list another tenant keys - tenant scoping must be tested explicitly, and an absent tenant_id is a 400 rather than an implicit self-scope; a tenant with no keys returns 200 with an empty array, not a 404; revoked keys are included in the result, so the caller must filter on state; there is no paging, so a tenant with many keys returns the whole set; a non-UUID tenant_id fails the uuid cast inside listKeys and surfaces as a 500 echoing the raw driver message.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/keys" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 400 | ValidationError | tenant_id required | the tenant_id query param is absent or empty |
| 500 | ListFailed | <error message> | the sdk-api-keys dynamic import or listKeys throws - a non-UUID tenant_id failing the uuid cast, or any DB error; the raw message is echoed in error |
{
"success": true,
"data": [
{
"key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": "array"
}POST/api/keys🔒 auth
Issues a new API key for a tenant and returns {key_id, plaintext}. The plaintext is returned exactly once and is never recoverable afterwards (only the hash is stored), so the caller MUST capture it from this response. Scopes may be supplied either as scopes[] or as a single scope string, which is normalised into a one-element array. Gated by the gateway default-deny authGate, so a valid tenant JWT is required. Edge cases: tenant_id is read from the body, not the JWT, so a caller may mint a key for another tenant - verify tenant scoping deliberately; an absent tenant_id and an empty effective scope set (no scopes[], no scope, or scopes:[]) produce the same single 400; scope strings are not validated against an allowlist in this route, so an unknown scope reaches issueKey; the endpoint is not idempotent - repeating the identical request mints an additional distinct key rather than returning the existing one; a non-UUID tenant_id or an unsatisfied tenant foreign key surfaces as a 500 with the raw driver message rather than a 400 or 404.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 400 | ValidationError | tenant_id + scope(s) required | tenant_id is absent, or the effective scope list is empty (neither scopes[] nor scope supplied, or scopes:[]) |
| 500 | IssueFailed | <error message> | the sdk-api-keys dynamic import or issueKey throws - a non-UUID tenant_id, an unsatisfied tenant foreign key, key-material generation failure, or any DB error; the raw message is echoed in error |
{
"entity": "api-key",
"field": "status",
"flow": [
"active",
"rotating",
"revoked",
"expired"
],
"transitions": [
{
"from": "none",
"to": "active",
"via": "POST /api/keys"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"name": "production-integration-key",
"scope": "crm.contact.read"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "production-integration-key",
"scope": "crm.contact.read"
}{
"success": true,
"data": {
"key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "production-integration-key",
"scope": "crm.contact.read",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"key_id": "string",
"plaintext": "string"
}
}POST/api/keys/:key_id/revoke🔒 auth
Revokes the API key named by the key_id path param, requiring a non-empty reason in the body, and returns {success:true} on the first successful revocation. Gated by the gateway default-deny authGate, so a valid tenant JWT is required. Edge cases: reason is trimmed before the check, so a whitespace-only string counts as missing and is a 400 - but note the reason is never persisted by this handler, it only gates the call; revocation is NOT idempotent in its response - a second revoke of the same key returns 404 "key not found or already revoked", the same status and message as a key that never existed, so the two cases are indistinguishable; there is no tenant check at all - the key is matched on key_id alone, so a caller can revoke another tenant key; a non-UUID key_id fails the uuid cast inside revokeKey and surfaces as a 500 with the raw driver message.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/keys" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 400 | ValidationError | reason required | body.reason is absent, empty, or whitespace-only after trim |
| 404 | NotFound | key not found or already revoked | revokeKey reports no row updated - the key_id does not exist, or it was already revoked by an earlier call |
| 500 | RevokeFailed | <error message> | the sdk-api-keys dynamic import or revokeKey throws - a non-UUID key_id failing the uuid cast, or any DB error; the raw message is echoed in error |
{
"entity": "api-key",
"field": "status",
"flow": [
"active",
"rotating",
"revoked",
"expired"
],
"transitions": [
{
"from": "active",
"to": "revoked",
"via": "POST /api/keys/:key_id/revoke"
}
]
}{
"key_id": "{{cache:keys.create.response.data.key_id}}"
}{
"reason": "compromised-credential-rotation"
}{
"reason": "compromised-credential-rotation"
}{
"success": true,
"data": {
"status": "completed",
"reason": "compromised-credential-rotation",
"revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": "boolean"
}POST/api/keys/:key_id/rotate🔒 auth
Rotates an API key: mints replacement material and returns 201 with the new record and its one-time plaintext. DEPRECATED ALIAS - /api/keys/* is retained for older integrations behind a deprecate preHandler and delegates to exactly the same rotateKeyHandler as the canonical /api/api-keys/:key_id/rotate. New callers should use the canonical path. Answers 201 rather than 200 because rotation CREATES new key material rather than editing the existing row; the plaintext is returned exactly once and is unrecoverable afterwards, so it must be captured from this response. Rotation is not idempotent - each call mints another key. A key_id that is already revoked, or that belongs to another tenant, is 404 'No rotatable key with that id' rather than 403, so the route never confirms another tenant's keys exist.
[ "POST /api/auth/signup-tenant", "POST /api/keys" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header - the gateway default-deny authGate |
| 400 | ValidationError | This credential carries no tenant context | tenantOf() cannot resolve a tenant - the JWT carries no tenant_id claim |
| 404 | NotFound | No rotatable key with that id | No active key matches (key_id, tenant_id) - the key never existed, is already revoked or expired, or belongs to a DIFFERENT tenant |
| 500 | InternalError | InternalError | rotateKey throws - a non-UUID key_id that fails the Postgres uuid cast, key-material generation failure, or any database error |
{
"key_id": "{{cache:keys.create.response.data.key_id}}"
}{}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"data": {
"key": {
"key_id": "string",
"prefix": "string"
},
"plaintext": "string"
}
}sdk-approval
GET/api/approvals/requests🔒 auth
Gateway-composed inbox of PENDING approval requests for a tenant, oldest-first. Optional ?assignee_persona_id= narrows it to requests having at least one undecided step assigned to that persona — note assignment lives on approval.step, not on the request. Edge cases: ?tenant_id= is REQUIRED (400 if absent) and is caller-asserted; only status='pending' rows are ever returned, so approved/rejected history is unreachable here; hard-capped at LIMIT 100 with no paging cursor; an assignee with nothing queued returns an empty array, not 404; a non-UUID tenant_id or assignee_persona_id fails its ::uuid cast and returns 500.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/approvals/routes", "POST /api/approvals/requests" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Gateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent |
| 401 | Unauthorized | Invalid or expired token | authGate ran requireAuth and the JWT failed verification or had expired |
| 400 | ValidationError | tenant_id required | ?tenant_id= query param is absent or empty |
| 500 | InternalError | <postgres error text> | tenant_id or assignee_persona_id is not a valid UUID (::uuid cast fails), or the request query errors |
{
"success": true,
"data": [
{
"request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": "boolean",
"data": "array"
}POST/api/approvals/requests🔒 auth
Submits an approval request against an existing route, materialising the route steps into pending approval.step rows and returning the request plus its first step(s). Edge cases: tenant_id, route_id and initiator_persona_id must all be well-formed UUIDs; subject_kind and subject_id are required non-empty strings identifying what is being approved; a syntactically valid route_id matching no route row returns 404 RouteNotFound; the route's tenant is not cross-checked against the body tenant_id, so a mismatch is not rejected at this layer; there is no duplicate-request guard — submitting the same (subject_kind, subject_id) twice creates two independent requests.
[ "POST /api/auth/signup-tenant", "POST /api/approvals/routes" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | tenant_id must be a UUID / route_id must be a UUID / subject_kind is required / subject_id is required / initiator_persona_id must be a UUID | any validateSubmitRequest check fails; all failures are returned together in details[] |
| 404 | RouteNotFound | Route <route_id> not found | route_id is a valid UUID but no approval.route row matches |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"entity": "approval.request",
"field": "status",
"flow": [
"pending",
"approved",
"rejected",
"escalated",
"timed-out",
"cancelled"
],
"transitions": [
{
"from": null,
"to": "pending",
"via": "POST /api/approvals/requests"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"route_id": "{{cache:approvals.routes.create.response.data.route.route_id}}",
"subject_kind": "payment.refund",
"subject_id": "{{dynamic:slug}}",
"initiator_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"reason": "Customer dispute - high value refund"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_kind": "payment.refund",
"subject_id": "sample-slug",
"initiator_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "Customer dispute - high value refund"
}{
"success": true,
"data": {
"request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_kind": "payment.refund",
"subject_id": "sample-slug",
"initiator_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "Customer dispute - high value refund",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"request": {
"request_id": "string",
"status": "string"
},
"pending_steps": "array"
}
}GET/api/approvals/requests/:request_id🔒 auth
Reads one approval request with its steps and current status. Edge cases: an unknown :request_id returns 404 NotFound with the id echoed in details[]; the read is not tenant-filtered, so any authenticated caller holding a request_id can read it; a non-UUID :request_id reaches the query and is routed through the shared fail() mapper, which matches none of the typed errors and therefore returns 500 InternalError rather than 400.
[ "POST /api/auth/signup-tenant", "POST /api/approvals/routes", "POST /api/approvals/requests" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 404 | NotFound | Request <request_id> not found | no approval.request row matches :request_id |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"request_id": "{{cache:approvals.requests.create.response.data.request.request_id}}"
}{
"success": true,
"data": {
"request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"request": {
"request_id": "string",
"status": "string"
},
"steps": "array"
}
}POST/api/approvals/requests/:request_id/decide🔒 auth
Gateway request-level decision shortcut: stamps approval.request with status, final_decision, resolved_at and a reason of the form '[decided by <persona>] <comment>'. Distinct from the step-level decide route — it resolves the whole request in one write. Edge cases: decision, comment AND decider_persona_id are all mandatory (one 400 covers all three); the UPDATE is guarded by status='pending', so deciding an already-resolved request updates zero rows yet STILL returns {success:true} — the same holds for an unknown :request_id, so this endpoint can never report 404 or 409; decider_persona_id is only recorded in free text and is not checked against the step's assigned approver.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/approvals/routes", "POST /api/approvals/requests" ]
decision: approved, rejected| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Gateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent |
| 401 | Unauthorized | Invalid or expired token | authGate ran requireAuth and the JWT failed verification or had expired |
| 400 | ValidationError | decision + comment + decider_persona_id required | any of decision, comment or decider_persona_id is absent/empty |
| 500 | InternalError | <postgres error text> | :request_id is not a valid UUID, or decision is not an accepted approval.request status value |
{
"entity": "approval.request",
"field": "status",
"flow": [
"pending",
"approved",
"rejected"
],
"transitions": [
{
"from": "pending",
"to": "approved",
"via": "POST /api/approvals/requests/:request_id/decide"
},
{
"from": "pending",
"to": "rejected",
"via": "POST /api/approvals/requests/:request_id/decide"
}
]
}{
"request_id": "{{cache:approvals.requests.create.response.data.request.request_id}}"
}{
"decision": "approved",
"comment": "Approved after finance review",
"decider_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}"
}{
"decision": "approved",
"comment": "Approved after finance review",
"decider_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"decide_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"decision": "approved",
"comment": "Approved after finance review",
"decider_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": "boolean"
}GET/api/approvals/routes🔒 auth
Gateway-composed list of a tenant's approval routes (route_id, name, status, created_at) with a derived sla_minutes = the MAX sla_minutes across the route's steps JSON, defaulting to 0 when no step declares one. Edge cases: ?tenant_id= is REQUIRED (400 if absent) and is caller-asserted from the query string rather than taken from the JWT; results are newest-first and hard-capped at LIMIT 100 with no paging cursor, so tenants with more than 100 routes are silently truncated; a tenant with no routes returns 200 with an empty data array; a tenant_id that is not a UUID fails the ::uuid cast and returns 500.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/approvals/routes" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Gateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent |
| 401 | Unauthorized | Invalid or expired token | authGate ran requireAuth and the JWT failed verification or had expired |
| 400 | ValidationError | tenant_id required | ?tenant_id= query param is absent or empty |
| 500 | InternalError | <postgres error text> | tenant_id is not a valid UUID (::uuid cast fails), or the route query errors |
{
"success": true,
"data": [
{
"route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": "boolean",
"data": "array"
}POST/api/approvals/routes🔒 auth
Creates an approval route: a named, ordered chain of steps for a tenant, where each step is single (one approver_persona_id), m-of-n (m + approvers[]) or role (role_template_id). Optional kind_pattern binds the route to a subject kind and delegation_rules configure stand-ins. Edge cases: tenant_id must be a well-formed UUID (presence alone is not enough); steps must be a NON-EMPTY array and every element must carry a name plus a recognised kind with that kind's required field — one bad element rejects the whole payload; all validation failures are collected and returned together in details[]; an m-of-n step's m is not range-checked against approvers.length; delegation_rules is accepted only if it is an object and is otherwise silently dropped.
[ "POST /api/auth/signup-tenant" ]
kind: single, m-of-n, rolestatus: draft, active, deprecated| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | tenant_id must be a UUID / name is required / steps must be a non-empty array / each step needs {name, kind: single|m-of-n|role, ...} | any validateCreateRoute check fails; all failures are returned together in details[] |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"name": "high-value-refund",
"description": "Refunds above $10k",
"kind_pattern": "payment.refund > 10000 USD",
"steps": [
{
"name": "manager",
"kind": "single",
"approver_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"sla_minutes": 60
}
],
"delegation_rules": {}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "high-value-refund",
"description": "Refunds above $10k",
"kind_pattern": "payment.refund > 10000 USD",
"steps": [
{
"name": "manager",
"kind": "single",
"approver_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"sla_minutes": 60
}
],
"delegation_rules": {}
}{
"success": true,
"data": {
"route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "high-value-refund",
"description": "Refunds above $10k",
"kind_pattern": "payment.refund > 10000 USD",
"steps": [
{
"name": "manager",
"kind": "single",
"approver_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"sla_minutes": 60
}
],
"delegation_rules": {},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"route": {
"route_id": "string",
"status": "string"
}
}
}POST/api/approvals/steps/:step_id/decide🔒 auth
Records an approve/reject decision on one approval step and advances the request. Edge cases: :step_id must be a UUID and decision must be exactly 'approve' or 'reject' (anything else, including 'approved'/'rejected', is a 400); acting_persona_id must be a UUID and must MATCH the step's assigned approver — a different persona gets 403 NotYourStep, the key authorisation case here; a step that already carries a decision returns 409 StepAlreadyDecided, so this endpoint is explicitly NOT idempotent on retry; an unknown step_id returns 404 StepNotFound; reason is optional even on reject.
[ "POST /api/auth/signup-tenant", "POST /api/approvals/routes", "POST /api/approvals/requests" ]
decision: approve, reject| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | step_id path param must be a UUID / decision must be 'approve' or 'reject' / acting_persona_id must be a UUID | any validateDecide check fails; all failures are returned together in details[] |
| 403 | NotYourStep | Step is assigned to <approver_persona_id>, not <acting_persona_id> | acting_persona_id is not the persona the step is assigned to |
| 404 | StepNotFound | Step <step_id> not found | step_id is a valid UUID but no approval.step row matches |
| 409 | StepAlreadyDecided | Step <step_id> already decided | the step already has a non-null decision (replay / double-submit) |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"entity": "approval.request",
"field": "status",
"flow": [
"pending",
"approved",
"rejected"
],
"transitions": [
{
"from": "pending",
"to": "approved",
"via": "POST /api/approvals/steps/:step_id/decide"
},
{
"from": "pending",
"to": "rejected",
"via": "POST /api/approvals/steps/:step_id/decide"
}
]
}{
"step_id": "{{cache:approvals.requests.create.response.data.pending_steps.0.step_id}}"
}{
"decision": "approve",
"acting_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"reason": "LGTM after review"
}{
"decision": "approve",
"acting_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "LGTM after review"
}{
"success": true,
"data": {
"decide_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"decision": "approve",
"acting_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "LGTM after review",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"step": {
"step_id": "string",
"decision": "string"
},
"request": {
"status": "string"
},
"next_steps": "array"
}
}POST/api/break-glass🔒 auth
Opens a scoped, approval-gated emergency access (break-glass) request against an approval route, returning 201 with the grant in pending status plus the approval steps it must clear. The requester persona is derived from the JWT (primary_persona_id, falling back to sub) and tenant_id defaults to the JWT tenant_id when the body omits it. Edge cases: route_id, a resolvable tenant_id and justification are all mandatory and produce a single combined 400; justification is re-checked inside the service and an empty one surfaces as a 400 BreakGlass error; ttl_minutes is optional and defaults in the service, bounding how long the grant stays usable once approved; scope defaults to an empty object, which grants nothing usable later since use-time action checks are scope-matched; a route_id that resolves to no approval route fails during grant creation and returns 400 BreakGlass "failed to create break-glass grant"; there is no idempotency key, so repeat submissions open additional pending grants.
[ "POST /api/auth/signup-tenant", "POST /api/approvals/routes" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | route_id, tenant_id, justification are required | route_id or justification is missing, or no tenant_id can be resolved from the body or the JWT |
| 400 | BreakGlass | justification is required | requestBreakGlass re-validates the justification and finds it empty |
| 400 | BreakGlass | failed to create break-glass grant | Grant creation returns no row — e.g. route_id does not resolve to a usable approval route |
| 500 | InternalError | InternalError | requestBreakGlass throws a non-BreakGlassError (approval-route expansion failure, database unreachable) |
{
"entity": "approval.break_glass_grant",
"field": "status",
"flow": [
"pending",
"active",
"expired",
"revoked"
],
"transitions": [
{
"from": null,
"to": "pending",
"via": "POST /api/break-glass"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"route_id": "{{cache:approvals.routes.create.response.data.route.route_id}}",
"justification": "Emergency access required to treat unconscious patient",
"scope": {
"resource": "patient.record",
"actions": [
"read"
]
},
"ttl_minutes": 60
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"justification": "Emergency access required to treat unconscious patient",
"scope": {
"resource": "patient.record",
"actions": [
"read"
]
},
"ttl_minutes": 60
}{
"success": true,
"data": {
"break_glass_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"justification": "Emergency access required to treat unconscious patient",
"scope": {
"resource": "patient.record",
"actions": [
"read"
]
},
"ttl_minutes": 60,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"grant": {
"grant_id": "string",
"status": "string"
},
"pending_step_ids": "array"
}
}GET/api/break-glass/:grant_id🔒 auth
Reads a break-glass grant by id, returning its current status (pending / active / expired / consumed / rejected), its scope and justification, its approval steps and the latest issued certificate. Edge cases: unlike the decide and use routes — which report a missing grant as a domain error with 400/403 — this read returns a plain 404 NotFound for an unknown grant_id; it performs no tenant ownership check against the JWT, so any authenticated caller holding a valid grant_id can read that grant; a malformed (non-UUID) grant_id fails the query cast and surfaces as 500 rather than 404; the response reflects expiry as recorded status, so a grant whose TTL has lapsed may still read as active until it is evaluated at use time.
[ "POST /api/auth/signup-tenant", "POST /api/approvals/routes", "POST /api/break-glass", "POST /api/break-glass/:grant_id/decide", "POST /api/break-glass/:grant_id/use" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 404 | NotFound | NotFound | getBreakGlass returns no grant for the supplied grant_id |
| 500 | InternalError | InternalError | getBreakGlass throws — e.g. grant_id is not a valid UUID and the query cast fails, or the database is unreachable |
{
"grant_id": "{{cache:break-glass.request.response.data.grant.grant_id}}"
}{
"success": true,
"data": {
"break_glass_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"grant": {
"grant_id": "string",
"status": "string",
"certificate": "object"
}
}
}POST/api/break-glass/:grant_id/decide🔒 auth
Records an approve or reject decision on one step of a pending break-glass grant and returns the updated grant, which becomes active once all required steps approve. The deciding persona is taken from the JWT (primary_persona_id, else sub). Edge cases: step_id is mandatory and decision must be exactly "approve" or "reject" — any other value, including "approved" or a boolean, is a 400 at the route; an unknown grant_id returns 400 BreakGlass "grant ... not found", not 404, because the service signals it as a domain error; deciding on a grant that is no longer pending (already approved, rejected, expired or used) returns 400 BreakGlass "grant ... is <status>, not pending", so a duplicate decision is rejected rather than silently accepted — the call is not idempotent; reason is optional and is recorded for audit.
[ "POST /api/auth/signup-tenant", "POST /api/approvals/routes", "POST /api/break-glass" ]
decision: approve, reject| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | step_id and decision (approve|reject) are required | step_id is missing, or decision is absent or not exactly "approve" or "reject" |
| 400 | BreakGlass | grant <grant_id> not found | No break-glass grant exists for the path grant_id |
| 400 | BreakGlass | grant <grant_id> is <status>, not pending | The grant has already left pending status (approved, rejected, expired or consumed) |
| 500 | InternalError | InternalError | decideBreakGlass throws a non-BreakGlassError (step write failure, database unreachable) |
{
"entity": "approval.break_glass_grant",
"field": "status",
"flow": [
"pending",
"active",
"expired",
"revoked"
],
"transitions": [
{
"from": "pending",
"to": "active",
"via": "POST /api/break-glass/:grant_id/decide"
},
{
"from": "pending",
"to": "revoked",
"via": "POST /api/break-glass/:grant_id/decide"
}
]
}{
"grant_id": "{{cache:break-glass.request.response.data.grant.grant_id}}"
}{
"step_id": "{{cache:break-glass.request.response.data.pending_step_ids.0}}",
"decision": "approve",
"reason": "Emergency verified - access approved"
}{
"step_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"decision": "approve",
"reason": "Emergency verified - access approved"
}{
"success": true,
"data": {
"decide_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"step_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"decision": "approve",
"reason": "Emergency verified - access approved",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"grant": {
"grant_id": "string",
"status": "string"
}
}
}POST/api/break-glass/:grant_id/use🔒 auth
Exercises an approved break-glass grant to perform one emergency action against an optional target, returning an audit certificate that records what was done, by which persona and when. Edge cases: action is mandatory at the route; every failure from the service is surfaced as 403 (not 400) because a refusal here is an authorization denial — an unknown grant_id, a grant that is still pending or already consumed ("is <status>, not active"), a grant whose TTL has elapsed ("has expired"), and an action that falls outside the grant's recorded scope all return 403 with the specific reason; the acting persona is taken from the JWT rather than the body so a caller cannot attribute the use to someone else; target_id is optional, so scope checks that depend on a target must encode that in the scope object.
[ "POST /api/auth/signup-tenant", "POST /api/approvals/routes", "POST /api/break-glass", "POST /api/break-glass/:grant_id/decide" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | action is required | The body omits action or supplies an empty value |
| 403 | BreakGlass | grant <grant_id> not found | No break-glass grant exists for the path grant_id |
| 403 | BreakGlass | grant <grant_id> has expired | The grant was approved but its TTL window has elapsed |
| 403 | BreakGlass | grant <grant_id> is <status>, not active | The grant is still pending approval, was rejected, or has already been consumed |
| 403 | BreakGlass | action '<action>' is outside the grant scope | The requested action is not permitted by the scope object recorded on the grant |
| 500 | InternalError | InternalError | useBreakGlass throws a non-BreakGlassError (certificate write failure, database unreachable) |
{
"grant_id": "{{cache:break-glass.request.response.data.grant.grant_id}}"
}{
"action": "read",
"target_id": "{{var:target_id}}"
}{
"action": "read",
"target_id": "{{var:target_id}}"
}{
"success": true,
"data": {
"use_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"action": "read",
"target_id": "{{var:target_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"certificate": {
"grant_id": "string",
"cert_hash": "string"
}
}
}sdk-asset
POST/api/assets🔒 auth
Registers a digital-twin asset (a robot) together with its component tree and sensors, returning 201 with the created registry entry. The tenant is taken from the body when supplied and otherwise falls back to the JWT tenant_id claim, so a caller with a tenant-scoped token may omit it - but a token with no tenant_id claim and no body tenant_id is a 400. components defaults to an empty array, so an asset can be registered with no component tree. Edge cases: every downstream failure (duplicate device_uuid, malformed component tree, database error) is collapsed into a single generic 500 with the raw error message - there is no 409 path; model and display_name are unvalidated free text; requires a valid JWT.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | tenant_id required | tenant_id required | Neither body.tenant_id nor the JWT tenant_id claim is present |
| 500 | <underlying error message> | Asset registration failed | assetRegister throws - duplicate device_uuid, malformed components payload, or database error. The raw message is echoed back |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"bu_id": "{{var:bu_id}}",
"device_uuid": "dev_robotA",
"model": "Humanoid-X1",
"display_name": "Unit-001",
"components": [
{
"kind": "head",
"name": "head",
"position": {
"x": 0,
"y": 0,
"z": 1
},
"sensors": [
{
"kind": "camera",
"unit": "frame",
"min_value": 0,
"max_value": 60,
"sample_rate_hz": 30
}
]
},
{
"kind": "arm",
"name": "left_arm",
"children": [
{
"kind": "hand",
"children": [
{
"kind": "finger",
"sensors": [
{
"kind": "force",
"unit": "N",
"min_value": 0,
"max_value": 100,
"sample_rate_hz": 200
}
]
}
]
}
]
}
]
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"bu_id": "{{var:bu_id}}",
"device_uuid": "dev_robotA",
"model": "Humanoid-X1",
"display_name": "Unit-001",
"components": [
{
"kind": "head",
"name": "head",
"position": {
"x": 0,
"y": 0,
"z": 1
},
"sensors": [
{
"kind": "camera",
"unit": "frame",
"min_value": 0,
"max_value": 60,
"sample_rate_hz": 30
}
]
},
{
"kind": "arm",
"name": "left_arm",
"children": [
{
"kind": "hand",
"children": [
{
"kind": "finger",
"sensors": [
{
"kind": "force",
"unit": "N",
"min_value": 0,
"max_value": 100,
"sample_rate_hz": 200
}
]
}
]
}
]
}
]
}{
"success": true,
"data": {
"asset_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"bu_id": "{{var:bu_id}}",
"device_uuid": "dev_robotA",
"model": "Humanoid-X1",
"display_name": "Unit-001",
"components": [
{
"kind": "head",
"name": "head",
"position": {
"x": 0,
"y": 0,
"z": 1
},
"sensors": [
{
"kind": "camera",
"unit": "frame",
"min_value": 0,
"max_value": 60,
"sample_rate_hz": 30
}
]
},
{
"kind": "arm",
"name": "left_arm",
"children": [
{
"kind": "hand",
"children": [
{
"kind": "finger",
"sensors": [
{
"kind": "force",
"unit": "N",
"min_value": 0,
"max_value": 100,
"sample_rate_hz": 200
}
]
}
]
}
]
}
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/assets/:asset_id/commands🔒 auth
Lists the commands issued against one asset, scoped to the caller's tenant taken from the JWT tenant_id claim, and returns 200 with the array. Because the tenant comes from the token, a caller cannot read another tenant's command history through this route. Edge cases: a token with no tenant_id claim is a 400; an asset_id belonging to a different tenant, or one with no commands yet, returns 200 with an empty array rather than a 403 or 404; the listing is unpaginated, so an asset with a long command history returns every row; a non-UUID asset_id fails the Postgres UUID cast and is reported as a generic 500; requires a valid JWT.
[ "POST /api/auth/signup-tenant", "POST /api/assets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | tenant context required | tenant context required | The JWT carries no tenant_id claim |
| 500 | <underlying error message> | Command listing failed | listCommandsByAsset throws - includes a non-UUID asset_id (Postgres 22P02) and database errors |
{
"asset_id": "{{cache:assets.create.response.data.asset_id}}"
}{
"success": true,
"data": {
"command_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/assets/:asset_id/credentials🔒 auth
Mints a per-robot scoped API credential for the asset and returns 201 with the plaintext key exactly once - it is never retrievable again, so the edge agent must persist it on receipt. The credential is scoped to command-ack plus this asset's delivery stream. The tenant comes solely from the JWT tenant_id claim (there is no body override), so a token with no tenant claim is a 400. Both body fields are optional: rate_limit_rpm and expires_at fall back to service defaults. Edge cases: repeated calls mint additional independent credentials rather than rotating or conflicting; an expires_at in the past is not validated here; an unknown asset_id is not pre-checked and surfaces as a generic 500 if the underlying insert fails; requires a valid JWT.
[ "POST /api/auth/signup-tenant", "POST /api/assets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | tenant context required | tenant context required | The JWT carries no tenant_id claim |
| 500 | <underlying error message> | Credential issuance failed | issueRobotCredential throws - invalid/unknown asset_id, unparseable expires_at, or database error |
{
"asset_id": "{{cache:assets.create.response.data.asset_id}}"
}{
"rate_limit_rpm": 600,
"expires_at": "{{dynamic:futuredatetime}}"
}{
"rate_limit_rpm": 600,
"expires_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"credential_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rate_limit_rpm": 600,
"expires_at": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/assets/:asset_id/readings🔒 auth
Queries the sensor time-series for one asset, either raw or aggregated, filtered by the optional sensor_id, from and to query parameters. The optional bucket parameter selects a rollup grain and is validated against the closed set second | minute | hour | day - an unrecognised bucket is silently ignored and the query falls back to raw readings rather than erroring. Edge cases: an unknown asset_id or an empty time window returns 200 with an empty result set, not a 404; from/to are passed through to the storage layer, so an unparseable timestamp surfaces as a generic 500; an inverted range (from after to) yields an empty set; no tenant check is performed against the JWT; requires a valid JWT.
[ "POST /api/auth/signup-tenant", "POST /api/assets" ]
bucket: second, minute, hour, day| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 500 | <underlying error message> | Reading query failed | assetQueryReadings throws - malformed from/to timestamps, a non-UUID asset_id, or a ClickHouse/Postgres error |
{
"asset_id": "{{cache:assets.create.response.data.asset_id}}"
}{
"success": true,
"data": {
"reading_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/assets/:asset_id/twin🔒 auth
Returns the full digital twin for one asset - the asset row plus its component tree and sensor definitions - with status 200. Read-only. The handler scopes by asset_id only and never compares the twin's tenant against the JWT, so it is not tenant-isolated. Edge cases: an unknown asset_id returns 404; an asset registered with no components returns 200 with an empty component tree rather than 404; a non-UUID asset_id fails the Postgres UUID cast inside the try block and is reported as a generic 500 rather than a 400; requires a valid JWT.
[ "POST /api/auth/signup-tenant", "POST /api/assets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 404 | asset not found | asset not found | No asset exists for the supplied asset_id |
| 500 | <underlying error message> | Twin lookup failed | assetGetTwin throws - includes a non-UUID asset_id (Postgres 22P02) and database errors |
{
"asset_id": "{{cache:assets.create.response.data.asset_id}}"
}{
"success": true,
"data": {
"twin_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-assignment
POST/api/assignment/assign-by-task🔒 auth
Auto-assign a task to the best-fit persona. The skill, availability-window and per-day capacity gates always run first; then the chosen strategy selects the winner. strategy='default' (or omitted) keeps the primary->backup + distance + capacity scoring; strategy='round_robin' cycles fairly through eligible candidates via a per-(tenant,pool,strategy) rotation cursor advanced atomically (concurrency-safe, no over-assignment); strategy='fair_share' biases toward the least-loaded candidate, rotating among ties. Returns the proposed assignment (status='proposed') plus the matched territory, distance and a reason string. The three candidate personas are seeded into assignment.workload by the setupScript (workload has no HTTP CRUD and no FK/tenant column). 409 when no eligible persona (empty pool / all at capacity).
[ "POST /api/auth/signup-tenant" ]
strategy: default, round_robin, fair_share| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | task_id and tenant_id are required | task_id or tenant_id missing from body |
| 400 | ValidationError | location {lat,lng} is required | location missing or lat/lng not numbers |
| 400 | ValidationError | invalid strategy | strategy is not one of default|round_robin|fair_share |
| 409 | NoEligiblePersona | no eligible persona for task | no candidate passes the skill/availability/capacity gates or all raced to capacity |
{
"task_id": "{{dynamic:uuid}}",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"location": {
"lat": 40.7128,
"lng": -74.006
},
"required_skills": [
"plumbing"
],
"strategy": "round_robin",
"pool_key": "default",
"candidate_persona_ids": [
"a1111111-1111-4111-8111-111111111111",
"a2222222-2222-4222-8222-222222222222",
"a3333333-3333-4333-8333-333333333333"
]
}{
"task_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"location": {
"lat": 40.7128,
"lng": -74.006
},
"required_skills": [
"plumbing"
],
"strategy": "round_robin",
"pool_key": "default",
"candidate_persona_ids": [
"a1111111-1111-4111-8111-111111111111",
"a2222222-2222-4222-8222-222222222222",
"a3333333-3333-4333-8333-333333333333"
]
}{
"success": true,
"data": {
"assign_by_task_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"task_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"location": {
"lat": 40.7128,
"lng": -74.006
},
"required_skills": [
"plumbing"
],
"strategy": "round_robin",
"pool_key": "default",
"candidate_persona_ids": [
"a1111111-1111-4111-8111-111111111111",
"a2222222-2222-4222-8222-222222222222",
"a3333333-3333-4333-8333-333333333333"
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"assignment": {
"assignment_id": "string",
"task_id": "string",
"persona_id": "string",
"status": "string"
},
"reason": "string"
}
}GET/api/assignment/decisions🔒 auth
Reads decisions back: one by decision_id, or a filtered list by subject or outcome. Filtering on REVIEW is how an operator works the queue of subjects the pipeline deliberately refused to guess about. QA edge cases: the trace returned is the one recorded AT THE TIME, including the rule version - it is not recomputed, so it stays valid after the rules change; a decision_id belonging to another tenant is 404 rather than 403, because confirming an id exists elsewhere is itself a leak; the list is newest-first and limit is clamped to 500.
[ "POST /api/auth/signup-tenant", "POST /api/assignment/route" ]
outcome: ASSIGNED, FALLBACK, REVIEW, UNROUTABLE| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | ROUTING_DECISION_NOT_FOUND | no decision <id> | the id names nothing belonging to this tenant |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
{
"success": true,
"data": [
{
"decision_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"decision_id": "string",
"steps": "array"
}
}GET/api/assignment/rotation🔒 auth
Reads EP-335's rotation cursors without advancing them. The read-only part is the point: advancing a cursor from a GET would skew the real rotation for everybody who merely looked at it, and a dashboard polling this endpoint would silently starve whoever came next in the rota. QA edge cases: a tenant with no rotation yet gets an empty array, not a 404 - the cursor is created on first use by the assignment engine; filtering by pool_key or strategy narrows the list.
[ "POST /api/auth/signup-tenant" ]
strategy: default, round_robin, fair_share| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
{
"success": true,
"data": [
{
"rotation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"cursors": "array"
}
}POST/api/assignment/route🔒 auth
Runs the six-step pipeline - eligibility, priority, specialty, availability, assignment, fallback - and returns the chosen persona WITH a per-step trace in plain language. The trace is the product: an operator asking "why did this go there" months later needs the explanation that was written down at the time, because re-running the pipeline today answers a different question once the rules and everybody's availability have moved on. QA edge cases: an outcome of REVIEW is a SUCCESSFUL answer (200), not an error - "this needs a human" is a decision; a subject that cannot ANSWER an eligibility predicate (it lacks the field the rule reads) goes to REVIEW rather than being force-assigned, and is deliberately distinct from UNROUTABLE, which means the rule was answered and the answer was no; if no coverage resolver is wired the availability step also returns REVIEW rather than assuming everybody is free; dry_run=true returns the same trace without recording the decision.
[ "POST /api/auth/signup-tenant", "POST /api/assignment/routes" ]
dry_run: true, false| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | subject_ref is required | subject_ref is missing, or candidate_persona_ids is empty |
| 400 | VALIDATION_ERROR | candidate_persona_ids must be a non-empty array | no candidates are supplied |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"subject_ref": "subject-{{dynamic:slug}}",
"subject": {
"region": "TX",
"severity": 9,
"required_specialty": "roofing"
},
"candidate_persona_ids": [
"{{cache:personas.create.response.data.persona.persona_id}}"
],
"persona_specialties": {},
"rule_set_name": "default",
"dry_run": false
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_ref": "subject-{{dynamic:slug}}",
"subject": {
"region": "TX",
"severity": 9,
"required_specialty": "roofing"
},
"candidate_persona_ids": [
"3fa85f64-5717-4562-b3fc-2c963f66afa6"
],
"persona_specialties": {},
"rule_set_name": "default",
"dry_run": false
}{
"success": true,
"data": {
"route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_ref": "subject-{{dynamic:slug}}",
"subject": {
"region": "TX",
"severity": 9,
"required_specialty": "roofing"
},
"candidate_persona_ids": [
"3fa85f64-5717-4562-b3fc-2c963f66afa6"
],
"persona_specialties": {},
"rule_set_name": "default",
"dry_run": false,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"outcome": "string",
"trace": "array",
"rule_set_version": "number"
}
}GET/api/assignment/routes🔒 auth
Lists every published version of a named rule set, newest first, with exactly one marked active. This is the audit view: which rules are in force, what they replaced, and what a rollback would return to. QA edge cases: versions are never deleted, so the list only grows - a decision from months ago still points at a version that is here; a tenant that has published nothing gets an empty array rather than a 404, because "no rules yet" is a normal state and routing simply treats every subject as routable.
[ "POST /api/auth/signup-tenant", "POST /api/assignment/routes" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
{
"success": true,
"data": [
{
"route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"versions": "array"
}
}POST/api/assignment/routes🔒 auth
Routing rules are DATA, not code: publishing a version is this call, and switching which one is in force is the same call with activate_version. A routing rule changes when the business changes, which is weekly; a deploy is not weekly, so rules that live in code get hard-coded into one vertical and the platform stops being one. A published version is FROZEN - it can never be edited, only superseded - because every decision names the version that produced it, and an editable version would explain last month's decision with this month's rules, which is worse than no explanation because it is a confident wrong one. QA edge cases: sending activate_version switches the active version and returns 200 (nothing was created); sending rules publishes a NEW version and returns 201, with the version number allocated server-side (never supplied by the caller); exactly one version per named set is active at a time, so activating one deactivates the previous automatically; activating a version that does not exist is 404; rolling back is simply activating an earlier version number.
[ "POST /api/auth/signup-tenant" ]
activate: true, false| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | rules (object) or activate_version (number) is required | the payload carries neither a rule body nor a version to activate |
| 404 | ROUTING_RULE_SET_NOT_FOUND | no active routing rule set 'x' for this tenant | activate_version names a version that was never published |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"name": "default-{{dynamic:slug}}",
"rules": {
"eligibility": [
{
"field": "region",
"op": "present",
"because": "a subject with no region cannot be routed"
}
],
"priority_bands": [
{
"band": "urgent",
"when": [
{
"field": "severity",
"op": "gte",
"value": 8
}
]
},
{
"band": "standard",
"when": []
}
],
"specialty": {
"field": "required_specialty"
},
"assignment": {
"pick": "most_headroom"
},
"fallback": {
"to_review": true
}
},
"activate": true,
"published_by": "{{static:qa}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "default-{{dynamic:slug}}",
"rules": {
"eligibility": [
{
"field": "region",
"op": "present",
"because": "a subject with no region cannot be routed"
}
],
"priority_bands": [
{
"band": "urgent",
"when": [
{
"field": "severity",
"op": "gte",
"value": 8
}
]
},
{
"band": "standard",
"when": []
}
],
"specialty": {
"field": "required_specialty"
},
"assignment": {
"pick": "most_headroom"
},
"fallback": {
"to_review": true
}
},
"activate": true,
"published_by": "qa"
}{
"success": true,
"data": {
"route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "default-{{dynamic:slug}}",
"rules": {
"eligibility": [
{
"field": "region",
"op": "present",
"because": "a subject with no region cannot be routed"
}
],
"priority_bands": [
{
"band": "urgent",
"when": [
{
"field": "severity",
"op": "gte",
"value": 8
}
]
},
{
"band": "standard",
"when": []
}
],
"specialty": {
"field": "required_specialty"
},
"assignment": {
"pick": "most_headroom"
},
"fallback": {
"to_review": true
}
},
"activate": true,
"published_by": "qa",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"rule_set_id": "string",
"version": "number",
"is_active": "boolean"
}
}POST/api/assignment/simulate🔒 auth
Replays recorded decisions through a candidate rule VERSION and reports what would have changed: per-persona actual versus candidate, the outcome mix, the subjects whose destination moves, and a skew audit naming both over-allocation and starvation. SIDE EFFECT FREE, and it proves it rather than claiming it - the response carries a side_effects block that must be all zeros (assignments, notifications, clocks, and decisions written, counted from the table before and after). Returns 200, not 201, because nothing was created. QA edge cases: the candidate is evaluated BY VERSION and is never activated - activating a rule set to try it out is the experiment changing production; the EP-335 rotation cursor is read but never advanced; history is replayed as the rules SAW it (the trace records the fields each rule read), so a candidate rule reading a field the history never carried comes out AMBIGUOUS and goes to review rather than being silently defaulted.
[ "POST /api/auth/signup-tenant", "POST /api/assignment/routes", "POST /api/assignment/route" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | candidate_version (number) is required | candidate_version is missing or not a number |
| 400 | VALIDATION_ERROR | candidate_persona_ids must be a non-empty array | no candidates are supplied |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"candidate_version": "{{cache:assignment.routes.publish.version}}",
"rule_set_name": "default",
"candidate_persona_ids": [
"{{cache:personas.create.response.data.persona.persona_id}}"
],
"persona_specialties": {},
"limit": 200,
"skew_tolerance": 0.5
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"candidate_version": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rule_set_name": "default",
"candidate_persona_ids": [
"3fa85f64-5717-4562-b3fc-2c963f66afa6"
],
"persona_specialties": {},
"limit": 200,
"skew_tolerance": 0.5
}{
"success": true,
"data": {
"simulate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"candidate_version": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rule_set_name": "default",
"candidate_persona_ids": [
"3fa85f64-5717-4562-b3fc-2c963f66afa6"
],
"persona_specialties": {},
"limit": 200,
"skew_tolerance": 0.5,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"subjects_replayed": "number",
"per_persona": "array",
"skew": "object",
"side_effects": "object"
}
}GET/api/assignment/simulations🔒 auth
The list a reviewer opens before they know which simulation_id they want: this tenant's recorded runs, newest first, each row a summary (simulation_id, candidate_version, rule_set_name, subjects_replayed, created_at) rather than the whole report - a full report per row would make the common case, browsing, pay for the rare case, reading one. Filters narrow to a rule set by name and to one candidate_version, which is how the question is actually asked: show me every simulation of the version we shipped. QA edge cases: results are tenant-scoped, so another tenant's runs never appear regardless of filter; a tenant with no runs gets 200 and an empty array, not 404, because no simulations is a valid state and not a missing resource; limit is clamped to a 1-500 range so a caller cannot ask for the whole table; rule_set_name and candidate_version are independent and combine; an unknown rule_set_name yields an empty array rather than an error, because a name that matched nothing is an answer.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/coverage/schedules", "POST /api/assignment/routes", "POST /api/assignment/route", "POST /api/assignment/simulate" ]
limit: 1, 50, 500| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
| 400 | VALIDATION_ERROR | candidate_version must be an integer | candidate_version is supplied but is not parseable as an integer |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"runs": "array"
}
}GET/api/assignment/simulations/:simulation_id🔒 auth
Resolves the simulation_id a routing proposal cited back to the IDENTICAL report the simulate call returned. This is the half that makes a simulation evidence rather than a screenshot: a routing change is proposed on the strength of a simulation, approved weeks later and questioned months after that, and candidate_version identifies the RULES, never the RUN - two simulations of the same version over different windows or candidate pools are different evidence carrying the same version number. The stored row is immutable, so the numbers a reviewer re-opens are the numbers the decision was made on. QA edge cases: the run is tenant-scoped, so a simulation_id belonging to another tenant is 404 and NOT another tenant's evidence - absence and forbidden are deliberately indistinguishable here, because confirming an id exists elsewhere leaks that a simulation happened; a well-formed but never-issued id is 404; a malformed id is refused as a validation error before it reaches the database; the returned body carries created_at alongside the report so a reviewer can see WHEN the question was asked, and its side_effects block still reads all zeros because recording a run is not a routing effect.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/coverage/schedules", "POST /api/assignment/routes", "POST /api/assignment/route", "POST /api/assignment/simulate" ]
outcome: ASSIGNED, FALLBACK, REVIEW, UNROUTABLE| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | SIMULATION_NOT_FOUND | no simulation run with that id for this tenant | the simulation_id is well-formed but names no run this tenant owns |
| 400 | VALIDATION_ERROR | simulation_id must be a UUID | the path segment is not a well-formed UUID |
| 404 | SIMULATION_NOT_FOUND | no simulation run with that id for this tenant | the run exists but belongs to another tenant - reported as absent rather than forbidden, because confirming it exists elsewhere leaks that a simulation happened |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
{
"simulation_id": "{{cache:assignment.simulate.simulation_id}}"
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"simulation_id": "string",
"candidate_version": "number",
"subjects_replayed": "number",
"per_persona": "array",
"outcome_shift": "object",
"skew": "object",
"side_effects": "object"
}
}PUT/api/assignment/workload/:persona_id🔒 auth
Upserts a persona's workload profile (capacity/day, skills, availability window) through the existing setWorkload service (FR-ASN-3) — the HTTP producer surface so tests and dispatchers provision assignment candidates via the API instead of a SQL seed. Idempotent on persona_id (ON CONFLICT DO UPDATE; capacity/skills COALESCE to prior value when omitted). open_tasks is dispatcher-owned and is NOT settable here. Edge cases: persona_id must be a UUID (non-UUID -> 400 ValidationError before the ::uuid cast); a valid tenant JWT is required (api-gateway default-deny authGate). NOTE: assignment.workload has no tenant column, so this is effectively a global/ops surface.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | persona_id must be a UUID | the :persona_id path param is not a valid UUID (guards the ::uuid cast in setWorkload) |
| 401 | Unauthorized | Missing bearer token | no Authorization header or an invalid/expired tenant JWT (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"persona_id": "{{dynamic:uuid}}"
}{
"capacity_per_day": 8,
"skills": [
"plumbing",
"hvac"
],
"available_from": "2026-01-01T00:00:00Z",
"available_to": "{{dynamic:futuredatetime+30d}}"
}{
"capacity_per_day": 8,
"skills": [
"plumbing",
"hvac"
],
"available_from": "2026-01-01T00:00:00Z",
"available_to": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"workload_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"capacity_per_day": 8,
"skills": [
"plumbing",
"hvac"
],
"available_from": "2026-01-01T00:00:00Z",
"available_to": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"persona_id": "string",
"open_tasks": "number",
"capacity_per_day": "number",
"skills": "array",
"available_from": "string",
"available_to": "string"
}
}POST/api/assignments🔒 auth
Opens the assignment: a primary, an optional backup, an optional manager, an acceptance window, and the SOURCE TIMESTAMP - when the WORLD produced the subject. That timestamp is required and never defaulted to now, because every SLA measures from it and quietly substituting "now" would restate a six-hour-old subject as fresh. It is then frozen for the life of the assignment: a decline, a reassignment, a fallback and a manager takeover all move OWNERSHIP, never the clock. QA edge cases: acceptance and response SLA clocks are started through sdk-sla FROM the source timestamp, and a clock that cannot be started leaves a null ref rather than losing the assignment; a backup equal to the primary is refused by the database; acceptance_window_minutes defaults to 5 and may be 0, which means the backup is notified at the same time. NOTE ON THE PERSONA FIELDS: primary comes from the sdk-persona producer, while the backup and the manager resolve from seeded ids. A backup or manager equal to the primary is refused (a backup that is the primary is not a backup), and the dependency graph is ONE node per METHOD+ENDPOINT so it cannot express a second and third persona from the same producer [MUST-51]; these columns are loose references with no foreign key into sdk-persona, so a seeded id is truthful rather than a fabricated FK.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | source_timestamp (ISO-8601) is required | the payload omits source_timestamp or it is not parseable |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"subject_ref": "subject-{{dynamic:slug}}",
"source_timestamp": "{{dynamic:pastdatetime}}",
"primary_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"backup_persona_id": "{{var:coverage_backup_persona_id}}",
"manager_persona_id": "{{var:assignment_manager_persona_id}}",
"acceptance_window_minutes": 5,
"routing_decision_id": "{{cache:assignment.route.decision_id}}",
"actor": "{{static:qa}}",
"metadata": {
"source": "api-test"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_ref": "subject-{{dynamic:slug}}",
"source_timestamp": "2026-01-15T10:30:00Z",
"primary_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"backup_persona_id": "{{var:coverage_backup_persona_id}}",
"manager_persona_id": "{{var:assignment_manager_persona_id}}",
"acceptance_window_minutes": 5,
"routing_decision_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor": "qa",
"metadata": {
"source": "api-test"
}
}{
"success": true,
"data": {
"assignment_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_ref": "subject-{{dynamic:slug}}",
"source_timestamp": "2026-01-15T10:30:00Z",
"primary_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"backup_persona_id": "{{var:coverage_backup_persona_id}}",
"manager_persona_id": "{{var:assignment_manager_persona_id}}",
"acceptance_window_minutes": 5,
"routing_decision_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor": "qa",
"metadata": {
"source": "api-test"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"record_id": "string",
"state": "string",
"source_timestamp": "string"
}
}GET/api/assignments/:record_id🔒 auth
The assignment as it stands, plus every prior owner and why they stopped being one. The history is the answer to "this has bounced three times, why" - a count tells an operator nothing, while "wrong specialty, wrong specialty, out of area" tells them the routing rules are wrong. QA edge cases: original_persona_id is who owned it FIRST and never changes, while primary_persona_id is who owns it now; entries are sequenced and cannot be rewritten; a system fallback carries no reason, because a reason is demanded of a person who had a choice.
[ "POST /api/auth/signup-tenant", "POST /api/assignments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | ASSIGNMENT_NOT_FOUND | no assignment <id> | the id names nothing belonging to this tenant |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
{
"record_id": "{{cache:assignment.assignments.create.record_id}}"
}{
"success": true,
"data": {
"assignment_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"record_id": "string",
"history": "array"
}
}POST/api/assignments/:record_id/accept🔒 auth
Marks the current primary as having accepted, which stops the acceptance window. IDEMPOTENT: accepting twice returns the ORIGINAL acceptance time rather than erroring, because a retrying client is ordinary and failing the retry would leave an acceptance that happened looking like one that did not. QA edge cases: accepting an assignment that is already COMPLETED or CANCELLED is 409; the source timestamp is untouched, as it is by every other transition.
[ "POST /api/auth/signup-tenant", "POST /api/assignments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 409 | INVALID_ASSIGNMENT_TRANSITION | cannot accept an assignment that is COMPLETED | the assignment has already been closed |
| 404 | ASSIGNMENT_NOT_FOUND | no assignment <id> | the id names nothing belonging to this tenant |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
{
"record_id": "{{cache:assignment.assignments.create.record_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"actor": "{{static:qa}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor": "qa"
}{
"success": true,
"data": {
"accept_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor": "qa",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"record_id": "string",
"state": "string"
}
}POST/api/assignments/:record_id/decline🔒 auth
The reason is REQUIRED by the schema, not merely encouraged, and the work goes to the backup IMMEDIATELY rather than on the next sweep - waiting would spend the acceptance window twice, once on somebody who has already said no. The backup becomes the primary and a fresh acceptance window starts for them; the RESPONSE clock is not restarted, because the subject has been waiting since its source timestamp either way. QA edge cases: a blank or whitespace-only reason is 400; a decline with NO backup designated is 409 rather than leaving the subject unowned and invisible; the history records who declined, who it went to, and the reason.
[ "POST /api/auth/signup-tenant", "POST /api/assignments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | REASON_REQUIRED | a decline must carry a reason | reason is missing, empty or whitespace |
| 409 | NO_BACKUP_DESIGNATED | assignment <id> has no backup to fall to | the assignment was offered without a backup |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
{
"record_id": "{{cache:assignment.assignments.create.record_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"reason": "outside my service area",
"actor": "{{static:qa}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "outside my service area",
"actor": "qa"
}{
"success": true,
"data": {
"decline_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "outside my service area",
"actor": "qa",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"record_id": "string",
"primary_persona_id": "string"
}
}POST/api/assignments/:record_id/reassign🔒 auth
Moves ownership to a named persona. The reason is required for the same reason a decline needs one: without it the history reads as noise. THE SOURCE TIMESTAMP DOES NOT MOVE - this is the invariant the whole lifecycle is built around, enforced by a database trigger rather than trusted to callers, because a reassignment that reset the clock would make a subject waiting six hours read as fresh and the breach report say all is well. QA edge cases: reassigning a COMPLETED or CANCELLED assignment is 409; the new owner gets a fresh acceptance window; original_persona_id still names whoever had it first.
[ "POST /api/auth/signup-tenant", "POST /api/assignments", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | to_persona_id is required | the payload omits to_persona_id |
| 400 | REASON_REQUIRED | a reassignment must carry a reason | reason is missing, empty or whitespace |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
{
"record_id": "{{cache:assignment.assignments.create.record_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"to_persona_id": "{{var:coverage_backup_persona_id}}",
"reason": "territory rebalance",
"actor": "{{static:qa-manager}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"to_persona_id": "{{var:coverage_backup_persona_id}}",
"reason": "territory rebalance",
"actor": "qa-manager"
}{
"success": true,
"data": {
"reassign_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"to_persona_id": "{{var:coverage_backup_persona_id}}",
"reason": "territory rebalance",
"actor": "qa-manager",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"record_id": "string",
"source_timestamp": "string"
}
}POST/api/assignments/sweep🔒 auth
Scans offers whose acceptance window has run out and moves each to its backup. Returns what moved AND what could not: an offer that expired with NO backup is reported as STRANDED rather than skipped, because that is the case most in need of a human and a sweep that counted it as "nothing to do" is how a subject sits unowned for a day. QA edge cases: safe to run repeatedly - an offer already accepted or already moved is re-checked under a row lock and left alone, so a concurrent accept wins; system fallbacks are recorded in the history with actor system:acceptance-window and no reason; limit is clamped to 1000.
[ "POST /api/auth/signup-tenant", "POST /api/assignments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or scoped API key on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"limit": 100
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"limit": 100
}{
"success": true,
"data": {
"sweep_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"limit": 100,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"scanned": "number",
"fell_back": "array",
"stranded": "array"
}
}sdk-audit
POST/api/audit/append🔒 auth
Appends a hash-chained immutable entry to a per-pool audit chain, updating audit.chain_head in the same transaction; returns 201 with the new entry id/seq/hashes. actor_id comes from the JWT subject (defaults "unknown"); actor_kind defaults "human". Edge cases: missing pool_index/event_type/payload, actor_kind outside [human,service,agent], retention_class outside [transient,operational,regulated], and an event_type not in the canonical EVENT_TYPE_REGISTRY (rejected 400 before any write).
[ "POST /api/auth/signup-tenant", "POST /api/tenants/:tenant_id/bus", "POST /scim/v2/Users" ]
actor_kind: human, service, agentretention_class: transient, operational, regulated| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | details[]: pool_index/event_type/payload required, actor_kind must be one of human,service,agent, retention_class must be one of transient,operational,regulated, or body must be an object | validateAppendInput fails |
| 400 | UnregisteredEventType | Unregistered event_type: <type>. Add it to EVENT_TYPE_REGISTRY first. | appendAuditEntry throws for an event_type not in EVENT_TYPE_REGISTRY |
| 500 | InternalError | InternalError | any other error thrown by appendAuditEntry (DB failure, etc.) |
{
"pool_index": "app-healthcare-007",
"event_type": "identity.login.v1",
"payload": {
"action": "create",
"resource": "{{dynamic:name}}"
},
"actor_kind": "human",
"retention_class": "operational",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"org_id": "{{cache:auth.signup-tenant.response.data.org_id}}",
"app_id": "{{cache:auth.signup-tenant.response.data.app_id}}",
"bu_id": "{{cache:tenants.create-bu.response.data.bu.bu_id}}",
"subject_kind": "user",
"subject_id": "{{cache:scim.create.response.data.person_id}}"
}{
"pool_index": "app-healthcare-007",
"event_type": "identity.login.v1",
"payload": {
"action": "create",
"resource": "Acme QA Sample"
},
"actor_kind": "human",
"retention_class": "operational",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"org_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"bu_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_kind": "user",
"subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"append_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"pool_index": "app-healthcare-007",
"event_type": "identity.login.v1",
"payload": {
"action": "create",
"resource": "Acme QA Sample"
},
"actor_kind": "human",
"retention_class": "operational",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"org_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"bu_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_kind": "user",
"subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"entry_id": "string",
"pool_index": "string",
"seq": "number",
"entry_hash": "string",
"prev_hash": "string",
"recorded_at": "string",
"retention_class": "string",
"expires_at": "string"
}
}POST/api/audit/export🔒 auth
Customer-facing self-audit export (FR-AUD-4): a tenant requests a signed PDF or JSONL dump of its own audit chain over an ISO-8601 date range. By default the request is only queued and returns 202 with a pending request_id; passing inline=true materializes the artifact synchronously and returns 201 with artifact_s3_key and signature_hex. QA edge cases: the handler collects ALL validation failures into one 400 (missing tenant_id, format outside {pdf,jsonl}, missing/unparseable range_start or range_end, and range_start > range_end are reported together in details[]); an empty body {} yields four validation messages at once. Dates are parsed with new Date(), so garbage strings ('not-a-date') fail as NaN while loose forms ('2026-01-01') are accepted. format defaults to 'jsonl' when omitted, and inline is only honoured when strictly boolean true (the string "true" queues instead of materializing). tenant_id is taken from the BODY and is not cross-checked against the caller's JWT, so tenant-scoping tests must assert on the returned request_id rather than on rejection. The endpoint is not idempotent — repeating the same range creates a new request_id every call. A zero-width range (range_start == range_end) is valid and exports an empty chain. No pagination; oversized ranges are accepted and produce a larger artifact. As a non-public gateway path it is behind the default-deny auth gate, so a missing/expired bearer token is rejected before the handler runs.
[ "POST /api/auth/signup-tenant" ]
format: pdf, jsonl| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — rejected by the gateway default-deny auth gate (authGate.ts → requireAuth) before exportHandler executes |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or expired) |
| 400 | ValidationError | tenant_id is required | body.tenant_id is absent, empty, or not a string |
| 400 | ValidationError | format must be pdf or jsonl | body.format is supplied but is neither 'pdf' nor 'jsonl' |
| 400 | ValidationError | range_start (ISO 8601) required | body.range_start is missing or parses to an invalid Date (NaN) |
| 400 | ValidationError | range_end (ISO 8601) required | body.range_end is missing or parses to an invalid Date (NaN) |
| 400 | ValidationError | range_start must be <= range_end | Both dates parse but the range is inverted |
| 500 | InternalError | InternalError | createExportRequest returns no row ('Failed to create export request') or materializeExport throws for inline=true (e.g. 'Export request <id> not found', DB/storage failure) |
{
"entity": "export_request",
"field": "status",
"flow": [
"pending",
"running",
"ready",
"failed"
],
"transitions": [
{
"from": "pending",
"to": "running",
"via": "POST /api/audit/export"
},
{
"from": "running",
"to": "ready",
"via": "POST /api/audit/export"
},
{
"from": "running",
"to": "failed",
"via": "POST /api/audit/export"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"format": "jsonl",
"range_start": "{{dynamic:pastdatetime}}",
"range_end": "{{dynamic:futuredatetime}}",
"inline": true
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"format": "jsonl",
"range_start": "2026-01-15T10:30:00Z",
"range_end": "2026-01-15T10:30:00Z",
"inline": true
}{
"success": true,
"data": {
"export_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"format": "jsonl",
"range_start": "2026-01-15T10:30:00Z",
"range_end": "2026-01-15T10:30:00Z",
"inline": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"request_id": "string",
"status": "string",
"artifact_s3_key": "string",
"signature_hex": "string"
}
}POST/api/audit/verify🔒 auth
On-demand hash-chain verification for one audit pool (P1-Foundation-Spine §7): walks audit.entry from from_seq to to_seq inclusive, recomputes each entry_hash from the canonical serialization, checks every prev_hash links to the preceding entry_hash, and returns the canonical proof object. QA edge cases: the status code is data-dependent — an intact chain returns 200 while a detected break returns 409 with the SAME body shape (data.ok=false, data.break_at_seq, data.break_reason of either 'prev_hash does not match the previous entry_hash' or 'entry_hash mismatch — payload tampered or canonicalization drift'), so tests must assert on data.ok, not only on status. An unknown pool_index or a from_seq/to_seq window containing no rows is NOT an error: it returns 200 with entries_checked=0, ok=true and head_hash_hex=null, so a missing-FK style negative test cannot be written here. pool_index is trimmed and a whitespace-only value is treated as empty (400). from_seq/to_seq are only honoured when typeof === 'number' — string "1" is silently ignored and the full pool is scanned; an inverted or out-of-range window simply yields zero rows. The call is read-only and fully idempotent apart from stamping audit.chain_head.last_verified_at on success. There is no pagination or row cap, so a large window scans the whole range in one request. pool_index is caller-supplied and not scoped to the JWT tenant, but the path sits behind the gateway default-deny gate so an invalid or expired token is rejected before the handler runs.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — the gateway default-deny auth gate (authGate.ts → requireAuth) blocks /api/audit/verify before verifyHandler runs |
| 401 | Unauthorized | Invalid or expired token | Bearer token present but verifyJwt rejects it (bad signature, malformed, expired) |
| 400 | ValidationError | pool_index is required | body.pool_index missing, not a string, or blank/whitespace-only after trim (includes an empty {} body) |
| 409 | ChainBreak | data.ok=false with break_at_seq and break_reason set | verifyChain detects a broken link (prev_hash does not match the previous entry_hash) or a tampered entry (recomputed entry_hash mismatch) |
| 500 | InternalError | InternalError | verifyChain throws — e.g. the audit.entry / audit.chain_head query fails or the DB pool is unavailable |
{
"pool_index": "app-healthcare-007",
"from_seq": 1,
"to_seq": 100
}{
"pool_index": "app-healthcare-007",
"from_seq": 1,
"to_seq": 100
}{
"success": true,
"data": {
"status": "completed",
"pool_index": "app-healthcare-007",
"from_seq": 1,
"to_seq": 100,
"verify_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"pool_index": "string",
"from_seq": "number",
"to_seq": "number",
"entries_checked": "number",
"ok": "boolean",
"break_at_seq": "number",
"break_reason": "string",
"head_hash_hex": "string",
"verified_at": "string"
}
}GET/api/events/typespublic
Lists every event type the caller may emit - the compile-time EVENT_TYPE_REGISTRY platform baseline plus the types this tenant has registered itself - with metadata, a total count and separate platform_count/tenant_count. Edge cases: the baseline half is static compile-time data, so it is identical on every call and never empty in a correctly built image, while the tenant half is a DB read scoped to the JWT's tenant_id claim and is empty for a tenant that has registered nothing; a token with no tenant_id claim gets the baseline only, never another tenant's types; the route takes no parameters and no pagination; the handler declares no requireAuth of its own, so its 401 comes from the gateway default-deny auth gate (the path is not on the public allowlist).
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate, not by the route itself |
| 500 | InternalError | InternalError | serializing the registry throws — logged and returned only if the reply has not already been sent |
{
"success": true,
"data": [
{
"type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"count": "number",
"types": "array"
}
}POST/api/events/types🔒 auth
Registers ONE event type for the caller's own tenant, extending the compile-time EVENT_TYPE_REGISTRY baseline so a consuming application can emit its own audit events without a platform release. The tenant comes from the verified JWT claim and is never read from the body, so a user of tenant A cannot define vocabulary inside tenant B. Resolution at append time is baseline-first, then this tenant's rows, so a registration can never shadow or redefine a platform type. Edge cases: registering an event_type that already exists for this tenant is ADDITIVE, returning 200 with the STORED metadata and created:false rather than overwriting it (a boot-time provisioner re-running on every deploy is the expected caller); an event_type that collides with a platform baseline name is rejected 400 rather than accepted as a shadow; a name that does not match <domain>.<entity>.<verb>.v<N> is rejected 400 at REGISTRATION, where the author can still fix it, rather than at append time in production; schema_state defaults to 'active', compaction_policy to 'none' and schema_version to 1 when omitted; a token carrying no tenant_id claim is rejected 400 because the row has nowhere to land. IDEMPOTENCY (MUST-47): the primary key is (tenant_id, event_type) and tenant_id comes from a tenant created fresh by the signup-tenant dependency on every run, so the fixed event_type below cannot collide across runs. NOTE ON fieldEnums (MUST-39): event_type is deliberately absent from fieldEnums because it is NOT enum-backed - it is pattern-validated against EVENT_TYPE_NAME_PATTERN (<domain>.<entity>.<verb>.v<N>) and any conforming name is valid, so publishing a finite list would tell QA something false about what the field accepts. The four metadata fields below ARE enum-backed (Postgres CHECK-in-list) and are enumerated.
[ "POST /api/auth/signup-tenant" ]
retention_class: transient, operational, regulatedconflict_policy: crdt, lww, merge, event-sourcing, human-reviewschema_state: active, deprecated, retiredcompaction_policy: none, lww, count| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate |
| 400 | ValidationError | details[]: a tenant-scoped token is required to register an event type | the verified claims carry no tenant_id, so the registration has no scope to land in |
| 400 | ValidationError | details[]: event_type '<name>' does not follow the naming convention <domain>.<entity>.<verb>.v<N> | the name fails EVENT_TYPE_NAME_PATTERN — no version suffix, uppercase, spaces, or a single segment |
| 400 | ValidationError | details[]: event_type '<name>' is a platform baseline type and cannot be redefined by a tenant | the requested name already exists in the compile-time EVENT_TYPE_REGISTRY |
| 400 | ValidationError | details[]: retention_class / conflict_policy / schema_state / compaction_policy must be one of the allowed values, schema_version must be an integer >= 1, tenant_id is required | registerTenantEventType validation fails; every problem is reported together so a caller fixing one at a time does not deploy twice |
| 500 | InternalError | InternalError | the insert or read-back throws for any other reason (DB unreachable, etc.) — logged and returned only if the reply has not already been sent |
{
"event_type": "capture.lead.created.v1",
"retention_class": "regulated",
"conflict_policy": "event-sourcing",
"schema_state": "active",
"compaction_policy": "none",
"schema_version": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"data": {
"event_type": "string",
"retention_class": "string",
"conflict_policy": "string",
"schema_state": "string",
"compaction_policy": "string",
"schema_version": "number",
"created": "boolean",
"source": "string"
}
}GET/api/events/types/:type🔒 auth
Looks up one event type by its exact key and returns its metadata plus a 'source' of 'platform' or 'tenant'. The compile-time EVENT_TYPE_REGISTRY baseline is checked FIRST and the caller's tenant-registered types second, which is the property that stops a tenant registration shadowing a platform type. Edge cases: the lookup is an exact, case-sensitive key match, so a versionless or mis-cased name (e.g. 'event.ticket.issued' instead of 'event.ticket.issued.v1') returns 404 UnregisteredEventType echoing the requested type and pointing at POST /api/events/types; a name registered by ANOTHER tenant also 404s, since only the caller's own tenant rows are consulted; the baseline half never varies per tenant or over the life of the process; the handler declares no requireAuth of its own, so its 401 comes from the gateway default-deny auth gate.
[ "POST /api/auth/register" ]
type: vault.key.issued.v1, vault.key.rotated.v1, vault.key.shredded.v1, vault.encounter.opened.v1, vault.encounter.sealed.v1, secrets.ref.resolved.v1, secrets.key.rotated.v1, tenant.pool.assigned.v1, pool.lifecycle.changed.v1, usage.event.v1, audit.chain.verified.v1, audit.chain.break.v1, audit.export.requested.v1, audit.export.ready.v1, tenant.created.v1, tenant.subtenant.created.v1, tenant.bu.created.v1, tenant.bu.moved.v1, tenant.role-template.updated.v1, tenant.fiscal-calendar.updated.v1, reseller.created.v1, reseller.tenant.attached.v1, identity.login.v1, identity.app-identity.created.v1, identity.alias.merged.v1, identity.federation.configured.v1, identity.mfa.challenged.v1, identity.mfa.verified.v1, identity.impersonation.requested.v1, identity.impersonation.granted.v1, identity.impersonation.ended.v1, consent.granted.v1, consent.revoked.v1, consent.purpose.registered.v1, consent.cross-tenant.granted.v1, policy.evaluated.v1, policy.updated.v1, rebac.relationship.created.v1, rebac.relationship.scope.changed.v1, rebac.relationship.terminated.v1, rebac.decision.v1, api-key.issued.v1, api-key.rotated.v1, api-key.revoked.v1, api-key.used.v1, identity.projection.refreshed.v1, identity.projection.miss.v1, profile.band.updated.v1, profile.field.shredded.v1, identity.persona.created.v1, identity.persona.shred.v1, identity.membership.created.v1, identity.membership.suspended.v1, identity.membership.reactivated.v1, identity.membership.terminated.v1, identity.role.assigned.v1, identity.role.revoked.v1, identity.resolver.fallback.v1, data-rights.request.submitted.v1, data-rights.request.transitioned.v1, data-rights.executed.v1, data-rights.certificate.issued.v1, data-rights.reconciliation.completed.v1, pool-residency.touched.v1, geo.address.canonicalized.v1, geo.address.merged.v1, device.registered.v1, device.attested.v1, device.revoked.v1, device.person-link.changed.v1, feature-flag.updated.v1, feature-flag.rollout.updated.v1, feature-flag.kill-switch.flipped.v1, hdk-sync.queue.replayed.v1, hdk-sync.conflict.resolved.v1, hdk-sync.conflict.escalated-to-human.v1, hdk-sync.event-type-policy.registered.v1, hdk-idp.device-claim.registered.v1, hdk-idp.offline-auth.synced.v1, hdk-permissions.surface.snapshot.v1, media.blob.uploaded.v1, media.transcode.completed.v1, media.blob.shredded.v1, notification.sent.v1, notification.delivered.v1, notification.failed.v1, payment.charge.v1, payment.refund.v1, payment.distributed.v1, billing.invoice.finalized.v1, billing.invoice.paid.v1, billing.dunning.advanced.v1, billing.reprice.dry-run.completed.v1, approval.step.decided.v1, engagement.encounter.opened.v1, engagement.encounter.closed.v1, engagement.encounter.sealed.v1, engagement.relationship.created.v1, engagement.relationship.terminated.v1, engagement.encounter.grant.issued.v1, engagement.encounter.grant.revoked.v1, crm.contact.created.v1, crm.contact.updated.v1, crm.deal.created.v1, crm.deal.transitioned.v1, crm.activity.logged.v1, content.item.created.v1, content.version.published.v1, service-request.ticket.created.v1, service-request.ticket.transitioned.v1, service-request.ticket.sla.breached.v1, event.session.opened.v1, event.ticket.issued.v1, event.ticket.checked-in.v1, campaign.created.v1, campaign.segment.computed.v1, campaign.journey.advanced.v1, social.handle.authorized.v1, social.interaction.ingested.v1, social.lead.captured.v1, connector.installed.v1, connector.uninstalled.v1, connector.sync.completed.v1, connector.sync.conflict.v1, hdk-scanner.code.captured.v1, hdk-image.edit.applied.v1, hdk-video.trim.applied.v1, tenant.lifecycle.transitioned.v1, tenant.lifecycle.sandbox.created.v1, tenant.lifecycle.offboarded.v1, slack.workspace.connected.v1, slack.message.posted.v1, slack.thread.message.v1, slack.interaction.received.v1, webhook.delivery.failed.v1, webhook.delivery.dlq.v1, agent.run.started.v1, agent.run.completed.v1, agent.run.terminated.v1, agent.run.replayed.v1, agent.run.rolled-back.v1, agent.tool.invoked.v1, agent.scope.exceeded.v1, agent.capability-token.minted.v1, agent.capability-token.revoked.v1, agent.log.purged.v1, agent.kill-switch.triggered.v1, ai-gateway.complete.v1, ai-gateway.stream.v1, ai-gateway.provider.circuit-state.changed.v1, trace.export.requested.v1, trace.export.ready.v1, mcp.server.registered.v1, mcp.server.disabled.v1, mcp.tool.invoked.v1, mcp.exposed-server.activated.v1, taxonomy.version.activated.v1, taxonomy.version.deprecated.v1, connector.github.webhook.received.v1, connector.github.pr.upserted.v1, rag.corpus.created.v1, rag.document.indexed.v1, rag.document.reindexed.v1, rag.retrieval.completed.v1, parsing.job.queued.v1, parsing.stage.completed.v1, parsing.field.extracted.v1, parsing.review.routed.v1, parsing.job.completed.v1, conversation.session.opened.v1, conversation.turn.recorded.v1, conversation.handoff.v1, conversation.session.closed.v1, recommendation.model.trained.v1, recommendation.suggestion.generated.v1, recommendation.feedback.captured.v1, analytics.rollup.executed.v1, analytics.extract.published.v1, lineage.edge.emitted.v1, lineage.projection.queued.v1, lineage.projection.completed.v1, lineage.projection.failed.v1, semantic.ontology.registered.v1, semantic.ontology.deprecated.v1, semantic.intent.planned.v1, semantic.plan.executed.v1, semantic.policy.evaluated.v1, semantic.bridge.created.v1, snowflake.installed.v1, snowflake.binding.created.v1, snowflake.sync.completed.v1, snowflake.query.executed.v1, storm.event.ingested.v1, storm.intensity.updated.v1, dispatch.task.enqueued.v1, dispatch.task.assigned.v1, dispatch.task.completed.v1, dispatch.route.optimized.v1, assignment.assigned.v1, assignment.accepted.v1, assignment.rejected.v1, lead-scoring.scored.v1, lead-scoring.model.trained.v1, evidence.captured.v1, evidence.variant.created.v1, evidence.chain.appended.v1, evidence.legal-export.generated.v1, evidence.shredded.v1, evidence.sealed.v1, diagnostic.crash.reported.v1, diagnostic.health.captured.v1, diagnostic.session-replay.event.v1, federation.route.resolved.v1, federation.failover.executed.v1, iceberg.table.compacted.v1, iceberg.query.executed.v1, usage.hardcap.exceeded.v1, usage.hardcap.override.applied.v1, hdk-measure.captured.v1, hdk-watermark.applied.v1, byok.binding.created.v1, byok.cmk.used.v1, byok.cmk.rotated.v1, byok.binding.revoked.v1, sovereign.bundle.shipped.v1, sovereign.bundle.applied.v1, sovereign.attestation.issued.v1, sovereign.leak.alert.v1, onprem.bundle.applied.v1, onprem.bundle.rolled-back.v1, onprem.local-llm.loaded.v1, active-active.profile.activated.v1, active-active.failover.drill.v1, active-active.tier.downgraded.v1, ai_gateway.tenant_credential.bound.v1, ai_gateway.tenant_credential.rotated.v1, ai_gateway.tenant_credential.revoked.v1, security.principal_token.key_rotated.v1, security.break_glass.granted.v1, security.break_glass.used.v1, resource_registry.quarantined.v1, mdm.candidate_link.created.v1, mdm.steward.decided.v1, mdm.merge.performed.v1, mdm.merge.reversed.v1, mdm.calibration.drift.v1| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate, not by the route itself |
| 404 | UnregisteredEventType | event_type '<type>' is not registered | the :type path param is not a key of EVENT_TYPE_REGISTRY (exact, case-sensitive match) |
| 500 | InternalError | InternalError | the lookup/serialization throws — logged and returned only if the reply has not already been sent |
{
"type": "event.session.opened.v1"
}{
"success": true,
"data": {
"type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-billing
POST/api/billing/invoices/generate🔒 auth
Generates an invoice for the caller's tenant over a closed billing period using a named rate catalog, returning 201 with the invoice and its line items. tenant_id is always taken from the verified JWT and never from the body, because invoice generation exposes financial PII. Edge cases: catalog_id is required and period_start/period_end must both be YYYY-MM-DD; period_start must be <= period_end so an inverted range is a 400; all validation failures are accumulated into one details array; a catalog_id that does not resolve raises CatalogNotFoundError and returns 404; generating for a period with no metered usage yields a zero-total invoice rather than an error; there is no idempotency key, so repeating the call for the same tenant/period/catalog produces another invoice.
[ "POST /api/auth/signup-tenant", "POST /admin/meter/pricing-catalogs", "PUT /admin/meter/pricing-catalogs/:catalog_id/rates/:sku", "PATCH /admin/meter/pricing-catalogs/:catalog_id/status" ]
actor_kind: human, agent, service| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | JWT missing tenant_id claim | Token verifies but carries no tenant_id claim; billing surfaces refuse to run unscoped |
| 400 | ValidationError | tenant_id must be a UUID | The tenant_id claim injected from the JWT is not a well-formed UUID |
| 400 | ValidationError | catalog_id is required | catalog_id is missing or blank in the body |
| 400 | ValidationError | period_start must be YYYY-MM-DD | period_start is missing or not in YYYY-MM-DD form |
| 400 | ValidationError | period_end must be YYYY-MM-DD | period_end is missing or not in YYYY-MM-DD form |
| 400 | ValidationError | period_start must be <= period_end | Both dates parse but the range is inverted |
| 404 | CatalogNotFound | Rate catalog not found | generateInvoice throws CatalogNotFoundError because catalog_id does not resolve to a catalog |
| 500 | InternalError | InternalError | Invoice generation throws for any other reason (usage aggregation failure, ClickHouse/Postgres unreachable) |
{
"entity": "invoice",
"field": "status",
"flow": [
"draft",
"finalized",
"paid",
"void",
"failed"
],
"transitions": [
{
"from": "draft",
"to": "finalized",
"via": "POST /api/billing/invoices/generate"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"catalog_id": "{{cache:meter-pricing-catalogs.create.response.data.catalog_id}}",
"period_start": "2026-01-01",
"period_end": "2026-01-31",
"currency": "USD",
"tax_rate": 0.0875,
"usage": [
{
"sku": "api.call",
"app_id": "admin",
"bu_id": "finance",
"persona_kind": "human",
"encounter_id": null,
"actor_kind": "human",
"units": 12000,
"vendor_cost": 0
}
]
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"period_start": "2026-01-01",
"period_end": "2026-01-31",
"currency": "USD",
"tax_rate": 0.0875,
"usage": [
{
"sku": "api.call",
"app_id": "admin",
"bu_id": "finance",
"persona_kind": "human",
"encounter_id": null,
"actor_kind": "human",
"units": 12000,
"vendor_cost": 0
}
]
}{
"success": true,
"data": {
"status": "completed",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"period_start": "2026-01-01",
"period_end": "2026-01-31",
"currency": "USD",
"tax_rate": 0.0875,
"usage": [
{
"sku": "api.call",
"app_id": "admin",
"bu_id": "finance",
"persona_kind": "human",
"encounter_id": null,
"actor_kind": "human",
"units": 12000,
"vendor_cost": 0
}
],
"generate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"invoice": {
"invoice_id": "string",
"status": "string",
"total": "string"
},
"line_items": "array"
}
}GET/api/billing/live🔒 auth
Returns the caller's current live meter reading — in-flight usage counters for the tenant that have not yet been rolled into an invoice. tenant_id comes exclusively from the verified JWT, so the endpoint always reports the caller's own tenant and cannot be pointed at another tenant via the query string. Edge cases: the only validation is that the JWT tenant_id is a well-formed UUID, so all other query params are ignored rather than rejected; a tenant with no recorded usage returns 200 with zeroed/empty counters rather than 404; values are point-in-time and unsmoothed, so consecutive calls can differ as the meter collector flushes; a token missing the tenant_id claim is 403 before any read.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | JWT missing tenant_id claim | Token verifies but carries no tenant_id claim; billing surfaces refuse to run unscoped |
| 400 | ValidationError | tenant_id must be a UUID | The tenant_id claim injected from the JWT is not a well-formed UUID |
| 404 | CatalogNotFound | Rate catalog not found | readLiveMeter throws CatalogNotFoundError while resolving the tenant's active rate catalog |
| 500 | InternalError | InternalError | The meter store (Redis/ClickHouse) is unreachable or the read throws |
{
"success": true,
"data": [
{
"live_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"tenant_id": "string",
"subtotal": "number",
"lag_ms": "number",
"by_sku": "object"
}
}POST/api/billing/reprice-dry-run🔒 auth
Simulates repricing the caller's tenant over a period by replaying recorded usage against a target rate catalog and comparing it to a baseline catalog, returning 201 with the delta — no invoice is written and no state changes. tenant_id is forced from the verified JWT. Edge cases: baseline_catalog_id and target_catalog_id are both required and may legitimately be the same value, which yields a zero delta; period_start and period_end must both be YYYY-MM-DD; unlike invoice generation this validator does not enforce period_start <= period_end, so an inverted range passes validation and simply produces an empty usage window; either catalog id failing to resolve raises CatalogNotFoundError and returns 404; being a dry run the call is fully idempotent and repeatable.
[ "POST /api/auth/signup-tenant", "POST /admin/meter/pricing-catalogs", "PUT /admin/meter/pricing-catalogs/:catalog_id/rates/:sku", "PATCH /admin/meter/pricing-catalogs/:catalog_id/status" ]
actor_kind: human, agent, service| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | JWT missing tenant_id claim | Token verifies but carries no tenant_id claim; billing surfaces refuse to run unscoped |
| 400 | ValidationError | tenant_id must be a UUID | The tenant_id claim injected from the JWT is not a well-formed UUID |
| 400 | ValidationError | period_start must be YYYY-MM-DD | period_start is missing or not in YYYY-MM-DD form |
| 400 | ValidationError | period_end must be YYYY-MM-DD | period_end is missing or not in YYYY-MM-DD form |
| 400 | ValidationError | baseline_catalog_id is required | baseline_catalog_id is missing or blank |
| 400 | ValidationError | target_catalog_id is required | target_catalog_id is missing or blank |
| 404 | CatalogNotFound | Rate catalog not found | runRepriceDryRun throws CatalogNotFoundError because the baseline or target catalog id does not resolve |
| 500 | InternalError | InternalError | The dry run throws for any other reason (usage replay failure, datastore unreachable) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"period_start": "2026-01-01",
"period_end": "2026-01-31",
"baseline_catalog_id": "{{cache:meter-pricing-catalogs.create.response.data.catalog_id}}",
"target_catalog_id": "{{cache:meter-pricing-catalogs.create.response.data.catalog_id}}",
"usage": [
{
"sku": "api.call",
"app_id": "admin",
"bu_id": "finance",
"persona_kind": "human",
"encounter_id": null,
"actor_kind": "service",
"units": 50000,
"vendor_cost": 0
}
]
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"period_start": "2026-01-01",
"period_end": "2026-01-31",
"baseline_catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"usage": [
{
"sku": "api.call",
"app_id": "admin",
"bu_id": "finance",
"persona_kind": "human",
"encounter_id": null,
"actor_kind": "service",
"units": 50000,
"vendor_cost": 0
}
]
}{
"success": true,
"data": {
"reprice_dry_run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"period_start": "2026-01-01",
"period_end": "2026-01-31",
"baseline_catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"usage": [
{
"sku": "api.call",
"app_id": "admin",
"bu_id": "finance",
"persona_kind": "human",
"encounter_id": null,
"actor_kind": "service",
"units": 50000,
"vendor_cost": 0
}
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"dry_run": {
"dry_run_id": "string",
"delta_amount": "string"
},
"baseline_total": "number",
"target_total": "number"
}
}GET/api/billing/showback🔒 auth
Produces a showback/chargeback breakdown of the caller's tenant usage across a period, optionally grouped by dimensions such as app_id and bu_id via the group_by query param. tenant_id is always taken from the verified JWT so one tenant can never pull another tenant's cost allocation. Edge cases: period_start and period_end are both required in YYYY-MM-DD form; the validator does not enforce start <= end, so an inverted range returns an empty breakdown rather than a 400; an unrecognised group_by dimension is not rejected at validation and either collapses the grouping or surfaces from the aggregation as a 500; a period with no usage returns 200 with empty groups rather than 404; results are computed on demand and unpaginated, so wide periods with many groups return a large single payload.
[ "POST /api/auth/signup-tenant", "POST /admin/meter/pricing-catalogs", "PUT /admin/meter/pricing-catalogs/:catalog_id/rates/:sku", "PATCH /admin/meter/pricing-catalogs/:catalog_id/status", "POST /api/billing/invoices/generate" ]
group_by: app_id, bu_id, persona_kind, encounter_id, sku, actor_kind| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | JWT missing tenant_id claim | Token verifies but carries no tenant_id claim; billing surfaces refuse to run unscoped |
| 400 | ValidationError | tenant_id must be a UUID | The tenant_id claim injected from the JWT is not a well-formed UUID |
| 400 | ValidationError | period_start must be YYYY-MM-DD | period_start is missing or not in YYYY-MM-DD form |
| 400 | ValidationError | period_end must be YYYY-MM-DD | period_end is missing or not in YYYY-MM-DD form |
| 404 | CatalogNotFound | Rate catalog not found | computeShowback throws CatalogNotFoundError while resolving the tenant's rate catalog |
| 500 | InternalError | InternalError | The aggregation throws or the usage store is unreachable |
{
"success": true,
"data": [
{
"showback_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"rows": "array",
"total_amount": "number"
}
}sdk-campaign
POST/api/campaigns🔒 auth
Creates a campaign for a tenant. tenant_id and name are both required; variant_flag_id is optional and links the campaign to a feature-flag variant for A/B allocation. Edge cases: missing or empty tenant_id/name -> 400; campaign names are not unique, so repeated POSTs create distinct campaigns (not idempotent); the new campaign starts in its default status and emits a campaign.created.v1 audit entry - audit failures are swallowed and do not fail the request.
[ "POST /api/auth/signup-tenant", "PUT /api/flags" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | missing fields | body omits tenant_id or name (details: ["missing fields"]) |
{
"entity": "campaign.campaign",
"field": "status",
"flow": [
"draft",
"scheduled",
"running",
"paused",
"completed"
],
"transitions": [
{
"from": "(none)",
"to": "draft",
"via": "POST /api/campaigns"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"name": "{{dynamic:name}}",
"variant_flag_id": "{{cache:flags.create.response.data.flag.flag_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"variant_flag_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"campaign_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"variant_flag_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/campaigns/:campaign_id/journeys🔒 auth
Creates a journey (an ordered step list stored as opaque jsonb) under a campaign. The handler does no body validation: an omitted steps array defaults to [], and step contents are never schema-checked here - downstream workers interpret them. Edge cases: an unknown or non-UUID campaign_id fails the FK/cast in the insert and surfaces as an unhandled 500, not a 404; a zero-step journey is accepted and any run against it completes on the first advance; each call inserts a new journey row, so this is not idempotent.
[ "POST /api/auth/signup-tenant", "POST /api/campaigns" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
{
"campaign_id": "{{cache:campaigns.create.response.data.campaign.campaign_id}}"
}{
"steps": [
{
"kind": "delay",
"duration_hours": 24
},
{
"kind": "notification",
"template_code": "welcome_v1"
}
]
}{
"steps": [
{
"kind": "delay",
"duration_hours": 24
},
{
"kind": "notification",
"template_code": "welcome_v1"
}
]
}{
"success": true,
"data": {
"journey_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"steps": [
{
"kind": "delay",
"duration_hours": 24
},
{
"kind": "notification",
"template_code": "welcome_v1"
}
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/campaigns/:campaign_id/segments🔒 auth
Creates (inserts) a segment under a campaign with an audience DSL document. The handler performs no body validation - an omitted dsl defaults to {} - so the only client-side edge cases are the path param and the campaign FK. Edge cases: an unknown or non-UUID campaign_id fails the FK/cast inside the insert and surfaces as an unhandled 500 rather than a 404; every call inserts a new segment row (there is no upsert on campaign_id), so this is not idempotent; population_estimate is left null until the compute endpoint is called.
[ "POST /api/auth/signup-tenant", "POST /api/campaigns" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
{
"campaign_id": "{{cache:campaigns.create.response.data.campaign.campaign_id}}"
}{
"dsl": {
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"match": "all",
"rules": [
{
"attr": "status",
"op": "eq",
"value": "active"
}
]
}
}{
"dsl": {
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"match": "all",
"rules": [
{
"attr": "status",
"op": "eq",
"value": "active"
}
]
}
}{
"success": true,
"data": {
"segment_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"dsl": {
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"match": "all",
"rules": [
{
"attr": "status",
"op": "eq",
"value": "active"
}
]
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/campaigns/journeys/:journey_id/runs🔒 auth
Starts a journey run for one subject persona; the run begins at current_step 0 in state 'active'. subject_persona_id is required. Edge cases: missing subject_persona_id -> 400; an unknown journey_id fails the FK inside the insert and surfaces as an unhandled 500 rather than a 404; nothing prevents starting a second concurrent run for the same (journey, persona) pair, so the endpoint is not idempotent and callers must de-duplicate.
[ "POST /api/auth/signup-tenant", "POST /api/campaigns", "POST /api/campaigns/:campaign_id/journeys", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | missing subject_persona_id | body omits subject_persona_id (details: ["missing subject_persona_id"]) |
{
"entity": "campaign.journey_run",
"field": "state",
"flow": [
"active",
"paused",
"completed",
"exited"
],
"transitions": [
{
"from": "(none)",
"to": "active",
"via": "POST /api/campaigns/journeys/:journey_id/runs"
},
{
"from": "active",
"to": "completed",
"via": "POST /api/campaigns/runs/:run_id/advance"
}
]
}{
"journey_id": "{{cache:campaigns.journey.create.response.data.journey.journey_id}}"
}{
"subject_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}{
"subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/campaigns/runs/:run_id/advance🔒 auth
Advances a journey run by one step, marking it completed once current_step walks past the journey's step count. Edge cases: an unknown run_id returns 404; a run whose state is already anything other than 'active' (e.g. completed) is returned unchanged with 200 rather than erroring, so repeated advances on a finished run are a safe no-op; if the parent journey row is missing the run is immediately completed; each call on an active run is a real state change, so the endpoint is not idempotent while the run is active.
[ "POST /api/auth/signup-tenant", "POST /api/campaigns", "POST /api/campaigns/:campaign_id/journeys", "POST /api/personas", "POST /api/campaigns/journeys/:journey_id/runs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 404 | NotFound | NotFound | no campaign.journey_run row exists for the given run_id |
{
"entity": "campaign.journey_run",
"field": "state",
"flow": [
"active",
"paused",
"completed",
"exited"
],
"transitions": [
{
"from": "active",
"to": "active",
"via": "POST /api/campaigns/runs/:run_id/advance"
},
{
"from": "active",
"to": "completed",
"via": "POST /api/campaigns/runs/:run_id/advance"
}
]
}{
"run_id": "{{cache:campaigns.run.create.response.data.run.run_id}}"
}{}{
"success": true,
"data": {
"advance_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/campaigns/segments/:segment_id/compute🔒 auth
Recomputes a segment's population_estimate and stamps last_computed_at. Only simple tenant_id equality predicates in the DSL are evaluated today; anything else yields an estimate of 0. Edge cases: an unknown segment_id returns 404; if projection.subject_view is missing (e.g. a fresh test environment) the count silently falls back to 0 rather than erroring; the call is safely repeatable/idempotent - each run overwrites the previous estimate; the emitted campaign.segment.computed.v1 audit records tenant 'unknown' when the parent campaign row cannot be read.
[ "POST /api/auth/signup-tenant", "POST /api/campaigns", "POST /api/campaigns/:campaign_id/segments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 404 | NotFound | NotFound | no campaign.segment row exists for the given segment_id |
{
"segment_id": "{{cache:campaigns.segment.create.response.data.segment.segment_id}}"
}{}{
"success": true,
"data": {
"compute_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-command
POST/api/admin/commands/dispatch-nowpublic
Runs ONE pass of the command dispatcher immediately, flipping every command in 'approved' to 'dispatched' and publishing each onto its per-asset delivery channel. Header-auth gated with ADMIN_OPS_TOKEN via checkAdminToken, mirroring /admin/storm/ingest-now and /api/admin/asset/rollup/backfill: it lets an operator force a background worker to run now rather than waiting for its cadence (COMMAND_DISPATCH_INTERVAL_MS, default 2s). Returns data.dispatched — the count of commands moved. ORDERING: dependsOn names the command-creation chain even though this endpoint takes no input from it. With an empty dependsOn the runner is free to schedule this pass BEFORE any command exists, dispatch nothing, and leave POST /api/commands/:command_id/ack facing a command still in 'approved' — the exact intermittent 409 this endpoint was added to remove. The dependency is on the STATE the pass needs to find, not on a captured value. Idempotent by construction: dispatchApprovedCommands only matches rows in 'approved', so an immediate second call dispatches nothing and returns 0. This endpoint also makes the 'dispatched' state reachable by request at all; before it existed the state was produced only by the periodic tick, so POST /api/commands/:command_id/ack (which requires status='dispatched') could only race that tick and failed intermittently. Edge cases: a missing or wrong x-admin-ops-token is a 401 and dispatches nothing; with no approved commands the call still succeeds with dispatched=0; the batch is capped by COMMAND_DISPATCH_BATCH (default 50), so a backlog larger than that needs repeated calls.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/assets", "POST /api/commands" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | admin token required | admin token required | The x-admin-ops-token header is absent, empty, or does not match ADMIN_OPS_TOKEN |
| 500 | <underlying error message> | Dispatch pass failed | dispatchApprovedCommands throws — a database error while selecting or updating command.command |
{}{success,data} envelope derived from the request contract; assert shape + HTTP 200.POST/api/commands🔒 auth
Issues a command to a robot asset or one of its components. The request is authorized through ReBAC plus policy inside sdk-command; commands whose risk_class is high enough land in a pending state awaiting approval via POST /api/commands/:command_id/decision instead of dispatching immediately. Returns 201 with the command record. tenant_id falls back to the JWT tenant_id claim; the issuer is always the JWT sub. Edge cases: a token with no tenant_id or no sub is a 400; target_asset_id and type are both mandatory; a ReBAC/policy denial is a 403 carrying the authorization error message; an unknown target_asset_id or target_component_id is not pre-checked and surfaces as a generic 500; params and risk_class are optional.
[ "POST /api/auth/signup-tenant", "POST /api/assets" ]
risk_class: low, medium, high, critical| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | tenant_id required | tenant_id required | Neither body.tenant_id nor the JWT tenant_id claim is present |
| 400 | issuer identity required | issuer identity required | The JWT carries no sub claim to record as issued_by |
| 400 | target_asset_id and type are required | target_asset_id and type are required | body.target_asset_id or body.type is absent or empty |
| 403 | <CommandAuthorizationError message> | Command authorization denied | issueCommand throws CommandAuthorizationError - the issuer lacks the ReBAC relation or the policy engine denied this command type/risk class for the asset |
| 500 | <underlying error message> | Command issuance failed | Any other issueCommand failure - unknown/non-UUID target_asset_id, unknown target_component_id, or database error |
{
"entity": "command.command",
"field": "status",
"flow": [
"pending",
"approved",
"rejected",
"dispatched",
"acked",
"failed",
"expired",
"cancelled"
],
"transitions": [
{
"from": null,
"to": "approved",
"via": "POST /api/commands"
},
{
"from": null,
"to": "pending",
"via": "POST /api/commands"
},
{
"from": "pending",
"to": "approved",
"via": "POST /api/commands/:command_id/decision"
},
{
"from": "pending",
"to": "rejected",
"via": "POST /api/commands/:command_id/decision"
},
{
"from": "approved",
"to": "dispatched",
"via": "INTERNAL sdk-command background dispatcher"
},
{
"from": "dispatched",
"to": "acked",
"via": "POST /api/commands/:command_id/ack"
},
{
"from": "dispatched",
"to": "failed",
"via": "POST /api/commands/:command_id/ack"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"target_asset_id": "{{cache:assets.create.response.data.asset_id}}",
"target_component_id": "{{var:component_id}}",
"type": "move",
"params": {
"x": 1,
"y": 2
},
"risk_class": "high"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_asset_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_component_id": "{{var:component_id}}",
"type": "move",
"params": {
"x": 1,
"y": 2
},
"risk_class": "high"
}{
"success": true,
"data": {
"command_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_asset_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_component_id": "{{var:component_id}}",
"type": "move",
"params": {
"x": 1,
"y": 2
},
"risk_class": "high",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/commands/:command_id🔒 auth
Looks up a single command's status and result by id, scoped to the caller's tenant from the JWT tenant_id claim. Returns 200 with the command record including its lifecycle state (pending, dispatched, acked, rejected, ...). Edge cases: a token with no tenant_id claim is a 400; a command_id that exists but belongs to a different tenant is indistinguishable from one that does not exist - both are 404, which is the intended tenant-isolation behaviour; a non-UUID command_id fails the Postgres UUID cast inside the try block and is reported as a generic 500 rather than a 400; requires a valid JWT.
[ "POST /api/auth/signup-tenant", "POST /api/assets", "POST /api/commands" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | tenant context required | tenant context required | The JWT carries no tenant_id claim |
| 404 | command not found | command not found | No command with that id exists within the caller's tenant - also returned when the command belongs to a different tenant |
| 500 | <underlying error message> | Command lookup failed | getCommand throws - includes a non-UUID command_id (Postgres 22P02) and database errors |
{
"command_id": "{{cache:commands.create.response.data.command_id}}"
}{
"success": true,
"data": {
"command_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/commands/:command_id/ack🔒 auth
Ingests a command acknowledgement/result FROM the robot edge agent. Unlike the other command routes this handler has no requireAuth preHandler: the bearer token is expected to be the per-robot scoped credential minted by POST /api/assets/:asset_id/credentials and is verified inside ackCommandWithCredential rather than as a JWT. Note that the api-gateway default-deny authGate still fronts this path, so in enforce mode the request must additionally satisfy the gate. ok is a mandatory boolean; code, message and data are optional detail. Edge cases: an empty, expired or revoked credential is a 401; a credential that is valid but not scoped to this command's asset is a 403; a command in a state other than dispatched (already acked, still pending, cancelled) is a 409, which also makes the call non-idempotent - a replayed ack conflicts; ok sent as the string "true" fails the strict boolean check with a 400.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/assets", "POST /api/assets/:asset_id/credentials", "POST /api/commands" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ok (boolean) is required | ok (boolean) is required | Body is absent or ok is not a JSON boolean (the string "true" is rejected) |
| 401 | missing credential | missing credential | No Bearer token on the request, so no robot credential was presented to ackCommandWithCredential |
| 401 | invalid, expired, or revoked key | invalid, expired, or revoked key | The presented robot credential fails verifyKey - unknown, past its expires_at, or revoked |
| 403 | credential not scoped to this command | credential not scoped to this command | The credential lacks the command-ack scope or the scope for this command's target_asset_id |
| 404 | command not found | command not found | No command with that id exists within the tenant that owns the presented credential |
| 409 | command not in dispatched state | command not in dispatched state | The command is not in the dispatched state - already acked, still pending approval, or cancelled. Replayed acks land here |
| 500 | <underlying error message> | Ack processing failed | ackCommandWithCredential throws - non-UUID command_id (Postgres 22P02) or database error |
{
"entity": "command.command",
"field": "status",
"flow": [
"dispatched",
"acked",
"failed"
],
"transitions": [
{
"from": "dispatched",
"to": "acked",
"via": "POST /api/commands/:command_id/ack"
},
{
"from": "dispatched",
"to": "failed",
"via": "POST /api/commands/:command_id/ack"
}
]
}{
"command_id": "{{cache:commands.create.response.data.command_id}}"
}{
"ok": true,
"code": "DONE",
"message": "executed",
"data": {}
}{
"ok": true,
"code": "DONE",
"message": "executed",
"data": {}
}{
"success": true,
"data": {
"ack_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ok": true,
"code": "DONE",
"message": "executed",
"data": {},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/commands/:command_id/decision🔒 auth
Approves or rejects a risky command that was gated into the pending state at issue time; the decision is audited with the approving subject and optional reason. Returns 200 with the updated command. approved is a mandatory boolean, so the string "true" is rejected. tenant_id and decided_by come solely from the JWT tenant_id and sub claims - there is no body override. Edge cases: a token missing either claim is a 400; an unknown command_id and a command that is no longer pending (already approved, rejected, dispatched, or belonging to another tenant) are collapsed into the same 409 "command not found or not pending" - there is no 404 path, and this also makes a replayed decision a 409; reason is optional.
[ "POST /api/auth/signup-tenant", "POST /api/assets", "POST /api/commands" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | tenant context required | tenant context required | The JWT carries no tenant_id claim |
| 400 | approver identity required | approver identity required | The JWT carries no sub claim to record as decided_by |
| 400 | approved (boolean) is required | approved (boolean) is required | Body is absent or approved is not a JSON boolean |
| 409 | command not found or not pending | command not found or not pending | applyCommandApprovalDecision returned null - the command does not exist in this tenant, or it is no longer pending (already decided/dispatched). Replayed decisions land here |
| 500 | <underlying error message> | Decision processing failed | applyCommandApprovalDecision throws - non-UUID command_id (Postgres 22P02), audit-write failure, or database error |
{
"entity": "command.command",
"field": "status",
"flow": [
"pending",
"approved",
"rejected"
],
"transitions": [
{
"from": "pending",
"to": "approved",
"via": "POST /api/commands/:command_id/decision"
},
{
"from": "pending",
"to": "rejected",
"via": "POST /api/commands/:command_id/decision"
}
]
}{
"command_id": "{{cache:commands.create.response.data.command_id}}"
}{
"approved": true,
"reason": "operator confirmed safe"
}{
"approved": true,
"reason": "operator confirmed safe"
}{
"success": true,
"data": {
"decision_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"approved": true,
"reason": "operator confirmed safe",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/commands/stream/:asset_idpublic · manual
Per-asset command delivery stream. This is a WebSocket-only route ({ websocket: true }): an edge agent for a robot subscribes here and receives dispatched commands in real time as JSON frames from the command broker. On connect the server immediately pushes a { kind: "hello", asset_id, emitted_at } frame, and the broker subscription is torn down by the socket close handler. The path prefix /api/commands/stream/ is deliberately exempt from the api-gateway default-deny authGate (WS auth belongs in the Sec-WebSocket-Protocol token, tracked as follow-up hardening), so no Authorization header is checked and any asset_id may be subscribed. The handler has no validation branches and never sends an HTTP error status: it does not verify the asset exists, so subscribing to an unknown asset_id simply yields a stream that never emits, and a send on an already-closed socket is swallowed. Edge cases are transport-level rather than status-code-level - a plain non-upgrade HTTP GET is rejected by the @fastify/websocket plugin before this handler runs, and an abandoned socket is cleaned up only on close.
[ "POST /api/auth/signup-tenant", "POST /api/assets" ]
{
"asset_id": "{{cache:assets.create.response.data.asset_id}}"
}{
"success": true,
"data": {
"stream_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 101.{
"success": false,
"error": "stream not found"
}sdk-config
GET/api/config🔒 auth
Lists active config rows for a scope. scope query param is required; scope_id defaults to the caller's tenant for tenant scope. Tenant JWT required. Edge cases: scope must be platform|tenant|app|app_user (400).
[ "POST /api/auth/signup-tenant", "POST /api/config" ]
scope: platform, tenant, app, app_user| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | scope must be platform|tenant|app|app_user | scope missing or invalid |
| 401 | Unauthorized | Missing bearer token | no/invalid tenant JWT |
{
"success": true,
"data": [
{
"config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": "array"
}POST/api/config🔒 auth
Upserts a config value in the multi-scope config plane (EP-341) on the (scope, scope_id, key) unique key. A non-secret value goes inline in `value`; a secret keeps only its sdk-secrets envelope pointer in `secret_ref` (value XOR secret_ref). Tenant JWT required; a tenant may only write its OWN tenant/app/app_user rows (403 otherwise) and platform scope requires a platform operator. Edge cases: scope must be platform|tenant|app|app_user and key is required (400); providing both value and secret_ref is 400.
[ "POST /api/auth/signup-tenant" ]
scope: platform, tenant, app, app_user| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | scope (platform|tenant|app|app_user) and key are required | scope missing/invalid or key missing |
| 400 | ValidationError | provide value OR secret_ref, not both | both value and secret_ref supplied |
| 403 | Forbidden | cannot write another tenant's config | tenant scope_id != caller tenant, or platform scope without operator role |
| 401 | Unauthorized | Missing bearer token | no/invalid tenant JWT (default-deny authGate) |
{
"scope": "tenant",
"scope_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"key": "qa.config.smoke",
"value": {
"provider": "anthropic",
"model": "claude-opus-4-8"
}
}{
"scope": "tenant",
"scope_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"key": "qa.config.smoke",
"value": {
"provider": "anthropic",
"model": "claude-opus-4-8"
}
}{
"success": true,
"data": {
"config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"scope": "tenant",
"scope_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"key": "qa.config.smoke",
"value": {
"provider": "anthropic",
"model": "claude-opus-4-8"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"config_id": "string",
"scope": "string",
"scope_id": "string",
"key": "string",
"status": "string"
}
}GET/api/config/resolve🔒 auth
Resolves the MOST-specific active value for a key in the caller's context, walking app_user -> app -> tenant -> platform (first active wins). tenant comes from the JWT; app_id/app_user_id may be overridden via query. Returns {data:{key, resolved:{scope,scope_id,value,secret_ref}|null}} — resolved is null when no scope in the chain has an active value. Edge cases: key query param is required (400); tenant JWT required (401).
[ "POST /api/auth/signup-tenant", "POST /api/config" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | key is required | the key query param is missing |
| 401 | Unauthorized | Missing bearer token | no/invalid tenant JWT |
{
"success": true,
"data": [
{
"resolve_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"key": "string",
"resolved": "object"
}
}POST/api/config/revoke🔒 auth
Soft-deletes a config value (status='revoked') so resolution stops without losing the audit row. Operates on its OWN seeded key so it never revokes the read tests' value. Tenant JWT required + scope-ownership guard. Edge cases: scope + key required (400); unknown row 404; cross-tenant/platform without operator 403.
[ "POST /api/auth/signup-tenant" ]
scope: platform, tenant, app, app_user| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | scope and key are required | scope missing/invalid or key missing |
| 404 | NotFound | config value not found | no row for that scope/scope_id/key |
| 401 | Unauthorized | Missing bearer token | no/invalid tenant JWT |
{
"scope": "tenant",
"scope_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"key": "qa.config.revoke.smoke"
}{
"scope": "tenant",
"scope_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"key": "qa.config.revoke.smoke"
}{
"success": true,
"data": {
"status": "completed",
"scope": "tenant",
"scope_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"key": "qa.config.revoke.smoke",
"revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"config_id": "string",
"status": "string"
}
}POST/api/config/rotate🔒 auth
Rotates a secret value's sdk-secrets envelope pointer in place (secret_ref swapped, value cleared, re-activated). Operates on its OWN seeded secret key. Tenant JWT required + scope-ownership guard. Edge cases: scope + key + secret_ref required (400); unknown row 404.
[ "POST /api/auth/signup-tenant" ]
scope: platform, tenant, app, app_user| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | scope, key and secret_ref are required | any required field missing |
| 404 | NotFound | config value not found | no row for that scope/scope_id/key |
| 401 | Unauthorized | Missing bearer token | no/invalid tenant JWT |
{
"scope": "tenant",
"scope_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"key": "qa.config.rotate.smoke",
"secret_ref": "vault:rotated-{{dynamic:slug}}"
}{
"scope": "tenant",
"scope_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"key": "qa.config.rotate.smoke",
"secret_ref": "vault:rotated-{{dynamic:slug}}"
}{
"success": true,
"data": {
"status": "completed",
"scope": "tenant",
"scope_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"key": "qa.config.rotate.smoke",
"secret_ref": "vault:rotated-{{dynamic:slug}}",
"rotate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"config_id": "string",
"secret_ref": "string",
"status": "string"
}
}GET/api/config/value🔒 auth
Returns one exact config row by (scope, scope_id, key). Tenant JWT required. Edge cases: scope + key required (400); unknown row is 404.
[ "POST /api/auth/signup-tenant", "POST /api/config" ]
scope: platform, tenant, app, app_user| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | scope and key are required | scope missing/invalid or key missing |
| 404 | NotFound | config value not found | no row for that scope/scope_id/key |
| 401 | Unauthorized | Missing bearer token | no/invalid tenant JWT |
{
"success": true,
"data": [
{
"value_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"config_id": "string",
"scope": "string",
"key": "string",
"status": "string"
}
}sdk-connectors
GET/api/connectors🔒 auth
Tenant-scoped connector install summary served directly by the api-gateway (not sdk-connectors): selects vendor, install_id, status, last_synced_at, last_error and installed_at from connectors.install for the tenant_id query param, ordered by vendor. QA edge cases: the handler swallows ALL database errors and returns { success: true, data: [] } — that fallback exists because connectors.install is absent in some deploys, so an empty array can mean 'no installs', 'table missing', or 'query failed', and this endpoint can never return a 5xx from the query path; the only rejection is a missing tenant_id (400); tenant_id is cast to ::uuid, so a non-UUID value is caught by the same swallow and also returns an empty list rather than a 400; there is no limit or pagination, and no filter by status, so uninstalled/errored installs are included and must be filtered client-side.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id required | the tenant_id query param is absent or empty |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"success": true,
"data": [
{
"connector_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/connectors/dlq/replay🔒 auth
Replay dead-lettered connector-sync items from the DLQ (connectors.sync_deadletter). With a deadletter_id it replays that single item (404 if unknown or already resolved); with tenant_id (and optional connector_kind) it bulk-replays every still-dead-lettered item for that tenant and returns replayed_count. Replayed items are marked 'retrying', their attempt count is bumped, and next_retry_at is set to now() so the retry/backoff worker re-drives them.
[ "POST /api/auth/signup-tenant" ]
connector_kind: slack, salesforce, jira, github, hubspot, linear, microsoft365, snowflake, zendesk, zoom, gworkspace| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | provide deadletter_id or tenant_id | neither deadletter_id nor tenant_id is present in the body |
| 404 | NotFound | deadletter_id not found or already resolved | a deadletter_id is given but no replayable (dlq/retrying/discarded) row matches |
| 409 | ReplayFailed | replay failed | an unexpected database error occurs during replay |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"connector_kind": "github"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"connector_kind": "github"
}{
"success": true,
"data": {
"status": "completed",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"connector_kind": "github",
"replay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"replayed_count": "number"
}
}POST/api/connectors/dlq/retry-tick🔒 auth
Drain one batch of due connector-sync dead-letters on demand: claims up to batch_size (default 20) entries in status dlq/retrying whose next_retry_at is past (FOR UPDATE SKIP LOCKED, concurrency-safe), re-drives each install's sync, and settles it — resolved on success, re-queued with exponential backoff on failure, or discarded once attempts reach max_attempts. The same logic runs on a timer when CONNECTORS_RETRY_WORKER_ENABLED. Returns per-tick counts; all zero when nothing is due.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid token | no valid Bearer token is supplied |
{
"batch_size": 20
}{
"batch_size": 20
}{
"success": true,
"data": {
"retry_tick_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"batch_size": 20,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"claimed": "number",
"resolved": "number",
"requeued": "number",
"discarded": "number"
}
}POST/api/connectors/inbound/:kindpublic
Generic inbound webhook receiver for a connector kind (unauthenticated — signature-gated). Unknown kind -> 404. An unsigned subscription-verification handshake ({ challenge }) is echoed back with 200 (standard across providers). Real event deliveries must carry a valid x-connector-signature HMAC (HMAC-SHA256 over the body with CONNECTORS_INBOUND_SECRET) -> 202 accepted, else 401.
kind: slack, salesforce, jira, github, hubspot, linear, microsoft365, snowflake, zendesk, zoom, gworkspace| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | UnknownConnectorKind | no connector kind '<kind>' | the :kind path segment is not a recognised connector kind |
| 401 | InvalidSignature | missing or invalid x-connector-signature | a non-challenge event is posted without a valid HMAC signature |
{
"kind": "slack"
}{
"type": "url_verification",
"challenge": "verify-token-abc123"
}{
"type": "url_verification",
"challenge": "verify-token-abc123"
}{
"success": true,
"data": {
"inbound_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "url_verification",
"challenge": "verify-token-abc123",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"challenge": "verify-token-abc123"
}POST/api/connectors/installs🔒 auth
Registers a connector install for a tenant and returns 201 with the install record. tenant_id, connector_kind and installed_by are required; display_name, credential_ref and vendor_account_id are optional. QA edge cases: connector_kind is NOT validated against the registered adapter list at install time, so an install can be created for a kind that has no adapter — the failure only appears later when /sync or /tools/call returns 409 'no adapter registered for <kind>', and that deferred failure is the key thing to cover; nothing enforces uniqueness on (tenant_id, connector_kind, vendor_account_id), so the call is not idempotent and repeated POSTs create duplicate installs for the same vendor account; credential_ref is stored as an opaque reference with no existence check against sdk-secrets, so a dangling ref installs cleanly and only fails at sync time; all three missing-field cases collapse into one generic 400 'missing fields' with no indication of which field was absent.
[ "POST /api/auth/signup-tenant" ]
connector_kind: slack, salesforce, jira, github, hubspot, linear, microsoft365, snowflake, zendesk, zoom, gworkspace| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | missing fields | any of tenant_id, connector_kind or installed_by is missing or empty in the body |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"entity": "connectors.install",
"field": "status",
"flow": [
"pending",
"active",
"paused",
"uninstalled"
],
"transitions": [
{
"from": "active",
"to": "uninstalled",
"via": "POST /api/connectors/installs/:install_id/uninstall"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"connector_kind": "slack",
"display_name": "Slack Workspace",
"credential_ref": "secret://connectors/slack/demo",
"vendor_account_id": "T0DEMO",
"installed_by": "{{cache:auth.signup-tenant.response.data.userId}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"connector_kind": "slack",
"display_name": "Slack Workspace",
"credential_ref": "secret://connectors/slack/demo",
"vendor_account_id": "T0DEMO",
"installed_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"install_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"connector_kind": "slack",
"display_name": "Slack Workspace",
"credential_ref": "secret://connectors/slack/demo",
"vendor_account_id": "T0DEMO",
"installed_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/connectors/installs/:install_id🔒 auth
Fetches one connector install by install_id, returning its full record (tenant, kind, display name, status, vendor account, credential reference, sync metadata). QA edge cases: an unknown install_id returns a bare 404 { error: 'NotFound' } with no details array, and a malformed (non-UUID) install_id returns the same 404, so the two cases cannot be told apart by status; an uninstalled connector is still readable — uninstall flips status rather than deleting the row, so this returns 200 with the terminal status instead of 404; the lookup is by install_id alone with no tenant predicate, so an authenticated caller holding another tenant's install_id reads that record.
[ "POST /api/auth/signup-tenant", "POST /api/connectors/installs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | NotFound | NotFound | no connectors.install row matches the :install_id path param (unknown, or malformed id) |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"install_id": "{{cache:connectors.create.response.data.install.install_id}}"
}{
"success": true,
"data": {
"install_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/connectors/installs/:install_id/health🔒 auth
Health snapshot for a connector install: its status, whether an adapter is registered for its kind, tool count, open dead-lettered syncs, last sync cursor timestamp, and an overall healthy flag (active + adapter registered + no open DLQ). 404 if the install does not exist.
[ "POST /api/auth/signup-tenant", "POST /api/connectors/installs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | NotFound | NotFound | install_id does not exist |
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"install_id": "{{cache:connectors.create.response.data.install.install_id}}"
}{
"success": true,
"data": {
"health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"health": {
"install_id": "string",
"connector_kind": "string",
"status": "string",
"adapter_registered": "boolean",
"tool_count": "number",
"open_deadletters": "number",
"last_synced_at": "string",
"healthy": "boolean"
}
}
}POST/api/connectors/installs/:install_id/sync🔒 auth
Triggers a resilient sync for one connector install. The status code encodes the outcome: 200 when the sync completed cleanly, and 202 when it failed transiently — in which case the item has been dead-lettered into connectors.sync_deadletter and will be re-driven by the retry/backoff worker. Only configuration-class errors escape as a 409. QA edge cases: the 200-vs-202 split is the thing to assert, because a 202 is NOT a success — it means the sync failed and was queued for retry, and a test that accepts any 2xx will pass against a completely broken connector; an unknown install_id is a 409 SyncFailed ('install <id> not found'), NOT a 404, and an install whose connector_kind has no registered adapter is also a 409 ('no adapter registered for <kind>'); syncing is not idempotent — each call performs real vendor work and can add another dead-letter row; concurrent syncs on one install are not guarded against.
[ "POST /api/auth/signup-tenant", "POST /api/connectors/installs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 409 | SyncFailed | install <install_id> not found | the :install_id path param matches no connectors.install row — surfaced as 409, not 404 |
| 409 | SyncFailed | no adapter registered for <connector_kind> | the install's connector_kind has no adapter registered in this gateway build |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"install_id": "{{cache:connectors.create.response.data.install.install_id}}"
}{}{
"success": true,
"data": {
"status": "completed",
"sync_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/connectors/installs/:install_id/tools🔒 auth
Returns the tool manifests advertised by a connector install — the MCP-style tool definitions the agent runtime can invoke through POST /installs/:install_id/tools/call. QA edge cases: an unknown install_id returns 200 with an empty tools array rather than a 404, so this endpoint cannot be used to test install existence; an install whose adapter has never synced also returns an empty array, meaning "unknown install", "no adapter" and "not yet synced" are all indistinguishable; the returned tool_name values are exactly the strings /tools/call validates against, so a name absent from this manifest produces a 409 ToolCallFailed there; the listing is unpaginated and not tenant-scoped.
[ "POST /api/auth/signup-tenant", "POST /api/connectors/installs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"install_id": "{{cache:connectors.create.response.data.install.install_id}}"
}{
"success": true,
"data": {
"tool_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/connectors/installs/:install_id/tools/call🔒 auth
Invokes one tool exposed by a connector install, passing args through to the adapter and returning its result. QA edge cases: only tool_name is validated at the edge (400 if absent) — args defaults to {} and is never schema-checked, so a call with wrong or missing arguments reaches the vendor and comes back as a 409 ToolCallFailed rather than a 400; every downstream failure collapses into that single 409, so assert on details to separate them — unknown install ('install <id> not found'), an install whose status is not active ('install <id> is <status>', i.e. an uninstalled or errored connector), a tool_name absent from the manifest ('tool <name> not in manifest for install <id>'), and a missing adapter ('no adapter registered for <kind>'); note the not-found and inactive-install cases are 409s, never 404 or 423; the call is not idempotent — it performs real vendor-side work, so retries can double-apply an effect.
[ "POST /api/auth/signup-tenant", "POST /api/connectors/installs" ]
tool_name: slack.message.post, slack.channel.list, slack.user.lookup, slack.thread.reply| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | missing tool_name | body.tool_name is absent or empty |
| 409 | ToolCallFailed | install <install_id> not found | the :install_id path param matches no connectors.install row — surfaced as 409, not 404 |
| 409 | ToolCallFailed | install <install_id> is <status> | the install exists but its status is not 'active' (e.g. uninstalled, error) |
| 409 | ToolCallFailed | tool <tool_name> not in manifest for install <install_id> | the requested tool_name is not present in the install's synced tool manifest |
| 409 | ToolCallFailed | no adapter registered for <connector_kind> | the install's connector_kind has no adapter registered in this gateway build |
| 409 | ToolCallFailed | <adapter or vendor error message> | the adapter itself throws — vendor API rejection, invalid args, expired credential_ref, network failure |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"install_id": "{{cache:connectors.create.response.data.install.install_id}}"
}{
"tool_name": "slack.message.post",
"args": {
"channel": "C0DEMO",
"text": "Hello from ProjexCloud"
}
}{
"tool_name": "slack.message.post",
"args": {
"channel": "C0DEMO",
"text": "Hello from ProjexCloud"
}
}{
"success": true,
"data": {
"call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tool_name": "slack.message.post",
"args": {
"channel": "C0DEMO",
"text": "Hello from ProjexCloud"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/connectors/installs/:install_id/uninstall🔒 auth
Uninstalls a connector install, flipping its status and recording the acting persona. actor_id is read from the body and silently defaults to the literal string 'unknown' when omitted — it is never validated, so a bad or missing actor still succeeds and the audit trail records 'unknown', which is worth asserting. QA edge cases: an unknown install_id returns 404 NotFound; whether uninstalling an ALREADY-uninstalled install is idempotent (200 again) or returns 404 depends on whether the underlying UPDATE still matches the row, so cover the repeat call explicitly; the row is not deleted, so GET /installs/:install_id continues to return 200 afterwards with the terminal status — do not assert a 404 on the subsequent read.
[ "POST /api/auth/signup-tenant", "POST /api/connectors/installs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | NotFound | NotFound | uninstallConnector returns null — no matching connectors.install row for the :install_id path param |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"entity": "connectors.install",
"field": "status",
"flow": [
"pending",
"active",
"paused",
"uninstalled"
],
"transitions": [
{
"from": "active",
"to": "uninstalled",
"via": "POST /api/connectors/installs/:install_id/uninstall"
}
]
}{
"install_id": "{{cache:connectors.create.response.data.install.install_id}}"
}{
"actor_id": "{{cache:auth.signup-tenant.response.data.userId}}"
}{
"actor_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"uninstall_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/connectors/kinds🔒 auth
Returns the list of connector kinds for which an adapter is currently registered in the sdk-connectors adapter registry, as { data: { kinds: [...] } }. QA edge cases: the list is built from the in-process registry, not from the database, so it reflects which adapter modules this gateway build actually loaded — a kind present in the catalogue but not compiled into the image will be absent, and that absence is exactly what makes POST /api/connectors/inbound/:kind return 404 UnknownConnectorKind for the same value; the response is global and identical for every tenant (no tenant scoping); it takes no parameters, has no pagination, and cannot return 404 or 409 — an empty kinds array is a valid 200 meaning no adapters registered.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"success": true,
"data": [
{
"kind_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/connectors/lead-forms/:tenant_id🔒 auth
Lists archived lead-form deliveries for a tenant, filterable by platform and outcome (accepted | rejected | duplicate). Proves AC4 — the triage view exists, and every row reports has_raw, so an operator can confirm the payload is still recoverable before attempting a re-process. Unlike the ingest endpoint this IS tenant-authed: the ingest caller is a platform proving itself with an HMAC, whereas reading a tenant's archived lead payloads is ordinary tenant data access and the default-deny gate applies (MUST-52). QA edge cases: rejected deliveries are the primary reason to call this, so outcome=rejected is the interesting filter and its rows always carry has_raw true — a flag with no reader would make 'archived on rejection' meaningless; the raw payload itself is deliberately NOT returned in the list, since these carry customer PII and a list endpoint should not spray it, so the caller fetches or re-processes a specific event instead; limit is clamped to 1..500; results are newest-first; an unknown platform filter simply matches nothing rather than erroring, since the filter is a narrowing rather than an assertion.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token — reading archived lead payloads is tenant data access, unlike the HMAC-gated ingest endpoint |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"success": true,
"data": {
"lead_form_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/connectors/lead-forms/:tenant_id/:platformpublic · manual
Receives a completed lead form from Meta/Instagram/Facebook, LinkedIn Lead Gen, TikTok or Google/YouTube. requiresAuth is FALSE by design and correctly so (MUST-52's signed-webhook exemption): the caller is a platform, not a tenant session, and the provider HMAC is the trust boundary. Proves all four criteria. The ORDER of the four steps is the whole design: verify signature, then reserve-and-archive in one INSERT, then normalise. AC2: an unsigned or wrongly-signed delivery is rejected 401 and stores NOTHING — it is not a rejected lead, it is not a lead at all, and archiving it would let anyone fill the tenant's table with material to triage; comparison is constant-time, since a plain === on an HMAC leaks the first wrong byte through timing. AC3: idempotency is the UNIQUE index on (tenant_id, platform, source_event_id) plus ON CONFLICT DO NOTHING — the insert IS the replay check, which is correct even when the provider delivers to several workers at once, where a read-then-write check would let two of them each create a lead from one form submission. AC4: the raw payload is written BEFORE normalisation is allowed an opinion, so a rejected mapping keeps the evidence and the row can be re-processed later; the lead form is the only record the person filled it in and the platform will not re-send it. AC1: each adapter extracts its own full field set — Meta (form + version, campaign/ad/creative, DM thread and comment id, permission fields), LinkedIn (campaign/form ids, member and company URNs, message thread), TikTok (ttclid, advertiser, page), Google (gclid, UTM, form proof) — and permission evidence is a REQUIRED field whose absence fails normalisation, because a lead form is a consent artefact and a lead with no recorded consent has no lawful basis for contact. QA edge cases: the payload is archived twice on purpose — raw_body as the exact signed BYTES (an HMAC is over bytes, so only this can re-verify the signature months later) and raw_payload as jsonb for querying, because jsonb reorders keys and could never round-trip a signature; Google additionally requires a form proof distinct from the transport signature, since the transport proves the request came from Google while the form proof proves the lead came from THIS advertiser's form — accepting one as the other would allow cross-account lead injection; a Google lead with neither email nor phone is refused as uncontactable; 202 is returned for everything past the trust boundary INCLUDING a rejected normalisation and a replay, because a 4xx there makes the provider retry forever against a payload already held; an unknown platform is 404.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | INVALID_SIGNATURE | invalid or missing x-hub-signature-256 signature | the signature header is absent, malformed, computed over different bytes, or computed with a different secret — nothing is archived, because an unsigned payload is not a lead and storing it would let anyone fill the tenant archive |
| 404 | UNKNOWN_PLATFORM | platform must be one of: META, LINKEDIN, TIKTOK, GOOGLE | the :platform segment names a provider with no registered adapter |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"platform": "meta"
}{
"entry": [
{
"id": "page-1",
"changes": [
{
"value": {
"leadgen_id": "lead-1",
"form_id": "form-9",
"form_version": "v2",
"campaign_id": "camp-1",
"ad_id": "ad-2",
"creative_id": "cr-3",
"created_time": "2026-08-01T09:00:00Z",
"platform": "instagram",
"thread_id": "dm-77",
"comment_id": "cm-88",
"consent": {
"consent_ref": "consent-abc",
"granted": true
},
"permission_fields": [
"marketing_opt_in"
],
"field_data": [
{
"name": "email",
"values": [
"jane@acme.test"
]
}
]
}
}
]
}
]
}{
"entry": [
{
"id": "page-1",
"changes": [
{
"value": {
"leadgen_id": "lead-1",
"form_id": "form-9",
"form_version": "v2",
"campaign_id": "camp-1",
"ad_id": "ad-2",
"creative_id": "cr-3",
"created_time": "2026-08-01T09:00:00Z",
"platform": "instagram",
"thread_id": "dm-77",
"comment_id": "cm-88",
"consent": {
"consent_ref": "consent-abc",
"granted": true
},
"permission_fields": [
"marketing_opt_in"
],
"field_data": [
{
"name": "email",
"values": [
"jane@acme.test"
]
}
]
}
}
]
}
]
}{
"success": true,
"data": {
"status": "accepted",
"job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 202.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/api/connectors/lead-forms/:tenant_id/events/:event_id/reprocess🔒 auth
Re-runs normalisation over an ALREADY ARCHIVED delivery. An action endpoint, so 200. This is the payoff for archiving raw on rejection (AC4) and the reason that criterion is worth having: a mapping fixed today can recover leads rejected last week, which is simply impossible if the payload was discarded when normalisation first refused it — and the platform will not re-send a lead form. Re-processing reads the stored raw_payload, runs the current adapter, and flips the row to accepted (clearing rejection_reason) or re-records a new rejection reason. QA edge cases: the signature is NOT re-checked here, correctly — it was verified at ingest and the row would not exist otherwise, and re-verifying would require the original per-tenant secret which may since have rotated (the byte-exact raw_body column is retained separately so that verification remains POSSIBLE for audit, just not required for re-processing); re-processing an already-accepted event is harmless and idempotent, since it recomputes the same normalisation from the same bytes; an event_id belonging to a different tenant is 404 rather than 403, so the endpoint does not confirm the id exists to someone who should not know; requiresAuth applies — this mutates tenant data.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | LEAD_FORM_EVENT_NOT_FOUND | NotFound | event_id names no archived delivery for this tenant — including one that belongs to a different tenant, which is 404 rather than 403 so the endpoint does not confirm the id exists |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token — re-processing mutates tenant data |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"event_id": "{{cache:connectors.leadform.response.data.event_id}}"
}{
"success": true,
"data": {
"reprocess_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/connectors/lead-forms/:tenant_id/websitepublic · manual
Receives demo-request, pricing-enquiry, contact and chat events from the tenant's own site and chat widget, on the SAME endpoint and the same contract as the paid-social adapters — platform 'website'. Sharing the pipeline is deliberate: a first-party form is the same KIND of thing as a paid-social lead (a completed intent signal with consent attached), and a parallel pipeline would mean a second place for the archive-first ordering and the replay guarantee to be got subtly wrong. requiresAuth is FALSE under MUST-52's signed-webhook exemption; the HMAC over the exact bytes is the trust boundary. AC1: the transcript is captured turn by turn with role and timestamp, and the handoff block records state, who took over, when and why — for a chat lead the conversation IS the qualifying information and the visitor will not repeat it, while the handoff state is what decides whether anyone is already talking to this person (get it wrong and either two reps reply or nobody does); a chat event carrying NO transcript is refused rather than half-accepted, since that is almost always an integration bug that would otherwise produce a lead nobody can qualify. AC2: the submitted permission block is stored VERBATIM in permission.submitted_raw — exact keys, casing and values, including the consent wording the person actually saw — because interpreting consent into a tidy shape loses the wording, and the wording is what a regulator asks to see; the normalised granted/consent_ref/scopes view is derived alongside it for code. AC3 and AC4 are inherited unchanged from the social contract: idempotency is the UNIQUE (tenant_id, platform, source_event_id) index with ON CONFLICT DO NOTHING, correct under concurrent delivery where a read-then-write check is not, and the raw event is archived BEFORE normalisation is allowed an opinion, in both a byte-exact raw_body column (so the signature stays re-verifiable) and queryable jsonb. QA edge cases: page_url, page_title, referrer and session_id are captured as context and UTM/gclid as attribution; an unknown event_kind is refused rather than guessed; a permission block present but with nothing granted is refused; a non-chat form event needs no transcript and defaults handoff.state to 'none'; the idempotency key falls back event_id then submission_id then session_id.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | INVALID_SIGNATURE | invalid or missing x-projex-signature signature | the signature header is absent, computed over different bytes, or computed with a different secret — nothing is archived, exactly as for the social adapters |
| 202 | UNKNOWN_EVENT_KIND | unknown event_kind '<kind>' — expected one of demo_request, pricing_enquiry, contact, chat | event_kind is outside the four supported kinds; the delivery is past the trust boundary so it is archived and reported as a rejected normalisation rather than a 4xx that would make the sender retry forever |
| 202 | PERMISSION_NOT_GRANTED | permission present but not granted | every submitted permission is false — archived with the reason so the raw consent block is still available for review |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"event_id": "web-1",
"event_kind": "chat",
"session_id": "sess-1",
"page_url": "https://acme.test/pricing",
"page_title": "Pricing",
"referrer": "https://google.test/search?q=crm",
"form_id": "chat-widget-v3",
"form_version": "3.1",
"submitted_at": "2026-08-01T09:00:00Z",
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "brand",
"fields": {
"email": "jane@acme.test",
"company": "Acme Ltd"
},
"permissions": {
"Marketing_Opt_In": "true",
"consent_ref": "web-consent-1",
"wording": "I agree to be contacted about my enquiry"
},
"transcript": [
{
"role": "visitor",
"text": "Do you support SSO?",
"at": "2026-08-01T08:58:00Z"
},
{
"role": "bot",
"text": "Yes, SAML and OIDC.",
"at": "2026-08-01T08:58:10Z"
},
{
"role": "visitor",
"text": "Can I talk to sales?",
"at": "2026-08-01T08:59:00Z"
}
],
"handoff": {
"state": "human",
"handed_to": "agent-7",
"handed_at": "2026-08-01T08:59:30Z",
"reason": "visitor asked for sales"
}
}{
"event_id": "web-1",
"event_kind": "chat",
"session_id": "sess-1",
"page_url": "https://acme.test/pricing",
"page_title": "Pricing",
"referrer": "https://google.test/search?q=crm",
"form_id": "chat-widget-v3",
"form_version": "3.1",
"submitted_at": "2026-08-01T09:00:00Z",
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "brand",
"fields": {
"email": "jane@acme.test",
"company": "Acme Ltd"
},
"permissions": {
"Marketing_Opt_In": "true",
"consent_ref": "web-consent-1",
"wording": "I agree to be contacted about my enquiry"
},
"transcript": [
{
"role": "visitor",
"text": "Do you support SSO?",
"at": "2026-08-01T08:58:00Z"
},
{
"role": "bot",
"text": "Yes, SAML and OIDC.",
"at": "2026-08-01T08:58:10Z"
},
{
"role": "visitor",
"text": "Can I talk to sales?",
"at": "2026-08-01T08:59:00Z"
}
],
"handoff": {
"state": "human",
"handed_to": "agent-7",
"handed_at": "2026-08-01T08:59:30Z",
"reason": "visitor asked for sales"
}
}{
"success": true,
"data": {
"status": "accepted",
"job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 202.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/api/connectors/slack/eventspublic
Slack Events API webhook receiver. This route is deliberately on the api-gateway public allowlist and carries no requireAuth — a bearer JWT is neither required nor consulted, and the trust boundary is instead the Slack signature check over the raw request body using SLACK_SIGNING_SECRET together with the x-slack-request-timestamp and x-slack-signature headers. QA edge cases: the url_verification handshake responds 200 with the challenge as plain text (Content-Type: text/plain), NOT as JSON, so a test asserting a JSON body will fail on the very first call Slack makes; a bad, absent, or replayed-beyond-the-timestamp-window signature returns 401 InvalidSignature; every other outcome — including an event type this build does not handle — returns 200, because Slack retries any non-2xx and an error status would cause duplicate deliveries, so 'unrecognised event' must be asserted on the response body rather than the status; signature verification runs over rawBody, so any middleware that re-serialises the JSON breaks it; Slack redelivers on timeout, so handlers must tolerate duplicate event ids.
type: url_verification, event_callback| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | InvalidSignature | InvalidSignature | the x-slack-signature / x-slack-request-timestamp pair does not validate against SLACK_SIGNING_SECRET over the raw body, or the headers are missing |
{
"type": "url_verification",
"challenge": "test-challenge-string"
}{
"type": "url_verification",
"challenge": "test-challenge-string"
}{
"success": true,
"data": {
"event_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "url_verification",
"challenge": "test-challenge-string",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/api/connectors/slack/install🔒 auth
Exchanges a Slack OAuth authorisation code for a workspace access token via oauth.v2.access, using SLACK_CLIENT_ID / SLACK_CLIENT_SECRET from the environment. On success the caller is expected to feed the returned team_id and access_token into POST /api/connectors/installs — this endpoint does NOT create the install itself, which is the integration gap worth testing. QA edge cases: a missing environment configuration is a 503 NotConfigured, distinct from the 400 you get for a missing code, so an unconfigured environment fails loudly rather than silently; Slack's own rejections (invalid_code, code_already_used, bad redirect_uri) come back as HTTP 400 SlackError with Slack's error string in details, NOT as a 401 — and because OAuth codes are single-use, replaying the same code returns invalid_code, so this call is explicitly non-idempotent; only a thrown transport error (network failure, malformed Slack response) becomes a 500 InternalError; redirect_uri is optional and passed through unvalidated.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | code required | body.code is absent, empty, or not a string |
| 503 | NotConfigured | SLACK_CLIENT_ID/SECRET env not set | either SLACK_CLIENT_ID or SLACK_CLIENT_SECRET is unset in the gateway environment |
| 400 | SlackError | <Slack error code, e.g. invalid_code / code_already_used / bad_redirect_uri> | Slack responds with ok:false to the oauth.v2.access exchange — including replaying an already-consumed single-use code |
| 500 | InternalError | InternalError | the oauthExchange call throws — network failure reaching slack.com or an unparseable response |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"code": "test-oauth-code",
"redirect_uri": "https://example.com/slack/callback"
}{
"code": "test-oauth-code",
"redirect_uri": "https://example.com/slack/callback"
}{
"success": true,
"data": {
"install_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"code": "test-oauth-code",
"redirect_uri": "https://example.com/slack/callback",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 503.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/connectors/slack/post-message🔒 auth
Server-side wrapper over Slack chat.postMessage. channel and text are both required and must be non-empty strings; blocks (array) and thread_ts (string) are optional passthroughs. QA edge cases: Slack-side rejections are returned as HTTP 400 SlackError carrying Slack's error code in details — so channel_not_found, not_in_channel, invalid_auth and a revoked or missing bot token ALL arrive as 400, never as 401/403/404, and tests must assert details rather than status; a non-string or empty channel/text is a local 400 ValidationError before any Slack call; only a thrown transport error becomes a 500 InternalError; the call is not idempotent — there is no dedupe key, so a retry posts a second visible message into the channel, which makes at-least-once retry logic dangerous here; blocks is passed through only when it is an array, and a malformed blocks payload surfaces as a Slack 400 rather than local validation.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | channel + text required | body.channel or body.text is absent, empty, or not a string |
| 400 | SlackError | <Slack error code, e.g. channel_not_found / not_in_channel / invalid_auth / invalid_blocks> | Slack responds with ok:false to chat.postMessage — including auth failures and unknown channels |
| 500 | InternalError | InternalError | the chatPostMessage call throws — network failure reaching slack.com or an unparseable response |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"channel": "C0DEMO",
"text": "Hello from ProjexCloud"
}{
"channel": "C0DEMO",
"text": "Hello from ProjexCloud"
}{
"success": true,
"data": {
"post_message_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "C0DEMO",
"text": "Hello from ProjexCloud",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 400.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/api/connectors/tenants/:tenant_id/dlq🔒 auth
List dead-lettered connector-sync items for a tenant, newest failure first. Optional query filters: status (dlq/retrying/resolved/discarded), connector_kind, and limit. Returns an empty array when the tenant has no dead-lettered syncs.
[ "POST /api/auth/signup-tenant" ]
status: dlq, retrying, resolved, discarded| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid token | no valid Bearer token is supplied |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"success": true,
"data": {
"dlq_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"deadletters": "array"
}
}POST/api/connectors/tenants/:tenant_id/dlq/reconcile🔒 auth
Reconcile duplicate/partial connector-sync DLQ state for a tenant. Collapses superseded duplicates (several active dlq/retrying entries sharing the same install_id + sync_kind + external_ref — only the newest is kept, older ones marked resolved) and requeues entries stuck in 'retrying' past the stale window (worker crashed mid-drive) back to 'dlq'. Idempotent: returns {superseded:0, requeued:0} on a clean queue.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid token | no valid Bearer token is supplied |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{}{
"success": true,
"data": {
"reconcile_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"superseded": "number",
"requeued": "number"
}
}GET/api/connectors/tenants/:tenant_id/installs🔒 auth
Lists every connector install belonging to one tenant, keyed on the :tenant_id path param. QA edge cases: an unknown tenant_id returns 200 with an empty array rather than a 404, so 'no such tenant' and 'tenant with no installs' are indistinguishable; the tenant_id comes from the URL and is NOT cross-checked against the tenant claim in the caller's JWT, so an authenticated caller can enumerate another tenant's installs by changing the path segment — the cross-tenant isolation assertion belongs here; the listing is unpaginated with no limit/offset and no status filter, so uninstalled and errored installs are returned alongside active ones.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"success": true,
"data": {
"install_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-consent
GET/api/consent/purposes🔒 auth
Returns the consent.purpose catalogue (purpose_id, app_id, description, legal_basis, default_jurisdictions, created_at) ordered by purpose_id and hard-capped at 200 rows. Gated by the gateway default-deny authGate, so a valid tenant JWT is required. Edge cases: tenant_id is required as a query param and its absence is a 400, but consent.purpose is an app-scoped catalogue with no tenant_id column - the value is only presence-checked and never used in the SQL, so every caller sees the same global catalogue no matter which tenant_id (even a bogus or foreign one) they pass; there is no paging, so a catalogue larger than 200 rows is silently truncated; an empty catalogue is a 200 with an empty data array, not a 404.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 400 | ValidationError | tenant_id required | the tenant_id query param is absent or empty |
| 500 | QueryFailed | <postgres error message> | the SELECT against consent.purpose throws - missing schema/table or any DB error; the raw driver message is echoed in error |
{
"success": true,
"data": [
{
"purpos_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": "boolean",
"data": "array"
}GET/api/consent/receipts🔒 auth
Lists consent receipts where the tenant_id query param matches either source_tenant_id or target_tenant_id, optionally narrowed by subject_persona_id and purpose_id, newest-granted first and hard-capped at 200 rows. Status is derived rather than stored: revoked_at IS NULL renders as "active", otherwise "revoked". Gated by the gateway default-deny authGate, so a valid tenant JWT is required. Edge cases: tenant_id is required (400 when absent) and is taken from the query string rather than the JWT, so a caller may request another tenant receipts - verify tenant scoping deliberately; subject_persona_id is matched against the person_id column, and both it and tenant_id are cast to uuid, so a non-UUID value is a 500 rather than a 400; no matches is a 200 with an empty array; there is no paging, so results beyond 200 are silently dropped.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/consents/purposes" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 400 | ValidationError | tenant_id required | the tenant_id query param is absent or empty |
| 500 | QueryFailed | <postgres error message> | the SELECT throws - a non-UUID tenant_id or subject_persona_id failing the ::uuid cast, or any DB error; the raw driver message is echoed in error |
{
"success": true,
"data": [
{
"receipt_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": "boolean",
"data": "array"
}POST/api/consent/receipts/:receipt_id/revoke🔒 auth
Revokes a consent receipt by stamping consent.receipt.revoked_at = now() for the given receipt_id, returning {success:true}. Gated by the gateway default-deny authGate, so a valid tenant JWT is required. Edge cases: the handler runs a bare UPDATE and never inspects the affected row count, so revoking a receipt_id that does not exist still returns 200 success:true - there is no 404; it is idempotent in effect but not in value, because a second call overwrites revoked_at with a later timestamp; the optional body reason is accepted but never persisted; there is no tenant check at all - the receipt is matched on receipt_id alone, so a caller can revoke another tenant receipt; a non-UUID receipt_id fails the comparison in Postgres and surfaces as a 500.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/consents/purposes", "POST /api/consents" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 500 | QueryFailed | <postgres error message> | the UPDATE against consent.receipt throws - a non-UUID receipt_id failing the uuid comparison, or any DB error; the raw driver message is echoed in error |
{
"entity": "consent.receipt",
"field": "status",
"flow": [
"active",
"revoked"
],
"transitions": [
{
"from": "active",
"to": "revoked",
"via": "POST /api/consent/receipts/:receipt_id/revoke"
}
]
}{
"receipt_id": "{{cache:consents.create.response.data.receipt.receipt_id}}"
}{
"reason": "User requested withdrawal of consent."
}{
"reason": "User requested withdrawal of consent."
}{
"success": true,
"data": {
"status": "completed",
"reason": "User requested withdrawal of consent.",
"revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": "boolean"
}POST/api/consents🔒 auth
Grants a new consent receipt for the (person, purpose, processor, jurisdiction) tuple (FR-CNS-1) and returns 201 with the receipt. person_id, purpose_id, processor, app_id, jurisdiction and granted_by_actor are all mandatory non-empty strings; expires_at is optional but must parse as ISO-8601 when supplied; source_tenant_id and target_tenant_id are optional and drive the cross-border check. Edge cases: a cross-border transfer refused on jurisdictional grounds returns HTTP 451 CrossBorderViolation (FR-CNS-5), not a 403; an existing active receipt for the same tuple is a 409, so granting twice is not idempotent - revoke first; a purpose_id that is not in the registry trips a foreign-key violation that is mapped to a 400 rather than a 404; an expires_at in the past is accepted (only parseability is validated); requires a valid JWT.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/consents/purposes" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | <field> is required | Any of person_id, purpose_id, processor, app_id, jurisdiction, granted_by_actor is absent or whitespace-only |
| 400 | ValidationError | expires_at must be ISO-8601 | expires_at is supplied but Date.parse cannot parse it |
| 400 | ValidationError | purpose_id does not exist | The receipt insert raises a foreign-key violation - purpose_id has not been registered via POST /api/consents/purposes |
| 409 | Conflict | Active receipt already exists for this tuple | A duplicate-key violation - an active receipt already covers this (person, purpose, processor, jurisdiction) tuple |
| 451 | CrossBorderViolation | <CrossBorderError message> | grantConsent throws CrossBorderError - the source_tenant_id/target_tenant_id transfer is not permitted for this jurisdiction (FR-CNS-5) |
| 500 | InternalError | InternalError | Any other grantConsent failure (database error, event-emit failure) |
{
"entity": "consent.receipt",
"field": "revoked_at",
"flow": [
"active",
"revoked"
],
"transitions": [
{
"from": "(none)",
"to": "active",
"via": "POST /api/consents"
},
{
"from": "active",
"to": "revoked",
"via": "POST /api/consents/:receipt_id/revoke"
}
]
}{
"person_id": "{{cache:auth.register.response.data.userId}}",
"purpose_id": "{{cache:consents.purposes.create.response.data.purpose.purpose_id}}",
"processor": "tenant",
"app_id": "healthcare",
"jurisdiction": "US-CA",
"granted_by_actor": "{{cache:auth.register.response.data.userId}}",
"expires_at": "{{dynamic:futuredatetime}}",
"source_tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"target_tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"purpose_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"processor": "tenant",
"app_id": "healthcare",
"jurisdiction": "US-CA",
"granted_by_actor": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"expires_at": "2026-01-15T10:30:00Z",
"source_tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"consent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"purpose_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"processor": "tenant",
"app_id": "healthcare",
"jurisdiction": "US-CA",
"granted_by_actor": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"expires_at": "2026-01-15T10:30:00Z",
"source_tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"receipt": {
"receipt_id": "string",
"person_id": "string",
"purpose_id": "string"
}
}
}POST/api/consents/:receipt_id/revoke🔒 auth
Revokes an existing consent receipt by appending a revocation row and stamping the parent receipt (FR-CNS-2). Returns 200 with the revocation record. Both revoked_by and reason are mandatory non-empty strings and are trimmed, so whitespace-only values count as missing. Edge cases: an unknown receipt_id is a 404; where the service reports an already-revoked or otherwise non-revocable receipt with a "not found" style error it also surfaces as a 404, so replaying a revoke does not silently succeed; a non-UUID receipt_id fails the Postgres UUID cast and, not matching the "not found" substring, is reported as a generic 500; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/consents/purposes", "POST /api/consents" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | revoked_by is required | revoked_by is absent or whitespace-only |
| 400 | ValidationError | reason is required | reason is absent or whitespace-only |
| 404 | NotFound | <service "not found" message> | revokeConsent throws with a message containing "not found" - no receipt exists for the supplied receipt_id (or it is no longer revocable) |
| 500 | InternalError | InternalError | Any other revokeConsent failure, including a non-UUID receipt_id (Postgres 22P02) and database errors |
{
"entity": "consent.receipt",
"field": "revoked_at",
"flow": [
"active",
"revoked"
],
"transitions": [
{
"from": "active",
"to": "revoked",
"via": "POST /api/consents/:receipt_id/revoke"
}
]
}{
"receipt_id": "{{cache:consents.create.response.data.receipt.receipt_id}}"
}{
"revoked_by": "{{cache:auth.register.response.data.userId}}",
"reason": "User requested withdrawal of marketing consent."
}{
"revoked_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "User requested withdrawal of marketing consent."
}{
"success": true,
"data": {
"status": "completed",
"revoked_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "User requested withdrawal of marketing consent.",
"revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"revocation": {
"revocation_id": "string",
"receipt_id": "string"
}
}
}POST/api/consents/check🔒 auth
Returns the current consent state for a (person_id, purpose_id, processor, jurisdiction) tuple. This is the hot-path gate every downstream SDK calls before processing PII (FR-CNS-1, FR-CNS-5). Always returns 200 with the evaluated result - a tuple with no receipt, an expired receipt or a revoked receipt is reported as not-allowed in the payload rather than as a 404 or 403. All four fields are mandatory non-empty strings and are trimmed. Edge cases: whitespace-only values count as missing; an unknown person_id or purpose_id yields a negative result, not an error; the call is read-only, side-effect-free and safely repeatable; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/consents/purposes", "POST /api/consents" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | <field> is required | Any of person_id, purpose_id, processor, jurisdiction is absent or whitespace-only |
| 500 | InternalError | InternalError | checkConsent throws (database unavailable or query error) |
{
"person_id": "{{cache:auth.register.response.data.userId}}",
"purpose_id": "{{cache:consents.purposes.create.response.data.purpose.purpose_id}}",
"processor": "tenant",
"jurisdiction": "US-CA"
}{
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"purpose_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"processor": "tenant",
"jurisdiction": "US-CA"
}{
"success": true,
"data": {
"check_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"purpose_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"processor": "tenant",
"jurisdiction": "US-CA",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"granted": "boolean",
"receipt_id": "string"
}
}POST/api/consents/check/bulk🔒 auth
The four-tuple consent check for up to 1000 subjects in ONE request and ONE query - the bulk form of POST /api/consents/check, with identical per-tuple semantics (a DISTINCT ON reproduces the single endpoint's ORDER BY granted_at DESC LIMIT 1 per input row). Results are order-preserving and each carries an explicit index, so a caller that filters before zipping cannot silently misalign subjects with verdicts. Failure is per item, never per batch: a malformed item reports ok=false with error_code VALIDATION_ERROR in its own slot while every other subject still returns a verdict, because a campaign check that fails whole is a campaign that silently does not go out. The same tuple repeated in one batch keeps both slots. Envelope-level 400s are reserved for a request that is meaningless rather than partly wrong. Read-only, side-effect-free and safely repeatable; same scope as the single endpoint (consent.check.write).
[ "POST /api/auth/register", "POST /api/consents/purposes", "POST /api/consents" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | body must be an object with an items[] array | Request body is absent, not a JSON object, or is itself an array |
| 400 | ValidationError | items must be an array | The items key is present but is not an array |
| 400 | ValidationError | items must not be empty | items is an empty array - rejected rather than answered '0 of 0 succeeded', which would let a campaign report a clean check having evaluated nobody |
| 400 | ValidationError | items exceeds the per-request maximum of 1000; page the batch | More than 1000 items are supplied |
| 500 | InternalError | InternalError | checkConsentBulk throws (database unavailable or query error) |
{
"items": [
{
"person_id": "{{cache:auth.register.response.data.userId}}",
"purpose_id": "{{cache:consents.purposes.create.response.data.purpose.purpose_id}}",
"processor": "tenant",
"jurisdiction": "US-CA"
},
{
"person_id": "not-a-uuid",
"purpose_id": "{{cache:consents.purposes.create.response.data.purpose.purpose_id}}",
"processor": "tenant",
"jurisdiction": "US-CA"
}
]
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"results": "array",
"summary": {
"requested": "number",
"succeeded": "number",
"failed": "number"
}
}
}GET/api/consents/export🔒 auth
Exports consent receipts as a JSONL-friendly array under { data: { receipts } } with status 200 - the DSAR / portability surface. The person_id query parameter is optional: when supplied the export is filtered to that person, and when omitted EVERY receipt in the store is returned. Edge cases: the export is unpaginated and has no limit, so calling it without person_id on a large store returns the entire table in one response; an unknown person_id returns 200 with an empty receipts array rather than a 404; revoked and expired receipts are included (this is a full history, not a live-consent view); no tenant scoping is applied - the only filter is person_id; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/consents/purposes", "POST /api/consents" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 500 | InternalError | InternalError | exportReceipts throws - includes a non-UUID person_id (Postgres 22P02) and database errors |
{
"success": true,
"data": [
{
"export_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"receipts": "array"
}
}GET/api/consents/purposes🔒 auth
Reads the purpose registry that POST /api/consents/purposes writes into. Optional query filters app_id, category and legal_basis are ANDed; limit (default 100, clamped 1..500) and offset page the result, and total reports the unpaged count so a caller can distinguish a complete taxonomy from a truncated first page. Ordered by (app_id, purpose_id) for a stable page boundary. The registry is a single platform-wide namespace and this reports it as one: consent.purpose.purpose_id is a global TEXT primary key, so two tenants cannot both register the same id and the second already learns of the first through a 409 on register - scoping the read while leaving the write globally unique would hide names the write path reveals on the next collision. Read-only, side-effect-free and safely repeatable. Requires a valid JWT, or a key holding consent.purpose.read (note this is a DIFFERENT scope from consent.check.write).
[ "POST /api/auth/register", "POST /api/consents/purposes" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 403 | Forbidden | Service token is missing required scope: consent.purpose.read | An API key or machine token that does not hold consent.purpose.read (or a covering wildcard) is presented |
| 400 | ValidationError | limit and offset must be numbers | limit or offset is present but not parseable as a number |
| 500 | InternalError | InternalError | listPurposes throws (database unavailable or query error) |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"purposes": "array",
"total": "number",
"limit": "number",
"offset": "number"
}
}POST/api/consents/purposes🔒 auth
Registers a typed processing purpose in the per-app purpose registry (FR-CNS-4) and emits consent.purpose.registered.v1. Returns 201 with the purpose. purpose_id, app_id and description are mandatory non-empty strings; legal_basis must be one of consent | contract | legitimate-interest | vital | public-task | legal-obligation; default_jurisdictions is optional, defaults to an empty array, and must be an array of strings when supplied. Edge cases: re-registering an existing purpose_id is a 409 rather than an upsert, so the call is not idempotent; string fields are trimmed, so whitespace-only values count as missing; a non-array or mixed-type default_jurisdictions is a 400; requires a valid JWT.
[ "POST /api/auth/register" ]
legal_basis: consent, contract, legitimate-interest, vital, public-task, legal-obligation| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | purpose_id / app_id / description is required | Any of purpose_id, app_id, description is absent or whitespace-only |
| 400 | ValidationError | legal_basis must be one of consent, contract, legitimate-interest, vital, public-task, legal-obligation | legal_basis is missing or outside the closed set |
| 400 | ValidationError | default_jurisdictions must be an array of strings | default_jurisdictions is supplied but is not an array, or contains a non-string element |
| 409 | Conflict | purpose_id already registered | The insert raises a duplicate-key violation - this purpose_id already exists in the registry |
| 500 | InternalError | InternalError | Any other registerPurpose failure (database error, event-emit failure) |
{
"purpose_id": "{{dynamic:slug}}",
"app_id": "healthcare",
"description": "Send transactional and marketing emails about appointments and offers.",
"legal_basis": "consent",
"default_jurisdictions": [
"US-CA",
"EU"
]
}{
"purpose_id": "sample-slug",
"app_id": "healthcare",
"description": "Send transactional and marketing emails about appointments and offers.",
"legal_basis": "consent",
"default_jurisdictions": [
"US-CA",
"EU"
]
}{
"success": true,
"data": {
"purpos_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"purpose_id": "sample-slug",
"app_id": "healthcare",
"description": "Send transactional and marketing emails about appointments and offers.",
"legal_basis": "consent",
"default_jurisdictions": [
"US-CA",
"EU"
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"purpose": {
"purpose_id": "string",
"app_id": "string",
"legal_basis": "string"
}
}
}sdk-content
POST/api/content/items🔒 auth
Creates a content item (the container that versions hang off): tenant_id, type_code and slug are required, owner_persona_id is optional. Edge cases: only presence is validated — tenant_id comes from the body rather than the JWT and is never checked against the caller's tenant, so cross-tenant creation is not blocked here; a tenant_id or owner_persona_id that is not a UUID, or references no row, fails downstream in Postgres as a 500 rather than a 400/404; slug uniqueness is enforced only by a DB constraint, so a duplicate (tenant, slug) surfaces as a 500 duplicate-key rather than a 409; the item is created with NO version — POST .../versions afterwards.
[ "POST /api/auth/signup-tenant" ]
status: draft, published, archived| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | missing fields | tenant_id, type_code or slug is absent/empty |
| 500 | Internal Server Error | Internal Server Error | tenant_id/owner_persona_id is not a UUID or violates a foreign key, or a duplicate slug hits a unique constraint — the route has no try/catch, so the rejection surfaces as Fastify's default 500 |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"type_code": "article",
"slug": "{{dynamic:name}}",
"owner_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type_code": "article",
"slug": "Acme QA Sample",
"owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"item_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type_code": "article",
"slug": "Acme QA Sample",
"owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/content/items/:item_id🔒 auth
Fetches a content item by id. Edge cases: an unknown :item_id returns 404 NotFound; the read is NOT tenant-filtered — the handler looks the item up by id alone, so any authenticated caller holding an item_id can read another tenant's item; a non-UUID :item_id is not validated and fails in Postgres as a 500; archived items are still returned (archive is a status, not a delete) and the response carries the item row only, not its versions.
[ "POST /api/auth/signup-tenant", "POST /api/content/items" ]
status: draft, published, archived| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 404 | NotFound | NotFound | no content item exists for :item_id |
| 500 | Internal Server Error | Internal Server Error | :item_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500 |
{
"item_id": "{{cache:content.item.create.response.data.item.item_id}}"
}{
"success": true,
"data": {
"item_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/content/items/:item_id/archive🔒 auth
Archives a content item (a soft state change; the row and its versions are retained and still readable via GET). Takes no body. Edge cases: an unknown :item_id returns 404; a non-UUID :item_id fails in Postgres as a 500; there is no already-archived guard, so repeating the call returns 200 with the same row (idempotent in effect, not by check); archiving does not unpublish a previously published version, and there is no un-archive counterpart on this route.
[ "POST /api/auth/signup-tenant", "POST /api/content/items" ]
status: draft, published, archived| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 404 | NotFound | NotFound | no content item exists for :item_id |
| 500 | Internal Server Error | Internal Server Error | :item_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500 |
{
"entity": "content.item",
"field": "status",
"flow": [
"draft",
"published",
"archived"
],
"transitions": [
{
"from": "draft",
"to": "archived",
"via": "POST /api/content/items/:item_id/archive"
},
{
"from": "published",
"to": "archived",
"via": "POST /api/content/items/:item_id/archive"
}
]
}{
"item_id": "{{cache:content.item.create.response.data.item.item_id}}"
}{}{
"success": true,
"data": {
"archive_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/content/items/:item_id/versions🔒 auth
Lists the versions of a content item. Edge cases: this is a list endpoint, so an unknown :item_id returns 200 with data.versions = [] rather than 404 — a typo'd item is indistinguishable from an item with no versions yet; there is no paging or limit parameter, so an item with a long edit history returns every version in one response; results are not tenant-filtered; a non-UUID :item_id fails in Postgres as a 500.
[ "POST /api/auth/signup-tenant", "POST /api/content/items" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 500 | Internal Server Error | Internal Server Error | :item_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500 |
{
"item_id": "{{cache:content.item.create.response.data.item.item_id}}"
}{
"success": true,
"data": {
"version_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/content/items/:item_id/versions🔒 auth
Appends a new draft version to an item. Every body field is optional — payload defaults to {}, media_refs and taxonomy_tags default to undefined — so an EMPTY body legitimately creates an empty version and there is no 400 branch on this route at all. Edge cases: an unknown or non-UUID :item_id is not checked by the handler and fails on the item_id foreign key / uuid cast as a 500 rather than 404; versions are append-only and each call creates a NEW version even with identical content (not idempotent); the created version is a draft and is not live until the publish route runs; media_refs and taxonomy_tags are stored as given and are not validated to exist.
[ "POST /api/auth/signup-tenant", "POST /api/content/items" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 500 | Internal Server Error | Internal Server Error | :item_id is not a valid UUID or references no content item (foreign-key violation) — the route has no try/catch, so the rejection surfaces as Fastify's default 500 |
{
"item_id": "{{cache:content.item.create.response.data.item.item_id}}"
}{
"payload": {
"title": "{{dynamic:name}}",
"body": "Draft body copy for the first version."
},
"media_refs": [
"media://hero-image"
],
"taxonomy_tags": [
"news",
"featured"
]
}{
"payload": {
"title": "Acme QA Sample",
"body": "Draft body copy for the first version."
},
"media_refs": [
"media://hero-image"
],
"taxonomy_tags": [
"news",
"featured"
]
}{
"success": true,
"data": {
"version_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"payload": {
"title": "Acme QA Sample",
"body": "Draft body copy for the first version."
},
"media_refs": [
"media://hero-image"
],
"taxonomy_tags": [
"news",
"featured"
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/content/items/:item_id/versions/:version_id/publish🔒 auth
Publishes a specific version of an item, making it the live version and recording published_by. Edge cases: published_by is the only required body field (400 if absent) and is accepted as free text — it is not verified to be a real persona; the 404 covers the (item_id, version_id) PAIR, so a valid version_id belonging to a DIFFERENT item is a 404 rather than a cross-item publish; there is no state guard, so re-publishing the already-live version returns 200 again (idempotent in effect); publishing an older version is permitted and acts as a rollback.
[ "POST /api/auth/signup-tenant", "POST /api/content/items", "POST /api/content/items/:item_id/versions" ]
status: draft, published, archived| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | missing published_by | body.published_by is absent/empty |
| 404 | NotFound | NotFound | no version matches the (:item_id, :version_id) pair — unknown item, unknown version, or the version belongs to another item |
| 500 | Internal Server Error | Internal Server Error | :item_id or :version_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500 |
{
"entity": "content.item",
"field": "status",
"flow": [
"draft",
"published",
"archived"
],
"transitions": [
{
"from": "draft",
"to": "published",
"via": "POST /api/content/items/:item_id/versions/:version_id/publish"
}
]
}{
"item_id": "{{cache:content.item.create.response.data.item.item_id}}",
"version_id": "{{cache:content.version.create.response.data.version.version_id}}"
}{
"published_by": "{{cache:auth.signup-tenant.response.data.userId}}"
}{
"published_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"status": "completed",
"published_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"publish_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}PUT/api/content/taxonomies🔒 auth
Upserts a tenant's named taxonomy tree. tenant_id and name are required; structure is optional and defaults to {} — so a PUT with no structure BLANKS the stored tree rather than leaving it untouched, the main destructive edge case here. Idempotent by (tenant_id, name): re-PUTting overwrites and returns 200, never 201, so it is safe to replay but cannot be used for partial updates. Edge cases: tenant_id comes from the body, not the JWT, and is not checked against the caller's tenant; a non-UUID or non-existent tenant_id fails in Postgres as a 500; structure is stored as opaque JSON and its shape is never validated.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | missing fields | tenant_id or name is absent/empty |
| 500 | Internal Server Error | Internal Server Error | tenant_id is not a valid UUID or violates the tenant foreign key — the route has no try/catch, so the rejection surfaces as Fastify's default 500 |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"name": "{{dynamic:name}}",
"structure": {
"root": "topics",
"children": [
{
"code": "news",
"label": "News"
},
{
"code": "guides",
"label": "Guides"
}
]
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"structure": {
"root": "topics",
"children": [
{
"code": "news",
"label": "News"
},
{
"code": "guides",
"label": "Guides"
}
]
}
}{
"success": true,
"data": {
"taxonomy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"structure": {
"root": "topics",
"children": [
{
"code": "news",
"label": "News"
},
{
"code": "guides",
"label": "Guides"
}
]
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-conversation
POST/api/conversations/compose-guardrail🔒 auth
Evaluates whether the tenant may compose on each requested channel, returning allow|review|deny per channel plus an ORDERED, human-readable reason list — never a bare boolean (AC1). It is an action endpoint, not a create, so the success status is 200. The ordering is the contract: reasons a human cannot resolve by waiting come first (LEGAL_HOLD, OPTED_OUT, SUPPRESSED, MISSING_CONSENT, NO_SENDER_IDENTITY) ahead of ones that clear on their own (FREQUENCY_CAP, RATE_LIMITED, QUIET_HOURS), so reasons[0] is the thing actually worth telling the user; every runner-up is retained because clearing only the headline would send the caller round the loop twice. Decision inputs come entirely from the caller (AC2/AC3): over HTTP a resolver function cannot be passed, so the caller sends channel_facts — the output ITS resolver produced — and this endpoint only ranks and explains. The SDK holds no consent table, no quiet-hours calendar, no DNC list and no policy constants, so a vertical changes its rules by changing its resolver, never by forking this package. QA edge cases: a channel the caller says nothing about resolves to no facts, hence no reasons, hence allow — silence must not become an invented denial, which would be this package holding an opinion about consent; available and has_sender_identity are the only flags read as 'false is bad', and undefined is deliberately NOT the same as false; identical facts must yield an identical verdict on every customer-facing channel, since any difference would betray a hard-coded assumption about that channel's regime; INTERNAL_NOTE is never passed to the resolver at all (a note to a colleague is not contact with the customer) and is never returned as recommended_channel; a closed thread yields review rather than deny because reopening is a decision a human can make; recommended_channel is the FIRST allowed channel in the caller's own submitted order, since they listed them in preference order and re-ordering would itself be policy. 401 without a tenant Bearer; 400 for an empty channels array, an unknown channel, or a missing channel_facts.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | channels must be a non-empty array | channels is absent, not an array, or empty |
| 400 | VALIDATION_ERROR | channel_facts is required — this SDK holds no consent or policy logic and cannot decide without resolver output | channel_facts is absent or not an object; guessing here would embed policy this package must not own |
| 400 | VALIDATION_ERROR | unknown channel(s): TELEPATHY | channels contains a value outside the eight enumerated channels |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channels": [
"SMS"
],
"channel_facts": {
"SMS": {
"opted_out": true,
"quiet_hours": true
}
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channels": [
"SMS"
],
"channel_facts": {
"SMS": {
"opted_out": true,
"quiet_hours": true
}
}
}{
"success": true,
"data": {
"compose_guardrail_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channels": [
"SMS"
],
"channel_facts": {
"SMS": {
"opted_out": true,
"quiet_hours": true
}
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/conversations/inbox🔒 auth
The agent inbox: threads for a tenant, most recently active first, with three independent filters that AND together — unread=true (at least one unread INBOUND message), awaiting_reply=true (we spoke last and are waiting on them) and channel=<CHANNEL>. Proves AC4. Reads the trigger-maintained rollup columns (unread_count, status, channel_set) rather than joining messages, which is why those columns exist; channel_set is GIN-indexed. The channel filter is 'has this thread EVER carried that channel' (channel_set @> ARRAY[channel]), not 'the latest message was that channel' — the latter would make a conversation vanish from the SMS view the moment somebody replied by email, which is the opposite of what an omnichannel inbox is for. QA edge cases: only the literal string 'true' enables a flag, so ?unread=false correctly means 'do not filter' rather than silently becoming unread-only (any-truthy parsing is the bug this guards against); closed threads are excluded unless include_closed=true; an unknown channel value is a 400 rather than a silently empty list, since an empty 200 is indistinguishable from 'no matches'; limit is clamped to 1..200 and offset floored at 0, so absurd paging cannot be used to pull the whole table; the response echoes the filters it actually applied so a caller can tell an empty inbox from a mis-parsed query. requiresAuth applies to this GET exactly as to the POSTs (MUST-52) — tenant_id is a query param but the Bearer is what scopes the read.
[ "POST /api/auth/signup-tenant", "POST /api/conversations/threads", "POST /api/conversations/messages" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query parameter is absent |
| 400 | VALIDATION_ERROR | channel must be one of: EMAIL, SMS, VOICE, VOICEMAIL, SOCIAL_DM, WEB_CHAT, IN_PERSON, INTERNAL_NOTE | channel is not one of the eight enumerated values — returning an empty 200 would be indistinguishable from 'no matching threads' |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request — GET is gated exactly as POST |
{
"success": true,
"data": [
{
"inbox_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/conversations/messages🔒 auth
Records one message onto an existing thread. Collection-root create, so 201 with { data: { message } }. body_ref is a vault/blob REFERENCE, never the body text: message content is customer data and a text column here would put it in every backup, replica and query plan that touches the table. occurred_at is when the provider says it HAPPENED and is the sort key; received_at is stamped server-side and is a diagnostic only, so a message delivered late still renders in the right place. Writes carrying external_message_id are idempotent on (tenant_id, channel, external_message_id) — a retried provider webhook returns the row it already wrote instead of double-posting into the thread. INTERNAL_NOTE (or direction INTERNAL) is routed internally to addInternalNote, which hard-codes the undispatchable shape; the ordinary path is refused for notes because it accepts a caller-supplied delivery_state and that is exactly the field a note must never set. QA edge cases: 401 without a tenant Bearer; 400 for a missing tenant_id/thread_id/channel/body_ref/actor, an unknown channel, a direction other than INBOUND/OUTBOUND on a customer-facing channel, a blank body_ref, an unparseable occurred_at, or delivery_state 'NOT_APPLICABLE' on a customer-facing channel (that state is reserved for internal notes and the conv_message_dispatchable_state CHECK rejects it at the database too); 404 when thread_id names no thread. An inbound message defaults to delivery_state RECEIVED and read_state UNREAD, an outbound one to PENDING and READ, and the rollup trigger then updates the thread's channel_set, unread_count, last_message_at and status.
[ "POST /api/auth/signup-tenant", "POST /api/conversations/threads" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | [sdk-conversation] delivery_state 'NOT_APPLICABLE' is reserved for internal notes | delivery_state NOT_APPLICABLE is sent on any channel other than INTERNAL_NOTE — that state is what marks a row as never-to-be-dispatched |
| 400 | VALIDATION_ERROR | channel must be one of: EMAIL, SMS, VOICE, VOICEMAIL, SOCIAL_DM, WEB_CHAT, IN_PERSON, INTERNAL_NOTE | channel is not one of the eight enumerated values |
| 400 | VALIDATION_ERROR | body_ref is required | body_ref is absent, empty or whitespace-only |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"thread_id": "{{cache:conversations.threads.response.data.thread.thread_id}}",
"channel": "SMS",
"direction": "OUTBOUND",
"body_ref": "vault:blob/outbound-sms-1",
"body_preview": "Following up on your renewal",
"actor": "persona:{{dynamic:uuid}}",
"occurred_at": "{{dynamic:pastdatetime}}",
"provider_message_key": "carrier-{{dynamic:uuid}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"thread_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "SMS",
"direction": "OUTBOUND",
"body_ref": "vault:blob/outbound-sms-1",
"body_preview": "Following up on your renewal",
"actor": "persona:{{dynamic:uuid}}",
"occurred_at": "2026-01-15T10:30:00Z",
"provider_message_key": "carrier-{{dynamic:uuid}}"
}{
"success": true,
"data": {
"message_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"thread_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "SMS",
"direction": "OUTBOUND",
"body_ref": "vault:blob/outbound-sms-1",
"body_preview": "Following up on your renewal",
"actor": "persona:{{dynamic:uuid}}",
"occurred_at": "2026-01-15T10:30:00Z",
"provider_message_key": "carrier-{{dynamic:uuid}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/conversations/threads🔒 auth
Opens a conversation thread for one subject, across every channel it will later carry. Collection-root create, so the handler returns 201 with { data: { thread } }. tenant_id, subject_ref and purpose are all required and none is defaulted: purpose in particular is refused rather than filled in, because a thread with no stated purpose is one nobody can decide is finished, and a placeholder would defeat the NOT NULL column that exists to prevent exactly that. channel_set starts empty and is maintained by trigger from the messages themselves rather than declared here — a caller-declared channel set drifts the first moment somebody replies by a route nobody predicted, and an inbox filtering on a stale set silently hides threads. current_eligibility_snapshot is stored verbatim with an eligibility_snapshot_at stamp; this SDK computes no eligibility of its own, so whatever the consumer sends is recorded as what it believed AT THAT TIME, which is what makes a message sent last Tuesday explainable against last Tuesday's rules. QA edge cases: the route is behind the gateway's default-deny authGate via a requireAuth preHandler, so a missing or invalid tenant Bearer is 401 (this is a GET-and-POST-alike rule — see MUST-52); a blank or whitespace-only purpose or subject_ref is a 400 from the service guard before any SQL runs; subject_kind, related_object_ref, sender_identity_ref, eligibility_snapshot and metadata are all optional and null/{} when omitted; status starts 'open' and closed_at stays NULL, enforced by the conv_thread_closed_shape constraint so a 'closed' thread with no closed_at is unrepresentable.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | purpose is required | purpose is absent, empty or whitespace-only |
| 400 | VALIDATION_ERROR | subject_ref is required | subject_ref is absent, empty or whitespace-only |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"subject_ref": "lead:{{dynamic:uuid}}",
"subject_kind": "lead",
"purpose": "win back the lapsed renewal",
"related_object_ref": "order:{{dynamic:uuid}}",
"sender_identity_ref": "mailbox:sales@tenant.example",
"eligibility_snapshot": {
"consent": true,
"quiet_hours": false
},
"metadata": {
"source": "api-regression"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_ref": "lead:{{dynamic:uuid}}",
"subject_kind": "lead",
"purpose": "win back the lapsed renewal",
"related_object_ref": "order:{{dynamic:uuid}}",
"sender_identity_ref": "mailbox:sales@tenant.example",
"eligibility_snapshot": {
"consent": true,
"quiet_hours": false
},
"metadata": {
"source": "api-regression"
}
}{
"success": true,
"data": {
"thread_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_ref": "lead:{{dynamic:uuid}}",
"subject_kind": "lead",
"purpose": "win back the lapsed renewal",
"related_object_ref": "order:{{dynamic:uuid}}",
"sender_identity_ref": "mailbox:sales@tenant.example",
"eligibility_snapshot": {
"consent": true,
"quiet_hours": false
},
"metadata": {
"source": "api-regression"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/conversations/threads/:id🔒 auth
Returns one thread plus its messages, ordered strictly by occurred_at with received_at and message_id as tie-breakers only, so the order is total and stable across repeated reads. That ordering is the whole point: a provider tells you about a message when it gets around to it, so an SMS sent at 09:00 can reach the webhook after a reply sent at 09:04, and sorting by arrival would render the answer above the question. exclude_internal=true omits INTERNAL_NOTE rows, which is what a customer-visible transcript export wants. QA edge cases: a thread belonging to a DIFFERENT tenant returns 404, not 403 and not an empty 200 — a 403 would confirm the id exists to a caller who should not know that, and an empty 200 would do the same more quietly; an unknown id is likewise 404; requiresAuth applies to this GET (MUST-52), so no Bearer is 401; limit is clamped to 1..500 and offset floored at 0; messages is an empty array on a thread with no messages, never null.
[ "POST /api/auth/signup-tenant", "POST /api/conversations/threads", "POST /api/conversations/messages" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | THREAD_NOT_FOUND | NotFound | the id names no thread |
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query parameter is absent, so the read cannot be tenant-scoped |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"id": "{{cache:conversations.threads.response.data.thread.thread_id}}"
}{
"success": true,
"data": {
"thread_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-coverage
GET/api/coverage/backup-designations🔒 auth
Every backup designation in the tenant, returned as data.designations. Takes no filter - the handler passes only the resolved tenant to listBackups - so a primary_persona_id query parameter is ignored rather than narrowing the result. Each row pairs a primary_persona_id with the backup_persona_id that covers it, plus the optional scope and acceptance_window_minutes. A persona designating itself is refused at write time as 422, so no row here is ever self-referential and a reader need not defend against that cycle. The tenant comes from the verified claim: a disagreeing tenant_id is 403 and no tenant at all is 400.
[ "POST /api/auth/signup-tenant", "POST /api/coverage/backup-designations" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it |
| 403 | Forbidden | tenant_id does not match the authenticated tenant | The query names a tenant_id different from the credential's tenant claim |
| 400 | ValidationError | tenant_id is required | The credential carries no tenant_id claim and none is supplied in the query |
| 500 | InternalError | InternalError | listBackups throws a non-domain error - a database failure |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"designations": "array"
}
}POST/api/coverage/backup-designations🔒 auth
Who catches the work when the primary does not accept in time. acceptance_window_minutes is how long the primary has before it falls to the backup; zero would mean the backup is notified simultaneously, which is a different arrangement and should be said explicitly rather than arrived at through an empty field. A persona cannot back themselves up - the whole purpose of a backup is that it is somebody else, so a row saying otherwise is a silent single point of failure and the database refuses it. NOTE ON THE PAYLOAD: primary_persona_id comes from the persona producer, while backup_persona_id is a seeded id from test-config. That is deliberate on both counts - the handler REFUSES a persona backing themselves up (422), and the dependency graph is one node per METHOD+ENDPOINT so it cannot express a SECOND persona from the same producer [MUST-51]; coverage.persona_id is a loose reference with no foreign key into sdk-persona, so a seeded id is a truthful stand-in rather than a fabricated FK.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — | |
— | — | — | |
| 422 | VALIDATION_ERROR | a persona cannot be their own backup | primary_persona_id and backup_persona_id are the same |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"primary_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"backup_persona_id": "{{var:coverage_backup_persona_id}}",
"scope": "primary-queue",
"acceptance_window_minutes": 5
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"primary_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"backup_persona_id": "{{var:coverage_backup_persona_id}}",
"scope": "primary-queue",
"acceptance_window_minutes": 5
}{
"success": true,
"data": {
"backup_designation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"primary_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"backup_persona_id": "{{var:coverage_backup_persona_id}}",
"scope": "primary-queue",
"acceptance_window_minutes": 5,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"designation": {
"designation_id": "string"
}
}
}GET/api/coverage/capacity-policies🔒 auth
Every capacity policy the tenant has configured, returned as data.policies. Takes no filter - the handler passes only the resolved tenant to listCapacityPolicies - so a persona_id or role_ref query parameter is ignored rather than narrowing the result. A policy is subject-scoped by exactly one of persona_id or role_ref (the POST's CapacityPolicySubjectError enforces that), so a reader must branch on which one is populated rather than assuming persona. max_concurrent_by_band and freeze_threshold_by_band are per-band maps, and daily_cap is nullable, meaning 'no daily ceiling' rather than zero. The tenant comes from the verified claim: a disagreeing tenant_id is 403 and no tenant at all is 400.
[ "POST /api/auth/signup-tenant", "POST /api/coverage/capacity-policies" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it |
| 403 | Forbidden | tenant_id does not match the authenticated tenant | The query names a tenant_id different from the credential's tenant claim |
| 400 | ValidationError | tenant_id is required | The credential carries no tenant_id claim and none is supplied in the query |
| 500 | InternalError | InternalError | listCapacityPolicies throws a non-domain error - a database failure |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"policies": "array"
}
}POST/api/coverage/capacity-policies🔒 auth
How much one persona, or everyone in a role, may hold at once. max_concurrent_by_band is keyed by the tenant OWN band names: naming the bands in the platform would be a business rule, and the first vertical with a third band would have to alter it. A band absent from the map is UNCAPPED, which is a different statement from capped at zero and both are expressible. freeze_threshold stops new assignment before the limit so headroom can be deliberately reserved, with per-band overrides. Exactly one of persona_id or role_ref: a policy naming both would leave the precedence undefined at the moment it matters.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — | |
— | — | — |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"max_concurrent_by_band": {
"urgent": 2,
"standard": 8
},
"freeze_threshold": 0.9,
"freeze_threshold_by_band": {
"urgent": 1
},
"daily_cap": 20
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"max_concurrent_by_band": {
"urgent": 2,
"standard": 8
},
"freeze_threshold": 0.9,
"freeze_threshold_by_band": {
"urgent": 1
},
"daily_cap": 20
}{
"success": true,
"data": {
"capacity_policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"max_concurrent_by_band": {
"urgent": 2,
"standard": 8
},
"freeze_threshold": 0.9,
"freeze_threshold_by_band": {
"urgent": 1
},
"daily_cap": 20,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"policy": {
"capacity_policy_id": "string"
}
}
}GET/api/coverage/eligible🔒 auth
THE core endpoint. Availability is a subtraction - schedule MINUS time-off MINUS holiday, intersected with live presence and capacity headroom - and every term stays a separate queryable fact so the answer can always say WHY somebody was skipped. Returns eligible personas sorted by most headroom first, so a router can take the head of the list, each with current load and remaining headroom. Ineligible personas are returned WITH their reasons by default, because the reasons are the point: a router that cannot explain a skip is a router nobody trusts. Reasons are COLLECTED, not short-circuited, so a persona who is both on PTO and at capacity reports both. tenant_id is taken from the credential; passing one that names a DIFFERENT tenant is a 403 rather than being silently preferred either way, so a misconfigured caller fails loudly instead of reading somebody else's roster.
[ "POST /api/auth/signup-tenant", "POST /api/coverage/schedules", "PUT /api/coverage/presence" ]
include_ineligible: true, falseignore_presence: true, false| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — | |
— | — | — |
{
"success": true,
"data": [
{
"eligible_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"eligible": "array",
"ineligible": "array",
"evaluated": "number",
"capacity_evaluated": "boolean"
}
}GET/api/coverage/gaps🔒 auth
Windows in which nobody is on call for a rotation and tier. The whole value is in the tense: a gap found while an incident is escalating is not a warning, it is an outage, so this is written to be run AHEAD of the window and each gap reports how long until it opens. Defaults to tier 1, because a hole there means the first page goes nowhere, whereas a tier-2 hole with tier 1 staffed is thin cover rather than none. Abutting shifts produce NO gap - a zero-length gap at every handover would bury the real ones.
[ "POST /api/auth/signup-tenant", "POST /api/coverage/on-call" ]
tier: 1, 2, 3| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — | |
— | — | — |
{
"success": true,
"data": [
{
"gap_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"gaps": "array",
"rotation_ref": "string"
}
}GET/api/coverage/holiday-calendars🔒 auth
Every holiday calendar the tenant maintains, returned as data.calendars. Takes no filter of any kind - the handler passes only the resolved tenant to listHolidayCalendars - so a region query parameter is accepted by the router and ignored rather than narrowing the result. Regions are per-tenant strings, not an ISO set, because a tenant's holiday regions follow its operating calendar and not a country code. The tenant comes from the verified claim: a disagreeing tenant_id in the query is 403 and no tenant at all is 400.
[ "POST /api/auth/signup-tenant", "POST /api/coverage/holiday-calendars" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it |
| 403 | Forbidden | tenant_id does not match the authenticated tenant | The query names a tenant_id different from the credential's tenant claim |
| 400 | ValidationError | tenant_id is required | The credential carries no tenant_id claim and none is supplied in the query |
| 500 | InternalError | InternalError | listHolidayCalendars throws a non-domain error - a database failure |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"calendars": "array"
}
}POST/api/coverage/holiday-calendars🔒 auth
A holiday list scoped to a tenant AND a region, because a company operating in two countries does not share one. region is free-form so a tenant can key by country, state or site - naming the granularity here would be a business rule. maintained_by records WHO keeps it current: a holiday list nobody owns goes stale silently, and the first anyone notices is a working day that should not have been. Re-posting the same region replaces its dates.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — | |
— | — | — |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"region": "US-TX",
"name": "Texas public holidays",
"dates": [
"2026-12-25",
"2027-01-01"
],
"maintained_by": "people-ops"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"region": "US-TX",
"name": "Texas public holidays",
"dates": [
"2026-12-25",
"2027-01-01"
],
"maintained_by": "people-ops"
}{
"success": true,
"data": {
"holiday_calendar_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"region": "US-TX",
"name": "Texas public holidays",
"dates": [
"2026-12-25",
"2027-01-01"
],
"maintained_by": "people-ops",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"calendar": {
"holiday_calendar_id": "string",
"region": "string"
}
}
}GET/api/coverage/on-call🔒 auth
The raw on-call roster for the tenant, optionally narrowed to one rotation via the rotation_ref query parameter. Returns 200 with data.roster as an array of entries, each carrying its tier and its starts_at/ends_at window. This is deliberately NOT the same question as GET /api/coverage/on-call/current: this route lists every entry as stored, including past and future windows, while /current resolves who is on call at one instant and applies tier fallback. Reading the roster to work out who is on duty now duplicates that resolution and gets the tier precedence wrong. The tenant comes from the verified claim: a disagreeing tenant_id is 403 and no tenant at all is 400.
[ "POST /api/auth/signup-tenant", "POST /api/coverage/on-call" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it |
| 403 | Forbidden | tenant_id does not match the authenticated tenant | The query names a tenant_id different from the credential's tenant claim |
| 400 | ValidationError | tenant_id is required | The credential carries no tenant_id claim and none is supplied in the query |
| 500 | InternalError | InternalError | listRoster throws a non-domain error - a database failure |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"roster": "array"
}
}POST/api/coverage/on-call🔒 auth
Puts a persona on call for an interval at a tier. Entries overlap on purpose - tier 1 and tier 2 are both on call at once, which is what a tier means - so there is no exclusion constraint and no "current on-call" column; the answer is computed at the instant it is asked about. Re-posting the same rotation, tier, persona and start extends the interval rather than duplicating it, so a roster import is safe to re-run.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
tier: 1, 2, 3| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — | |
— | — | — |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"rotation_ref": "primary",
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"tier": 1,
"starts_at": "{{dynamic:futuredatetime+1h}}",
"ends_at": "{{dynamic:futuredatetime+7d}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rotation_ref": "primary",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tier": 1,
"starts_at": "2026-01-15T10:30:00Z",
"ends_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"on_call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rotation_ref": "primary",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tier": 1,
"starts_at": "2026-01-15T10:30:00Z",
"ends_at": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"entry": {
"roster_id": "string",
"tier": "number"
}
}
}GET/api/coverage/on-call/current🔒 auth
The on-call audience at an instant, in tier order so the caller pages tier 1 before tier 2, plus whoever is manager-on-duty. The interval test is half-open, so at exactly a handover instant the outgoing shift is off and the incoming one is on - neither doubled up nor a second uncovered. Reports uncovered=true explicitly when nobody is on call: a caller that reads an empty list as "no result" will escalate into the void, which is precisely the failure this endpoint exists to prevent.
[ "POST /api/auth/signup-tenant", "POST /api/coverage/on-call" ]
tier: 1, 2, 3| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — |
{
"success": true,
"data": [
{
"current_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"tiers": "array",
"persona_ids": "array",
"manager_on_duty_ids": "array",
"uncovered": "boolean"
}
}PUT/api/coverage/presence🔒 auth
Sets a persona live presence, recording WHERE the claim came from. A MANUAL claim outranks an automated one until manual_hold_until: somebody saying "I am here" must not be overwritten two seconds later by a calendar that still thinks they are in a meeting, and equally a manual toggle cannot win forever or the calendar could never recover. A CALENDAR or SYSTEM write arriving inside a live manual hold is accepted as a no-op and reports which claim currently stands, rather than failing - the sync is not wrong, it is simply outranked.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
status: AVAILABLE, MEETING, OFFLINE, PTO, ON_CALLsource: MANUAL, CALENDAR, SYSTEM| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"status": "AVAILABLE",
"source": "MANUAL",
"manual_hold_minutes": 30
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "AVAILABLE",
"source": "MANUAL",
"manual_hold_minutes": 30
}{
"success": true,
"data": {
"presence_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "AVAILABLE",
"source": "MANUAL",
"manual_hold_minutes": 30,
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"presence": {
"status": "string",
"source": "string"
}
}
}GET/api/coverage/schedules🔒 auth
Working schedules for the tenant, optionally narrowed to one persona. Returns the windows as written, in the persona own timezone, rather than projected into UTC - a schedule shown in a zone its owner does not work in is unreadable to the person who has to correct it.
[ "POST /api/auth/signup-tenant", "POST /api/coverage/schedules" ]
weekday: 0, 1, 2, 3, 4, 5, 6| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — |
{
"success": true,
"data": [
{
"schedule_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"schedules": "array"
}
}POST/api/coverage/schedules🔒 auth
Upserts the recurring working windows for one persona in ONE named IANA timezone. The timezone is validated at write time rather than at the first sweep - an unresolvable zone accepted here would surface as a persona mysteriously never eligible, hours later and far from the cause. weekly_windows is an array of {weekday 0-6, start HH:MM, end HH:MM} in the persona local wall time, which is the only representation that survives a DST change without being silently wrong for an hour twice a year. One ACTIVE schedule per persona; re-posting replaces it.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
weekday: 0, 1, 2, 3, 4, 5, 6| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — | |
— | — | — | |
— | — | — |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"iana_timezone": "America/Chicago",
"weekly_windows": [
{
"weekday": 1,
"start": "09:00",
"end": "17:00"
},
{
"weekday": 2,
"start": "09:00",
"end": "17:00"
}
]
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"iana_timezone": "America/Chicago",
"weekly_windows": [
{
"weekday": 1,
"start": "09:00",
"end": "17:00"
},
{
"weekday": 2,
"start": "09:00",
"end": "17:00"
}
]
}{
"success": true,
"data": {
"schedule_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"iana_timezone": "America/Chicago",
"weekly_windows": [
{
"weekday": 1,
"start": "09:00",
"end": "17:00"
},
{
"weekday": 2,
"start": "09:00",
"end": "17:00"
}
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"schedule": {
"schedule_id": "string",
"iana_timezone": "string"
}
}
}GET/api/coverage/time-off🔒 auth
Time-off records for the tenant, optionally narrowed to one persona via the persona_id query parameter. Returns 200 with data.time_off as an array; an empty tenant answers 200 with [] rather than 404, because 'this persona has booked no leave' is an answer, not a missing resource. The tenant is taken from the verified claim: a tenant_id in the query that disagrees with the credential is 403, not silently preferred, and a caller with neither is 400. Unlike the POST, this route performs no date parsing, so it has no 422 path - the only refusals are the auth and tenant-resolution ones below.
[ "POST /api/auth/signup-tenant", "POST /api/coverage/time-off" ]
kind: PTO, MEETING, OUTAGE, HOLIDAY| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it |
| 403 | Forbidden | tenant_id does not match the authenticated tenant | The query names a tenant_id different from the credential's tenant claim - tenantOf() refuses rather than preferring either side |
| 400 | ValidationError | tenant_id is required | The credential carries no tenant_id claim and none is supplied in the query |
| 500 | InternalError | InternalError | listTimeOff throws a non-domain error - a database failure; domain errors are 422 but this read path raises none |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"time_off": "array"
}
}POST/api/coverage/time-off🔒 auth
Records an interval a persona is unavailable. Intervals MAY overlap by design - a meeting inside a PTO day is not a contradiction, and eligibility takes the union - so there is no exclusion constraint. kind separates the reasons because they read differently to an operator deciding whether to interrupt somebody. source distinguishes a calendar sync from a manual entry, so a re-sync can update its own rows without overwriting what a human typed.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
kind: PTO, MEETING, OUTAGE, HOLIDAYsource: MANUAL, CALENDAR, SYSTEM| HTTP | Code | Message | When it happens |
|---|---|---|---|
— | — | — | |
— | — | — |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"kind": "PTO",
"starts_at": "{{dynamic:futuredatetime+1d}}",
"ends_at": "{{dynamic:futuredatetime+2d}}",
"reason": "annual leave"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "PTO",
"starts_at": "2026-01-15T10:30:00Z",
"ends_at": "2026-01-15T10:30:00Z",
"reason": "annual leave"
}{
"success": true,
"data": {
"time_off_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "PTO",
"starts_at": "2026-01-15T10:30:00Z",
"ends_at": "2026-01-15T10:30:00Z",
"reason": "annual leave",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"time_off": {
"time_off_id": "string",
"kind": "string"
}
}
}sdk-crm
POST/api/crm/activities🔒 auth
Logs a generic CRM activity on an encounter timeline. encounter_id, kind and actor_persona_id are required, and kind must be one of the ACTIVITY_KINDS enum defined in the crm model (widened with 'voicemail' by migration 003). Edge cases: missing required fields -> 400 'missing fields'; a kind outside the enum -> 400 'invalid activity kind'; summary and occurred_at are optional and occurred_at defaults server-side when omitted (a future-dated occurred_at is not rejected); there is no de-duplication key, so retrying the same log creates a second activity - unlike POST /api/crm/activities/call this endpoint is not idempotent; an unknown encounter_id fails the FK inside the insert and surfaces as an unhandled 500.
[ "POST /api/auth/signup-tenant", "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/vault/keys", "POST /api/encounters" ]
kind: call, email, meeting, note, task, voicemail| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | missing fields | body omits encounter_id, kind, or actor_persona_id |
| 400 | ValidationError | invalid activity kind | body.kind is not a member of ACTIVITY_KINDS |
{
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}",
"kind": "note",
"actor_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"summary": "Follow-up call with prospect",
"occurred_at": "{{dynamic:pastdatetime}}"
}{
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "note",
"actor_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"summary": "Follow-up call with prospect",
"occurred_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"activity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "note",
"actor_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"summary": "Follow-up call with prospect",
"occurred_at": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/crm/activities/call🔒 auth
Log a call on the contact/lead timeline with its structured telephony fields: direction, disposition, duration, phone number, recording url + recording consent, and the provider call id. Emits crm.call.missed.v1 when the disposition means nobody picked up (no_answer/busy/failed) and crm.call.logged.v1 otherwise. Edge cases: writes are IDEMPOTENT on external_call_id (partial unique index) because telephony webhooks retry — re-logging the same provider call id UPDATES the existing timeline entry in place rather than appending a duplicate, and deliberately does NOT re-emit the domain event, so downstream consumers cannot double-count one call; validation happens before the insert so an unknown call_disposition, an unknown call_direction, or a negative call_duration_seconds returns a 400 listing every offending field instead of a raw Postgres CHECK violation surfacing as a 500; 400 when encounter_id or actor_persona_id is missing; activities with no external_call_id (manually logged calls) are unconstrained and may repeat.
[ "POST /api/auth/signup-tenant", "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/vault/keys", "POST /api/encounters" ]
call_direction: inbound, outboundcall_disposition: answered, no_answer, busy, failed, voicemail, left_message| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | encounter_id and actor_persona_id are required | encounter_id or actor_persona_id missing from body |
| 400 | ValidationError | call_disposition must be one of answered|no_answer|busy|failed|voicemail|left_message | call_disposition is outside the enum |
| 400 | ValidationError | call_direction must be one of inbound|outbound | call_direction is outside the enum |
| 400 | ValidationError | call_duration_seconds must be a non-negative number | call_duration_seconds is negative or not a number |
| 401 | Unauthorized | Unauthorized | Authorization bearer token missing or invalid |
{
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}",
"actor_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"call_direction": "outbound",
"call_disposition": "answered",
"call_duration_seconds": 137,
"phone_number": "+14155550123",
"recording_url": "https://api.twilio.com/2010-04-01/Recordings/RE{{dynamic:slug}}",
"recording_consent": true,
"external_call_id": "CA{{dynamic:slug}}",
"summary": "Discovery call - budget and timeline confirmed",
"occurred_at": "{{dynamic:pastdatetime}}"
}{
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"call_direction": "outbound",
"call_disposition": "answered",
"call_duration_seconds": 137,
"phone_number": "+14155550123",
"recording_url": "https://api.twilio.com/2010-04-01/Recordings/RE{{dynamic:slug}}",
"recording_consent": true,
"external_call_id": "CA{{dynamic:slug}}",
"summary": "Discovery call - budget and timeline confirmed",
"occurred_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"call_direction": "outbound",
"call_disposition": "answered",
"call_duration_seconds": 137,
"phone_number": "+14155550123",
"recording_url": "https://api.twilio.com/2010-04-01/Recordings/RE{{dynamic:slug}}",
"recording_consent": true,
"external_call_id": "CA{{dynamic:slug}}",
"summary": "Discovery call - budget and timeline confirmed",
"occurred_at": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"activity": {
"activity_id": "string",
"kind": "string",
"call_direction": "string",
"call_disposition": "string",
"call_duration_seconds": "number"
}
}
}GET/api/crm/activities/calls🔒 auth
Read the call and voicemail entries on one encounter's timeline, newest first, optionally narrowed to a kind (call|voicemail) or a call_disposition. Only telephony activities are returned — note/email/meeting/task entries are excluded even though they share the crm.activity table. Edge cases: 400 when the encounter_id query param is missing; an unknown encounter yields an empty array rather than 404, since this is a filtered read not a record fetch; limit defaults to 50 and offset to 0; entries logged manually (no external_call_id) appear alongside webhook-sourced ones.
[ "POST /api/auth/signup-tenant", "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/vault/keys", "POST /api/encounters", "POST /api/crm/activities/call" ]
kind: call, voicemailcall_disposition: answered, no_answer, busy, failed, voicemail, left_message| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | encounter_id query param required | encounter_id query param missing |
| 401 | Unauthorized | Unauthorized | Authorization bearer token missing or invalid |
{
"success": true,
"data": [
{
"call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"activities": [
{
"activity_id": "string",
"kind": "string",
"call_disposition": "string"
}
]
}
}POST/api/crm/activities/voicemail🔒 auth
Log a voicemail on the contact/lead timeline, optionally with its transcript, as activity kind 'voicemail' (added by migration 003). call_disposition defaults to 'voicemail' (the call reached the contact's voicemail) and may be 'left_message' when the rep recorded one — any OTHER disposition is rejected, since a voicemail activity cannot be 'answered' or 'busy'. Emits crm.voicemail.received.v1, which is deliberately distinct from crm.call.missed.v1 so reaching voicemail is not double-counted as a plain missed call in follow-up reporting. Edge cases: idempotent on external_call_id exactly like the call endpoint (retry updates in place and does not re-emit the event); 400 when encounter_id or actor_persona_id is missing, when the disposition is outside voicemail|left_message, when call_direction is not inbound|outbound, or when call_duration_seconds is negative.
[ "POST /api/auth/signup-tenant", "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/vault/keys", "POST /api/encounters" ]
call_direction: inbound, outboundcall_disposition: voicemail, left_message| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | encounter_id and actor_persona_id are required | encounter_id or actor_persona_id missing from body |
| 400 | ValidationError | voicemail call_disposition must be voicemail or left_message | call_disposition is any other value, including otherwise-valid call dispositions like answered |
| 400 | ValidationError | call_direction must be one of inbound|outbound | call_direction is outside the enum |
| 400 | ValidationError | call_duration_seconds must be a non-negative number | call_duration_seconds is negative or not a number |
| 401 | Unauthorized | Unauthorized | Authorization bearer token missing or invalid |
{
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}",
"actor_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"call_direction": "outbound",
"call_disposition": "left_message",
"call_duration_seconds": 18,
"phone_number": "+14155550123",
"recording_url": "https://api.twilio.com/2010-04-01/Recordings/RE{{dynamic:slug}}",
"recording_consent": true,
"voicemail_transcript": "Hi, following up on your demo request - call me back when you get a chance.",
"external_call_id": "CA{{dynamic:slug}}",
"summary": "Left voicemail after no answer",
"occurred_at": "{{dynamic:pastdatetime}}"
}{
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"call_direction": "outbound",
"call_disposition": "left_message",
"call_duration_seconds": 18,
"phone_number": "+14155550123",
"recording_url": "https://api.twilio.com/2010-04-01/Recordings/RE{{dynamic:slug}}",
"recording_consent": true,
"voicemail_transcript": "Hi, following up on your demo request - call me back when you get a chance.",
"external_call_id": "CA{{dynamic:slug}}",
"summary": "Left voicemail after no answer",
"occurred_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"voicemail_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"call_direction": "outbound",
"call_disposition": "left_message",
"call_duration_seconds": 18,
"phone_number": "+14155550123",
"recording_url": "https://api.twilio.com/2010-04-01/Recordings/RE{{dynamic:slug}}",
"recording_consent": true,
"voicemail_transcript": "Hi, following up on your demo request - call me back when you get a chance.",
"external_call_id": "CA{{dynamic:slug}}",
"summary": "Left voicemail after no answer",
"occurred_at": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"activity": {
"activity_id": "string",
"kind": "string",
"call_disposition": "string",
"voicemail_transcript": "string"
}
}
}POST/api/crm/close-reasons🔒 auth
Upserts one code in the TENANT's close-reason taxonomy, so adding a reason is an INSERT rather than a release - a hard-coded list is a claim that every business loses the same way, and it is unfalsifiable because people just pick the closest option and the report reads back the categories that shipped. The code carries its own rules: whether a closed subject may be approached again, after how many days, and whether closing on it requires naming a competitor or writing a learning note. Upsert on (tenant_id, code), hence 200 rather than 201 - re-sending a code edits it in place and is idempotent. Edge cases: code and label are required and must be non-blank after trimming (400 VALIDATION_ERROR); outcome_class defaults to 'lost' and is constrained to won/lost/disqualified/paused; reactivation_after_days together with reactivation_allowed=false is refused - 'never come back, after 90 days' is two rules that contradict each other and whichever was meant, somebody downstream reads the other; reactivation_after_days must be >= 0; sort_order defaults to 100 and only orders the picker.
[ "POST /api/auth/signup-tenant" ]
outcome_class: won, lost, disqualified, paused| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | tenant_id is required | body omits tenant_id |
| 400 | VALIDATION_ERROR | code and label are required | code or label is missing or blank after trimming |
| 400 | VALIDATION_ERROR | reactivation_after_days makes no sense when reactivation is not allowed | reactivation_allowed is false and reactivation_after_days is set |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"code": "lost-to-competitor",
"label": "Lost to a competing option",
"outcome_class": "lost",
"reactivation_allowed": true,
"reactivation_after_days": 90,
"requires_competitor": true,
"requires_learning_note": true,
"sort_order": 10
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"code": "lost-to-competitor",
"label": "Lost to a competing option",
"outcome_class": "lost",
"reactivation_allowed": true,
"reactivation_after_days": 90,
"requires_competitor": true,
"requires_learning_note": true,
"sort_order": 10
}{
"success": true,
"data": {
"close_reason_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"code": "lost-to-competitor",
"label": "Lost to a competing option",
"outcome_class": "lost",
"reactivation_allowed": true,
"reactivation_after_days": 90,
"requires_competitor": true,
"requires_learning_note": true,
"sort_order": 10,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"close_reason": {
"close_reason_type_id": "string",
"code": "string",
"outcome_class": "string"
}
}
}POST/api/crm/contacts🔒 auth
Creates a CRM contact binding a persona to a tenant, with optional lifecycle_stage, source, owner_persona_id, custom_fields and external_refs. tenant_id and persona_id are both required. Edge cases: missing or empty tenant_id/persona_id -> 400; lifecycle_stage is not enum-validated at this route; there is no uniqueness guard on (tenant_id, persona_id), so posting twice creates two contacts - the endpoint is not idempotent; a persona_id that does not exist fails the FK inside the insert and surfaces as an unhandled 500 rather than a 400/404.
[ "POST /api/auth/signup-tenant", "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas" ]
lifecycle_stage: lead, prospect, customer, churned, former| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | missing fields | body omits tenant_id or persona_id (details: ["missing fields"]) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"lifecycle_stage": "lead",
"source": "web",
"owner_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"custom_fields": {
"segment": "enterprise"
},
"external_refs": {
"salesforce_id": "SF-001"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"lifecycle_stage": "lead",
"source": "web",
"owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"custom_fields": {
"segment": "enterprise"
},
"external_refs": {
"salesforce_id": "SF-001"
}
}{
"success": true,
"data": {
"contact_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"lifecycle_stage": "lead",
"source": "web",
"owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"custom_fields": {
"segment": "enterprise"
},
"external_refs": {
"salesforce_id": "SF-001"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/crm/contacts/:contact_id🔒 auth
Fetches a single CRM contact by contact_id. Edge cases: an unknown contact_id returns 404; the route takes no tenant_id param and does not re-check tenant ownership beyond the JWT gate, so QA should confirm cross-tenant reads are blocked upstream; a malformed (non-UUID) contact_id fails the Postgres uuid cast and surfaces as an unhandled 500, not a 404.
[ "POST /api/auth/signup-tenant", "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/crm/contacts" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 404 | NotFound | NotFound | no crm contact row exists for the given contact_id |
{
"contact_id": "{{cache:crm.contacts.response.data.contact.contact_id}}"
}{
"success": true,
"data": {
"contact_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}PATCH/api/crm/contacts/:contact_id🔒 auth
Partially updates a CRM contact; the whole request body is passed through as the field patch. Edge cases: an unknown contact_id returns 404; an empty body is accepted and results in a no-op update; field names are not whitelisted at the route, so unknown keys are handled by the service layer; the patch is idempotent - applying the same body twice yields the same row; a malformed (non-UUID) contact_id surfaces as an unhandled 500 from the uuid cast.
[ "POST /api/auth/signup-tenant", "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/crm/contacts" ]
lifecycle_stage: lead, prospect, customer, churned, former| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 404 | NotFound | NotFound | no crm contact row exists for the given contact_id |
{
"entity": "contact",
"field": "lifecycle_stage",
"flow": [
"lead",
"prospect",
"customer",
"churned",
"former"
],
"transitions": [
{
"from": "lead",
"to": "prospect",
"via": "PATCH /api/crm/contacts/:contact_id"
},
{
"from": "prospect",
"to": "customer",
"via": "PATCH /api/crm/contacts/:contact_id"
},
{
"from": "customer",
"to": "churned",
"via": "PATCH /api/crm/contacts/:contact_id"
},
{
"from": "churned",
"to": "former",
"via": "PATCH /api/crm/contacts/:contact_id"
}
]
}{
"contact_id": "{{cache:crm.contacts.response.data.contact.contact_id}}"
}{
"lifecycle_stage": "customer",
"owner_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"custom_fields": {
"tier": "gold"
},
"external_refs": {
"hubspot_id": "HS-002"
}
}{
"lifecycle_stage": "customer",
"owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"custom_fields": {
"tier": "gold"
},
"external_refs": {
"hubspot_id": "HS-002"
}
}{
"success": true,
"data": {
"contact_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"lifecycle_stage": "customer",
"owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"custom_fields": {
"tier": "gold"
},
"external_refs": {
"hubspot_id": "HS-002"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/crm/deals🔒 auth
List a tenant's deals, optionally filtered by stage, newest first. Paginated via limit/offset query params.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
{
"success": true,
"data": [
{
"deal_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"deals": "array"
}
}POST/api/crm/deals🔒 auth
Creates a deal on an encounter. tenant_id, encounter_id and name are required; contact_id, amount, currency, close_probability, custom_fields and external_refs are optional. Edge cases: any of the three required fields missing or empty -> 400; amount and close_probability are not range-checked here (negative or >100 values pass the route); a non-existent encounter_id or contact_id fails the FK inside the insert and surfaces as an unhandled 500; deal names are not unique, so repeat POSTs create duplicates - the endpoint is not idempotent.
[ "POST /api/auth/signup-tenant", "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/crm/contacts", "POST /api/vault/keys", "POST /api/encounters" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | missing fields | body omits tenant_id, encounter_id, or name (details: ["missing fields"]) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}",
"contact_id": "{{cache:crm.contacts.response.data.contact.contact_id}}",
"name": "{{dynamic:name}}",
"amount": 5000,
"currency": "USD",
"close_probability": 50,
"custom_fields": {
"priority": "high"
},
"external_refs": {
"crm_ref": "DEAL-001"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"contact_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"amount": 5000,
"currency": "USD",
"close_probability": 50,
"custom_fields": {
"priority": "high"
},
"external_refs": {
"crm_ref": "DEAL-001"
}
}{
"success": true,
"data": {
"deal_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"contact_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"amount": 5000,
"currency": "USD",
"close_probability": 50,
"custom_fields": {
"priority": "high"
},
"external_refs": {
"crm_ref": "DEAL-001"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/crm/deals/:deal_id🔒 auth
Fetch a single deal with enriched pipeline fields (funnel_stage_id, priority, fit, forecast, stage-aging anchors). Tenant-scoped via tenant_id query param. 404 if not found for the tenant.
[ "POST /api/auth/signup-tenant", "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/crm/contacts", "POST /api/vault/keys", "POST /api/encounters", "POST /api/crm/deals" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
| 404 | NotFound | NotFound | deal_id not found for the tenant |
{
"deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}{
"success": true,
"data": {
"deal_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"deal": {
"deal_id": "string",
"stage": "string"
}
}
}PATCH/api/crm/deals/:deal_id🔒 auth
Update a deal's richer pipeline fields (priority, fit, pain/impact/outcome, decision_date, offer_version, forecast, stakeholders, amount, currency, funnel_stage_id, stage). Changing stage or funnel_stage_id re-stamps the stage-aging anchors (entered_stage_at / last_stage_change_at). tenant_id is required. 404 if not found.
[ "POST /api/auth/signup-tenant", "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/crm/contacts", "POST /api/vault/keys", "POST /api/encounters", "POST /api/crm/deals" ]
priority: low, medium, high, criticalfit: poor, moderate, strong, idealforecast: omitted, pipeline, best_case, commit, closed| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing from body |
| 404 | NotFound | NotFound | deal_id not found for the tenant |
{
"deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"priority": "high",
"fit": "strong",
"pain": "Manual data entry",
"impact": "20h/week saved",
"outcome": "Automated pipeline",
"forecast": "commit",
"close_probability": 70,
"stakeholders": [
"economic-buyer",
"champion"
]
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"priority": "high",
"fit": "strong",
"pain": "Manual data entry",
"impact": "20h/week saved",
"outcome": "Automated pipeline",
"forecast": "commit",
"close_probability": 70,
"stakeholders": [
"economic-buyer",
"champion"
]
}{
"success": true,
"data": {
"deal_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"priority": "high",
"fit": "strong",
"pain": "Manual data entry",
"impact": "20h/week saved",
"outcome": "Automated pipeline",
"forecast": "commit",
"close_probability": 70,
"stakeholders": [
"economic-buyer",
"champion"
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"deal": {
"deal_id": "string",
"priority": "string"
}
}
}GET/api/crm/deals/:deal_id/next-action🔒 auth
Get the deal's current open NEXT action. 404 if none is open. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/crm/deals", "POST /api/crm/deals/:deal_id/next-action" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
| 404 | NotFound | no open NEXT action for this deal | deal has no open next action |
{
"deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}{
"success": true,
"data": {
"next_action_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"next_action": {
"next_action_id": "string"
}
}
}POST/api/crm/deals/:deal_id/next-action🔒 auth
Set (replace) the deal's single open NEXT action: type, owner, due-time and purpose. Any prior open action is cancelled so there is always exactly one open. tenant_id and due_at are required; 404 if the deal does not exist.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/crm/deals" ]
action_type: call, email, meeting, task, linkedin, sms, proposal, other| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and due_at are required | required field missing |
| 404 | NotFound | deal not found | no deal for tenant |
{
"deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"action_type": "call",
"owner_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"due_at": "{{dynamic:futuredatetime}}",
"purpose": "Confirm budget and timeline"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"action_type": "call",
"owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"due_at": "2026-01-15T10:30:00Z",
"purpose": "Confirm budget and timeline"
}{
"success": true,
"data": {
"next_action_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"action_type": "call",
"owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"due_at": "2026-01-15T10:30:00Z",
"purpose": "Confirm budget and timeline",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"next_action": {
"next_action_id": "string",
"status": "string"
}
}
}POST/api/crm/deals/:deal_id/next-action/complete🔒 auth
Complete the deal's open NEXT action with an outcome (status -> completed). 404 if there is no open action. After completing, the deal needs a new NEXT action to pass the save-gate again (unless terminal). tenant_id required.
[ "POST /api/auth/signup-tenant", "POST /api/crm/deals", "POST /api/crm/deals/:deal_id/next-action", "GET /api/crm/deals/:deal_id/next-action" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing |
| 404 | NotFound | no open NEXT action to complete | deal has no open next action |
{
"deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"outcome": "Spoke with buyer; budget confirmed"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"outcome": "Spoke with buyer; budget confirmed"
}{
"success": true,
"data": {
"complete_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"outcome": "Spoke with buyer; budget confirmed",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"next_action": {
"next_action_id": "string",
"status": "string"
}
}
}GET/api/crm/deals/:deal_id/save-gate🔒 auth
The save-gate verdict: a non-terminal deal may save/advance only if it has an open NEXT action; terminal deals (closed-won/lost or an is_terminal funnel stage) are always allowed. Returns {allowed, reason, is_terminal, has_open_next_action}. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/crm/deals", "POST /api/crm/deals/:deal_id/next-action" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
| 404 | NotFound | deal not found | no deal for tenant |
{
"deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}{
"success": true,
"data": {
"save_gate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"gate": {
"allowed": "boolean"
}
}
}GET/api/crm/deals/:deal_id/stage-guard🔒 auth
Evaluate whether a deal may transition to to_stage: validates against the injectable allowed-transition map, runs the entry/exit criteria hook, and enforces terminal gating (a terminal stage closed-won/closed-lost has no permitted exits; a same-stage move is a no-op). Returns {allowed, reason, from_stage, to_stage, is_terminal_exit}. tenant_id and to_stage query params required.
[ "POST /api/auth/signup-tenant", "POST /api/crm/deals" ]
to_stage: qualifying, proposal, negotiation, closed-won, closed-lost| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
| 400 | ValidationError | to_stage query param must be a valid stage | to_stage missing/invalid |
| 404 | NotFound | deal not found | no deal |
{
"deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}{
"success": true,
"data": {
"stage_guard_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"gate": {
"allowed": "boolean",
"from_stage": "string"
}
}
}POST/api/crm/deals/:deal_id/transition🔒 auth
Moves a deal to a new stage through the stage guard, which enforces transition validity, stage entry criteria, and terminal-stage gating; the stage-changed event is emitted only when the move is permitted. Valid stages are qualifying, proposal, negotiation, closed-won and closed-lost. Edge cases: an absent or non-listed stage -> 400; an unknown deal_id -> 404 (from either the null result or a DealNotFoundError); a disallowed move - e.g. out of a terminal closed-won/closed-lost stage, a skipped stage, or unmet stage criteria - is 409 InvalidTransition with the guard's reason in details; re-transitioning a deal to the stage it is already in is evaluated by the same guard rather than short-circuiting.
[ "POST /api/auth/signup-tenant", "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/crm/contacts", "POST /api/vault/keys", "POST /api/encounters", "POST /api/crm/deals" ]
stage: qualifying, proposal, negotiation, closed-won, closed-lost| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | invalid stage | body.stage is missing or not one of qualifying|proposal|negotiation|closed-won|closed-lost |
| 404 | NotFound | NotFound | no deal exists for deal_id (guardedTransition returns null or throws DealNotFoundError) |
| 409 | InvalidTransition | <StageTransitionError message> | the stage guard rejects the move: invalid stage sequence, unmet stage criteria, or the deal is already in a terminal stage |
{
"entity": "deal",
"field": "stage",
"flow": [
"qualifying",
"proposal",
"negotiation",
"closed-won",
"closed-lost"
],
"transitions": [
{
"from": "qualifying",
"to": "proposal",
"via": "POST /api/crm/deals/:deal_id/transition"
},
{
"from": "proposal",
"to": "negotiation",
"via": "POST /api/crm/deals/:deal_id/transition"
},
{
"from": "negotiation",
"to": "closed-won",
"via": "POST /api/crm/deals/:deal_id/transition"
},
{
"from": "negotiation",
"to": "closed-lost",
"via": "POST /api/crm/deals/:deal_id/transition"
}
]
}{
"deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}{
"stage": "proposal"
}{
"stage": "proposal"
}{
"success": true,
"data": {
"transition_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"stage": "proposal",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/crm/funnel-stages🔒 auth
List a tenant's configurable pipeline stages in board order (sort_order).
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
{
"success": true,
"data": [
{
"funnel_stage_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"stages": "array"
}
}POST/api/crm/funnel-stages🔒 auth
Create a per-tenant configurable pipeline stage (name, sort_order, criteria, default win-probability, is_default/is_terminal/is_won). UNIQUE per (tenant, name). tenant_id and name are required.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and name are required | tenant_id or name missing |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"name": "{{dynamic:name}}",
"sort_order": 1,
"description": "Initial qualification",
"criteria": "Budget confirmed",
"probability": 20,
"is_default": false,
"is_terminal": false,
"is_won": false
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"sort_order": 1,
"description": "Initial qualification",
"criteria": "Budget confirmed",
"probability": 20,
"is_default": false,
"is_terminal": false,
"is_won": false
}{
"success": true,
"data": {
"funnel_stage_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"sort_order": 1,
"description": "Initial qualification",
"criteria": "Budget confirmed",
"probability": 20,
"is_default": false,
"is_terminal": false,
"is_won": false,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"stage": {
"stage_id": "string",
"name": "string"
}
}
}POST/api/crm/next-actions/:id/reschedule🔒 auth
Moves an open action's due date and logs the move: from, to, reason, who pushed it and who authorised it. Returns the new due_at, the incremented push_count, the frozen original_due_at and total_slip_minutes from the FIRST commitment - which is what a count alone cannot say. 200 rather than 201 because this is an action on an existing row, not a create. Edge cases: reason is REQUIRED and a blank or whitespace-only reason is refused with 400 RESCHEDULE_REASON_REQUIRED, enforced by a table constraint as well as by the service; moving a date to the value it already has is refused with the same code - that is a no-op somebody logged, not a push; once push_count reaches the tenant's push_threshold a further push needs approved_by and is otherwise refused with 409 PUSH_THRESHOLD_REACHED, because a ceiling that only warns is not a ceiling; with no overdue_policy row for the tenant the threshold is null and pushes are unlimited; an unknown id, or one whose action is completed or cancelled, returns 404 NEXT_ACTION_NOT_FOUND; original_due_at is frozen on the first push by a trigger and never moves again; the row is locked FOR UPDATE, so concurrent pushes serialise and each gets its own seq.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/crm/subjects/:subject_ref/next-action" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | tenant_id and new_due_at are required | body omits tenant_id or new_due_at |
| 400 | RESCHEDULE_REASON_REQUIRED | moving a due date requires a reason | reason is missing or blank, new_due_at is unparseable, or the new date equals the current one |
| 404 | NEXT_ACTION_NOT_FOUND | no open next action | the id does not exist for this tenant, or its action is completed or cancelled |
| 409 | PUSH_THRESHOLD_REACHED | this action has already been pushed at or past the threshold - a further push needs a manager's authorisation | push_count >= the tenant's push_threshold and approved_by is absent or blank |
{
"id": "{{cache:crm.subject-next-action.response.data.next_action.next_action_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"new_due_at": "{{dynamic:futuredatetime}}",
"reason": "Buyer moved the budget review to next week",
"pushed_by": "{{cache:personas.create.response.data.persona.persona_id}}",
"approved_by": "{{cache:personas.create.response.data.persona.persona_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"new_due_at": "2026-01-15T10:30:00Z",
"reason": "Buyer moved the budget review to next week",
"pushed_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"approved_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"reschedule_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"new_due_at": "2026-01-15T10:30:00Z",
"reason": "Buyer moved the budget review to next week",
"pushed_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"approved_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"reschedule": {
"next_action_id": "string",
"due_at": "string",
"push_count": "number"
}
}
}GET/api/crm/next-actions/overdue🔒 auth
Lists open next actions whose due_at has passed, oldest first, each carrying minutes_overdue, push_count, original_due_at and the escalation_level it has reached. Returns the ladder actually applied in `offsets` so a caller renders the tenant's own levels rather than guessing. Edge cases: tenant_id query param is required (400 without it); escalation_level is the HIGHEST offset passed, not the first, so an action a week late is not reported at 'nudge' and buried under actions an hour late; escalation_level is null when the action is overdue but has not yet passed the first configured offset; with no overdue_policy row for the tenant, offsets is [] and every escalation_level is null - the queue still lists everything overdue, it just has no levels to name; subject_kind selects the kind-specific policy and falls back to the tenant-wide one; limit is clamped to 1..1000 (default 100), so an absurd limit degrades instead of erroring; an empty entries[] is a normal answer, not a 404.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/crm/subjects/:subject_ref/next-action" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | tenant_id query param required | tenant_id query param omitted |
{
"success": true,
"data": [
{
"overdue_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"queue": {
"entries": "array",
"offsets": "array",
"as_of": "string"
}
}
}GET/api/crm/pipeline/aging🔒 auth
Reports how long each subject has sat in its current stage and how long it has been silent, counted in BUSINESS days: a deal that goes quiet on Friday is not two days stale on Sunday, and a queue that says otherwise trains people to ignore it every Monday. Edge cases: tenant_id query param is required (400 without it); business time comes from sdk-sla's calendars through a registered hook, and when nothing is wired the response says so with business_days_available=false and leaves business_days_in_stage null rather than silently answering in calendar days - calendar_days_in_stage is always present and is clearly labelled as the calendar figure; last_activity_at is set only by an explicit activity record, never by an edit, because aging is about silence and fixing a phone number is not contact; min_business_days filters on the business figure when one exists and falls back to the calendar figure when it does not; only the OPEN stage entry per subject is reported (exited_at IS NULL); limit is clamped to 1..2000 (default 200); an empty entries[] is a normal answer for a tenant with no stage entries, not a 404.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | tenant_id query param required | tenant_id query param omitted |
{
"success": true,
"data": [
{
"aging_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"aging": {
"entries": "array",
"as_of": "string",
"business_days_available": "boolean"
}
}
}GET/api/crm/pipeline/board🔒 auth
Pipeline kanban board: open deals grouped by stage with per-stage count + total_amount, ordered by recent activity. Tenant-scoped; empty array when the tenant has no open deals.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
{
"success": true,
"data": [
{
"board_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"board": "array"
}
}GET/api/crm/pipeline/stale🔒 auth
Open deals whose current stage has aged past business_days (default 5, weekends excluded) since last_stage_change_at. Business-day aware, not calendar-day. Tenant-scoped.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
{
"success": true,
"data": [
{
"stale_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"deals": "array"
}
}GET/api/crm/subjects/:subject_ref/next-action🔒 auth
Returns the single open NEXT action for a subject, with its five committed elements plus push_count and original_due_at so a caller can see how far the commitment has already slipped. Edge cases: tenant_id query param is required (400 without it); a subject that has never had an action, or whose only action was completed, cancelled or superseded, returns 404 NotFound rather than an empty object - 'no commitment' is a state a caller must handle, not an empty shape it can render; the lookup is scoped to the tenant, so another tenant's action on the same subject_ref is invisible.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/crm/subjects/:subject_ref/next-action" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | tenant_id query param required | tenant_id query param omitted |
| 404 | NotFound | no open next action for this subject | the subject has no action in status 'open' for this tenant |
{
"subject_ref": "{{var:crm_subject_ref}}"
}{
"success": true,
"data": {
"next_action_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"next_action": {
"next_action_id": "string",
"subject_ref": "string",
"status": "string"
}
}
}POST/api/crm/subjects/:subject_ref/next-action🔒 auth
Commits the single open NEXT action for ANY subject - a lead, a contact, a ticket or a deal - addressed by its subject_ref (`<kind>:<id>`, e.g. `lead:abc123`). All five elements of the commitment are required: action_type, owner_persona_id, due_at (an exact instant), purpose and intended_outcome. Edge cases: a missing or blank element returns 400 NEXT_ACTION_INCOMPLETE with EVERY missing element listed individually in details[] (field + message), never just the first one, so a client can render each against its own input; committing again SUPERSEDES the prior open action (it is cancelled, not queued behind), so exactly one action is open per subject at any time; subject_kind is parsed from the ref prefix and cannot be sent separately, so a ref and a kind can never disagree; the legacy deal FK is back-filled only when the ref is `deal:<uuid>` AND that deal exists for the tenant, so naming a deal that is gone still records the action instead of failing on a constraint; due_at is stored as sent, and a past instant is accepted (it simply lands in the overdue queue immediately).
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
action_type: call, email, meeting, task, linkedin, sms, proposal, other| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | tenant_id is required | body omits tenant_id |
| 400 | NEXT_ACTION_INCOMPLETE | this subject cannot be saved until its next action is complete | action_type is not one of the eight types, or owner_persona_id / due_at / purpose / intended_outcome is missing or blank - details[] carries one {field, message} entry per missing element |
{
"subject_ref": "{{var:crm_subject_ref}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"action_type": "call",
"owner_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"due_at": "{{dynamic:futuredatetime}}",
"purpose": "Confirm the budget holder and the decision date",
"intended_outcome": "A named budget holder and a decision date agreed in writing"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"action_type": "call",
"owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"due_at": "2026-01-15T10:30:00Z",
"purpose": "Confirm the budget holder and the decision date",
"intended_outcome": "A named budget holder and a decision date agreed in writing"
}{
"success": true,
"data": {
"next_action_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"action_type": "call",
"owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"due_at": "2026-01-15T10:30:00Z",
"purpose": "Confirm the budget holder and the decision date",
"intended_outcome": "A named budget holder and a decision date agreed in writing",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"next_action": {
"next_action_id": "string",
"subject_ref": "string",
"action_type": "string",
"status": "string"
}
}
}GET/api/crm/subjects/:subject_ref/save-gate🔒 auth
Answers whether a subject may be saved, and if not, exactly what is missing. Returns {allowed, subject_ref, missing[], next_action_id}, where missing[] carries one {field, message} entry per element - never collapsed into a single sentence, because a verdict of 'next action incomplete' forces the user to guess which of five fields is wrong. Edge cases: tenant_id query param is required (400 without it); the verdict is 200 whether allowed is true or false - a refusal is an answer, not an error, so a client can render it inline and there is no 404 for an unknown subject either; a subject with NO open action is refused with the single element `next_action`; a subject whose open action is missing several elements gets one entry per element in one pass; the gate is subject-kind agnostic and answers identically for a lead, a ticket or a deal.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/crm/subjects/:subject_ref/next-action" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | tenant_id query param required | tenant_id query param omitted |
{
"subject_ref": "{{var:crm_subject_ref}}"
}{
"success": true,
"data": {
"save_gate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"gate": {
"allowed": "boolean",
"subject_ref": "string",
"missing": "array"
}
}
}sdk-data-credits
POST/admin/tenants/:tenant_id/credits/grantpublic
Operator grant that funds a tenant's data_credits.credit_account and writes the matching GRANT row to the append-only credit_ledger, in one transaction with the account row held FOR UPDATE. Admin-ops-token gated: sdk-data-credits deliberately exposes NO tenant-facing top-up (only balance, reservations, settle, ledger, budgets), because a tenant able to fund itself is a revenue hole and would mint GRANT entries indistinguishable from ones an operator authorised. Exactly one of credits (additive) or top_up_to (raise the balance TO this figure, adding only the shortfall) must be supplied; naming both, or neither, is a 400. This definition uses top_up_to so it is re-runnable: once the account is at or above the figure the grant moves nothing and writes no ledger entry, so a suite re-run does not walk the balance upward and invalidate assertions that spend from it. Replaces tests/setup_scripts/data_credits_account.sql, a producer-less SQL seed attached via a setupScript mechanism the Test MCP never executes. The optional `reason` body field is FREE TEXT, not an enum: it lands verbatim in credit_ledger.reason, a plain nullable TEXT column with no CHECK and no vocabulary anywhere in 001_init_data_credits.sql, so no fieldEnums can honestly be declared for it. These cases omit it deliberately and exercise the handler's default instead — app.ts passes `b.reason ?? 'operator grant via admin-ops-token'`, which is what a real operator call without a stated reason writes into the export.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | requireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash |
| 400 | ValidationError | tenant_id must be a UUID | the :tenant_id path segment is not a UUID |
| 400 | VALIDATION_ERROR | exactly one of credits or top_up_to is required | the body names both credits and top_up_to, or neither — GrantRefused from grantCredits() |
| 400 | VALIDATION_ERROR | credits must be a positive number | the named figure is zero, negative or not finite — a grant that moves nothing is refused rather than written, since credit_ledger_moves_something would reject the entry anyway |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"top_up_to": 100
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": "boolean",
"data": {
"balance": "number",
"reserved": "number",
"available": "number"
}
}GET/api/capabilities🔒 auth
The catalog in the tenant's own language: an outcome-named key ("validate.phone"), a label, a description and a price in credits. What it deliberately does NOT contain is any trace of who serves the outcome - no provider name, no credentials reference, no routing detail and no true vendor cost - because a tenant that learns which vendor answered starts building on that vendor, and the day it is replaced their integration breaks along with the abstraction. The response is built by naming the fields a tenant may see rather than by removing fields from a row, so a column added to the catalog later cannot appear here by default. A tenant with a negotiated price sees THEIR price: a tenant-scoped catalog row overrides the platform default for the same key, and exactly one row per key is returned. QA edge cases: a tenant with no negotiated prices sees the platform catalog; inactive capabilities are omitted entirely rather than returned with a flag; the list is never empty in a seeded environment because the catalog is reference data, so an empty array means the seed did not run.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"capability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"capabilities": "array"
}
}GET/api/capabilities/estimate🔒 auth
Quotes one capability for this tenant before anything is held: the credit price, the credits currently available (balance minus the part already reserved for in-flight requests) and an explicit affordable flag. The flag is stated rather than left to the caller to compute, because a caller that gets the comparison wrong finds out at the reserve, halfway through a flow. QA edge cases: an unknown or inactive capability_key is 404 CAPABILITY_NOT_FOUND, not an empty quote; a tenant with no credit account at all quotes available=0 and affordable=false rather than erroring, because "you have no account" and "you have no credits" lead to the same next step for the caller; a tenant-specific negotiated price is preferred over the platform price; available NEVER includes credits already held by an unsettled request. NOTE ON capability_key: it resolves from {{var:}} and not from a producer because NO api creates a capability — the catalog is platform reference data seeded by tests/setup_scripts/data_credits_catalog.sql, and its provider bindings are deliberately unreachable from any tenant-scoped route. MUST-46 permits {{var:}} precisely for a value nothing produces.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | capability_key is required | the query omits capability_key |
| 404 | CAPABILITY_NOT_FOUND | no capability 'x' is available to this tenant | the capability_key names nothing active in the platform or tenant catalog |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"estimate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"capability": {
"key": "string",
"credit_price": "number"
},
"credits": "number",
"available": "number",
"affordable": "boolean"
}
}GET/api/capability-requests🔒 auth
Lists this tenant's requests newest first, each with the outcome-named capability key, its status, the settlement outcome once it has one, whether it was served from cache, and the credits reserved and charged. It carries no provider identity for the same reason as every other tenant-scoped read here. QA edge cases: credits_charged is 0 for an unsettled request AND for a settled one that cost nothing (a no-match, a provider failure or a cache hit) - the outcome field is what distinguishes them, not the number; filtering by status accepts only the real request_status values and an unknown one returns an empty list rather than an error; limit is clamped to 500.
[ "POST /api/auth/signup-tenant", "POST /api/capability-requests" ]
status: PENDING_APPROVAL, APPROVED, REJECTED, EXECUTING, COMPLETED, FAILED| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"capability_request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"requests": "array"
}
}POST/api/capability-requests🔒 auth
Creates the request and HOLDS the quoted credits in one transaction. The hold exists because both alternatives are worse: charging up front turns every no-match into a refund somebody has to promise to make later, and charging afterwards lets a thousand concurrent requests run against a balance of five. The subject is a FINGERPRINT, never the raw phone number or email - the broker does not need the raw value to bill, cache or audit, and a table of everything every tenant ever looked up is a breach waiting for an excuse. The role_ref decides the governance: a REQUEST_ONLY role (or any request at or above the bulk-approval threshold, whatever the role) comes back with status PENDING_APPROVAL and cannot execute until it is approved; a FULL role comes back APPROVED. QA edge cases: a refused hold writes NOTHING - no orphan request is left behind, because the whole reserve is one transaction; two concurrent reserves cannot both spend the last credit (the account row is read FOR UPDATE); the same subject may be requested repeatedly and each request gets its own hold; a daily-capped role that has exhausted its window is 403 DAILY_CAP_EXCEEDED, which is NOT the same as 402 INSUFFICIENT_CREDITS - the tenant HAS the credits, this requester may not spend them.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | capability_key is required | the payload omits capability_key or subject_fingerprint |
| 404 | CAPABILITY_NOT_FOUND | no such capability for this tenant | capability_key names nothing in the catalog |
| 402 | INSUFFICIENT_CREDITS | this request needs N credits and M are available | the account balance minus existing holds cannot cover the quoted price |
| 403 | DAILY_CAP_EXCEEDED | this role has spent X of Y credits in the last 24 hours | the requester's role is under a DAILY_CAP policy whose rolling window is full |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"capability_key": "{{var:data_credits_capability_key}}",
"subject_fingerprint": "fp-{{dynamic:slug}}",
"requested_by_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"role_ref": "{{static:analyst}}",
"metadata": {
"source": "api-test"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"capability_key": "{{var:data_credits_capability_key}}",
"subject_fingerprint": "fp-{{dynamic:slug}}",
"requested_by_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"role_ref": "analyst",
"metadata": {
"source": "api-test"
}
}{
"success": true,
"data": {
"capability_request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"capability_key": "{{var:data_credits_capability_key}}",
"subject_fingerprint": "fp-{{dynamic:slug}}",
"requested_by_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"role_ref": "analyst",
"metadata": {
"source": "api-test"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"request_id": "string",
"reservation_id": "string",
"estimated_credits": "number",
"status": "string"
}
}GET/api/capability-requests/:request_id🔒 auth
One request: the capability as the tenant sees it, the settlement outcome, the result, the credits reserved and the credits actually charged. The fallback that produced the result - which provider was tried first, which one answered, how long each took and what they truly cost us - is recorded internally and appears in NONE of this. QA edge cases: a request id belonging to another tenant is 404 rather than 403, because confirming that an id exists elsewhere is itself a leak; result is null until the request has executed; credits_charged is 0 for every settlement except MATCHED.
[ "POST /api/auth/signup-tenant", "POST /api/capability-requests" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | CAPABILITY_REQUEST_NOT_FOUND | no capability request <id> | the id names nothing belonging to this tenant |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"request_id": "{{cache:capability-requests.create.request_id}}"
}{
"success": true,
"data": {
"capability_request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"request_id": "string",
"outcome": "string",
"credits_reserved": "number"
}
}POST/api/capability-requests/:request_id/approve🔒 auth
Records the decision a PENDING_APPROVAL request is waiting for. Approving lets it execute and stamps the sdk-approval reference; approving twice is a NO-OP that returns the original decision time, because an approval webhook that retries is ordinary and failing the retry would leave a decision that WAS made looking like one that was not. Sending approved=false REFUSES the request and gives the held credits back - the endpoint has to be able to do this, because a refusal that only changed a status would leave the hold sitting against the tenant's available balance forever, which is the quiet version of losing their money. A refusal is recorded as a CANCELLATION of the reservation rather than as a settlement: the four settlement outcomes are all statements about a lookup that happened, and a refused request never looked at anything. QA edge cases: a refusal with no reason is 400 - "it was cancelled" with no reason is unanswerable three weeks later; approving a request that is already APPROVED is a no-op, but REJECTING one is 409 NOT_AWAITING_APPROVAL because the hold is already committed to it; rejecting an already-rejected request releases nothing a second time.
[ "POST /api/auth/signup-tenant", "POST /api/capability-requests" ]
approved: true, false| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | a refusal must carry a reason | approved=false is sent without a reason |
| 409 | NOT_AWAITING_APPROVAL | request <id> is <status>, not waiting for an approval decision | the request has already been decided or has already executed |
| 404 | CAPABILITY_REQUEST_NOT_FOUND | no capability request <id> | the id names nothing belonging to this tenant |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"request_id": "{{cache:capability-requests.create.request_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"approved": true,
"approval_ref": "apr-{{dynamic:slug}}",
"decided_by": "{{static:qa-manager}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"approved": true,
"approval_ref": "apr-{{dynamic:slug}}",
"decided_by": "qa-manager"
}{
"success": true,
"data": {
"approve_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"approved": true,
"approval_ref": "apr-{{dynamic:slug}}",
"decided_by": "qa-manager",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"request_id": "string",
"status": "string"
}
}POST/api/capability-requests/:request_id/execute🔒 auth
Runs the request and settles the hold in one call. The result cache is consulted BEFORE any provider, so a repeat question about the same subject inside its TTL is answered free and calls no vendor at all - a cache consulted afterwards would save the credit and still spend the call. Otherwise the provider chain is walked by priority and live health: a provider that fails is stepped over invisibly, a no-match does NOT stop the walk (the next vendor may hold the record the first one lacks), and only a match stops it. The settlement is the point: MATCHED charges exactly the quoted credits, and NO_MATCH, TECHNICAL_FAILURE and CACHE_HIT all settle to ZERO and release the hold, because a tenant pays for answers, not for attempts. The `subject` field is the raw value the provider needs; it is passed through and NOT stored - the request keeps only the fingerprint. QA edge cases: executing a PENDING_APPROVAL request is 409 APPROVAL_REQUIRED and invokes no provider; "everybody looked and nobody has it" (NO_MATCH) is kept distinct from "nobody managed to look" (TECHNICAL_FAILURE), and an unwired provider adapter produces the latter, never the former; executing a second time re-settles identically and charges once, but a second execution that would settle DIFFERENTLY is 409 SETTLEMENT_CONFLICT rather than being merged.
[ "POST /api/auth/signup-tenant", "POST /api/capability-requests" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 409 | APPROVAL_REQUIRED | request <id> needs an approval decision before it can execute | the requester's role is REQUEST_ONLY, or the request is at or above the bulk threshold, and nobody has approved it yet |
| 409 | SETTLEMENT_CONFLICT | request <id> already settled as X for N credits | a second execution would settle the same reservation differently |
| 404 | CAPABILITY_REQUEST_NOT_FOUND | no capability request <id> | the id names nothing belonging to this tenant |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"request_id": "{{cache:capability-requests.create.request_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"subject": "+15551234567"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject": "+15551234567"
}{
"success": true,
"data": {
"execute_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject": "+15551234567",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"request_id": "string",
"outcome": "string",
"credits_charged": "number",
"served_from_cache": "boolean"
}
}GET/api/credits/balance🔒 auth
Three numbers: the balance, the part of it reserved for in-flight requests, and what is actually available (balance minus reserved). Available is a subtraction and is deliberately NOT stored anywhere - a third column would be a second source of truth for a number that is already implied, and the day the two disagree nobody can say which is right. QA edge cases: a tenant with no credit account is 404 CREDIT_ACCOUNT_NOT_FOUND rather than a zero balance, because "no account" and "no credits" are different situations with different fixes; reserved never exceeds balance (the database refuses it), so available is never negative; a hold released by a zero settlement restores available immediately, without touching balance.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | CREDIT_ACCOUNT_NOT_FOUND | this tenant has no credit account | no credit account has been provisioned for the tenant |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"balance_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"balance": "number",
"reserved": "number",
"available": "number"
}
}GET/api/credits/budgets🔒 auth
Lists every role policy this tenant has set, in role order. QA edge cases: a role that has NO policy does not appear here, and its requests still require approval - the absence IS the policy, so an empty list does not mean "everybody may spend freely"; daily_cap and bulk_approval_threshold are null when the mode does not use them, rather than zero, because a cap of zero would mean nobody may spend anything and that is a different statement.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"budget_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"policies": "array"
}
}PUT/api/credits/budgets🔒 auth
Sets one role's budget policy, replacing whatever that role had. REQUEST_ONLY means every request waits for an approval decision; DAILY_CAP allows spending up to a limit over a ROLLING 24 hours (rolling, not calendar - a calendar day resets at a moment somebody has to pick a timezone for, and a tenant in the wrong one gets two days of spend inside one working day); FULL spends freely. bulk_approval_threshold outranks all three: a single request at or above it needs approval regardless of role, because one enormous request is a different decision from the thousand small ones the role was trusted with. Spend against the cap is derived from the append-only ledger rather than from a counter, so it can always be re-derived and never quietly drifts; holds do not count, only charges - otherwise an in-flight request would eat into a cap that a no-match is about to hand back. QA edge cases: DAILY_CAP with no daily_cap is 422 - a policy that reads as a limit and enforces nothing is worse than no policy at all, because the dashboard says "capped" and the spend says otherwise; a negative cap or threshold is 422; the same role sent twice is one policy, not two (the upsert is keyed on tenant + role); a role with NO policy at all is not refused and not waved through - its requests come back PENDING_APPROVAL, because absent policy could mean "not configured yet" or "no restriction" and the two are indistinguishable from here.
[ "POST /api/auth/signup-tenant" ]
mode: REQUEST_ONLY, DAILY_CAP, FULLis_active: true, false| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 422 | VALIDATION_ERROR | a DAILY_CAP policy must carry a daily_cap | mode is DAILY_CAP and daily_cap is absent |
| 400 | VALIDATION_ERROR | mode must be one of REQUEST_ONLY, DAILY_CAP, FULL | the payload names a mode that does not exist, or omits role_ref |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"role_ref": "analyst-{{dynamic:slug}}",
"mode": "DAILY_CAP",
"daily_cap": 250,
"bulk_approval_threshold": 50,
"is_active": true
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"role_ref": "analyst-{{dynamic:slug}}",
"mode": "DAILY_CAP",
"daily_cap": 250,
"bulk_approval_threshold": 50,
"is_active": true
}{
"success": true,
"data": {
"budget_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"role_ref": "analyst-{{dynamic:slug}}",
"mode": "DAILY_CAP",
"daily_cap": 250,
"bulk_approval_threshold": 50,
"is_active": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"policy_id": "string",
"mode": "string",
"daily_cap": "number"
}
}GET/api/credits/ledger🔒 auth
Every credit movement in order, with the balance and reserved deltas kept SEPARATE and the account as it stood after each entry. Separate deltas are what let the export show "quoted 5, charged 0, released 5" instead of a single net number that hides the quote - which is exactly the question a disputed invoice asks. Entry types: GRANT (credits added), RESERVATION (a hold, moving reserved only), CHARGE (a match, moving both), RELEASE (a hold handed back, naming why nothing was charged), REFUND and ADJUSTMENT. The table is append-only in the database: corrections are new ADJUSTMENT entries, never edits, so an export can be trusted to be what happened. QA edge cases: entries survive the request they describe being purged, because a financial record must outlive the operational row (request_id stays as a fact, not as a live join); filtering by request_id gives the whole story of one request; entry_no is a total order, so two entries in the same millisecond still export in the order they happened; the true vendor cost appears nowhere in this export.
[ "POST /api/auth/signup-tenant", "POST /api/capability-requests" ]
entry_type: GRANT, RESERVATION, CHARGE, REFUND, RELEASE, ADJUSTMENT| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"ledger_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"entries": "array"
}
}POST/api/credits/reservations🔒 auth
The MANUAL lane. Holds the quoted credits for a capability that the caller will execute and settle itself, and returns the balance as it stands after the hold. It is deliberately distinct from POST /api/capability-requests + /execute, which is the BROKERED lane where the broker picks the provider, walks the fallback chain and settles for you; here the caller performs the lookup. Both lanes hold credits the same way and settle under the same rules - what differs is who does the looking. QA edge cases: the same governance applies, so a REQUEST_ONLY role gets a PENDING_APPROVAL reservation it cannot settle until it is approved; a hold that cannot be covered is 402 and writes nothing; the returned balance already reflects the hold, so available has gone down while balance has not moved.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | capability_key is required | the payload omits capability_key or subject_fingerprint |
| 402 | INSUFFICIENT_CREDITS | this request needs N credits and M are available | balance minus existing holds cannot cover the quote |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"capability_key": "{{var:data_credits_capability_key}}",
"subject_fingerprint": "fp-{{dynamic:slug}}",
"role_ref": "{{static:analyst}}",
"requested_by_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"metadata": {
"lane": "manual"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"capability_key": "{{var:data_credits_capability_key}}",
"subject_fingerprint": "fp-{{dynamic:slug}}",
"role_ref": "analyst",
"requested_by_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"metadata": {
"lane": "manual"
}
}{
"success": true,
"data": {
"reservation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"capability_key": "{{var:data_credits_capability_key}}",
"subject_fingerprint": "fp-{{dynamic:slug}}",
"role_ref": "analyst",
"requested_by_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"metadata": {
"lane": "manual"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"reservation_id": "string",
"estimated_credits": "number",
"balance": {
"available": "number"
}
}
}POST/api/credits/reservations/:reservation_id/settle🔒 auth
Closes a hold with one of the four settlement outcomes. MATCHED charges exactly the quoted credits; NO_MATCH, TECHNICAL_FAILURE and CACHE_HIT settle to ZERO and hand the hold straight back, because a tenant pays for answers and not for attempts. The rule is enforced by the database as well as by this handler - these are promises about somebody's money, and a promise kept only by the current version of one function is not kept. Settling is IDEMPOTENT on an identical retry: the same outcome and the same credits return the same result, charge once, and keep the FIRST settlement time, which is what an at-least-once caller needs. QA edge cases: a retry asserting a DIFFERENT outcome is 409 SETTLEMENT_CONFLICT rather than being merged, because merging means the last retry to arrive decides what the tenant paid; an outcome outside the four values is 400 with the valid set named; a reservation belonging to another tenant is 404; the ledger gains a CHARGE entry for a match and a RELEASE entry naming WHY nothing was charged for the other three, so an export can tell them apart.
[ "POST /api/auth/signup-tenant", "POST /api/credits/reservations" ]
outcome: MATCHED, NO_MATCH, TECHNICAL_FAILURE, CACHE_HIT| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | outcome must be one of MATCHED, NO_MATCH, TECHNICAL_FAILURE, CACHE_HIT | the payload names an outcome that is not a settlement case |
| 404 | RESERVATION_NOT_FOUND | no reservation <id> | the id names nothing belonging to this tenant |
| 409 | SETTLEMENT_CONFLICT | request <id> already settled as X for N credits | a retry asserts a different outcome or a different charge |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"reservation_id": "{{cache:credits-reservations.create.reservation_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"outcome": "NO_MATCH",
"result": null
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"outcome": "NO_MATCH",
"result": null
}{
"success": true,
"data": {
"settle_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"outcome": "NO_MATCH",
"result": null,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"outcome": "string",
"credits_charged": "number",
"credits_reserved": "number"
}
}sdk-data-rights
POST/api/data-rights/executions/:execution_id/result🔒 auth
Records the terminal outcome of a single DSAR execution: status is required and is expected to be 'succeeded' or 'failed', with an optional audit_entry_id sealing the proof and an optional error_detail on failure. Edge cases: a missing status -> 400 (note the value itself is not enum-checked at the route, so any non-empty string is accepted); an unknown execution_id -> 404; posting a result for an execution that already has one overwrites the previous outcome rather than 409-ing, so retries are idempotent for a given body.
[ "POST /api/auth/signup-tenant", "POST /api/data-rights/residency/touch", "POST /api/data-rights/requests", "POST /api/data-rights/requests/:request_id/plan-executions" ]
status: succeeded, failed| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | missing status | body omits status (details: ["missing status"]) |
| 404 | NotFound | NotFound | recordExecutionResult finds no execution row for execution_id |
{
"entity": "data_rights.execution",
"field": "status",
"flow": [
"pending",
"running",
"succeeded",
"failed"
],
"transitions": [
{
"from": "pending",
"to": "succeeded",
"via": "POST /api/data-rights/executions/:execution_id/result"
},
{
"from": "pending",
"to": "failed",
"via": "POST /api/data-rights/executions/:execution_id/result"
},
{
"from": "running",
"to": "succeeded",
"via": "POST /api/data-rights/executions/:execution_id/result"
},
{
"from": "running",
"to": "failed",
"via": "POST /api/data-rights/executions/:execution_id/result"
}
]
}{
"execution_id": "{{cache:data-rights-executions.plan.response.data.executions.0.execution_id}}"
}{
"status": "succeeded",
"audit_entry_id": "{{var:audit_entry_id}}",
"error_detail": null
}{
"status": "succeeded",
"audit_entry_id": "{{var:audit_entry_id}}",
"error_detail": null
}{
"success": true,
"data": {
"result_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "succeeded",
"audit_entry_id": "{{var:audit_entry_id}}",
"error_detail": null,
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/data-rights/reconciliation/run🔒 auth
Records a reconciliation run comparing expected vs actual data classes per (person, pool) and flips the traffic light that gates DSAR certificate issuance. Edge cases: the body is entirely optional - an omitted discrepancies array defaults to [], which records a clean (green) run and unblocks certificate issuance, so QA must treat an empty-body call as semantically meaningful rather than a no-op; discrepancy entries are not shape-validated at the route; the endpoint always answers 200 (not 201) and every call appends a new run, making the most recent run authoritative.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
{
"discrepancies": []
}{
"discrepancies": []
}{
"success": true,
"data": {
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"discrepancies": [],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/data-rights/requests🔒 auth
Submits a DSAR (Data Subject Access Request) and creates a new data_rights.request row in submitted status. Requires person_id and kind; optional tenant_id, jurisdiction, and approval_policy. kind must be one of the six DSAR kinds (access, erasure, rectification, restriction, objection, portability) — any other value is rejected 400. Returns 201 with the created request; edge cases: missing person_id, missing kind, invalid/unknown kind.
[ "POST /api/auth/signup-tenant", "POST /scim/v2/Users" ]
kind: access, erasure, rectification, restriction, objection, portabilityjurisdiction: GDPR, DPDP, CCPA, LGPDapproval_policy: auto, manager-approval, cross-tenant-approval| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | missing fields | person_id or kind missing from body |
| 400 | ValidationError | invalid kind | kind not in the six DSAR kinds |
{
"entity": "data_rights.request",
"field": "status",
"flow": [
"submitted",
"identity-verified",
"approval-pending",
"grace-period",
"executing",
"certificate-issued",
"audited"
],
"transitions": [
{
"from": null,
"to": "submitted",
"via": "POST /api/data-rights/requests"
}
]
}{
"person_id": "{{cache:scim.create.response.data.person_id}}",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"kind": "erasure",
"jurisdiction": "GDPR",
"approval_policy": "manager-approval"
}{
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "erasure",
"jurisdiction": "GDPR",
"approval_policy": "manager-approval"
}{
"success": true,
"data": {
"request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "erasure",
"jurisdiction": "GDPR",
"approval_policy": "manager-approval",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/data-rights/requests/:request_id🔒 auth
Fetches one DSAR request with its current lifecycle status, kind, jurisdiction and SLA deadline. Edge cases: an unknown request_id returns 404; the route takes no tenant param, so tenant isolation rests on the JWT gate and QA should verify a foreign request_id is not readable; a malformed (non-UUID) request_id fails the uuid cast and surfaces as an unhandled 500 rather than a 404.
[ "POST /api/auth/signup-tenant", "POST /api/data-rights/requests" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 404 | NotFound | NotFound | no data_rights DSAR request row exists for the given request_id |
{
"request_id": "{{cache:data-rights-requests.create.response.data.request.request_id}}"
}{
"success": true,
"data": {
"request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/data-rights/requests/:request_id/certificate🔒 auth
Issues the completion certificate for a DSAR request, sealing it with an audit entry whose entry_id becomes signed_by_audit_entry_id (a caller-supplied signed_by_audit_entry_id wins so an externally-orchestrated flow can pre-seal). Guarded by the reconciliation traffic light: if the latest reconciliation run is red the request is refused 409 before anything is written. Edge cases: the whole body is optional - shred_proofs defaults to {} and an empty proof set is accepted; an unknown request_id is NOT rejected here, the certificate is still inserted with the request_id as its subject fallback; the endpoint is not idempotent - each call inserts another certificate row for the same request.
[ "POST /api/auth/signup-tenant", "POST /api/data-rights/requests", "POST /api/data-rights/reconciliation/run" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 409 | ReconciliationRed | Reconciliation red — DSAR completion blocked | isReconciliationGreen() is false, i.e. the most recent reconciliation run recorded outstanding discrepancies |
{
"request_id": "{{cache:data-rights-requests.create.response.data.request.request_id}}"
}{
"shred_proofs": {
"admin-us-east-1": "audit-hash-base64"
},
"artifact_s3_key": "s3://dsar-certs/erasure-cert.pdf",
"signed_by_audit_entry_id": "{{var:signed_by_audit_entry_id}}"
}{
"shred_proofs": {
"admin-us-east-1": "audit-hash-base64"
},
"artifact_s3_key": "s3://dsar-certs/erasure-cert.pdf",
"signed_by_audit_entry_id": "{{var:signed_by_audit_entry_id}}"
}{
"success": true,
"data": {
"certificate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"shred_proofs": {
"admin-us-east-1": "audit-hash-base64"
},
"artifact_s3_key": "s3://dsar-certs/erasure-cert.pdf",
"signed_by_audit_entry_id": "{{var:signed_by_audit_entry_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/data-rights/requests/:request_id/plan-executions🔒 auth
Fans a DSAR request out into one pending execution row per pool the subject has residency in, choosing the action from the request kind: erasure -> shred-person-key, access/portability -> export, everything else -> rectify. Edge cases: an unknown request_id is NOT a 404 - planExecutions returns [] and the route still answers 201 with an empty executions array; likewise a subject with no residency rows yields an empty plan, which is the signal to touch residency first; the endpoint is not idempotent - calling it twice inserts a second full set of execution rows for the same request.
[ "POST /api/auth/signup-tenant", "POST /api/data-rights/residency/touch", "POST /api/data-rights/requests" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
{
"request_id": "{{cache:data-rights-requests.create.response.data.request.request_id}}"
}{}{
"success": true,
"data": {
"plan_execution_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/data-rights/requests/:request_id/transition🔒 auth
Advances a DSAR request to a new lifecycle state. body.to must be one of submitted, identity-verified, approval-pending, grace-period, executing, certificate-issued, audited, rejected; approval_ref and grace_until are optional companions (grace_until is parsed as a Date). Edge cases: a missing or non-enum target state -> 400; an unknown request_id -> 404; a transition the state machine forbids from the request's current status -> 409 InvalidTransition carrying 'Invalid transition <current> -> <to>'; an unparseable grace_until becomes an Invalid Date rather than a 400; re-sending the same transition after it has been applied is itself an invalid transition and returns 409, so the endpoint is not idempotent.
[ "POST /api/auth/signup-tenant", "POST /api/data-rights/requests" ]
to: submitted, identity-verified, approval-pending, grace-period, executing, certificate-issued, audited, rejected| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | invalid target state | body.to is missing or not one of the eight DSAR_STATES values |
| 404 | NotFound | NotFound | transitionRequest finds no request row for request_id |
| 409 | InvalidTransition | Invalid transition <current status> → <to> | the DSAR state machine does not permit moving from the request's current status to the requested one |
{
"entity": "data_rights.request",
"field": "status",
"flow": [
"submitted",
"identity-verified",
"approval-pending",
"grace-period",
"executing",
"certificate-issued",
"audited"
],
"transitions": [
{
"from": "submitted",
"to": "identity-verified",
"via": "POST /api/data-rights/requests/:request_id/transition"
},
{
"from": "identity-verified",
"to": "approval-pending",
"via": "POST /api/data-rights/requests/:request_id/transition"
},
{
"from": "approval-pending",
"to": "grace-period",
"via": "POST /api/data-rights/requests/:request_id/transition"
},
{
"from": "grace-period",
"to": "executing",
"via": "POST /api/data-rights/requests/:request_id/transition"
},
{
"from": "executing",
"to": "certificate-issued",
"via": "POST /api/data-rights/requests/:request_id/transition"
},
{
"from": "certificate-issued",
"to": "audited",
"via": "POST /api/data-rights/requests/:request_id/transition"
},
{
"from": "submitted",
"to": "rejected",
"via": "POST /api/data-rights/requests/:request_id/transition"
},
{
"from": "identity-verified",
"to": "rejected",
"via": "POST /api/data-rights/requests/:request_id/transition"
},
{
"from": "approval-pending",
"to": "rejected",
"via": "POST /api/data-rights/requests/:request_id/transition"
}
]
}{
"request_id": "{{cache:data-rights-requests.create.response.data.request.request_id}}"
}{
"to": "identity-verified",
"approval_ref": "{{var:approval_ref}}",
"grace_until": "{{dynamic:futuredatetime}}"
}{
"to": "identity-verified",
"approval_ref": "{{var:approval_ref}}",
"grace_until": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"transition_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"to": "identity-verified",
"approval_ref": "{{var:approval_ref}}",
"grace_until": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/data-rights/residency/:person_id🔒 auth
Lists all person_pool_residency rows for the given person_id path param. No body validation and no not-found branch — an unknown person_id returns 200 with an empty residency array (not 404). The only error surface is authentication.
[ "POST /api/auth/signup-tenant", "POST /api/data-rights/residency/touch", "POST /scim/v2/Users" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
{
"person_id": "{{cache:scim.create.response.data.person_id}}"
}{
"success": true,
"data": {
"residency_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/data-rights/residency/touch🔒 auth
Upserts (touches) a person_pool_residency record for a person+pool, returning 200 with the record. Requires person_id, pool_index and tenant_id; data_classes is optional and defaults to an empty array. Edge cases: each of the three required fields missing.
[ "POST /api/auth/signup-tenant", "POST /scim/v2/Users" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | missing fields | person_id, pool_index or tenant_id missing from body |
{
"person_id": "{{cache:scim.create.response.data.person_id}}",
"pool_index": "admin-us-east-1",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"data_classes": [
"profile",
"persona"
]
}{
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"pool_index": "admin-us-east-1",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"data_classes": [
"profile",
"persona"
]
}{
"success": true,
"data": {
"touch_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"pool_index": "admin-us-east-1",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"data_classes": [
"profile",
"persona"
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-deliverability
GET/api/deliverability/bounce-events🔒 auth
List a tenant's processed provider bounce/complaint events (newest first), optionally filtered by classification. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/deliverability/webhooks/:provider" ]
classification: hard_bounce, soft_bounce, complaint, delivered, other| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"success": true,
"data": [
{
"bounce_event_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"events": "array"
}
}POST/api/deliverability/check🔒 auth
Pre-send enforcement: check whether a recipient (single address or batch addresses[]) is suppressed for the tenant or globally, on the given channel, before delivering. Returns {suppressed} for a single address or {results:[{address,suppressed}]} for a batch. Global suppression and channel="all" both match.
[ "POST /api/auth/signup-tenant" ]
channel: email, sms, all| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and channel are required | tenant_id or channel missing |
| 400 | ValidationError | address or addresses[] is required | neither address nor addresses provided |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "email",
"address": "{{dynamic:email}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"address": "qa.user@example.com"
}{
"success": true,
"data": {
"check_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"address": "qa.user@example.com",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"suppressed": "boolean"
}
}POST/api/deliverability/check/bulk🔒 auth
Pre-send suppression enforcement for up to 1000 recipients in ONE request and ONE query. Distinct from the addresses[] form of POST /api/deliverability/check, which is kept for compatibility but holds tenant and channel fixed across the list and issues a query per address: here every item carries its own channel, so a mixed email+sms audience is a single call. tenant_id may be omitted per item and falls back to the caller's credential. Addresses are never sent to the database in plaintext - each is normalized (email lowercased/trimmed, sms reduced to +digits) and sha256-hashed with the channel as salt, and the query joins on hashes. A row is suppressed when a tenant-scoped or global entry matches the address for that channel (or channel 'all') and has not expired. Results are order-preserving with an explicit index and echo back the address and channel asked about; a malformed item reports ok=false in its own slot rather than failing the batch. The same address may appear more than once and each occurrence keeps its slot. Read-only, side-effect-free and safely repeatable.
[ "POST /api/auth/signup-tenant" ]
channel: email, sms, allerror_code: VALIDATION_ERROR| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | body must be an object with an items[] array | Request body is absent, not a JSON object, or is itself an array |
| 400 | ValidationError | items must not be empty | items is an empty array |
| 400 | ValidationError | items exceeds the per-request maximum of 1000; page the batch | More than 1000 items are supplied |
{
"items": [
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "email",
"address": "{{dynamic:email}}"
},
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "sms",
"address": "+15551234567"
},
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "carrier-pigeon",
"address": "someone@example.com"
}
]
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"results": "array",
"summary": {
"requested": "number",
"succeeded": "number",
"failed": "number"
}
}
}GET/api/deliverability/mailboxes🔒 auth
List a tenant's IMAP mailboxes, newest first. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/deliverability/mailboxes" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"success": true,
"data": [
{
"mailbox_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"mailboxes": "array"
}
}POST/api/deliverability/mailboxes🔒 auth
Register a tenant mailbox for inbound reply sync (upsert per tenant+username+folder). The reply worker polls it incrementally over IMAP by UID. tenant_id, imap_host and username are required; the IMAP secret is a vault ref (never raw).
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, imap_host and username are required | required field missing |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"imap_host": "imap.example.com",
"imap_port": 993,
"username": "replies-{{dynamic:uuid}}@example.com",
"secret_ref": "vault://imap/{{dynamic:uuid}}",
"folder": "INBOX",
"use_tls": true
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"imap_host": "imap.example.com",
"imap_port": 993,
"username": "replies-{{dynamic:uuid}}@example.com",
"secret_ref": "vault://imap/{{dynamic:uuid}}",
"folder": "INBOX",
"use_tls": true
}{
"success": true,
"data": {
"mailbox_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"imap_host": "imap.example.com",
"imap_port": 993,
"username": "replies-{{dynamic:uuid}}@example.com",
"secret_ref": "vault://imap/{{dynamic:uuid}}",
"folder": "INBOX",
"use_tls": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"mailbox": {
"mailbox_id": "string"
}
}
}POST/api/deliverability/mailboxes/:mailbox_id/replies🔒 auth
Ingest a single inbound reply (what the IMAP worker calls per fetched message; also the direct-capture surface). Classifies human vs auto_reply/OOO (RFC 3834 heuristics), records the reply_event (idempotent per mailbox+message_id), and fires pause-on-reply for a human reply. tenant_id and message_id required.
[ "POST /api/auth/signup-tenant", "POST /api/deliverability/mailboxes" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and message_id are required | required field missing |
{
"mailbox_id": "{{cache:deliverability.mailbox.create.response.data.mailbox.mailbox_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"message_id": "reply-{{dynamic:uuid}}@example.com",
"from_address": "{{dynamic:email}}",
"subject": "Re: your proposal",
"snippet": "Thanks, looks good — lets talk.",
"in_reply_to": "sent-{{dynamic:uuid}}@projexcloud",
"references": "sent-abc@projexcloud",
"headers": {}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"message_id": "reply-{{dynamic:uuid}}@example.com",
"from_address": "qa.user@example.com",
"subject": "Re: your proposal",
"snippet": "Thanks, looks good — lets talk.",
"in_reply_to": "sent-{{dynamic:uuid}}@projexcloud",
"references": "sent-abc@projexcloud",
"headers": {}
}{
"success": true,
"data": {
"reply_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"message_id": "reply-{{dynamic:uuid}}@example.com",
"from_address": "qa.user@example.com",
"subject": "Re: your proposal",
"snippet": "Thanks, looks good — lets talk.",
"in_reply_to": "sent-{{dynamic:uuid}}@projexcloud",
"references": "sent-abc@projexcloud",
"headers": {},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"reply": {
"reply_event_id": "string",
"classification": "string"
}
}
}POST/api/deliverability/mailboxes/:mailbox_id/sync🔒 auth
Poll a mailbox once over IMAP: fetch messages after last_uid via the pluggable fetcher, capture each as a reply_event, and advance the UID cursor. Idempotent (already-captured messages skipped). Returns {fetched, captured, paused}. With no live IMAP client wired it returns zero. tenant_id required.
[ "POST /api/auth/signup-tenant", "POST /api/deliverability/mailboxes" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing |
| 404 | NotFound | mailbox not found | no mailbox for tenant |
{
"mailbox_id": "{{cache:deliverability.mailbox.create.response.data.mailbox.mailbox_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"status": "completed",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"sync_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"fetched": "number",
"captured": "number",
"paused": "number"
}
}POST/api/deliverability/optout-tokens🔒 auth
Mint a single-purpose opt-out (unsubscribe) token for an address. The raw token is returned ONCE (only its hash is persisted) — embed it in the unsubscribe link. tenant_id, channel and address required.
[ "POST /api/auth/signup-tenant" ]
channel: email, sms, allpurpose: unsubscribe, resubscribe, preferencesscope: tenant, global| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, channel and address are required | required field missing |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "email",
"address": "{{dynamic:email}}",
"purpose": "unsubscribe",
"scope": "tenant",
"ttl_seconds": 604800
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"address": "qa.user@example.com",
"purpose": "unsubscribe",
"scope": "tenant",
"ttl_seconds": 604800
}{
"success": true,
"data": {
"optout_token_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"address": "qa.user@example.com",
"purpose": "unsubscribe",
"scope": "tenant",
"ttl_seconds": 604800,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"token_id": "string",
"token": "string"
}
}POST/api/deliverability/optout/redeem🔒 auth
Redeem an opt-out token (one-time): verify by hash, mark it used, add the suppression, and write an opt-out audit event. Returns 410 if the token is unknown, already used, or expired. token required.
[ "POST /api/auth/signup-tenant", "POST /api/deliverability/optout-tokens" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | token is required | token missing |
| 410 | Gone | token is unknown, already used, or expired | token invalid/used/expired |
{
"token": "{{cache:deliverability.optout-token.create.response.data.token}}",
"feedback": "Too many emails"
}{
"token": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"feedback": "Too many emails"
}{
"success": true,
"data": {
"redeem_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"token": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"feedback": "Too many emails",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"redeemed": "boolean"
}
}GET/api/deliverability/reply-events🔒 auth
List a tenant's captured inbound reply events (newest first), optionally filtered by classification. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/deliverability/mailboxes/:mailbox_id/replies" ]
classification: human, auto_reply, ooo, bounce, other| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"success": true,
"data": [
{
"reply_event_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"events": "array"
}
}GET/api/deliverability/reputation🔒 auth
Expose a tenant's send reputation signals to callers: all channels, or a single channel via ?channel=. Includes sent/bounce/complaint counts, derived rates and the sending status (good/watch/paused). tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/deliverability/reputation/record" ]
channel: email, smsstatus: good, watch, paused| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"success": true,
"data": [
{
"reputation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"reputation": {
"status": "string"
}
}
}POST/api/deliverability/reputation/record🔒 auth
Increment per-(tenant,channel) send/delivery/bounce/complaint counters and recompute the reputation status. Auto-pauses the channel (status=paused) when the bounce rate >= 5% or complaint rate >= 0.1% over a minimum volume; a paused channel never auto-un-pauses. Returns the fresh reputation row. tenant_id required.
[ "POST /api/auth/signup-tenant" ]
channel: email, sms| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing |
| 400 | ValidationError | channel must be email or sms | invalid channel |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "email",
"sent": 100,
"delivered": 96,
"bounced": 2,
"complained": 0
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"sent": 100,
"delivered": 96,
"bounced": 2,
"complained": 0
}{
"success": true,
"data": {
"record_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"sent": 100,
"delivered": 96,
"bounced": 2,
"complained": 0,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"reputation": {
"status": "string",
"bounce_rate": "string"
}
}
}POST/api/deliverability/reputation/resume🔒 auth
Manually resume a channel that auto-paused for reputation (human override): clears the pause, resets the counter window, and sets status back to good. Returns the reset reputation row; 404 if no reputation row exists for the tenant/channel. tenant_id required.
[ "POST /api/auth/signup-tenant", "POST /api/deliverability/reputation/record" ]
channel: email, sms| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing |
| 404 | NotFound | no reputation row for that tenant/channel | nothing to resume |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "email"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email"
}{
"success": true,
"data": {
"resume_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"reputation": {
"status": "string"
}
}
}GET/api/deliverability/suppressions🔒 auth
List a tenant's suppressions (plus global rows), newest first, optionally filtered by channel. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/deliverability/suppressions" ]
channel: email, sms, all| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"success": true,
"data": [
{
"suppression_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"suppressions": "array"
}
}POST/api/deliverability/suppressions🔒 auth
Add (or refresh) a suppression for an address on a channel. Idempotent per (scope-bucket, channel, address) — a repeat upserts the reason/detail. Addresses are sha256-hashed server-side (never stored raw). tenant_id, channel and address required.
[ "POST /api/auth/signup-tenant" ]
channel: email, sms, allreason: manual, optout, hard_bounce, soft_bounce, complaint, dnc, list_unsubscribescope: tenant, global| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, channel and address are required | required field missing |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "email",
"address": "{{dynamic:email}}",
"reason": "manual",
"reason_detail": "Requested by customer",
"source": "support",
"scope": "tenant"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"address": "qa.user@example.com",
"reason": "manual",
"reason_detail": "Requested by customer",
"source": "support",
"scope": "tenant"
}{
"success": true,
"data": {
"suppression_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"address": "qa.user@example.com",
"reason": "manual",
"reason_detail": "Requested by customer",
"source": "support",
"scope": "tenant",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"suppression": {
"suppression_id": "string"
}
}
}POST/api/deliverability/suppressions/remove🔒 auth
Remove (un-suppress) an address for a channel and scope. Idempotent — removing a non-existent suppression still returns 200. tenant_id, channel and address required.
[ "POST /api/auth/signup-tenant" ]
channel: email, sms, allscope: tenant, global| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, channel and address are required | required field missing |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "email",
"address": "{{dynamic:email}}",
"scope": "tenant"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"address": "qa.user@example.com",
"scope": "tenant"
}{
"success": true,
"data": {
"remove_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"address": "qa.user@example.com",
"scope": "tenant",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"removed": "boolean"
}
}POST/api/deliverability/webhook-secrets🔒 auth
Register (or rotate) a tenant's HMAC signing secret for a provider's bounce/complaint webhook. Once set, inbound webhooks for that (tenant, provider) are signature-enforced. tenant_id, provider and signing_secret are required.
[ "POST /api/auth/signup-tenant" ]
provider: ses, sendgrid, mailgun, postmark, twilioalgo: sha1, sha256| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, provider and signing_secret are required | required field missing |
| 400 | ValidationError | provider must be ses, sendgrid, mailgun, postmark or twilio | invalid provider |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"provider": "sendgrid",
"signing_secret": "{{dynamic:uuid}}",
"algo": "sha256"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider": "sendgrid",
"signing_secret": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"algo": "sha256"
}{
"success": true,
"data": {
"webhook_secret_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider": "sendgrid",
"signing_secret": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"algo": "sha256",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"secret_id": "string",
"provider": "string"
}
}POST/api/deliverability/webhooks/:providerpublic
PUBLIC provider webhook receiver (on the gateway allowlist) — HMAC-verified when a signing secret is configured for the (tenant, provider), else accepted (dev/unconfigured). Classifies each event as hard_bounce/soft_bounce/complaint (SES/SendGrid/Mailgun/Postmark shapes + a normalized envelope) and AUTO-SUPPRESSES the recipient on hard bounce or complaint. tenant_id is carried on the per-tenant webhook URL via ?tenant_id=. Returns 401 if a configured signature fails to verify.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
| 400 | ValidationError | unknown provider | provider not in the supported set |
| 401 | InvalidSignature | webhook HMAC signature verification failed | a signing secret is configured for (tenant, provider) and the signature does not match |
{
"provider": "{{static:postmark}}"
}{
"event_type": "hard_bounce",
"address": "{{dynamic:email}}",
"message_id": "msg-{{dynamic:uuid}}"
}{
"event_type": "hard_bounce",
"address": "qa.user@example.com",
"message_id": "msg-{{dynamic:uuid}}"
}{
"success": true,
"data": {
"webhook_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"event_type": "hard_bounce",
"address": "qa.user@example.com",
"message_id": "msg-{{dynamic:uuid}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"processed": "number",
"suppressed": "number"
}
}sdk-device
POST/api/devices🔒 auth
Registers a device, returning 201 with the device record. The insert is an upsert on device_uuid: re-registering an existing device updates os_version, app_version and last_seen_at and COALESCEs device_key_ref (an omitted key ref preserves the stored one) rather than conflicting - so the call is idempotent and never returns a 409. device_uuid and platform are mandatory; platform is validated against the closed set ios | android | web | desktop. Edge cases: an unrecognised platform is a 400; os_version, app_version and device_key_ref are optional and default to null; re-registering a device that was previously revoked does NOT reset its status back to active; requires a valid JWT.
[ "POST /api/auth/register" ]
platform: ios, android, web, desktop| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | missing fields | device_uuid or platform is absent or an empty string |
| 400 | ValidationError | invalid platform | platform is not one of ios, android, web, desktop |
{
"device_uuid": "{{dynamic:uuid}}",
"platform": "ios",
"os_version": "17.2",
"app_version": "1.0.0",
"device_key_ref": "{{dynamic:uuid}}"
}{
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"platform": "ios",
"os_version": "17.2",
"app_version": "1.0.0",
"device_key_ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"device_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"platform": "ios",
"os_version": "17.2",
"app_version": "1.0.0",
"device_key_ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/devices/:device_uuid🔒 auth
Reads one device record by device_uuid - platform, versions, key ref, status and the first/last seen timestamps - returning 200 with { data: { device } }. Read-only. Devices are platform-scoped rather than tenant-scoped, so the handler performs no tenant check and any authenticated caller can read any device_uuid. Edge cases: an unregistered device_uuid is a 404; a revoked device is still returned with status 200 (its status field reflects revoked/stolen) rather than 404ing; a non-UUID device_uuid reaches the Postgres UUID cast unguarded and surfaces as a 500; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/devices" ]
platform: ios, android, web, desktopstatus: active, revoked, stolen| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 404 | NotFound | NotFound | No device.device row exists for the supplied device_uuid |
| 500 | Internal Server Error | invalid input syntax for type uuid | device_uuid is not a valid UUID - no format guard, so Postgres 22P02 escapes as an unhandled 500 |
{
"device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}"
}{
"success": true,
"data": {
"device_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/devices/:device_uuid/attest🔒 auth
Records a device attestation, returning 201 with the attestation record. method is validated against the closed set secure-enclave | key-attestation | safetynet | play-integrity, and signature_envelope is a base64 string decoded into bytes and stored verbatim. Edge cases: an unrecognised method is a 400; the handler does not pre-check that the device exists, so attesting an unregistered device_uuid trips the attestation foreign key and escapes as an unhandled 500; expires_at is optional, defaults to null, and a past expiry is accepted without validation; verified is optional and defaults to false, so a bare attestation is recorded as unverified; every call appends a new attestation row, so the operation is not idempotent - repeat attestations accumulate; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/devices" ]
method: secure-enclave, key-attestation, safetynet, play-integrity| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | missing fields | method or signature_envelope is absent or an empty string |
| 400 | ValidationError | invalid method | method is not one of secure-enclave, key-attestation, safetynet, play-integrity |
| 500 | Internal Server Error | insert or update on table "attestation" violates foreign key constraint | device_uuid does not reference a registered device (or is not a valid UUID) - the insert has no pre-check, so the Postgres error escapes unhandled |
{
"device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}"
}{
"method": "secure-enclave",
"signature_envelope": "YmFzZTY0ZW52ZWxvcGU=",
"expires_at": "{{dynamic:futuredatetime}}",
"verified": true
}{
"method": "secure-enclave",
"signature_envelope": "YmFzZTY0ZW52ZWxvcGU=",
"expires_at": "2026-01-15T10:30:00Z",
"verified": true
}{
"success": true,
"data": {
"attest_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"method": "secure-enclave",
"signature_envelope": "YmFzZTY0ZW52ZWxvcGU=",
"expires_at": "2026-01-15T10:30:00Z",
"verified": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/devices/:device_uuid/link-person🔒 auth
Links a person to a device (device_uuid path param) via linkPerson, returning 200 with the created link record. Requires person_id in the body; no not-found guard on device_uuid (an unknown device passes through to the service layer). Edge cases: missing person_id, auth failures.
[ "POST /api/auth/register", "POST /api/devices", "POST /scim/v2/Users" ]
status: active, suspended, revoked| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | missing person_id | person_id missing from body |
{
"device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}"
}{
"person_id": "{{cache:scim.create.response.data.person_id}}"
}{
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"link_person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/devices/:device_uuid/persons🔒 auth
Lists the person links for one device - every person that has been associated with this device_uuid, with each link's status and first/last used timestamps - returning 200 with { data: { links } }. Read-only and unpaginated: a device with many linked persons returns every row. Edge cases: an unregistered device_uuid, or one with no links yet, returns 200 with an empty links array rather than a 404; links whose status is no longer active are included in the listing; devices are platform-scoped so no tenant filter is applied; a non-UUID device_uuid reaches the Postgres UUID cast unguarded and surfaces as a 500; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/devices" ]
status: active, suspended, revoked| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 500 | Internal Server Error | invalid input syntax for type uuid | device_uuid is not a valid UUID - no format guard, so Postgres 22P02 escapes as an unhandled 500 |
{
"device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}"
}{
"success": true,
"data": {
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/devices/:device_uuid/revoke🔒 auth
Revokes a device by setting its status to the supplied reason and bumping last_seen_at, returning 200 with the updated device record. The body is optional: reason may be revoked or stolen and defaults to revoked when omitted. Edge cases: an unregistered device_uuid updates no rows and is reported as a 404; revoking an already-revoked device succeeds again and re-emits the device.revoked.v1 audit event, so the call is idempotent in effect but not silent; a reason outside revoked | stolen is NOT validated here and is written straight to the status column, where an invalid enum value surfaces as an unhandled 500; a non-UUID device_uuid fails the Postgres UUID cast and also escapes as a 500; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/devices" ]
reason: revoked, stolen| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 404 | NotFound | NotFound | The UPDATE matched no row - no device.device row exists for the supplied device_uuid |
| 500 | Internal Server Error | invalid input value for enum / invalid input syntax for type uuid | reason is outside the revoked|stolen enum, or device_uuid is not a valid UUID - neither is guarded, so the Postgres error escapes unhandled |
{
"entity": "device",
"field": "status",
"flow": [
"active",
"revoked",
"stolen"
],
"transitions": [
{
"from": "active",
"to": "revoked",
"via": "POST /api/devices/:device_uuid/revoke"
},
{
"from": "active",
"to": "stolen",
"via": "POST /api/devices/:device_uuid/revoke"
}
]
}{
"device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}"
}{
"reason": "revoked"
}{
"reason": "revoked"
}{
"success": true,
"data": {
"status": "completed",
"reason": "revoked",
"revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-diagnostic-telemetry
GET/api/diagnostic/crash🔒 auth
Lists the crash snapshots recorded for one device, selected by the required device_uuid query param. Edge cases: a missing device_uuid query param -> 400; a device_uuid with no crashes is NOT a 404 - it returns 200 with an empty data array; the endpoint accepts no pagination or time-window params, so a chatty device returns its full crash history in one response.
[ "POST /api/auth/register", "POST /api/devices" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate, not by the route itself |
| 400 | BadRequest | device_uuid query param required | the device_uuid query param is absent or empty |
{
"success": true,
"data": [
{
"crash_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/diagnostic/crash🔒 auth
Intake for a mobile/desktop crash snapshot. device_uuid, app_version, os_version, stack_envelope and occurred_at are all required; person_id and tenant_id are optional and default to null for anonymous/pre-login crashes. Edge cases: any missing required field -> 400 listing all five; a stack_envelope that is oversized or fails the service-layer checks is also 400 (the service throw is caught and downgraded, so a storage failure looks like a validation failure to the client); occurred_at is not format-validated at the route, so a bad timestamp fails inside recordCrash and returns 400; there is no de-duplication key, so retried uploads create duplicate crash rows.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/devices", "POST /scim/v2/Users" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate, not by the route itself |
| 400 | ValidationError | device_uuid, app_version, os_version, stack_envelope, occurred_at are required | the body omits any one of the five required fields |
| 400 | BadRequest | <error message thrown by recordCrash> | recordCrash throws — malformed occurred_at, oversized stack_envelope, or an insert failure (caught and returned as 400, not 500) |
{
"device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}",
"person_id": "{{cache:scim.create.response.data.person_id}}",
"app_version": "1.2.3",
"os_version": "17.0",
"stack_envelope": "eyJzdGFjayI6IFtdfQ==",
"occurred_at": "{{dynamic:pastdatetime}}",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_version": "1.2.3",
"os_version": "17.0",
"stack_envelope": "eyJzdGFjayI6IFtdfQ==",
"occurred_at": "2026-01-15T10:30:00Z",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"crash_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_version": "1.2.3",
"os_version": "17.0",
"stack_envelope": "eyJzdGFjayI6IFtdfQ==",
"occurred_at": "2026-01-15T10:30:00Z",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}GET/api/diagnostic/crash/:id🔒 auth
Fetches a single crash snapshot by its id. Edge cases: an unknown id returns 404 with {success:false,error:'not found'}; a malformed (non-UUID) id fails the Postgres uuid cast and surfaces as an unhandled 500 rather than a 404; the route applies no device or tenant filter, so any authenticated caller who knows an id can read that crash - worth confirming against tenant-isolation expectations.
[ "POST /api/auth/register", "POST /api/diagnostic/crash" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate, not by the route itself |
| 404 | NotFound | not found | no crash row exists for the given id |
{
"id": "{{cache:diagnostic.crash.response.data.crash_id}}"
}{
"success": true,
"data": {
"crash_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/diagnostic/healthpublic
Returns the most recent health snapshot for one device, selected by the required device_uuid query param. NOTE: the path ends in /health, so the gateway auth gate treats it as public - no bearer token is required and there is no 401. Edge cases: a missing device_uuid query param -> 400; a known device that has never posted a snapshot returns 404 'no snapshots recorded' (distinct from the 400 for an absent param); only the latest snapshot is returned - there is no history or pagination on this route.
[ "POST /api/auth/register", "POST /api/devices", "POST /api/diagnostic/health" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | BadRequest | device_uuid query param required | the device_uuid query param is absent or empty |
| 404 | NotFound | no snapshots recorded | the device has no health snapshot rows yet |
{
"success": true,
"data": [
{
"health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/api/diagnostic/healthpublic
Intake for a periodic device health probe: device_uuid and captured_at are required, while permissions, battery_pct, wifi_state and sensor_state are optional and default to null/undefined. NOTE: the path ends in /health, so the gateway auth gate treats it as public - no bearer token is required and there is no 401. Edge cases: missing device_uuid or captured_at -> 400; a malformed captured_at is not caught at the route and fails inside recordHealth, which is downgraded to a 400 rather than a 500; battery_pct is not range-checked, so out-of-range values are stored as given; snapshots are append-only, so repeated posts from the same device are all retained.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/devices" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | device_uuid and captured_at are required | the body omits device_uuid or captured_at |
| 400 | BadRequest | <error message thrown by recordHealth> | recordHealth throws — malformed captured_at or an insert failure (caught and returned as 400, not 500) |
{
"device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}",
"permissions": {
"camera": true,
"location": false
},
"battery_pct": 85,
"wifi_state": "connected",
"sensor_state": {
"accelerometer": "ok"
},
"captured_at": "{{dynamic:pastdatetime}}",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"permissions": {
"camera": true,
"location": false
},
"battery_pct": 85,
"wifi_state": "connected",
"sensor_state": {
"accelerometer": "ok"
},
"captured_at": "2026-01-15T10:30:00Z",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"permissions": {
"camera": true,
"location": false
},
"battery_pct": 85,
"wifi_state": "connected",
"sensor_state": {
"accelerometer": "ok"
},
"captured_at": "2026-01-15T10:30:00Z",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"success": true
}POST/api/diagnostic/session-replay🔒 auth
Intake for one sanitized session-replay event. device_uuid, sanitized_event_kind and occurred_at are required; payload is optional and is expected to be pre-sanitized by the client - the route performs no PII scrubbing of its own. Edge cases: any missing required field -> 400; sanitized_event_kind is not enum-validated at the route, so unknown kinds are rejected only if the service layer rejects them (which is downgraded to a 400); an oversized payload or a malformed occurred_at also surfaces as 400 rather than 500; events are append-only with no de-duplication, so replayed uploads duplicate rows.
[ "POST /api/auth/register", "POST /api/devices" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate, not by the route itself |
| 400 | ValidationError | device_uuid, sanitized_event_kind, occurred_at are required | the body omits any one of the three required fields |
| 400 | BadRequest | <error message thrown by recordSessionReplay> | recordSessionReplay throws — unknown event kind, oversized payload, or an insert failure (caught and returned as 400, not 500) |
{
"device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}",
"sanitized_event_kind": "tap",
"payload": {
"x": 120,
"y": 340
},
"occurred_at": "{{dynamic:pastdatetime}}"
}{
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"sanitized_event_kind": "tap",
"payload": {
"x": 120,
"y": 340
},
"occurred_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"session_replay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"sanitized_event_kind": "tap",
"payload": {
"x": 120,
"y": 340
},
"occurred_at": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}sdk-dispatch
POST/api/dispatch/routes/optimize🔒 auth
P7 FR-DSP-3 — optimizes a dispatcher's stop order for a set of tasks: loads each task's lat/lng, runs nearest-neighbour plus a 2-opt improvement pass, estimates drive time from DISPATCH_AVG_SPEED_KMH (default 35), persists a dispatch.route row and returns {route_id, persona_id, stops[], optimized_at, total_drive_mins} at 200. QA edge cases: validation is a single branch — persona_id must be present AND task_ids must be a non-empty array, so {} , a missing persona_id, task_ids:[] and task_ids as a string all return the same 400. Beyond that there is no 404 path: task_ids that do not exist, or exist but have null lat/lng, are silently dropped by loadStops, and if that leaves zero stops the optimizer throws and the handler returns 500 — so a bad-FK test surfaces as 500, not 404. A single valid stop short-circuits the algorithm and returns a one-stop route with total_drive_mins 0. start_task_id is optional and, when it does not match any loaded stop, falls back to the first stop rather than erroring. persona_id is cast to ::uuid on INSERT, so a non-UUID persona_id also produces a 500. Not idempotent — every call mints a fresh route_id (dsr_<hex>) and inserts a new row; there is no dedupe on (persona_id, task_ids) and no cap on how many task_ids may be sent. No pagination. persona_id comes from the body and is not checked against the caller's JWT tenant, but the path is not on the gateway public allowlist so a valid tenant JWT is required.
[ "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — authGate.ts default-deny gate rejects /api/dispatch/routes/optimize before the handler |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or expired) |
| 400 | ValidationError | persona_id and task_ids[] are required | Body missing, persona_id absent/empty, task_ids not an array, or task_ids is an empty array |
| 500 | RouteOptimizeFailed | [route-optimizer] no stops have lat/lng — refusing to optimize an empty route | None of the supplied task_ids resolve to a task with coordinates (unknown task ids, or tasks whose lat/lng are null) |
| 500 | RouteOptimizeFailed | <error message from the DB, e.g. invalid input syntax for type uuid for persona_id, or a dispatch.route insert/FK failure> | loadStops or the INSERT INTO dispatch.route throws — non-UUID persona_id, missing dispatch schema, or DB pool unavailable |
{
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"task_ids": [
"{{var:dispatch_task_id}}"
],
"start_task_id": "{{var:dispatch_task_id}}"
}{
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"task_ids": [
"{{var:dispatch_task_id}}"
],
"start_task_id": "{{var:dispatch_task_id}}"
}{
"success": true,
"data": {
"optimize_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"task_ids": [
"{{var:dispatch_task_id}}"
],
"start_task_id": "{{var:dispatch_task_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/dispatch/ws/:persona_idpublic
P7 FR-DSP-2 — WebSocket live-updates channel. On upgrade the connection subscribes to the in-process dispatch broker for :persona_id (server-side filtering, so the socket only receives that dispatcher's events) and is immediately sent a {kind:'hello', persona_id, emitted_at} frame; subsequent dispatch events stream as JSON frames until close, when the subscription is torn down. QA edge cases: this is a WebSocket-only route (@fastify/websocket `websocket: true` with no wsHandler), so a plain HTTP GET without the Upgrade/Connection/Sec-WebSocket-Key handshake headers gets 404 with an EMPTY body — an HTTP-only test harness cannot assert 200 here and must either perform a real upgrade or expect 404. :persona_id is accepted verbatim from the path with NO validation, NO existence check and NO tenant scoping: an unknown, non-UUID or empty-ish persona_id still upgrades successfully and simply yields a channel that never emits, so there is no 400 or 404 for a bad id. /api/dispatch/ws/ is on the authGate.ts WS bypass list (WS auth belongs in Sec-WebSocket-Protocol, tracked as follow-up hardening), so NO bearer token is required and no 401 is produced — matching requiresAuth:false. Subscribing twice for the same persona opens two independent sockets that each receive every event (no dedupe), and broker delivery is best-effort: a send on a socket closing mid-flight is swallowed rather than surfaced as an error.
[ "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | NotFound | (empty response body - reply.code(404).send() is called with no payload) | The request is a plain HTTP GET rather than a WebSocket upgrade — @fastify/websocket's default non-upgrade handler replies reply.code(404).send() with an empty body for a `websocket: true` route that defines no wsHandler |
{
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}{
"success": true,
"data": {
"ws_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "ws not found"
}sdk-engagement
POST/api/encounters🔒 auth
Opens an encounter (FR-EN-1 / FR-EN-3). The handler issues a per-encounter Vault key FIRST — parented to parent_key_id, tagged with the caller's region — and only then inserts the engagement.encounter row, so a Vault failure leaves no orphaned encounter. Returns 201 with the encounter including its vault_key_ref, state 'open', and retention_policy defaulting to 'default-7y'. Emits engagement.encounter.opened.v1 to the audit ledger (regulated retention). QA edge cases: tenant_id, kind, parent_key_id and region are all required and their absence is a single generic 400 'missing fields' with no per-field detail; parent_key_id is NOT validated by the route — an unknown or non-parent key makes issueKey throw, which is unhandled in the handler and surfaces as a Fastify 500 rather than a 400/404, which is the trap for QA; parent_encounter_id, address_id and billing_ref are optional passthroughs with no existence check, so a bogus parent_encounter_id either violates the FK (500) or is accepted silently depending on schema constraints; the call is not idempotent — every POST mints a new encounter AND a new Vault key; the audit emit is try/catch-wrapped and non-fatal, so a 201 does not prove the audit entry landed.
[ "POST /api/auth/signup-tenant", "POST /api/vault/keys" ]
kind: visit, order, deal, session, capital-call, support| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | missing fields | any of tenant_id, kind, parent_key_id or region is missing or empty in the body |
| 500 | InternalServerError | <Vault or database error message> | issueKey fails (unknown/invalid parent_key_id, Vault unavailable) or the INSERT into engagement.encounter violates a constraint — neither is caught by the handler, so Fastify returns its default 500 |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"kind": "visit",
"retention_policy": "default-7y",
"region": "us-east-1",
"parent_key_id": "{{cache:vault.keys.create.response.data.key_id}}",
"parent_encounter_id": null,
"address_id": null,
"billing_ref": "billing-ref-001"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "visit",
"retention_policy": "default-7y",
"region": "us-east-1",
"parent_key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"parent_encounter_id": null,
"address_id": null,
"billing_ref": "billing-ref-001"
}{
"success": true,
"data": {
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "visit",
"retention_policy": "default-7y",
"region": "us-east-1",
"parent_key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"parent_encounter_id": null,
"address_id": null,
"billing_ref": "billing-ref-001",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/encounters/:encounter_id🔒 auth
Fetches one encounter by encounter_id, returning the full row: state, vault_key_ref, opened_at/closed_at/sealed_at, retention_policy, retention_expires_at, and the parent/address/billing references. QA edge cases: an unknown encounter_id returns a bare 404 { error: 'NotFound' } with no details array, and a malformed (non-UUID) id returns the same 404 shape, so status alone does not separate them; the query is NOT tenant-scoped — it selects purely on encounter_id, so an authenticated caller from tenant A who learns an id belonging to tenant B reads that row, which is the cross-tenant assertion worth writing here; sealed encounters remain readable (sealing shreds the Vault key, it does not delete the metadata row), so a sealed encounter returns 200 with sealed_at set and a vault_key_ref that no longer resolves to usable key material.
[ "POST /api/auth/register", "POST /api/encounters" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | NotFound | NotFound | no engagement.encounter row matches the :encounter_id path param (unknown, or malformed id) |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}{
"success": true,
"data": {
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/encounters/:encounter_id/grants🔒 auth
Lists the currently ACTIVE grants on an encounter, ordered by issued_at. The query filters to revoked_at IS NULL AND expires_at > now(), so revoked and expired grants are excluded server-side. QA edge cases: expiry is evaluated at query time, which makes this the natural place to test TTL behaviour — issue a grant with a short ttl_ms, confirm it appears, then confirm it disappears once expires_at passes, with no state change or explicit sweep required; a revoked grant vanishes from this list immediately, so the listing cannot be used to audit historical grants; an unknown encounter_id returns 200 with an empty array rather than a 404; the listing is unpaginated and keyed only on encounter_id with no tenant predicate.
[ "POST /api/auth/register", "POST /api/encounters" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}{
"success": true,
"data": {
"grant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/encounters/:encounter_id/grants🔒 auth
Issues an Encounter Grant (FR-EN-5): a time- and scope-bounded authorisation letting a non-participant persona invoke listed methods on the encounter until expires_at, computed as now() + ttl_ms. Returns 201 with the grant and emits engagement.encounter.grant.issued.v1 (regulated retention). QA edge cases: all four of grantee_persona_id, issuer_persona_id, scope and ttl_ms are required, and the ttl_ms check is falsy-based — ttl_ms: 0 is therefore rejected as a missing field rather than treated as an immediate expiry, which is the boundary case to cover; a negative ttl_ms passes validation and produces a grant whose expires_at is already in the past, so it never appears in the grants listing and never authorises anything; nothing validates the shape of scope, so a scope without a methods array is accepted and yields a grant that authorises nothing at check time; issuing is not idempotent and there is no cap — repeated POSTs stack multiple concurrent grants for the same grantee, and the check endpoint uses only the most recently issued one; the encounter's existence and state are not verified, so an unknown encounter_id trips the foreign key as an uncaught 500 and granting on a sealed encounter succeeds.
[ "POST /api/auth/register", "POST /api/encounters", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | missing fields | any of grantee_persona_id, issuer_persona_id, scope or ttl_ms is missing/falsy in the body — note ttl_ms: 0 is treated as missing |
| 500 | InternalServerError | <database error message> | the INSERT violates the encounter_id foreign key (unknown encounter), scope is not serialisable to jsonb, or an id is not a valid UUID — not caught by the handler |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}{
"grantee_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"issuer_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"scope": {
"methods": [
"chart.read"
]
},
"ttl_ms": 28800000,
"capability_token_ref": "cap-token-ref-001"
}{
"grantee_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"issuer_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"scope": {
"methods": [
"chart.read"
]
},
"ttl_ms": 28800000,
"capability_token_ref": "cap-token-ref-001"
}{
"success": true,
"data": {
"grant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"grantee_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"issuer_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"scope": {
"methods": [
"chart.read"
]
},
"ttl_ms": 28800000,
"capability_token_ref": "cap-token-ref-001",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/encounters/:encounter_id/grants/check🔒 auth
Boolean authorisation probe used by downstream SDKs to gate non-participant access: returns { data: { allowed: true|false } } for a (encounter_id, grantee_persona_id, method) triple. It selects the single most recent non-revoked, non-expired grant for that grantee on that encounter and reports whether method appears in scope.methods, with '*' acting as a wildcard. QA edge cases: this endpoint returns 200 with allowed:false for every negative outcome — no grant, revoked grant, expired grant, wrong method, or an entirely nonexistent encounter all look identical, so never assert 403/404 for a denial; the ORDER BY issued_at DESC LIMIT 1 is the trap — when a grantee holds several concurrent grants, only the newest is consulted, so issuing a narrow grant after a broad one silently REVOKES the broader access at check time; a grant whose scope has no methods array evaluates to allowed:false; method matching is exact and case-sensitive apart from the '*' wildcard; the check is read-only and idempotent.
[ "POST /api/auth/register", "POST /api/encounters", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | missing fields | grantee_persona_id or method is missing or empty in the body |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}{
"grantee_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"method": "chart.read"
}{
"grantee_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"method": "chart.read"
}{
"success": true,
"data": {
"check_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"grantee_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"method": "chart.read",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/encounters/:encounter_id/participants🔒 auth
Lists every participant ever attached to an encounter, ordered by joined_at, including their role, required flag, joined_at and left_at. QA edge cases: this returns ALL participants, not just currently-present ones — rows for people who have left are included with a non-null left_at, so a caller wanting the active roster must filter client-side; an unknown encounter_id returns 200 with an empty array rather than a 404, so "no such encounter" and "encounter with no participants" are indistinguishable here; the listing is unpaginated with no limit or cursor; the query keys on encounter_id alone with no tenant predicate, so cross-tenant reads are possible for a caller who knows the id.
[ "POST /api/auth/register", "POST /api/encounters" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}{
"success": true,
"data": {
"participant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/encounters/:encounter_id/participants🔒 auth
Adds a persona to an encounter as a participant with a role, optionally flagged required. The insert is an upsert on (encounter_id, persona_id, role) that clears left_at on conflict, so re-adding a participant who previously left rejoins them rather than erroring. Also performs a sdk-data-rights residency touch recording that the persona's data now lives in this app pool. QA edge cases: because of the ON CONFLICT the call IS idempotent per (encounter, persona, role) — repeat POSTs return 201 with the same participant_id, so tests must not expect a duplicate-key 409; the same persona added under a DIFFERENT role creates a second, separate participant row; the handler validates only persona_id and role, and never checks that the encounter exists or that its state still allows joins — an unknown encounter_id hits the foreign key and surfaces as an uncaught Fastify 500, and adding a participant to an already-closed or sealed encounter is accepted with 201; required defaults to false when omitted, which matters because only required participants can later block a close; the residency touch is try/catch-wrapped and non-fatal.
[ "POST /api/auth/register", "POST /api/encounters", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | missing fields | persona_id or role is missing or empty in the body |
| 500 | InternalServerError | <database error message> | the INSERT violates the encounter_id foreign key (unknown encounter) or encounter_id/persona_id is not a valid UUID — not caught by the handler, so Fastify returns its default 500 |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}{
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"role": "attendee",
"required": false
}{
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"role": "attendee",
"required": false
}{
"success": true,
"data": {
"participant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"role": "attendee",
"required": false,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/encounters/:encounter_id/transition🔒 auth
Drives the encounter state machine. Legal transitions are open -> in-progress|closed|sealed, in-progress -> closed|sealed, closed -> sealed, and sealed -> nothing (terminal). Closing or sealing first checks that no participant marked required has already left. Sealing additionally stamps sealed_at (plus closed_at if not already set) and cryptographically shreds the per-encounter Vault key. QA edge cases: the 400/409 split is the thing to get right — a `to` value outside the four known states is a 400 ValidationError, while a syntactically valid but illegal move (e.g. sealed -> open, or closed -> in-progress) is a 409 InvalidTransition; a nonexistent encounter_id is ALSO a 409, not a 404, because transitionEncounter throws 'Encounter <id> not found' inside the try block; blocking a close because a required participant left surfaces as the same 409 with a different details message, so assert on details; sealing is destructive and irreversible — re-sealing an already-sealed encounter is a 409 since sealed has no legal successors; the shredKey call is try/catch-wrapped and only logs, so a 200 seal response does not prove the key was actually shredded.
[ "POST /api/auth/register", "POST /api/encounters", "POST /api/personas" ]
to: open, in-progress, closed, sealed| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | invalid target state | body.to is missing or is not one of 'open', 'in-progress', 'closed', 'sealed' |
| 409 | InvalidTransition | Encounter <encounter_id> not found | the :encounter_id path param matches no engagement.encounter row — reported as 409, not 404 |
| 409 | InvalidTransition | Invalid encounter transition <from> -> <to> | the requested move is not permitted by VALID_TRANSITIONS (e.g. any transition out of sealed, or closed -> in-progress) |
| 409 | InvalidTransition | Cannot close encounter — required participants have left: <roles> | transitioning to closed or sealed while one or more participants with required = TRUE have a non-null left_at |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler) |
{
"entity": "encounter",
"field": "state",
"flow": [
"open",
"in-progress",
"closed",
"sealed"
],
"transitions": [
{
"from": "open",
"to": "in-progress",
"via": "POST /api/encounters/:encounter_id/transition"
},
{
"from": "open",
"to": "closed",
"via": "POST /api/encounters/:encounter_id/transition"
},
{
"from": "open",
"to": "sealed",
"via": "POST /api/encounters/:encounter_id/transition"
},
{
"from": "in-progress",
"to": "closed",
"via": "POST /api/encounters/:encounter_id/transition"
},
{
"from": "in-progress",
"to": "sealed",
"via": "POST /api/encounters/:encounter_id/transition"
},
{
"from": "closed",
"to": "sealed",
"via": "POST /api/encounters/:encounter_id/transition"
}
]
}{
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}{
"to": "in-progress",
"actor_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}{
"to": "in-progress",
"actor_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"transition_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"to": "in-progress",
"actor_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/grants/:grant_id/revoke🔒 auth
Revokes an encounter grant (FR-EN-5) by stamping engagement.encounter_grant.revoked_at = now(), returning 200 with the revoked grant (grant_id, encounter_id, grantee/issuer persona ids, scope, issued_at, expires_at, revoked_at, capability_token_ref). The UPDATE is guarded by `revoked_at IS NULL`. Edge cases: a second revoke of the same grant matches zero rows and returns 404 (not a silent 200), as does an unknown grant_id; a grant that has merely EXPIRED is still revocable because expiry is not part of the WHERE clause, while an already-revoked one is not; a non-UUID grant_id fails the Postgres cast and surfaces as an uncaught 500. Requires a valid tenant JWT.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/vault/keys", "POST /api/encounters", "POST /api/personas", "POST /api/encounters/:encounter_id/grants" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 404 | NotFound | NotFound | No engagement.encounter_grant row with that grant_id and revoked_at IS NULL — unknown grant, or it was already revoked |
| 500 | Internal Server Error | Fastify default error payload from the uncaught service throw | revokeGrant throws — grant_id is not a valid UUID, or the UPDATE fails |
{
"entity": "encounter_grant",
"field": "status",
"flow": [
"active",
"revoked"
],
"transitions": [
{
"from": null,
"to": "active",
"via": "POST /api/encounters/:encounter_id/grants"
},
{
"from": "active",
"to": "revoked",
"via": "POST /api/grants/:grant_id/revoke"
}
]
}{
"grant_id": "{{cache:encounters.issue-grant.response.data.grant.grant_id}}"
}{}{
"success": true,
"data": {
"status": "completed",
"revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/participants/:participant_id/leave🔒 auth
Marks an encounter participant as having left by stamping engagement.encounter_participant.left_at = now(), returning 200 with the updated participant row (participant_id, encounter_id, persona_id, role, joined_at, left_at, required). The UPDATE is guarded by `left_at IS NULL`, so only currently-joined participants are affected. Edge cases: leaving twice returns 404 on the second call because zero rows match, as does an unknown participant_id; the handler does not check the parent encounter's state, so a participant can leave an encounter that is already closed or sealed; there is no re-join endpoint, and required=true participants are not protected from leaving; a non-UUID participant_id fails the Postgres cast as an uncaught 500. Requires a valid tenant JWT.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/vault/keys", "POST /api/personas", "POST /api/encounters", "POST /api/encounters/:encounter_id/participants" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 404 | NotFound | NotFound | No engagement.encounter_participant row with that participant_id and left_at IS NULL — unknown participant, or they already left |
| 500 | Internal Server Error | Fastify default error payload from the uncaught service throw | removeParticipant throws — participant_id is not a valid UUID, or the UPDATE fails |
{
"participant_id": "{{cache:encounters.add-participant.response.data.participant.participant_id}}"
}{}{
"success": true,
"data": {
"leave_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-event
POST/api/events/checkin🔒 auth
Checks a ticket in at the door by its qr_token, atomically transitioning the ticket issued -> used and inserting a checkin row; a UNIQUE constraint on checkin.ticket_id backstops double check-in. qr_token and checked_in_by_persona_id are required; device_uuid is optional and stored as null when absent. Edge cases: missing required fields -> 400; an unknown token, a ticket that was never issued, and a ticket already checked in all collapse into the same 409 CannotCheckIn with 'Ticket not found, not issued, or already used' - so a duplicate scan is a 409, never a silent success; if the parent session row cannot be read the emitted audit records tenant 'unknown' but the check-in still succeeds.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/vault/keys", "POST /api/encounters", "POST /api/events/sessions", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/events/tickets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | missing fields | body omits qr_token or checked_in_by_persona_id |
| 409 | CannotCheckIn | Ticket not found, not issued, or already used | the conditional UPDATE matches no ticket: unknown qr_token, ticket status is not "issued", or the ticket was already checked in |
{
"entity": "event.ticket",
"field": "status",
"flow": [
"issued",
"used",
"refunded",
"void"
],
"transitions": [
{
"from": "issued",
"to": "used",
"via": "POST /api/events/checkin"
}
]
}{
"qr_token": "{{cache:events.tickets.create.response.data.ticket.qr_token}}",
"checked_in_by_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"device_uuid": "gate-scanner-01"
}{
"qr_token": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"checked_in_by_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_uuid": "gate-scanner-01"
}{
"success": true,
"data": {
"checkin_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"qr_token": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"checked_in_by_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_uuid": "gate-scanner-01",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/events/sessions🔒 auth
Opens an event session (a schedulable, ticketable occurrence) on an encounter. tenant_id, encounter_id, title, capacity, starts_at and ends_at are all required - note capacity is checked with `== null`, so 0 is accepted and immediately makes the session unsellable; address_id is optional. Edge cases: any missing required field -> 400; starts_at/ends_at are passed straight to `new Date()` with no ordering or validity check, so an inverted or unparseable range is stored rather than rejected; a negative capacity is not rejected at the route; an unknown encounter_id fails the FK inside the insert and surfaces as an unhandled 500; sold_count starts at 0 and the session emits event.session.opened.v1 (audit failures are swallowed).
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/vault/keys", "POST /api/encounters" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | missing fields | body omits tenant_id, encounter_id, title, capacity, starts_at, or ends_at |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}",
"title": "{{dynamic:name}}",
"address_id": null,
"capacity": 100,
"starts_at": "2026-09-01T10:00:00Z",
"ends_at": "2026-09-01T12:00:00Z"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"title": "Acme QA Sample",
"address_id": null,
"capacity": 100,
"starts_at": "2026-09-01T10:00:00Z",
"ends_at": "2026-09-01T12:00:00Z"
}{
"success": true,
"data": {
"session_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"title": "Acme QA Sample",
"address_id": null,
"capacity": 100,
"starts_at": "2026-09-01T10:00:00Z",
"ends_at": "2026-09-01T12:00:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/events/sessions/:session_id🔒 auth
Fetches one event session with its capacity and current sold_count - the pair QA needs to verify oversell protection. Edge cases: an unknown session_id returns 404; the route applies no tenant filter of its own beyond the JWT gate, so cross-tenant readability should be verified; a malformed (non-UUID) session_id fails the uuid cast and surfaces as an unhandled 500 rather than a 404.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/vault/keys", "POST /api/encounters", "POST /api/events/sessions" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 404 | NotFound | NotFound | no event.session row exists for the given session_id |
{
"session_id": "{{cache:events.sessions.create.response.data.session.session_id}}"
}{
"success": true,
"data": {
"session_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/events/tickets🔒 auth
Issues a ticket for a session and returns its generated qr_token. session_id and holder_persona_id are required; price is optional. Capacity is reserved atomically by a conditional UPDATE (sold_count < capacity AND status IN ('scheduled','live')) that row-locks the session, so concurrent buyers cannot oversell. Edge cases: missing required fields -> 400; a sold-out session, a cancelled/ended session, and a session_id that does not exist are indistinguishable to the client - all three return the same 409 CannotIssueTicket with 'Session sold out, cancelled, or not found'; nothing stops the same persona holding multiple tickets for one session, so the endpoint is not idempotent and each retry consumes another seat.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/vault/keys", "POST /api/encounters", "POST /api/events/sessions", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | missing fields | body omits session_id or holder_persona_id |
| 409 | CannotIssueTicket | Session sold out, cancelled, or not found | the capacity-reserving UPDATE matches no row: sold_count has reached capacity, the session status is not scheduled/live, or the session_id does not exist |
{
"session_id": "{{cache:events.sessions.create.response.data.session.session_id}}",
"holder_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"price": 50
}{
"session_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"holder_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"price": 50
}{
"success": true,
"data": {
"ticket_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"session_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"holder_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"price": 50,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-evidence
GET/api/evidence/capture🔒 auth
Lists every evidence capture belonging to one encounter, selected by the required encounter_id query param. Gated by the gateway default-deny authGate, so a valid tenant JWT is required. Edge cases: encounter_id is mandatory and its absence (or an empty value) is a 400, since the route would otherwise scan unbounded; an unknown but well-formed encounter_id returns 200 with an empty data array rather than a 404; there is no limit/offset paging, so an encounter with many captures returns the whole set in one payload; the caller JWT tenant is not compared to the rows, so captures on another tenant encounter are readable - verify tenant scoping deliberately; a non-UUID encounter_id fails the uuid cast in an untried service call and surfaces as a Fastify 500, not a 400.
[ "POST /api/auth/register", "POST /api/encounters", "POST /api/evidence/capture" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 400 | ValidationError | encounter_id query param required | the encounter_id query param is absent or empty |
| 500 | InternalServerError | Internal Server Error | listCapturesForEncounter throws and the route has no try/catch, so the Fastify default error handler responds - chiefly a non-UUID encounter_id failing the uuid cast, or any DB error |
{
"success": true,
"data": [
{
"capture_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/evidence/capture🔒 auth
Provenance-stamped evidence intake: records a capture (device, attestation, blob id + checksum, capture time, consent ref and optional lat/lng/altitude/IMU signature) against an encounter and returns 201. Gated by the gateway default-deny authGate, so a valid tenant JWT is required. Edge cases: nine fields are required (tenant_id, encounter_id, capturer_persona_id, device_uuid, device_attestation_id, raw_blob_id, blob_checksum, consent_ref, captured_at) and the 400 names every missing one in a single message; the geo/IMU fields default to null when omitted; if the encounter was sealed between upload and intake the write is refused with 409 encounter_sealed carrying encounter_id and sealed_at - the canonical AC-11 path; every other service failure (unknown encounter foreign key, bad checksum, malformed captured_at, non-UUID identifiers, retention-class violations) is flattened into a 400 with the raw error message rather than a 404 or 500, so a missing FK looks like a validation error; tenant_id comes from the body, not the JWT, so cross-tenant capture is not blocked here.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/devices", "POST /api/devices/:device_uuid/attest", "POST /api/media/:blob_id/ready", "POST /api/consents", "POST /api/encounters" ]
retention_class: transient, operational, regulated| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 400 | ValidationError | missing required fields: <comma-separated list> | any of tenant_id, encounter_id, capturer_persona_id, device_uuid, device_attestation_id, raw_blob_id, blob_checksum, consent_ref or captured_at is absent or falsy |
| 409 | encounter_sealed | encounter <encounter_id> is sealed (at <sealed_at>) - no new evidence captures may reference it | captureEvidence raises EncounterSealedError; the response also carries encounter_id and sealed_at |
| 400 | CaptureFailed | <service error message> | captureEvidence throws anything other than EncounterSealedError - unknown encounter/persona foreign key, non-UUID identifiers, malformed captured_at, checksum or retention failures, or any DB error; all collapse to 400 |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}",
"capturer_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}",
"device_attestation_id": "{{cache:devices.attest.response.data.attestation.attestation_id}}",
"raw_blob_id": "{{cache:media.ready.response.data.blob.blob_id}}",
"blob_checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"captured_at": "{{dynamic:pastdatetime}}",
"lat": 37.7749,
"lng": -122.4194,
"altitude": 12.5,
"imu_signature": "aW11LXNpZ25hdHVyZS1zYW1wbGU=",
"consent_ref": "{{cache:consents.create.response.data.receipt.receipt_id}}",
"retention_class": "regulated",
"retention_expires_at": "{{dynamic:futuredatetime}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"capturer_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_attestation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"raw_blob_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"blob_checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"captured_at": "2026-01-15T10:30:00Z",
"lat": 37.7749,
"lng": -122.4194,
"altitude": 12.5,
"imu_signature": "aW11LXNpZ25hdHVyZS1zYW1wbGU=",
"consent_ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"retention_class": "regulated",
"retention_expires_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"capture_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"capturer_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"device_attestation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"raw_blob_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"blob_checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"captured_at": "2026-01-15T10:30:00Z",
"lat": 37.7749,
"lng": -122.4194,
"altitude": 12.5,
"imu_signature": "aW11LXNpZ25hdHVyZS1zYW1wbGU=",
"consent_ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"retention_class": "regulated",
"retention_expires_at": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/evidence/capture/:id🔒 auth
Fetches a single evidence capture row by its id path param. Gated by the gateway default-deny authGate, so a valid tenant JWT is required. Edge cases: the lookup is by id alone and the caller JWT tenant is never compared to the row tenant_id, so any authenticated caller can read any capture - tenant scoping must be tested explicitly; a well-formed but unknown id returns 404 {success:false,error:"not found"}, while a malformed non-UUID id fails the uuid cast inside an untried service call and surfaces as a Fastify 500 instead; the handler does not filter sealed or retention-expired captures, so those still return 200.
[ "POST /api/auth/register", "POST /api/evidence/capture" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 404 | NotFound | not found | getCapture returns no row for the id |
| 500 | InternalServerError | Internal Server Error | getCapture throws and the route has no try/catch, so the Fastify default error handler responds - chiefly a non-UUID id failing the uuid cast, or any DB error |
{
"id": "{{cache:evidence.capture.response.data.capture.capture_id}}"
}{
"success": true,
"data": {
"capture_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-feature-flags
GET/api/flags🔒 auth
Lists every registered feature flag with its kind, default_value and kill_switch state. Edge cases: the route accepts no filter or pagination params and is not tenant-scoped - flag definitions are global, so every authenticated caller sees the full catalogue; an empty registry returns 200 with an empty array rather than a 404; rollouts are not included in this response, only the flag definitions.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
{
"success": true,
"data": [
{
"flag_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}PUT/api/flags🔒 auth
Creates or updates (upserts) a feature flag definition keyed by flag_id, with an optional description, kind, default_value, kill_switch and schema_ref. kind, when supplied, must be one of boolean, variant, numeric or json. Edge cases: a missing flag_id -> 400; an unrecognised kind -> 400, but omitting kind entirely is allowed and leaves the existing/default kind in place; default_value is not validated against kind or schema_ref, so a boolean flag can be given a string default; the operation is a true upsert and therefore idempotent, and it answers 200 (not 201) even when creating; flags are global rather than tenant-scoped - per-tenant behaviour is expressed through rollouts.
[ "POST /api/auth/register" ]
kind: boolean, variant, numeric, json| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | missing flag_id | body omits flag_id (details: ["missing flag_id"]) |
| 400 | ValidationError | invalid kind | body.kind is present but is not one of boolean|variant|numeric|json |
{
"flag_id": "agent.cost-steward.enabled",
"description": "Per-agent kill switch",
"kind": "boolean",
"default_value": true,
"kill_switch": false,
"schema_ref": null
}{
"flag_id": "agent.cost-steward.enabled",
"description": "Per-agent kill switch",
"kind": "boolean",
"default_value": true,
"kill_switch": false,
"schema_ref": null
}{
"success": true,
"data": {
"flag_id": "agent.cost-steward.enabled",
"description": "Per-agent kill switch",
"kind": "boolean",
"default_value": true,
"kill_switch": false,
"schema_ref": null,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/flags/:flag_id🔒 auth
Fetches a single feature-flag definition by flag_id. Edge cases: an unknown flag_id returns 404 (in contrast to the evaluate endpoint, which answers 200 with a null value for an unknown flag - a deliberate fail-open asymmetry QA should verify); flag_id is a caller-chosen string key, not a UUID, so lookups are exact and case-sensitive; the response carries the definition only, not the flag's rollout rules.
[ "POST /api/auth/register", "PUT /api/flags" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 404 | NotFound | NotFound | no feature_flags.flag row exists for the given flag_id |
{
"flag_id": "{{cache:flags.create.response.data.flag.flag_id}}"
}{
"success": true,
"data": {
"flag_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/flags/:flag_id/evaluate🔒 auth
Evaluates a flag for an evaluation context (tenant_id, persona_id, bu_id, arbitrary attributes) and returns resolved_value, matched_rollout_id and kill_switch_engaged. Resolution order: kill switch first, then active rollouts ordered tenant-specific-first by ascending priority whose predicate matches and whose deterministic percentage bucket (hashed on persona_id, else tenant_id, else 'anon') falls under rollout_percent, then the flag's default_value. Edge cases: the entire body is optional and an unknown flag_id does NOT 404 - it fails open with 200 and resolved_value null, matched_rollout_id null, kill_switch_engaged false, so tests must assert the body rather than the status; percentage bucketing is stable per (flag, subject), so repeated calls for the same subject return the same answer; every evaluation is sampled for telemetry.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "PUT /api/flags" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
{
"flag_id": "{{cache:flags.create.response.data.flag.flag_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"persona_id": "{{var:persona_id}}",
"bu_id": "{{var:bu_id}}",
"attributes": {}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "{{var:persona_id}}",
"bu_id": "{{var:bu_id}}",
"attributes": {}
}{
"success": true,
"data": {
"status": "completed",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "{{var:persona_id}}",
"bu_id": "{{var:bu_id}}",
"attributes": {},
"evaluate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/flags/:flag_id/kill-switch🔒 auth
Engages or releases a flag's kill switch. body.engaged is required and must be a real boolean - the check is `typeof !== 'boolean'`, so the strings "true"/"false", 0/1 and null are all rejected 400. Edge cases: an unknown flag_id -> 404; setting the switch to the value it already holds is accepted and the call is idempotent; while engaged, evaluate() short-circuits every rollout and returns the type-appropriate off value (false for boolean, 0 for numeric, null otherwise) with kill_switch_engaged true, so QA should assert the evaluate response shape immediately after flipping this.
[ "POST /api/auth/register", "PUT /api/flags" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | missing engaged | body.engaged is absent or is not a JSON boolean (strings such as "true" are rejected) |
| 404 | NotFound | NotFound | no feature_flags.flag row exists for the given flag_id |
{
"entity": "feature_flags.flag",
"field": "kill_switch",
"flow": [
false,
true
],
"transitions": [
{
"from": false,
"to": true,
"via": "POST /api/flags/:flag_id/kill-switch"
},
{
"from": true,
"to": false,
"via": "POST /api/flags/:flag_id/kill-switch"
}
]
}{
"flag_id": "{{cache:flags.create.response.data.flag.flag_id}}"
}{
"engaged": true
}{
"engaged": true
}{
"success": true,
"data": {
"kill_switch_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"engaged": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/flags/:flag_id/rollouts🔒 auth
Creates or updates a rollout rule for a flag: an optional tenant_id (null means the rule applies to every tenant), a predicate matched against the evaluation context, the value to serve, a priority (lower wins, evaluated tenant-specific-first) and an active flag. Only value is required, and it is checked with `=== undefined`, so an explicit null IS a valid value. Edge cases: omitting value -> 400; an unknown flag_id is not verified at this route, so a rollout can be attached to a flag that does not exist; predicate contents are not schema-validated; two rules with the same priority resolve in an unspecified order; the response is 201 even when the call updated an existing rollout.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "PUT /api/flags" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | missing value | body.value is undefined (an explicit null is accepted) |
{
"flag_id": "{{cache:flags.create.response.data.flag.flag_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"predicate": {},
"value": true,
"priority": 100,
"active": true
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"predicate": {},
"value": true,
"priority": 100,
"active": true
}{
"success": true,
"data": {
"rollout_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"predicate": {},
"value": true,
"priority": 100,
"active": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-geo
GET/api/geo/addresses/:address_id🔒 auth
Reads one canonical address row by address_id and returns 200 with { data: { address } }. Read-only, requires a valid JWT; the record is not tenant-scoped (geo.address is a platform-wide canonical table), so any authenticated caller can read any address_id. Edge cases: unknown but well-formed UUID returns 404, an address_id that was consumed as the loser of POST /api/geo/merge is deleted and therefore also 404s afterwards, and a non-UUID address_id reaches the Postgres UUID cast unguarded (22P02) and surfaces as a 500.
[ "POST /api/auth/register", "POST /api/geo/canonicalize" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 404 | NotFound | NotFound | No geo.address row exists for the supplied address_id (never created, or deleted as the loser of a merge) |
| 500 | Internal Server Error | invalid input syntax for type uuid | address_id is not a valid UUID - the handler has no format guard, so the Postgres 22P02 error escapes as an unhandled 500 |
{
"address_id": "{{cache:geo.canonicalize.response.data.address.address_id}}"
}{
"success": true,
"data": {
"address_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/geo/bbox-query🔒 auth
Returns the canonical addresses whose geometry falls inside the supplied bounding box, using PostGIS ST_Intersects when the extension is available and silently falling back to a plain lat/lng BETWEEN range scan when it is not. All four coordinates must be JSON numbers. Edge cases: the caller-supplied limit is clamped to a maximum of 1000 and defaults to 100, so an oversized or absent limit never returns more than 1000 rows; an inverted or zero-area box returns an empty array with 200, not an error; coordinates sent as strings fail the strict typeof guard; addresses with NULL lat/lng are excluded by the fallback path; requires a valid JWT.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | missing bbox coords | min_lat, min_lng, max_lat or max_lng is absent or not a JSON number |
{
"min_lat": 37.7,
"min_lng": -122.5,
"max_lat": 37.8,
"max_lng": -122.4,
"limit": 10
}{
"min_lat": 37.7,
"min_lng": -122.5,
"max_lat": 37.8,
"max_lng": -122.4,
"limit": 10
}{
"success": true,
"data": {
"bbox_query_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"min_lat": 37.7,
"min_lng": -122.5,
"max_lat": 37.8,
"max_lng": -122.4,
"limit": 10,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/geo/canonicalize🔒 auth
Canonicalizes a raw address string into a deduplicated geo.address row and records a geo.address_alias for the raw input, returning 200 with the canonical address. raw_input, street, city and country are all mandatory; the alias hash is looked up first, so re-posting the same raw_input is idempotent and returns the existing address rather than creating a duplicate. Edge cases: repeated/duplicate raw_input (alias hit, no new row, no audit event), a different raw_input that normalizes to the same (street, city, country, postal_code) hash (sibling reuse - same address_id), empty-string street/city/country (rejected as missing), optional lat/lng/region/postal_code/geo_node_id/provider_refs omitted, a geo_node_id that does not exist (FK violation surfaces as an unhandled 500), and missing/expired JWT.
[ "POST /api/auth/register", "POST /api/geo-nodes" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | missing fields | raw_input, street, city or country is absent or empty in the body |
{
"raw_input": "123 Main St, Cityville, US",
"street": "123 Main St",
"city": "Cityville",
"region": "CA",
"postal_code": "94110",
"country": "US",
"lat": 37.7749,
"lng": -122.4194,
"geo_node_id": "{{cache:geo-nodes.create.response.data.geo_node.geo_node_id}}",
"provider_refs": {
"mapbox": "poi.a1b2c3"
}
}{
"raw_input": "123 Main St, Cityville, US",
"street": "123 Main St",
"city": "Cityville",
"region": "CA",
"postal_code": "94110",
"country": "US",
"lat": 37.7749,
"lng": -122.4194,
"geo_node_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider_refs": {
"mapbox": "poi.a1b2c3"
}
}{
"success": true,
"data": {
"status": "completed",
"raw_input": "123 Main St, Cityville, US",
"street": "123 Main St",
"city": "Cityville",
"region": "CA",
"postal_code": "94110",
"country": "US",
"lat": 37.7749,
"lng": -122.4194,
"geo_node_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider_refs": {
"mapbox": "poi.a1b2c3"
},
"canonicalize_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/geo/geocode🔒 auth
Forward-geocodes a free-text address via the active geo provider and then canonicalizes the enriched result, returning 200 with the canonical address (or { address: null } when the provider yields nothing). Only raw_input is required. Edge cases: raw_input missing or empty string is rejected with 400; a provider miss returns 200 with a null address rather than a 404; on a provider hit the degraded canonicalize path stores raw_input as the street with city and country literals of "?", so repeated geocodes of the same raw_input hit the alias cache and are idempotent; requires a valid JWT.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | missing raw_input | raw_input is absent or an empty string |
{
"raw_input": "123 Main St, Cityville, US"
}{
"raw_input": "123 Main St, Cityville, US"
}{
"success": true,
"data": {
"geocode_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"raw_input": "123 Main St, Cityville, US",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/geo/merge🔒 auth · manual
Merges two canonical addresses: every alias of the loser is re-pointed at the winner, a geo.merge_event audit row is written, and the loser geo.address row is deleted. Both ids must be well-formed UUIDs - the handler pre-validates the format so a malformed id cannot reach Postgres. Edge cases: a syntactically valid but non-existent winner or loser trips the merge_event foreign key (23503), which the handler maps to 404; passing the same id as winner and loser is not blocked and self-deletes the row; the operation is destructive and NOT idempotent - replaying the same merge 404s the second time because the loser no longer exists; operator_id is optional and defaults to null; requires a valid JWT.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | missing fields | winner_address_id or loser_address_id is absent or empty |
| 400 | ValidationError | address ids must be uuids | winner_address_id or loser_address_id does not match the UUID regex |
| 404 | NotFound | winner or loser address not found | merge_event insert raises Postgres FK violation 23503 - a referenced address row does not exist |
| 500 | InternalError | <underlying error message> | Any other failure during the alias re-point / merge_event insert / loser delete sequence |
{
"winner_address_id": "{{var:geo_winner_address_id}}",
"loser_address_id": "{{var:geo_loser_address_id}}",
"operator_id": "{{var:operator_id}}"
}{
"winner_address_id": "{{var:geo_winner_address_id}}",
"loser_address_id": "{{var:geo_loser_address_id}}",
"operator_id": "{{var:operator_id}}"
}{
"success": true,
"data": {
"merge_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"winner_address_id": "{{var:geo_winner_address_id}}",
"loser_address_id": "{{var:geo_loser_address_id}}",
"operator_id": "{{var:operator_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/geo/reverse-geocode🔒 auth
Reverse-geocodes a lat/lng pair through the active geo provider and returns 200 with { street, city, country } or null when the provider has no match. Both lat and lng must be JSON numbers - the guard is a strict typeof check, so numeric strings such as "12.97" are rejected with 400. Edge cases: lat/lng sent as strings, either coordinate missing, lat/lng of 0 (valid - 0 is a number and passes the guard), out-of-range coordinates (not validated here; passed to the provider, which returns null), a provider miss returning 200 with a null address, and missing/expired JWT.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | missing lat/lng | lat or lng is absent or not a JSON number (numeric strings are rejected) |
{
"lat": 37.7749,
"lng": -122.4194
}{
"lat": 37.7749,
"lng": -122.4194
}{
"success": true,
"data": {
"reverse_geocode_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"lat": 37.7749,
"lng": -122.4194,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-handoff
GET/api/handoffs🔒 auth
List handoffs for a tenant, most-recently-updated first. Tenant-scoped via the required tenant_id query param; optionally filtered by status and/or deal_id, with limit/offset paging (defaults 50/0).
[ "POST /api/auth/signup-tenant" ]
status: draft, pending, accepted, rejected, completed, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
{
"success": true,
"data": [
{
"handoff_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"handoffs": []
}
}POST/api/handoffs🔒 auth
Create a Sales->Delivery handoff record in status 'draft'. tenant_id and from_persona_id (the sales persona handing off) are required; deal_id (loose ref to crm.deal), cs_owner/backup personas, kickoff_ref, and the prework/promises/risks/integrations/milestones JSONB arrays are optional. Emits handoff.created.v1. The record then moves through the lifecycle via POST /api/handoffs/:handoff_id/transition.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and from_persona_id are required | tenant_id or from_persona_id missing from body |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"from_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"kickoff_ref": "kickoff-2026-Q3",
"promises": [
"go-live in 30 days"
],
"risks": [
"data migration scope unknown"
]
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"from_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kickoff_ref": "kickoff-2026-Q3",
"promises": [
"go-live in 30 days"
],
"risks": [
"data migration scope unknown"
]
}{
"success": true,
"data": {
"handoff_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"from_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kickoff_ref": "kickoff-2026-Q3",
"promises": [
"go-live in 30 days"
],
"risks": [
"data migration scope unknown"
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"handoff": {
"handoff_id": "string",
"status": "string"
}
}
}GET/api/handoffs/:handoff_id🔒 auth
Fetch a single handoff by id, tenant-scoped via the required tenant_id query param. Returns the full record including lifecycle timestamps (submitted_at/accepted_at/rejected_at/completed_at). 404 when the handoff is not found for the tenant.
[ "POST /api/auth/signup-tenant", "POST /api/handoffs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
| 404 | NotFound | NotFound | handoff_id not found for the tenant |
{
"handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}{
"success": true,
"data": {
"handoff_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"handoff": {
"handoff_id": "string",
"status": "string"
}
}
}PATCH/api/handoffs/:handoff_id🔒 auth
Update editable fields (cs_owner/backup personas, kickoff_ref, prework/promises/risks/integrations/milestones, workflow_run_id, approval_id, metadata) while the handoff is still draft or pending. tenant_id is required. Emits handoff.updated.v1. Returns 409 NotEditable once the handoff is accepted/rejected/completed/cancelled, 404 if not found.
[ "POST /api/auth/signup-tenant", "POST /api/handoffs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing from body |
| 404 | NotFound | NotFound | handoff_id not found for the tenant |
| 409 | NotEditable | handoff in status '<status>' is no longer editable | the handoff is already accepted/rejected/completed/cancelled |
{
"handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"cs_owner_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"milestones": [
{
"name": "Kickoff",
"due": "2026-08-01"
}
],
"metadata": {
"priority": "high"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"cs_owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"milestones": [
{
"name": "Kickoff",
"due": "2026-08-01"
}
],
"metadata": {
"priority": "high"
}
}{
"success": true,
"data": {
"handoff_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"cs_owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"milestones": [
{
"name": "Kickoff",
"due": "2026-08-01"
}
],
"metadata": {
"priority": "high"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"handoff": {
"handoff_id": "string",
"status": "string"
}
}
}POST/api/handoffs/:handoff_id/approval/decision🔒 auth
Record the sdk-approval outcome against a handoff, mapping the approval decision onto the handoff lifecycle: 'approved' moves pending -> accepted (delivery now owns the engagement), 'rejected' moves pending -> rejected and records reject_reason. The move runs through the standard handoff transition path, so it is validated against HANDOFF_TRANSITIONS, stamps accepted_at/rejected_at, and emits the lifecycle event (handoff.accepted.v1 / handoff.rejected.v1) via sdk-audit. Edge cases: the decision is only legal from 'pending' — deciding on a draft handoff (approval never requested) or on an already-decided/terminal one returns 409; 'rejected' is terminal, so a rejected handoff cannot later be accepted; reject_reason is persisted only when decision=rejected and ignored otherwise; 400 when tenant_id or decision is missing, or decision is outside approved|rejected; 404 when the handoff does not exist for that tenant.
[ "POST /api/auth/signup-tenant", "POST /api/handoffs", "PATCH /api/handoffs/:handoff_id", "POST /api/handoffs/:handoff_id/transition", "POST /api/handoffs/:handoff_id/approval/request" ]
decision: approved, rejectedstatus: draft, pending, accepted, rejected, completed, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and decision are required | tenant_id or decision missing from body |
| 400 | ValidationError | decision must be approved or rejected | decision is any value other than approved|rejected |
| 401 | Unauthorized | Unauthorized | Authorization bearer token missing or invalid |
| 404 | NotFound | NotFound | handoff_id not found for the tenant |
| 409 | InvalidTransition | invalid transition <from> -> accepted|rejected | the handoff is not in 'pending' (never submitted for approval, or already decided/terminal) |
| Transition | Triggered by |
|---|---|
draft -> pending | POST /api/handoffs/:handoff_id/approval/request (submit for CS review) |
pending -> accepted | POST /api/handoffs/:handoff_id/approval/decision with decision=approved |
pending -> rejected | POST /api/handoffs/:handoff_id/approval/decision with decision=rejected (reject_reason recorded) |
accepted -> completed | POST /api/handoffs/:handoff_id/transition with status=completed |
{
"handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"decision": "approved",
"reject_reason": "not applicable when approving"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"decision": "approved",
"reject_reason": "not applicable when approving"
}{
"success": true,
"data": {
"decision_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"decision": "approved",
"reject_reason": "not applicable when approving",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"handoff": {
"handoff_id": "string",
"status": "string"
}
}
}POST/api/handoffs/:handoff_id/approval/request🔒 auth
File the CS accept/reject approval for a Sales->Delivery handoff and submit it for review. The gate itself is delegated to sdk-approval — no new approval engine is built in sdk-handoff: the pluggable creator files an approval.request whose subject is the handoff (subject_kind 'handoff.handoff') and the returned request id is stored in handoff.approval_id. A handoff in 'draft' is moved to 'pending' as part of the request; a handoff already past draft is left in its current state, so re-requesting is IDEMPOTENT and returns the existing record rather than 409ing. When no handoff approval route is configured on the gateway (HANDOFF_APPROVAL_ROUTE_ID unset) the SDK's default creator mints a synthetic UUID ref, so the happy path needs no seeded approval route. Edge cases: 400 when tenant_id is absent from the body; 404 when the handoff_id does not exist for that tenant (tenant-scoped — another tenant's handoff reads as not-found); 409 only if the underlying draft->pending transition is rejected from a terminal state (completed/cancelled/rejected).
[ "POST /api/auth/signup-tenant", "POST /api/handoffs", "PATCH /api/handoffs/:handoff_id", "POST /api/handoffs/:handoff_id/transition" ]
status: draft, pending, accepted, rejected, completed, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing from body |
| 401 | Unauthorized | Unauthorized | Authorization bearer token missing or invalid |
| 404 | NotFound | NotFound | handoff_id not found for the tenant |
| 409 | InvalidTransition | invalid transition <from> -> pending | the handoff is in a terminal state (completed/cancelled/rejected) so it cannot be submitted for review |
| Transition | Triggered by |
|---|---|
draft -> pending | POST /api/handoffs/:handoff_id/approval/request (submit for CS review; approval_id stamped) |
pending -> accepted | POST /api/handoffs/:handoff_id/approval/decision with decision=approved |
pending -> rejected | POST /api/handoffs/:handoff_id/approval/decision with decision=rejected |
{
"handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"handoff": {
"handoff_id": "string",
"status": "string"
},
"approval_id": "string"
}
}GET/api/handoffs/:handoff_id/saga🔒 auth
List the per-phase saga projection for a handoff (handoff.saga_step rows): each row is one saga phase (kickoff/prework/promises/risks/milestones) with its status ('done' once the sdk-workflow step succeeded, 'compensated' if a later failure rolled it back), the driving workflow run_id and created_at. Returns an empty list if the saga has not been started. tenant_id query param is required.
[ "POST /api/auth/signup-tenant", "POST /api/handoffs", "POST /api/handoffs/:handoff_id/saga/start" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
{
"handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}{
"success": true,
"data": {
"saga_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"steps": "array"
}
}POST/api/handoffs/:handoff_id/saga/start🔒 auth
Start the Sales->Delivery handoff saga for a handoff. Drives the kickoff -> prework -> promises -> risks -> milestones phases as a durable sdk-workflow saga (each phase is a typed sdk-workflow step with a registered compensator; on a step failure the prior steps' compensators run in reverse, flipping their handoff.saga_step projection to 'compensated'). No new workflow engine is introduced — registration, execution and durability come from sdk-workflow, and the definition ('handoff.saga') is registered at gateway boot. Returns 202 with the workflow run_id, run status and the ordered phase list. tenant_id is required; 404 if the handoff does not exist for the tenant.
[ "POST /api/auth/signup-tenant", "POST /api/handoffs" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing from body |
| 404 | NotFound | NotFound | handoff_id not found for the tenant |
{
"handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"status": "accepted",
"job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 202.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"saga": {
"run_id": "string",
"status": "string",
"phases": "array"
}
}
}POST/api/handoffs/:handoff_id/transition🔒 auth
Advance a handoff's status. Valid transitions: draft->pending (submit), pending->accepted|rejected, accepted->completed; a handoff may be cancelled from any non-terminal state. The matching lifecycle timestamp is stamped, reject_reason recorded when rejecting, and a lifecycle event emitted (handoff.submitted/accepted/rejected/completed/cancelled.v1). tenant_id and status are required. 409 InvalidTransition when the transition is not allowed from the current state; 404 if not found.
[ "POST /api/auth/signup-tenant", "POST /api/handoffs" ]
status: draft, pending, accepted, rejected, completed, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and status are required | tenant_id or status missing from body |
| 400 | ValidationError | invalid status | status is not one of draft|pending|accepted|rejected|completed|cancelled |
| 404 | NotFound | NotFound | handoff_id not found for the tenant |
| 409 | InvalidTransition | invalid transition <from> -> <to> | the requested transition is not allowed from the current status |
{
"handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"status": "pending"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "pending"
}{
"success": true,
"data": {
"transition_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "pending",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"handoff": {
"handoff_id": "string",
"status": "string"
}
}
}sdk-identity
GET/.well-known/jwks.jsonpublic
Publishes the JSON Web Key Set used to verify tokens issued by this gateway, served under /.well-known/ and therefore exempt from the default-deny auth gate — it is intentionally public and requires no bearer token. The response carries cache-control: public, max-age=300, so clients and proxies may serve a cached copy for up to five minutes; a key rotation is therefore not immediately visible to every verifier. Edge cases: the key set is derived from the JWT_SECRET environment variable and falls back to the literal "change-me-in-prod" when it is unset, so a misconfigured deployment still returns 200 with a key set built from the placeholder secret rather than failing — a QA check should assert the served key actually verifies a freshly minted token; the handler takes no parameters and has no failure branch of its own.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 500 | InternalError | InternalError | buildJwks throws while deriving the key set from JWT_SECRET; caught by the route wrapper |
{
"success": true,
"data": [
{
"jwks.json_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"keys": "array"
}GET/.well-known/openid-configurationpublic
Serves the OIDC discovery document describing this gateway as an identity provider — issuer, authorization/token/userinfo endpoints and jwks_uri. Served under /.well-known/ so it is on the gateway public allowlist and needs no bearer token. Edge cases: the issuer is built from the request itself — x-forwarded-proto (falling back to the connection protocol) plus the Host header — so a request arriving with a spoofed or missing Host produces a discovery document advertising the wrong issuer, and the handler falls back to the literal "localhost:3000" when Host is absent; behind nginx the proxy must set x-forwarded-proto or the advertised URLs come back as http on an https deployment; the response is not cached and the handler has no validation or not-found branch.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 500 | InternalError | InternalError | buildOidcDiscovery throws while assembling the document; caught by the route wrapper |
{
"success": true,
"data": [
{
"openid_configuration_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"issuer": "string",
"jwks_uri": "string",
"userinfo_endpoint": "string"
}POST/admin/identity/federation-configs🔒 auth
Creates/updates an identity.federation_config row (SAML/SCIM/social federation, P2 §5.2) — the create producer so tenant onboarding provisions federation via the API instead of the 10_seed_scim_federation_config.sql fixture. Admin-ops-token gated. Idempotent on (tenant_id, protocol). For protocol='scim' the PLAINTEXT scim_bearer_token is hashed (sha256) into scim_bearer_envelope exactly as scimAuthMiddleware verifies it — the plaintext is never stored or returned. scimBearerAuth matches the presented SCIM bearer against this envelope globally (not per-tenant), so provisioning one scim config with the test bearer is what lets the SCIM endpoints authenticate. Edge cases: tenant_id must be a UUID; protocol must be saml|scim|oidc-social; scim_bearer_token is required when protocol='scim'.
[ "POST /api/auth/signup-tenant" ]
protocol: saml, scim, oidc-social| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id (UUID) is required | tenant_id missing or not a UUID |
| 400 | ValidationError | protocol must be saml|scim|oidc-social | protocol missing or not in the enum |
| 400 | ValidationError | scim_bearer_token is required when protocol='scim' | protocol='scim' without a scim_bearer_token |
| 401 | Unauthorized | admin token required | missing or invalid x-admin-ops-token (requireAdmin) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"protocol": "scim",
"scim_bearer_token": "{{var:scim_bearer_token}}",
"jit_enabled": true
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"protocol": "scim",
"scim_bearer_token": "{{var:scim_bearer_token}}",
"jit_enabled": true
}{
"success": true,
"data": {
"federation_config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"protocol": "scim",
"scim_bearer_token": "{{var:scim_bearer_token}}",
"jit_enabled": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"federation_id": "string",
"tenant_id": "string",
"protocol": "string",
"jit_enabled": "boolean"
}
}POST/api/auth/loginpublic
Verifies an email + password credential and mints a six-layer JWT. Public - the gateway authGate allowlists /api/auth/login, so no bearer token is needed. An optional tenant_id selects the tenant context from the person memberships, and an optional app_id auto-mints the L2 AppIdentity on first per-app login. Edge cases: only presence of email and password is validated (no format or length rule), so a blank value is a 400 but a syntactically invalid email reaches the credential check and returns 401; a wrong password and an unknown email return the same 401 InvalidCredentials, so there is no user enumeration; a valid credential plus a tenant_id the person has no active membership in is a 403, distinct from the 401; omitting tenant_id yields a token with tenant_id null and skips both the membership check and the AppIdentity mint; passing app_id without tenant_id silently skips the mint.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | email is required / password is required | validateLoginInput fails - email or password absent or empty; details[] carries every failed rule |
| 401 | InvalidCredentials | Invalid email or password | verifyEmailPassword raises InvalidCredentialsError - unknown email or wrong password |
| 403 | NoMembership | Person <person_id> has no active membership in tenant <tenant_id> | a tenant_id was supplied but the authenticated person has no matching active membership |
| 500 | InternalError | InternalError | any other throw - projection-version read, AppIdentity mint, JWT signing, or a DB error |
{
"email": "{{cache:auth.register.response.data.email}}",
"password": "{{static:DefaultTestPass123!}}"
}{
"email": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"password": "DefaultTestPass123!"
}{
"success": true,
"data": {
"login_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"password": "DefaultTestPass123!",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"userId": "string",
"email": "string",
"tenant_id": "string",
"token": "string"
}
}POST/api/auth/registerpublic
Creates a canonical identity.person plus an email alias and a password credential, then returns 201 with the person id and a freshly minted six-layer JWT that carries no tenant scope. Public - the gateway authGate allowlists /api/auth/register. Edge cases: email must match the email regex and password must be at least 8 characters, so a 7-character password is a 400; given_name, family_name and display_name are each capped at 120 characters and an oversized value is a 400; phone is optional but regex-checked when present; re-registering an email that already exists is a 409 UserExists, not a 200 - the endpoint is not idempotent; validation accumulates, so one 400 may list several messages in details[].
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | email is required / email is invalid / password is required / password must be at least 8 characters / given_name|family_name|display_name must be 120 characters or fewer / phone is invalid | validateRegisterInput fails; details[] carries every failed rule |
| 409 | UserExists | A person with this email already exists | registerPerson raises PersonExistsError because the email alias is already taken |
| 500 | InternalError | InternalError | any other throw - password hashing, JWT signing, or a DB error |
{
"email": "{{dynamic:email}}",
"password": "{{static:DefaultTestPass123!}}",
"given_name": "{{dynamic:name}}",
"family_name": "{{dynamic:name}}",
"display_name": "{{dynamic:name}}",
"phone": "{{dynamic:phone}}"
}{
"email": "qa.user@example.com",
"password": "DefaultTestPass123!",
"given_name": "Acme QA Sample",
"family_name": "Acme QA Sample",
"display_name": "Acme QA Sample",
"phone": "+15555550123"
}{
"success": true,
"data": {
"register_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "qa.user@example.com",
"password": "DefaultTestPass123!",
"given_name": "Acme QA Sample",
"family_name": "Acme QA Sample",
"display_name": "Acme QA Sample",
"phone": "+15555550123",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"user": {
"id": "string",
"email": "string"
},
"tokens": {
"accessToken": "string"
}
}
}POST/api/auth/send-verification-emailpublic
Separate, additive endpoint. Mints a signed email-verification token and sends the email (via the gateway send hook + platform email provider). Body accepts { email } and an optional { userId }; when userId is omitted it is resolved from the email, so a resend flow works with only the email. Returns 202 { sent, email }. Unknown email is a 404. Public - allowlisted (pre-login, no JWT). Does not alter register/signup/login.
{
"userId": "{{optional}}",
"email": "{{dynamic:email}}"
}{
"userId": "{{optional}}",
"email": "qa.user@example.com"
}{
"success": true,
"data": {
"status": "accepted",
"job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 202.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"sent": "boolean",
"email": "string"
}
}POST/api/auth/signup-tenantpublic
Self-serve onboarding: creates the person, their org, a default app, a trial tenant and the admin membership in one transaction, then returns 201 with a JWT already scoped to the new tenant and app. Public - the gateway authGate allowlists /api/auth/signup-tenant. Edge cases: email format and an 8-character minimum password are enforced, company_name is required and capped at 80 characters, and given_name/family_name/display_name are each capped at 120 - each violation is a 400 and details[] may list several at once; signing up with an email that already has a person is a 409 UserExists and the whole org/app/tenant transaction rolls back, so no partial tenant is left behind; the endpoint is not idempotent - retrying with a fresh email creates a second org and tenant.
region: us-east-1, us-west-2, eu-west-1, ap-south-1| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | email is required / email is invalid / password is required / password must be at least 8 characters / company_name is required / company_name must be 80 characters or fewer / given_name|family_name|display_name must be 120 characters or fewer / phone is invalid | validateSignupTenantInput fails; details[] carries every failed rule |
| 409 | UserExists | A person with this email already exists | signupTenant raises PersonExistsError; the org/app/tenant/membership transaction is rolled back |
| 500 | InternalError | InternalError | any other throw - the org/app/tenant/membership transaction fails, JWT signing fails, or a DB error |
{
"email": "{{dynamic:email}}",
"password": "{{static:DefaultTestPass123!}}",
"company_name": "{{dynamic:name}}",
"region": "{{static:us-east-1}}",
"given_name": "{{dynamic:name}}",
"family_name": "{{dynamic:name}}",
"display_name": "{{dynamic:name}}",
"phone": "{{dynamic:phone}}"
}{
"email": "qa.user@example.com",
"password": "DefaultTestPass123!",
"company_name": "Acme QA Sample",
"region": "us-east-1",
"given_name": "Acme QA Sample",
"family_name": "Acme QA Sample",
"display_name": "Acme QA Sample",
"phone": "+15555550123"
}{
"success": true,
"data": {
"signup_tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "qa.user@example.com",
"password": "DefaultTestPass123!",
"company_name": "Acme QA Sample",
"region": "us-east-1",
"given_name": "Acme QA Sample",
"family_name": "Acme QA Sample",
"display_name": "Acme QA Sample",
"phone": "+15555550123",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"userId": "string",
"email": "string",
"token": "string"
}
}GET/api/auth/verification-statuspublic
Separate, additive read used by the UI BEFORE calling /api/auth/login to enforce email verification client-side. Takes an ?email= query param and returns { exists, verified }. exists=false for unknown emails (verified is then false). Public - the gateway authGate allowlists it (pre-login, no JWT). Never gates login itself; enforcement is the caller's.
{
"success": true,
"data": [
{
"verification_statu_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"exists": "boolean",
"verified": "boolean"
}
}POST/api/auth/verify-emailpublic
Separate, additive endpoint. Validates the signed email-verification token (from the link in the verification email) and marks the email alias verified (sets verified_at). Body { token }. Returns 200 { verified, email }. An invalid or expired token is a 400 InvalidToken; no matching email is a 404. Public - allowlisted (pre-login, no JWT).
{
"token": "{{static:SIGNED_EMAIL_VERIFY_JWT}}"
}{
"token": "SIGNED_EMAIL_VERIFY_JWT"
}{
"success": true,
"data": {
"verify_email_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"token": "SIGNED_EMAIL_VERIFY_JWT",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"verified": "boolean",
"email": "string"
}
}POST/api/identity/aliases🔒 auth
Merges an external identifier (alias) onto a person record so that logins arriving through different channels resolve to the same identity, returning 201 with the stored alias. Edge cases: person_id, kind and value are all mandatory and a missing one yields a combined 400; kind is enum-checked against email, phone, gov_id, biometric_template_ref, social_idp_subject and saml_nameid, and any other value is rejected with the allowed list echoed in the message; a person_id that does not exist surfaces as 404 NotFound because the service error message contains "not found"; merging an alias that is already attached to the same person is treated as an idempotent merge rather than a duplicate error, but attaching a value already bound to a different person collides at the datastore and surfaces as a 500 rather than a 409; values are stored as supplied, so casing and formatting are not normalised at this layer.
[ "POST /api/auth/register" ]
kind: email, phone, gov_id, biometric_template_ref, social_idp_subject, saml_nameid| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | person_id, kind, value are required | Any of person_id, kind or value is absent or empty |
| 400 | ValidationError | kind must be one of email, phone, gov_id, biometric_template_ref, social_idp_subject, saml_nameid | kind is supplied but is not in the allowed alias-kind list |
| 400 | ValidationError | <message containing "At least one"> | mergeAlias throws a validation error whose message contains "At least one" (insufficient identifying input for the merge) |
| 404 | NotFound | <entity> not found | mergeAlias throws an error whose message contains "not found" — typically person_id has no matching person row |
| 500 | InternalError | InternalError | mergeAlias throws for any other reason — e.g. the alias value is already bound to a different person and violates a unique constraint, or the database is unreachable |
{
"person_id": "{{cache:auth.register.response.data.userId}}",
"kind": "phone",
"value": "{{dynamic:phone}}"
}{
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "phone",
"value": "+15555550123"
}{
"success": true,
"data": {
"alias_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "phone",
"value": "+15555550123",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"alias": {
"alias_id": "string",
"person_id": "string",
"kind": "string",
"created_at": "string",
"merged_into": "string"
}
}
}POST/api/impersonation/:grant_id/approve🔒 auth
Records the dual-control approval on an impersonation grant by setting manager_approval_id and/or customer_consent_ref, returning the grant with its recomputed status. Requires a valid tenant JWT (requireAuth). Status is derived, not stored: it stays "pending_approval" until BOTH manager_approval_id and customer_consent_ref are non-null, so approving with only one of the two returns 200 with status still pending_approval - send both, or call twice, to reach "active". Edge cases: sending neither field is a 400; an unknown grant_id is a 404; both fields are cast to ::uuid so a non-UUID value is a 500 rather than a 400; there is no state guard, so an already-approved, already-ended or expired grant can be re-approved and simply has its columns overwritten (an expired grant recomputes to "ended", not "active"); the caller identity is never checked against the grant support_user_id, so self-approval is not prevented here.
[ "POST /api/auth/signup-tenant", "POST /api/impersonation/request" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | At least one of manager_approval_id or customer_consent_ref must be provided | the body contains neither manager_approval_id nor customer_consent_ref (empty body, or both empty) |
| 404 | NotFound | Impersonation grant <grant_id> not found | the UPDATE matches no row for the grant_id |
| 500 | InternalError | InternalError | the UPDATE throws - a non-UUID grant_id, manager_approval_id or customer_consent_ref failing the ::uuid cast, or any DB error |
{
"entity": "impersonation_grant",
"field": "status",
"flow": [
"pending_approval",
"active",
"ended"
],
"transitions": [
{
"from": null,
"to": "pending_approval",
"via": "POST /api/impersonation/request"
},
{
"from": "pending_approval",
"to": "active",
"via": "POST /api/impersonation/:grant_id/approve"
},
{
"from": "active",
"to": "ended",
"via": "POST /api/impersonation/:grant_id/end"
}
]
}{
"grant_id": "{{cache:impersonation.request.response.data.grant.grant_id}}"
}{
"manager_approval_id": "{{dynamic:uuid}}",
"customer_consent_ref": "{{var:customer_consent_ref}}"
}{
"manager_approval_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"customer_consent_ref": "{{var:customer_consent_ref}}"
}{
"success": true,
"data": {
"approve_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"manager_approval_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"customer_consent_ref": "{{var:customer_consent_ref}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"grant": {
"grant_id": "string",
"support_user_id": "string",
"target_tenant_id": "string",
"ticket_ref": "string",
"manager_approval_id": "string",
"customer_consent_ref": "string",
"expires_at": "string",
"certificate_audit_id": "string",
"status": "string"
}
}
}POST/api/impersonation/:grant_id/end🔒 auth
Terminates an impersonation grant: stamps a generated certificate_audit_id and pulls expires_at back to LEAST(expires_at, now()), returning the grant with status forced to "ended". Requires a valid tenant JWT (requireAuth). Edge cases: no body is read, so any payload is ignored; there is no state precondition - a grant still in pending_approval, one already ended, or one already expired can all be ended and return 200; it is repeatable but not strictly idempotent, because each call mints a fresh certificate_audit_id that overwrites the previous one; an unknown grant_id is a 404; a non-UUID grant_id fails the uuid comparison and is a 500; the caller identity is never compared to the grant support_user_id, so any authenticated caller can end any grant.
[ "POST /api/auth/signup-tenant", "POST /api/impersonation/request", "POST /api/impersonation/:grant_id/approve" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 404 | NotFound | Impersonation grant <grant_id> not found | the UPDATE matches no row for the grant_id |
| 500 | InternalError | InternalError | the UPDATE throws - a non-UUID grant_id failing the uuid comparison, or any DB error |
{
"entity": "impersonation_grant",
"field": "status",
"flow": [
"pending_approval",
"active",
"ended"
],
"transitions": [
{
"from": null,
"to": "pending_approval",
"via": "POST /api/impersonation/request"
},
{
"from": "pending_approval",
"to": "active",
"via": "POST /api/impersonation/:grant_id/approve"
},
{
"from": "active",
"to": "ended",
"via": "POST /api/impersonation/:grant_id/end"
}
]
}{
"grant_id": "{{cache:impersonation.request.response.data.grant.grant_id}}"
}{}{
"success": true,
"data": {
"end_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"grant": {
"grant_id": "string",
"support_user_id": "string",
"target_tenant_id": "string",
"ticket_ref": "string",
"manager_approval_id": "string",
"customer_consent_ref": "string",
"expires_at": "string",
"certificate_audit_id": "string",
"status": "string"
}
}
}POST/api/impersonation/request🔒 auth
Opens a support impersonation grant against a target tenant, recording support_user_id, target_tenant_id and the ticket_ref, and returns 201 with the grant. Requires a valid tenant JWT (requireAuth). duration_minutes is optional and clamped server-side to the 5..240 range (default 30), so 1 becomes 5 and 10000 becomes 240 rather than erroring. The new grant is always created in status "pending_approval" because manager_approval_id and customer_consent_ref are still null - it does not grant access yet. Edge cases: support_user_id, target_tenant_id and ticket_ref are presence-checked only (no UUID or existence check), so a non-existent or malformed target_tenant_id passes validation and fails on the INSERT as a 500; the caller JWT identity is never compared to support_user_id, so a caller can open a grant naming someone else; there is no duplicate suppression - repeating the same ticket_ref creates another grant.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | support_user_id, target_tenant_id, ticket_ref are required | any of support_user_id, target_tenant_id or ticket_ref is absent or empty |
| 500 | InternalError | InternalError | the INSERT into identity.impersonation_grant throws - a non-UUID support_user_id or target_tenant_id failing the uuid cast, an unsatisfied foreign key, or any DB error |
{
"entity": "impersonation_grant",
"field": "status",
"flow": [
"pending_approval",
"active",
"ended"
],
"transitions": [
{
"from": null,
"to": "pending_approval",
"via": "POST /api/impersonation/request"
},
{
"from": "pending_approval",
"to": "active",
"via": "POST /api/impersonation/:grant_id/approve"
},
{
"from": "active",
"to": "ended",
"via": "POST /api/impersonation/:grant_id/end"
}
]
}{
"support_user_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"target_tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"ticket_ref": "SUP-998",
"duration_minutes": 30
}{
"support_user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ticket_ref": "SUP-998",
"duration_minutes": 30
}{
"success": true,
"data": {
"request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"support_user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ticket_ref": "SUP-998",
"duration_minutes": 30,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"grant": {
"grant_id": "string",
"support_user_id": "string",
"target_tenant_id": "string",
"ticket_ref": "string",
"manager_approval_id": "string",
"customer_consent_ref": "string",
"expires_at": "string",
"certificate_audit_id": "string",
"status": "string"
}
}
}PUT/api/me/profile🔒 auth
Updates the CURRENT caller's profile (person_id comes from the verified JWT sub, never from the body): display_name/given_name/family_name/avatar are merged into the profile.band_l2 'profile' band via a jsonb || upsert, while phone is stored as a person-level identity.alias. Returns 200 with { person_id, band_written, phone_updated }. Edge cases: the name/avatar band write needs a tenant context, resolved from the caller's OLDEST active identity.tenant_membership — a caller with no active membership still gets 200 but with band_written=false and the name fields silently dropped; only truthy fields are written, so empty strings are ignored and no field can be cleared this way; phone is replace-semantics (any existing phone alias is DELETEd first) and passing phone:"" clears it while still reporting phone_updated=true; the new phone alias is inserted with verified_at NULL and its hash needs pgcrypto digest(); the band upsert merges rather than replaces, so omitted keys are preserved. Requires a valid tenant JWT.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 401 | Unauthorized | Unauthorized | requireAuth passed but req.auth.sub is absent, so no person_id can be resolved (payload is { success:false, error:"Unauthorized" }) |
| 500 | InternalError | <Postgres error message> | Any DB failure in the try block — phone alias DELETE/INSERT (e.g. pgcrypto digest() unavailable or a unique-constraint clash on value_hash), the app_identity upsert, or the profile.band_l2 upsert; returned as { success:false, error:<message> } |
{
"display_name": "{{dynamic:name}}",
"given_name": "Ada",
"family_name": "Lovelace",
"phone": "{{dynamic:phone}}",
"avatar": "https://cdn.example.com/avatar.png"
}{
"display_name": "Acme QA Sample",
"given_name": "Ada",
"family_name": "Lovelace",
"phone": "+15555550123",
"avatar": "https://cdn.example.com/avatar.png"
}{
"success": true,
"data": {
"profile_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"display_name": "Acme QA Sample",
"given_name": "Ada",
"family_name": "Lovelace",
"phone": "+15555550123",
"avatar": "https://cdn.example.com/avatar.png",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true,
"data": {
"person_id": "string",
"band_written": true,
"phone_updated": true
}
}GET/api/memberships🔒 auth
Lists every tenant and app the signed-in person holds an active membership in — the read behind a single login that works across several providers. One email is one identity.person GLOBALLY (identity.alias is UNIQUE (kind, value_hash)), and that one person may hold memberships in any number of tenants, which is exactly what lets one credential open several providers apps. Until now nothing exposed that list: listMemberships() was internal and the only exposed read was scoped to a single app_identity, so a client had no way to learn the tenant_id it must send to obtain a scoped token, and an app-switcher could not be built at all. THE SUBJECT COMES FROM THE VERIFIED TOKEN AND THERE IS DELIBERATELY NO PARAMETER TO OVERRIDE IT: accepting a person_id would let any authenticated caller enumerate another persons provider relationships, which is precisely the cross-tenant disclosure the default-deny gate exists to prevent. Retired apps are omitted because a membership in one is a door that does not open. Each row carries the tenant, its app, the business unit, the role template and whether an app_identity has been minted yet — the app_identity mints itself on first per-app login, so false simply means they have not entered that app.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | the route is behind requireAuth; the subscription list is personal data and is never public |
| 401 | Unauthorized | Invalid or expired token | the token fails verification, so no subject can be trusted from it |
{}{
"success": true,
"data": [
{
"membership_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {}
}POST/api/mfa/challenge🔒 auth
Issues a step-up MFA challenge for the CALLER (person_id is taken from the verified JWT sub, never from the body), returning 201 with challenge_id, kind, a kind-specific payload and expires_at. kind defaults to 'totp' and must be one of totp|webauthn|sms_otp; the payload is an instruction string for totp, a { challenge, allow_credentials } object for webauthn, and an sms delivery hint for sms_otp. Edge cases: challenges live in an in-process Map with a short TTL and are purged on each call, so they do not survive a gateway restart and are not shared across replicas; issuing repeatedly creates independent challenge_ids with no rate limit or per-person cap, and older ones stay valid until they expire; an empty body is accepted and silently defaults to totp. Requires a valid tenant JWT (route preHandler requireAuth plus the gateway default-deny gate).
[ "POST /api/auth/register" ]
kind: totp, webauthn, sms_otp| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 401 | Unauthorized | Missing person_id | The JWT verified but carries no sub claim, so the handler cannot bind the challenge to a person |
| 400 | ValidationError | kind must be totp|webauthn|sms_otp | body.kind is present but is not one of totp, webauthn, sms_otp |
| 500 | InternalError | InternalError | issueMfaChallenge throws unexpectedly (crypto/UUID or challenge-store failure); caught by the controller fail() helper and by the route wrapper |
{
"kind": "totp"
}{
"kind": "totp"
}{
"success": true,
"data": {
"challenge_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "totp",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"challenge_id": "string",
"kind": "string",
"payload": {},
"expires_at": "string"
}
}POST/api/mfa/verifypublic
Completes an MFA challenge issued by POST /api/mfa/challenge and returns 200 with { verified, person_id, mfa_level: 2 }, also stamping identity.credential.last_used_at for that person and credential kind. This path is deliberately PUBLIC — it is on the gateway authGate allowlist and has no requireAuth preHandler, because the caller is mid-login and has no full JWT yet. Both challenge_id and response are required. Edge cases: a challenge is SINGLE-USE — it is deleted from the in-process store on the first verify attempt whether or not it succeeded, so replaying the same challenge_id returns challenge_not_found_or_expired; challenges also expire on their TTL and are purged on every call, and do not survive a restart or reach another replica; for totp any 6-digit numeric string is accepted in dev, while webauthn/sms_otp require the exact issued secret; a failed verification is reported as 401 (not 200 with verified:false).
[ "POST /api/auth/register", "POST /api/mfa/challenge" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | challenge_id and response are required | Body is missing either challenge_id or response (empty strings are falsy and count as missing) |
| 401 | MfaFailed | challenge_not_found_or_expired | No live challenge for that challenge_id — unknown id, TTL expired and purged, already consumed by a previous verify, or issued by a different gateway process |
| 401 | MfaFailed | invalid_response | The challenge exists but the response does not match — not a 6-digit code for totp, or not the exact issued secret for webauthn/sms_otp |
| 500 | InternalError | InternalError | The identity.credential last_used_at UPDATE fails after a successful match, or verifyMfaChallenge throws unexpectedly |
{
"challenge_id": "{{cache:mfa.challenge.response.data.challenge_id}}",
"response": "123456"
}{
"challenge_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"response": "123456"
}{
"success": true,
"data": {
"status": "completed",
"challenge_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"response": "123456",
"verify_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"verified": true,
"person_id": "string",
"mfa_level": 2
}
}GET/api/userinfo🔒 auth
Returns the profile of the currently authenticated principal, merging the stored person record with the verified JWT claims, and is the endpoint the portals call to resolve a session cookie into a user. Edge cases: requires a bearer token, so a missing or expired token is 401 from requireAuth; a token that verifies but carries no sub claim is a second, distinct 401 raised inside the handler ("Missing person_id claim"); a token whose sub references a person row that has been deleted returns 404 NotFound even though the token is still cryptographically valid — clients must treat that as a forced re-login rather than a transient error; the response merges req.auth over the stored record, so claim values shadow stored fields of the same name; there are no parameters and the call is read-only and idempotent.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 401 | Unauthorized | Missing person_id claim | The token verifies but has no sub claim, so no person can be resolved |
| 404 | NotFound | Person not found | readUserinfo returns nothing for the token's sub — the person row was deleted or never existed |
| 500 | InternalError | InternalError | readUserinfo throws for any reason other than a "not found" message |
{
"success": true,
"data": [
{
"userinfo_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"sub": "string",
"person_id": "string",
"email": "string",
"display_name": "string",
"avatar": "string",
"roles": "array",
"persona": "string"
}
}POST/saml/:tenant_id/acspublic
SAML Assertion Consumer Service (FR-IDN-8). The route has no auth preHandler (the IdP POSTs directly). With the default SAML_ADAPTER=mock the body is treated as an already-parsed assertion { name_id, email, groups, attributes }; downstream consumeSamlAssertion() finds-or-JIT-provisions identity.person, merges saml_nameid+email aliases, upserts identity.app_identity, and returns person_id/app_identity_id/alias_ids/jit_provisioned/role_template_id.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | SAML_ADAPTER=<kind> but <package> is not installed. Run: pnpm --filter @projexlight/sdk-identity add <package> | SAML_ADAPTER is node-saml or saml2-js but the corresponding library is not installed (error message contains "not installed") |
| 400 | ValidationError | SAML response missing NameID | A real adapter parsed the AuthnResponse but the profile carried no NameID (error message contains "NameID") |
| 401 | SamlSignatureFailed | <signature/cert verification error from the adapter> | The adapter rejects the assertion signature, or requires idp_cert and identity.federation_config has none for this tenant (message contains "signature" or "cert") |
| 404 | NotFound | <not found error from consumeSamlAssertion> | A downstream lookup reports "not found" while resolving the person/app identity |
| 500 | InternalError | InternalError | Any other failure — notably the default mock adapter rejecting a body with no name_id ("mock adapter requires name_id in body" matches none of the 400/401 filters), JIT person-insert failure, alias merge failure, or a DB error reading identity.federation_config |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"name_id": "{{dynamic:email}}",
"email": "{{dynamic:email}}",
"groups": [
"Engineering"
],
"attributes": {}
}{
"name_id": "qa.user@example.com",
"email": "qa.user@example.com",
"groups": [
"Engineering"
],
"attributes": {}
}{
"success": true,
"data": {
"acs_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name_id": "qa.user@example.com",
"email": "qa.user@example.com",
"groups": [
"Engineering"
],
"attributes": {},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"jit_provisioned": true
}
}GET/saml/:tenant_id/metadatapublic
Public SAML SP metadata XML for a tenant (FR-IDN-8). No auth; buildSamlSpMetadata() renders the SP EntityDescriptor from the request host + tenant_id and replies application/samlmetadata+xml with 200.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 500 | InternalError | InternalError | buildSamlSpMetadata or the reply serialization throws; caught by the route wrapper, which sends 500 only if the reply has not already been sent |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"success": true,
"data": {
"metadata_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "metadata not found"
}POST/scim/v2/Users🔒 auth
SCIM 2.0 user provisioning (FR-IDN-9): find-or-create an identity.person from the primary email alias and upsert an identity.tenant_membership for the resolved tenant, mapping user.active to membership status active|suspended. Returns 201 with the SCIM User representation when a new person was created and 200 when an existing email alias was matched, which makes repeated pushes from Okta/Azure AD idempotent. Tenant scoping comes from scimBearerAuth (Authorization: Bearer matched against identity.federation_config.scim_bearer_envelope where protocol='scim' and jit_enabled), falling back to the x-tenant-id header in dev when the stored envelope is NULL. Edge cases: the body's schemas array MUST include urn:ietf:params:scim:schemas:core:2.0:User; a user with no emails[] entry is rejected; /scim/* is NOT on the gateway public allowlist, so the Bearer must also be a valid tenant JWT to clear the default-deny authGate before scimBearerAuth runs.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 401 | Unauthorized | Missing SCIM Bearer token | scimBearerAuth finds no Authorization: Bearer <token> header (SCIM error payload with schemas urn:ietf:params:scim:api:messages:2.0:Error) |
| 401 | Unauthorized | SCIM Bearer token not recognized | No identity.federation_config row with protocol=scim, jit_enabled=TRUE and a matching (or NULL) scim_bearer_envelope hash |
| 400 | ValidationError | No SCIM Bearer token resolved and no x-tenant-id header set | req.scimContext.tenant_id is unset and no x-tenant-id header was supplied |
| 400 | ValidationError | schemas must include SCIM 2.0 User | Body is missing, or schemas[] does not contain urn:ietf:params:scim:schemas:core:2.0:User |
| 400 | ValidationError | SCIM user must include at least one email | emails[] is absent or empty, so provisionScimUser cannot derive the email alias (message contains "must include") |
| 404 | NotFound | <not found error from provisionScimUser> | A downstream lookup during provisioning reports "not found" |
| 500 | InternalError | InternalError | Any other provisioning failure — person insert returning no row ("Failed to provision SCIM person"), alias merge failure, or the tenant_membership upsert failing (e.g. tenant_id is not a valid UUID / violates its FK) |
{
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User"
],
"userName": "{{dynamic:email}}",
"active": true,
"emails": [
{
"value": "{{dynamic:email}}",
"primary": true
}
],
"name": {
"givenName": "Ada",
"familyName": "Lovelace"
},
"groups": [
{
"display": "Engineering",
"value": "eng"
}
]
}{
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User"
],
"userName": "qa.user@example.com",
"active": true,
"emails": [
{
"value": "qa.user@example.com",
"primary": true
}
],
"name": {
"givenName": "Ada",
"familyName": "Lovelace"
},
"groups": [
{
"display": "Engineering",
"value": "eng"
}
]
}{
"success": true,
"data": {
"User_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User"
],
"userName": "qa.user@example.com",
"active": true,
"emails": [
{
"value": "qa.user@example.com",
"primary": true
}
],
"name": {
"givenName": "Ada",
"familyName": "Lovelace"
},
"groups": [
{
"display": "Engineering",
"value": "eng"
}
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}DELETE/scim/v2/Users/:person_id🔒 auth
SCIM 2.0 deprovisioning (FR-IDN-9): soft-offboards a person in the resolved tenant by setting identity.tenant_membership.status = 'offboarded' for that (person_id, tenant_id) pair, replying 204 with no body. The person row, aliases and app identities are deliberately left intact — this is a membership status change, not a delete. Tenant scoping comes from scimBearerAuth (Bearer matched against identity.federation_config.scim_bearer_envelope), falling back to x-tenant-id in dev. Edge cases: the call is fully idempotent and returns 204 even when person_id is unknown or the membership is already offboarded, because the UPDATE simply matches zero rows; it is tenant-scoped, so a person_id belonging to another tenant is a silent no-op; a non-UUID person_id fails the Postgres cast and surfaces as 500; /scim/* is NOT on the gateway public allowlist, so the Bearer must also be a valid tenant JWT to clear the default-deny authGate.
[ "POST /api/auth/signup-tenant", "POST /scim/v2/Users" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 401 | Unauthorized | Missing SCIM Bearer token | scimBearerAuth finds no Authorization: Bearer <token> header (SCIM error payload with schemas urn:ietf:params:scim:api:messages:2.0:Error) |
| 401 | Unauthorized | SCIM Bearer token not recognized | No identity.federation_config row with protocol=scim, jit_enabled=TRUE and a matching (or NULL) scim_bearer_envelope hash |
| 400 | ValidationError | No SCIM Bearer token resolved and no x-tenant-id header set | req.scimContext.tenant_id is unset and no x-tenant-id header was supplied |
| 500 | InternalError | InternalError | deprovisionScimUser throws — person_id or tenant_id is not a valid UUID, or the tenant_membership UPDATE fails |
{
"person_id": "{{cache:scim.create.response.data.person_id}}"
}{
"success": true
}{success,data} envelope derived from the request contract; assert shape + HTTP 204.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-identity-resolver
GET/api/empi/candidate-links🔒 auth
Steward review queue: POSSIBLY_SAME candidate links by confidence band (band=high|medium|low, or explicit min/max) and status. TENANT-SCOPED — results are restricted to the tenant on the calling credential; there is no tenant_id parameter and pre-2026-08 rows with no tenant are excluded.
[ "POST /api/auth/register" ]
band: high, medium, lowstatus: open, merged, rejected, superseded| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 500 | InternalError | <error message from queryCandidateLinksByBand> | the candidate-link query throws (bad band range, DB failure) — caught and returned as a bare InternalError |
{
"success": true,
"data": [
{
"candidate_link_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"candidate_links": "array"
}
}POST/api/empi/candidate-links/:link_id/adjudicate🔒 auth
Records a steward verdict (approve merges the pair reversibly; reject marks the link rejected and stamps decided_at, feeding review-latency). Needs step_id from steward-review, and the acting persona MUST equal that step's approver or sdk-approval rejects it. 404 when the link is not the caller tenant's.
[ "POST /api/auth/signup-tenant", "POST /api/empi/candidate-links/:link_id/steward-review" ]
decision: approve, reject| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | step_id and decision (approve|reject) are required | body.step_id is missing or body.decision is anything other than "approve" or "reject" |
| 500 | InternalError | empi: candidate link <link_id> not found | the link_id in the path matches no candidate-link row — surfaced as a 500, not a 404 |
| 500 | InternalError | <error message from adjudicateCandidate> | the approval step is already decided, does not belong to the link, or the decision write fails |
{
"entity": "candidate_link",
"field": "status",
"flow": [
"open",
"merged",
"rejected",
"superseded"
],
"transitions": [
{
"from": "open",
"to": "merged",
"via": "POST /api/empi/candidate-links/:link_id/adjudicate"
},
{
"from": "open",
"to": "rejected",
"via": "POST /api/empi/candidate-links/:link_id/adjudicate"
}
]
}{
"link_id": "{{cache:empi.steward-review.response.data.link.link_id}}"
}{
"step_id": "{{cache:empi.steward-review.response.data.pending_step_ids.0}}",
"decision": "approve",
"reason": "Records confirmed as the same patient"
}{
"step_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"decision": "approve",
"reason": "Records confirmed as the same patient"
}{
"success": true,
"data": {
"adjudicate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"step_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"decision": "approve",
"reason": "Records confirmed as the same patient",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"link": {
"link_id": "string",
"status": "string"
}
}
}POST/api/empi/candidate-links/:link_id/steward-review🔒 auth
Queues a candidate link for steward review via sdk-approval; returns pending step ids for adjudicate. Requires route_id (an approval route whose step approver is the persona the adjudicating caller will present). tenant_id in the body is IGNORED — the tenant comes from the credential.
[ "POST /api/auth/signup-tenant", "POST /api/approvals/routes" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | route_id, tenant_id are required | body.route_id is missing, or tenant_id is absent from both the body and the JWT tenant claim |
| 500 | InternalError | empi: candidate link <link_id> not found | the link_id in the path matches no candidate-link row — surfaced as a 500 (with the message in details), not a 404 |
| 500 | InternalError | <error message from enqueueStewardReview> | the approval-step insert or any other service call throws |
{
"link_id": "{{var:link_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"route_id": "{{cache:approvals.routes.create.response.data.route.route_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"steward_review_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"link": {
"link_id": "string",
"steward_request_id": "string"
},
"pending_step_ids": "array"
}
}POST/api/empi/merges🔒 auth
Merges two persons as a reversible, event-sourced merge event; never destructive. Attributed to the calling tenant. NOTE: the merge acts on the global L1 identity.person, so its effect is visible to every tenant sharing that person — attribution is per tenant, the effect is not.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 400 | ValidationError | surviving_person_id, merged_person_id are required | body omits surviving_person_id or merged_person_id |
| 500 | InternalError | empi: merge failed | the merge insert returns no row — unknown person ids, a self-merge, or an FK/constraint violation |
{
"entity": "candidate_link",
"field": "status",
"flow": [
"open",
"merged",
"rejected",
"superseded"
],
"transitions": [
{
"from": "open",
"to": "merged",
"via": "POST /api/empi/merges"
}
]
}{
"surviving_person_id": "{{cache:auth.register.response.data.userId}}",
"merged_person_id": "{{var:person_id}}",
"link_id": "{{var:link_id}}",
"reason": "Duplicate patient records confirmed by steward"
}{
"surviving_person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"merged_person_id": "{{var:person_id}}",
"link_id": "{{var:link_id}}",
"reason": "Duplicate patient records confirmed by steward"
}{
"success": true,
"data": {
"merge_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"surviving_person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"merged_person_id": "{{var:person_id}}",
"link_id": "{{var:link_id}}",
"reason": "Duplicate patient records confirmed by steward",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"merge": {
"merge_id": "string",
"kind": "string"
}
}
}POST/api/empi/merges/:merge_id/unmerge🔒 auth
Reverses a merge with a compensating unmerge event and reopens the originating candidate link (clearing decided_at). 404 MergeNotFound when the id is unknown OR belongs to another tenant — deliberately indistinguishable.
[ "POST /api/auth/register", "POST /api/empi/merges" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 500 | InternalError | empi: merge <merge_id> not found | the merge_id in the path matches no merge row — surfaced as a 500, not a 404 |
| 500 | InternalError | empi: unmerge failed | the compensating insert returns no row (e.g. the merge was already reversed) |
{
"entity": "candidate_link",
"field": "status",
"flow": [
"open",
"merged",
"rejected",
"superseded"
],
"transitions": [
{
"from": "merged",
"to": "open",
"via": "POST /api/empi/merges/:merge_id/unmerge"
}
]
}{
"merge_id": "{{cache:empi.merges.create.response.data.merge.merge_id}}"
}{
"reason": "Merge reversed - records belong to distinct persons"
}{
"reason": "Merge reversed - records belong to distinct persons"
}{
"success": true,
"data": {
"unmerge_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "Merge reversed - records belong to distinct persons",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"merge": {
"merge_id": "string",
"kind": "string",
"reverses_merge_id": "string"
}
}
}GET/api/empi/metrics🔒 auth
EMPI observability for the CALLING TENANT only: unresolved links, merge reversals, confidence distribution, calibration ECE + drift alert, per-band adjudicated outcomes (band_outcomes / high_risk_precision) and review latency (review_latency: median_minutes, p90_minutes over window_days). Null precision/median means nothing adjudicated yet — render 'not measured', not zero.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in the form "Bearer <jwt>" |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or past its exp claim |
| 500 | InternalError | <error message from getEmpiMetrics> | the calibration/metrics aggregation throws — caught and returned as a bare InternalError |
{
"success": true,
"data": [
{
"metric_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"metrics": {
"unresolved_candidate_links": "number",
"merge_reversals": "number",
"calibration_ece": "number"
}
}
}POST/api/resolver/explain🔒 auth
Returns provenance metadata (source SDK, computed_at, projection_version) for a single resolved identity attribute of a person within a tenant/app context. Requires person_id, app_id, tenant_id and attribute — any missing field yields 400. attribute is NOT enum-validated: an unrecognized attribute returns 200 with source_sdk="sdk-identity" via the explain() default branch. A downstream resolve failure surfaces as Fastify default 500 (no explicit branch).
[ "POST /api/auth/signup-tenant", "POST /scim/v2/Users" ]
attribute: primary_persona_id, all_persona_ids, effective_role_closure, active_consents, admin_pool_index, app_pool_index, rebac_edges, abac_attributes| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | missing required fields | person_id, app_id, tenant_id or attribute missing/falsy in body |
{
"person_id": "{{cache:scim.create.response.data.person_id}}",
"app_id": "{{cache:auth.signup-tenant.response.data.app_id}}",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"attribute": "primary_persona_id"
}{
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"attribute": "primary_persona_id"
}{
"success": true,
"data": {
"explain_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"attribute": "primary_persona_id",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/resolver/resolve🔒 auth
Resolves and returns the full IdentityContext for a person in a tenant/app (personas, role closure, consents, pool indices) from the Redis/Postgres projection with a live-compose fallback on projection miss. Requires person_id, app_id and tenant_id; optional bypass_cache boolean skips the projection cache. Any missing required field yields 400; an internal resolve failure falls through to Fastify default 500.
[ "POST /api/auth/signup-tenant", "POST /scim/v2/Users" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | missing required fields | person_id, app_id or tenant_id missing/falsy in body |
{
"person_id": "{{cache:scim.create.response.data.person_id}}",
"app_id": "{{cache:auth.signup-tenant.response.data.app_id}}",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"bypass_cache": false
}{
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"bypass_cache": false
}{
"success": true,
"data": {
"status": "completed",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"bypass_cache": false,
"resolve_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-import
GET/api/imports/mapping-templates🔒 auth
List a tenant's mapping templates, grouped by slug with the newest version first, optionally narrowed by slug, kind or active flag. limit is clamped to 1..500 (default 50). Required: tenant_id query param.
[ "POST /api/auth/signup-tenant", "POST /api/imports/mapping-templates" ]
kind: certified, customcrosswalk_strategy: preserve_existing, add_alias, reject_conflict| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query param is absent |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"mapping_template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"templates": "array",
"count": "number"
}
}POST/api/imports/mapping-templates🔒 auth
Create a reusable, versioned column mapping. Version 1 is created here; later revisions are NEW ROWS via POST /:template_id/version, never edits — a template referenced by a committed run is frozen by database trigger, because rewriting the mapping that produced already-landed rows would make that run's lineage a lie. crosswalk_strategy decides what happens when an incoming external id already maps to a known entity. kind is certified (platform-curated) or custom (the tenant's own). UNIQUE(tenant_id, slug, version). Required: tenant_id, slug, name.
[ "POST /api/auth/signup-tenant" ]
kind: certified, customcrosswalk_strategy: preserve_existing, add_alias, reject_conflicttarget: person.given_name, person.family_name, person.full_name, person.date_of_birth, contact.email, contact.phone, contact.handle, org.name, org.domain, org.size, place.address_line1, place.address_line2, place.locality, place.region, place.postal_code, place.country, external.id, attribute.custom, unmapped| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id, slug and name are required | any required field is missing from the body |
| 409 | DUPLICATE_TEMPLATE_VERSION | a template with this slug and version already exists for the tenant | UNIQUE(tenant_id, slug, version) is violated |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"slug": "contacts-{{dynamic:slug}}",
"name": "Contact extract mapping",
"description": "Maps a partner contact extract onto canonical targets",
"kind": "custom",
"field_map": {},
"transforms": [],
"value_crosswalks": {},
"crosswalk_strategy": "preserve_existing",
"created_by": "qa-runner",
"metadata": {
"origin": "qa"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"slug": "contacts-{{dynamic:slug}}",
"name": "Contact extract mapping",
"description": "Maps a partner contact extract onto canonical targets",
"kind": "custom",
"field_map": {},
"transforms": [],
"value_crosswalks": {},
"crosswalk_strategy": "preserve_existing",
"created_by": "qa-runner",
"metadata": {
"origin": "qa"
}
}{
"success": true,
"data": {
"mapping_template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"slug": "contacts-{{dynamic:slug}}",
"name": "Contact extract mapping",
"description": "Maps a partner contact extract onto canonical targets",
"kind": "custom",
"field_map": {},
"transforms": [],
"value_crosswalks": {},
"crosswalk_strategy": "preserve_existing",
"created_by": "qa-runner",
"metadata": {
"origin": "qa"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"template": {
"template_id": "string",
"version": "number"
}
}
}POST/api/imports/mapping-templates/:template_id/version🔒 auth
Publish the next version of a template as a NEW ROW, carrying the parent version's values for anything not overridden and recording parent_template_id so "which mapping did v3 come from" stays answerable. This is the supported way to change a template: once any run that referenced it has committed, the database trigger freezes its definition columns, since rewriting them would make that run's lineage describe a mapping that no longer exists. Returns 201 — a version is a new resource, not an edit. Required: tenant_id.
[ "POST /api/auth/signup-tenant", "POST /api/imports/mapping-templates" ]
crosswalk_strategy: preserve_existing, add_alias, reject_conflicttarget: person.given_name, person.family_name, person.full_name, person.date_of_birth, contact.email, contact.phone, contact.handle, org.name, org.domain, org.size, place.address_line1, place.address_line2, place.locality, place.region, place.postal_code, place.country, external.id, attribute.custom, unmapped| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id is required | tenant_id missing from the body |
| 404 | MAPPING_TEMPLATE_NOT_FOUND | mapping template <id> not found for tenant | the template does not exist, or belongs to another tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"template_id": "{{cache:imports.template.create.response.data.template.template_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"name": "Contact extract mapping v2",
"description": "Adds the postal code column",
"field_map": {},
"transforms": [],
"value_crosswalks": {},
"crosswalk_strategy": "add_alias",
"created_by": "qa-runner"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Contact extract mapping v2",
"description": "Adds the postal code column",
"field_map": {},
"transforms": [],
"value_crosswalks": {},
"crosswalk_strategy": "add_alias",
"created_by": "qa-runner"
}{
"success": true,
"data": {
"version_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Contact extract mapping v2",
"description": "Adds the postal code column",
"field_map": {},
"transforms": [],
"value_crosswalks": {},
"crosswalk_strategy": "add_alias",
"created_by": "qa-runner",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"template": {
"template_id": "string",
"version": "number",
"parent_template_id": "string"
}
}
}GET/api/imports/runs🔒 auth
List a tenant's import runs, newest first, optionally narrowed by status or source kind. limit is clamped to 1..500 (default 50). Returns an empty array rather than a 404 when nothing matches. Required: tenant_id query param.
[ "POST /api/auth/signup-tenant", "POST /api/imports/runs" ]
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query param is absent |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"runs": "array",
"count": "number"
}
}POST/api/imports/runs🔒 auth
Open a governed import. The run is the unit of governance: preview, mapping, transform plan, dry run, commit and rollback all hang off it. UNIQUE(tenant_id, file_fingerprint, source_kind) is the COMMIT IDEMPOTENCY KEY, so submitting the same file for the same source twice is refused with 409 DUPLICATE_IMPORT_RUN carrying existing_run_id — the caller almost always wants to continue that run rather than fork a second one. rollback_window_hours sets how long after commit an undo stays possible (default 24h); the DEADLINE itself is derived at commit time and cannot be supplied or extended. Required: tenant_id, source_kind, file_fingerprint.
[ "POST /api/auth/signup-tenant" ]
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id, source_kind and file_fingerprint are required | any required field is missing from the body |
| 409 | DUPLICATE_IMPORT_RUN | file <fingerprint> was already submitted for this source | a run already exists for this (tenant, file_fingerprint, source_kind) |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"source_kind": "csv_upload",
"file_fingerprint": "fp-{{dynamic:uuid}}",
"source_ref": "upload://{{dynamic:uuid}}",
"file_name": "contacts-{{dynamic:slug}}.csv",
"attestation_id": "{{dynamic:uuid}}",
"row_count": 2,
"rollback_window_hours": 24,
"started_by": "qa-runner",
"metadata": {
"channel": "qa"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"source_kind": "csv_upload",
"file_fingerprint": "fp-{{dynamic:uuid}}",
"source_ref": "upload://{{dynamic:uuid}}",
"file_name": "contacts-{{dynamic:slug}}.csv",
"attestation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"row_count": 2,
"rollback_window_hours": 24,
"started_by": "qa-runner",
"metadata": {
"channel": "qa"
}
}{
"success": true,
"data": {
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"source_kind": "csv_upload",
"file_fingerprint": "fp-{{dynamic:uuid}}",
"source_ref": "upload://{{dynamic:uuid}}",
"file_name": "contacts-{{dynamic:slug}}.csv",
"attestation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"row_count": 2,
"rollback_window_hours": 24,
"started_by": "qa-runner",
"metadata": {
"channel": "qa"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"run": {
"run_id": "string",
"status": "string"
}
}
}GET/api/imports/runs/:run_id🔒 auth
Read one run together with its lineage: every entity the run created, the action that created it, and whether it has been reversed. The lineage is returned inline because it is what makes a rollback possible at all — a created entity with no lineage row is indistinguishable from one a human made. Required: tenant_id query param.
[ "POST /api/auth/signup-tenant", "POST /api/imports/runs" ]
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_backaction: created, linked, updated, asserted, reversed| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query param is absent |
| 404 | IMPORT_RUN_NOT_FOUND | import run <id> not found for tenant | the run does not exist, or belongs to another tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}{
"success": true,
"data": {
"run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"run": {
"run_id": "string"
},
"lineage": "array"
}
}POST/api/imports/runs/:run_id/commit🔒 auth
Land every row and mark the run complete. IDEMPOTENT BY CONSTRUCTION: entity keys are derived from run_id + the row's own content fingerprint, lineage is written under UNIQUE(run_id, entity_kind, entity_id, action) with ON CONFLICT DO NOTHING, and a transaction-scoped advisory lock stops two workers committing the same run — so interrupting a commit and retrying it produces an identical entity set, and a commit of an already-complete run returns replayed:true without changing anything. Refuses without a signed source-rights attestation (422). A place is written as its own entity plus a located_at relationship, never as columns on the person. CONSENT IS NEVER FABRICATED: declare the consent columns and a receipt is created ONLY for a recognised affirmative value that also carries a capture date that parses; a blank, a generic placeholder ("n/a", "unknown", "-"), an unrecognised value or a missing date all land the row WITHOUT a receipt and file an explained exception. Returns 200: it moves an existing run. Required: tenant_id, rows[].
[ "POST /api/auth/signup-tenant", "POST /api/imports/runs", "POST /api/imports/runs/:run_id/preview", "PUT /api/imports/runs/:run_id/mapping", "POST /api/imports/runs/:run_id/transform-plan", "POST /api/imports/runs/:run_id/dry-run" ]
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id and rows[] are required | either field is missing from the body |
| 404 | IMPORT_RUN_NOT_FOUND | import run <id> not found for tenant | the run does not exist, or belongs to another tenant |
| 409 | IMPORT_RUN_LOCKED | run <id> is already being committed by another worker | a concurrent commit holds the run lock; retry shortly |
| 409 | INVALID_RUN_TRANSITION | run <id> cannot move rolled_back -> committing | the run was already rolled back, or has no confirmed mapping / transform plan |
| 422 | ATTESTATION_NOT_SIGNED | run <id> has no signed source-rights attestation — the commit is refused | no attestation covers the source |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"rows": [
{
"full_name": "Ada Lovelace",
"email": "{{dynamic:email}}",
"street_address": "1 Analytical Way",
"city": "London",
"country": "GB",
"external_id": "CRM-{{dynamic:uuid}}",
"contact_ok": "yes",
"consent_date": "2026-01-05T10:00:00Z"
},
{
"full_name": "Alan Turing",
"email": "{{dynamic:email}}",
"street_address": "2 Bombe Road",
"city": "Manchester",
"country": "GB",
"external_id": "CRM-{{dynamic:uuid}}",
"contact_ok": "",
"consent_date": ""
}
],
"consent": {
"value_column": "contact_ok",
"purpose": "outreach",
"captured_at_column": "consent_date"
},
"actor_id": "qa-runner"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rows": [
{
"full_name": "Ada Lovelace",
"email": "qa.user@example.com",
"street_address": "1 Analytical Way",
"city": "London",
"country": "GB",
"external_id": "CRM-{{dynamic:uuid}}",
"contact_ok": "yes",
"consent_date": "2026-01-05T10:00:00Z"
},
{
"full_name": "Alan Turing",
"email": "qa.user@example.com",
"street_address": "2 Bombe Road",
"city": "Manchester",
"country": "GB",
"external_id": "CRM-{{dynamic:uuid}}",
"contact_ok": "",
"consent_date": ""
}
],
"consent": {
"value_column": "contact_ok",
"purpose": "outreach",
"captured_at_column": "consent_date"
},
"actor_id": "qa-runner"
}{
"success": true,
"data": {
"commit_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rows": [
{
"full_name": "Ada Lovelace",
"email": "qa.user@example.com",
"street_address": "1 Analytical Way",
"city": "London",
"country": "GB",
"external_id": "CRM-{{dynamic:uuid}}",
"contact_ok": "yes",
"consent_date": "2026-01-05T10:00:00Z"
},
{
"full_name": "Alan Turing",
"email": "qa.user@example.com",
"street_address": "2 Bombe Road",
"city": "Manchester",
"country": "GB",
"external_id": "CRM-{{dynamic:uuid}}",
"contact_ok": "",
"consent_date": ""
}
],
"consent": {
"value_column": "contact_ok",
"purpose": "outreach",
"captured_at_column": "consent_date"
},
"actor_id": "qa-runner",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"run": {
"status": "string"
},
"entities_created": "number",
"consent_receipts": "number",
"replayed": "boolean"
}
}POST/api/imports/runs/:run_id/dry-run🔒 auth
Show what the commit WOULD do — new, exact-link, review-case, related-entity and invalid counts, plus the governance verdicts — without writing anything. The guarantee is enforced, then proven, then rolled back: the simulation runs in a READ ONLY transaction (a write raises 25006 at the source), then checks pg_current_xact_id_if_assigned() as belt and braces, then rolls back unconditionally. It reuses the commit's own transform code path, so the counts describe what will actually happen rather than a parallel estimate that drifts. Governance verdicts report an unsigned attestation, an unconfirmed mapping, the columns that will be tokenized, and the review backlog. Recording that the dry run happened is a separate, deliberate write outside the proven region. Requires the transform plan (409 TRANSFORM_PLAN_REQUIRED otherwise). Required: tenant_id, rows[].
[ "POST /api/auth/signup-tenant", "POST /api/imports/runs", "POST /api/imports/runs/:run_id/preview", "PUT /api/imports/runs/:run_id/mapping", "POST /api/imports/runs/:run_id/transform-plan" ]
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id and rows[] are required | either field is missing from the body |
| 404 | IMPORT_RUN_NOT_FOUND | import run <id> not found for tenant | the run does not exist, or belongs to another tenant |
| 409 | TRANSFORM_PLAN_REQUIRED | build the transform plan before running a dry run | the run has no stored transform plan yet |
| 500 | DRY_RUN_WROTE | the dry run acquired transaction id <xid> — it wrote to the database | the simulation somehow wrote; surfaced loudly rather than passing silently |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"rows": [
{
"full_name": "Ada Lovelace",
"email": "{{dynamic:email}}",
"street_address": "1 Analytical Way",
"city": "London",
"country": "GB",
"external_id": "CRM-{{dynamic:uuid}}",
"contact_ok": "yes",
"consent_date": "2026-01-05T10:00:00Z"
},
{
"full_name": "Alan Turing",
"email": "{{dynamic:email}}",
"street_address": "2 Bombe Road",
"city": "Manchester",
"country": "GB",
"external_id": "CRM-{{dynamic:uuid}}",
"contact_ok": "",
"consent_date": ""
}
]
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rows": [
{
"full_name": "Ada Lovelace",
"email": "qa.user@example.com",
"street_address": "1 Analytical Way",
"city": "London",
"country": "GB",
"external_id": "CRM-{{dynamic:uuid}}",
"contact_ok": "yes",
"consent_date": "2026-01-05T10:00:00Z"
},
{
"full_name": "Alan Turing",
"email": "qa.user@example.com",
"street_address": "2 Bombe Road",
"city": "Manchester",
"country": "GB",
"external_id": "CRM-{{dynamic:uuid}}",
"contact_ok": "",
"consent_date": ""
}
]
}{
"success": true,
"data": {
"dry_run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rows": [
{
"full_name": "Ada Lovelace",
"email": "qa.user@example.com",
"street_address": "1 Analytical Way",
"city": "London",
"country": "GB",
"external_id": "CRM-{{dynamic:uuid}}",
"contact_ok": "yes",
"consent_date": "2026-01-05T10:00:00Z"
},
{
"full_name": "Alan Turing",
"email": "qa.user@example.com",
"street_address": "2 Bombe Road",
"city": "Manchester",
"country": "GB",
"external_id": "CRM-{{dynamic:uuid}}",
"contact_ok": "",
"consent_date": ""
}
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"dry_run": {
"new_count": "number",
"writes_observed": "number",
"governance": "array"
}
}
}GET/api/imports/runs/:run_id/exceptions🔒 auth
Every row that did not land, with the ORIGINAL input kept verbatim so the operator fixes and re-submits their own data rather than the platform's interpretation of it. reason_code says why: INVALID_VALUE (the value could not be transformed), NEEDS_REVIEW (a human decision is required first), CONSENT_NOT_EVIDENCED (an affirmative marker with no capture date, a blank, or a generic placeholder — none of which is consent). Ordered by row number so it lines up with the source file. limit is clamped to 1..5000 (default 500). Required: tenant_id query param.
[ "POST /api/auth/signup-tenant", "POST /api/imports/runs", "POST /api/imports/runs/:run_id/preview", "PUT /api/imports/runs/:run_id/mapping", "POST /api/imports/runs/:run_id/transform-plan", "POST /api/imports/runs/:run_id/commit" ]
reason_code: INVALID_VALUE, NEEDS_REVIEW, CONSENT_NOT_EVIDENCED| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query param is absent |
| 404 | IMPORT_RUN_NOT_FOUND | import run <id> not found for tenant | the run does not exist, or belongs to another tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}{
"success": true,
"data": {
"exception_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"exceptions": "array",
"count": "number"
}
}PUT/api/imports/runs/:run_id/mapping🔒 auth
Apply a human's explicit per-field decisions and move the run to mapping. ONLY the columns named in confirmations[] become confirmed; everything else stays inert, so a partially reviewed mapping commits exactly the part that was reviewed. A confirmation that differs from the suggestion is recorded as a human override with the assistant's original proposal kept in the reason, which is what makes the mapping auditable later. A confirmation for an unknown column or a target outside the canonical vocabulary is refused with 422. Returns 200 (upsert of the run's mapping). Required: tenant_id, confirmations[].
[ "POST /api/auth/signup-tenant", "POST /api/imports/runs", "POST /api/imports/runs/:run_id/preview" ]
target: person.given_name, person.family_name, person.full_name, person.date_of_birth, contact.email, contact.phone, contact.handle, org.name, org.domain, org.size, place.address_line1, place.address_line2, place.locality, place.region, place.postal_code, place.country, external.id, attribute.custom, unmappedstatus: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id and confirmations[] are required | either field is missing from the body |
| 404 | IMPORT_RUN_NOT_FOUND | import run <id> not found for tenant | the run does not exist, or belongs to another tenant |
| 409 | PREVIEW_REQUIRED | run the preview before confirming a mapping | the run has no stored preview yet |
| 422 | UNKNOWN_MAPPING_COLUMN | no column named '<name>' in this run's preview | a confirmation names a column the preview never produced |
| 422 | UNKNOWN_MAPPING_TARGET | '<target>' is not a canonical mapping target | a confirmation names a target outside the canonical vocabulary |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"confirmations": [
{
"source_column": "full_name",
"target": "person.full_name",
"confirmed_by": "qa-runner"
},
{
"source_column": "email",
"target": "contact.email",
"confirmed_by": "qa-runner"
},
{
"source_column": "street_address",
"target": "place.address_line1",
"confirmed_by": "qa-runner"
},
{
"source_column": "city",
"target": "place.locality",
"confirmed_by": "qa-runner"
},
{
"source_column": "country",
"target": "place.country",
"confirmed_by": "qa-runner"
},
{
"source_column": "external_id",
"target": "external.id",
"confirmed_by": "qa-runner",
"external_system": "partner-extract"
}
],
"mapping_template_id": null
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"confirmations": [
{
"source_column": "full_name",
"target": "person.full_name",
"confirmed_by": "qa-runner"
},
{
"source_column": "email",
"target": "contact.email",
"confirmed_by": "qa-runner"
},
{
"source_column": "street_address",
"target": "place.address_line1",
"confirmed_by": "qa-runner"
},
{
"source_column": "city",
"target": "place.locality",
"confirmed_by": "qa-runner"
},
{
"source_column": "country",
"target": "place.country",
"confirmed_by": "qa-runner"
},
{
"source_column": "external_id",
"target": "external.id",
"confirmed_by": "qa-runner",
"external_system": "partner-extract"
}
],
"mapping_template_id": null
}{
"success": true,
"data": {
"mapping_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"confirmations": [
{
"source_column": "full_name",
"target": "person.full_name",
"confirmed_by": "qa-runner"
},
{
"source_column": "email",
"target": "contact.email",
"confirmed_by": "qa-runner"
},
{
"source_column": "street_address",
"target": "place.address_line1",
"confirmed_by": "qa-runner"
},
{
"source_column": "city",
"target": "place.locality",
"confirmed_by": "qa-runner"
},
{
"source_column": "country",
"target": "place.country",
"confirmed_by": "qa-runner"
},
{
"source_column": "external_id",
"target": "external.id",
"confirmed_by": "qa-runner",
"external_system": "partner-extract"
}
],
"mapping_template_id": null,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"run": {
"status": "string"
},
"field_map": "object"
}
}POST/api/imports/runs/:run_id/mapping-suggestions🔒 auth
Propose a canonical target for each previewed column, with a confidence and a human-readable reason on every suggestion. NOTHING IS APPLIED: the response carries confirmed:false throughout, and the mapping is only stored once a human confirms it through PUT /mapping. An AI assistant, when wired, sees column names, types and REDACTED samples only, may override the deterministic matcher only when it beats its confidence, and its reasons are prefixed "assistant:" so a reviewer can tell a model's guess from a rule's match. Address columns propose a place target plus a located_at relationship, never a column on the person. Requires the preview to have run (409 PREVIEW_REQUIRED otherwise). Returns 200: it computes, it does not create. Required: tenant_id.
[ "POST /api/auth/signup-tenant", "POST /api/imports/runs", "POST /api/imports/runs/:run_id/preview" ]
target: person.given_name, person.family_name, person.full_name, person.date_of_birth, contact.email, contact.phone, contact.handle, org.name, org.domain, org.size, place.address_line1, place.address_line2, place.locality, place.region, place.postal_code, place.country, external.id, attribute.custom, unmapped| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id is required | tenant_id missing from the body |
| 404 | IMPORT_RUN_NOT_FOUND | import run <id> not found for tenant | the run does not exist, or belongs to another tenant |
| 409 | PREVIEW_REQUIRED | run the preview before asking for mapping suggestions | the run has no stored preview yet |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"mapping_suggestion_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"suggestions": "array",
"count": "number",
"confirmed": "boolean"
}
}POST/api/imports/runs/:run_id/preview🔒 auth
Detect delimiter, encoding, header row and per-column type, each with a confidence, and move the run to previewing. Delimiter detection scores CONSISTENCY across lines rather than frequency, so a pipe-delimited file full of commas in its free text is read correctly. Columns holding direct identifiers, contact points, locations, government ids or financial data are flagged for tokenization at trusted ingress AND have their sample values REDACTED — a preview travels into UIs, logs and tickets. Source-identifier columns are detected by name and by all-distinct-uuid shape and reported as crosswalks that are never replaced by platform ids. Send either raw `content` or a pre-parsed `rows[]`. Returns 200: it moves an existing run. Required: tenant_id and one of content / rows[].
[ "POST /api/auth/signup-tenant", "POST /api/imports/runs" ]
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | either content or a non-empty rows[] is required | neither content nor rows[] was supplied |
| 404 | IMPORT_RUN_NOT_FOUND | import run <id> not found for tenant | the run does not exist, or belongs to another tenant |
| 409 | INVALID_RUN_TRANSITION | run <id> cannot move <from> -> previewing | the run has already moved past the preview stage (e.g. it is complete) |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"content": "full_name,email,street_address,city,country,external_id,contact_ok,consent_date\\nAda Lovelace,{{dynamic:email}},1 Analytical Way,London,GB,CRM-{{dynamic:uuid}},yes,2026-01-05T10:00:00Z\\nAlan Turing,{{dynamic:email}},2 Bombe Road,Manchester,GB,CRM-{{dynamic:uuid}},,",
"delimiter": ",",
"has_header_row": true,
"encoding": "utf-8",
"sample_size": 200
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"content": "full_name,email,street_address,city,country,external_id,contact_ok,consent_date\\nAda Lovelace,{{dynamic:email}},1 Analytical Way,London,GB,CRM-{{dynamic:uuid}},yes,2026-01-05T10:00:00Z\\nAlan Turing,{{dynamic:email}},2 Bombe Road,Manchester,GB,CRM-{{dynamic:uuid}},,",
"delimiter": ",",
"has_header_row": true,
"encoding": "utf-8",
"sample_size": 200
}{
"success": true,
"data": {
"preview_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"content": "full_name,email,street_address,city,country,external_id,contact_ok,consent_date\\nAda Lovelace,{{dynamic:email}},1 Analytical Way,London,GB,CRM-{{dynamic:uuid}},yes,2026-01-05T10:00:00Z\\nAlan Turing,{{dynamic:email}},2 Bombe Road,Manchester,GB,CRM-{{dynamic:uuid}},,",
"delimiter": ",",
"has_header_row": true,
"encoding": "utf-8",
"sample_size": 200,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"run": {
"status": "string"
},
"preview": {
"delimiter": "string",
"columns": "array"
}
}
}POST/api/imports/runs/:run_id/rollback🔒 auth
Reverse every entity the run created. Permitted only while BOTH hold: the rollback deadline has not passed, and no downstream governed action has touched an affected entity. The second rule protects people rather than data — once a message has gone out against a record this import created, deleting the record does not undo the consequence, it destroys the evidence of it. That refusal is a 409 that NAMES the blocking action, the entity and when it happened, because "cannot roll back" with no reason leaves the operator nothing to act on. Refusing changes nothing: the entities and the run status are left exactly as they were. Rolling back an already-rolled-back run is an idempotent no-op. Returns 200. Required: tenant_id.
[ "POST /api/auth/signup-tenant", "POST /api/imports/runs", "POST /api/imports/runs/:run_id/preview", "PUT /api/imports/runs/:run_id/mapping", "POST /api/imports/runs/:run_id/transform-plan", "POST /api/imports/runs/:run_id/dry-run", "POST /api/imports/runs/:run_id/commit" ]
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id is required | tenant_id missing from the body |
| 404 | IMPORT_RUN_NOT_FOUND | import run <id> not found for tenant | the run does not exist, or belongs to another tenant |
| 409 | INVALID_RUN_TRANSITION | run <id> cannot move <from> -> rolled_back | the run never completed, so there is nothing to reverse |
| 409 | ROLLBACK_WINDOW_CLOSED | the rollback window for run <id> closed at <deadline> | the derived rollback deadline has passed |
| 409 | ROLLBACK_BLOCKED_BY_DOWNSTREAM_ACTION | run <id> cannot be rolled back: <action> already occurred against <kind> <id> | a downstream governed action has already touched an entity the run created |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"reason": "wrong file uploaded",
"actor_id": "qa-runner"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "wrong file uploaded",
"actor_id": "qa-runner"
}{
"success": true,
"data": {
"status": "completed",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "wrong file uploaded",
"actor_id": "qa-runner",
"rollback_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"run": {
"status": "string"
},
"entities_reversed": "number"
}
}POST/api/imports/runs/:run_id/transform-plan🔒 auth
Build the deterministic transform plan from the confirmed mapping: the same mapping always produces the same steps in the same order, every step says in words what it will do, and every step preserves the raw input as evidence. The plan is built BEFORE identity resolution runs, because reviewing a transform afterwards is reviewing a decision already made. Mapping the source system's own status values onto platform workflow states is a per-tenant business judgement, so that step is present in the plan but DISABLED unless enable_source_state_mapping is true. default_calling_region is what lets a bare national telephone number be normalized at all — without it those rows go to review rather than being guessed into the wrong country. Returns 200: it moves an existing run. Required: tenant_id.
[ "POST /api/auth/signup-tenant", "POST /api/imports/runs", "POST /api/imports/runs/:run_id/preview", "PUT /api/imports/runs/:run_id/mapping" ]
target: person.given_name, person.family_name, person.full_name, person.date_of_birth, contact.email, contact.phone, contact.handle, org.name, org.domain, org.size, place.address_line1, place.address_line2, place.locality, place.region, place.postal_code, place.country, external.id, attribute.custom, unmapped| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id is required | tenant_id missing from the body |
| 404 | IMPORT_RUN_NOT_FOUND | import run <id> not found for tenant | the run does not exist, or belongs to another tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"enable_source_state_mapping": false,
"default_calling_region": "44",
"default_country": "GB"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"enable_source_state_mapping": false,
"default_calling_region": "44",
"default_country": "GB"
}{
"success": true,
"data": {
"transform_plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"enable_source_state_mapping": false,
"default_calling_region": "44",
"default_country": "GB",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"run": {
"run_id": "string"
},
"transform_plan": {
"steps": "array"
}
}
}sdk-incident
GET/api/incidents🔒 auth
List incidents for a tenant, most-recently-updated first. Tenant-scoped via the required tenant_id query param; optionally filtered by status, severity and/or owner_persona_id, with limit/offset paging (defaults 50/0).
[ "POST /api/auth/signup-tenant" ]
status: open, investigating, mitigated, resolved, closed, cancelledseverity: low, medium, high, critical| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
{
"success": true,
"data": [
{
"incident_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"incidents": []
}
}POST/api/incidents🔒 auth
Open an operational incident in status 'open'. tenant_id, incident_type and title are required; severity (low|medium|high|critical, default medium), description, affected_records, owner/reported_by personas, source, subject_ref, sla_due_at (SLA deadline for the breach scan) and metadata are optional. Emits incident.opened.v1. Advance via POST /api/incidents/:incident_id/transition.
[ "POST /api/auth/signup-tenant" ]
severity: low, medium, high, critical| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, incident_type and title are required | tenant_id, incident_type or title missing from body |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"incident_type": "data-quality",
"title": "Bad import batch flagged",
"severity": "high",
"reported_by_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"affected_records": [
"batch-42"
]
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"incident_type": "data-quality",
"title": "Bad import batch flagged",
"severity": "high",
"reported_by_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"affected_records": [
"batch-42"
]
}{
"success": true,
"data": {
"incident_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"incident_type": "data-quality",
"title": "Bad import batch flagged",
"severity": "high",
"reported_by_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"affected_records": [
"batch-42"
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"incident": {
"incident_id": "string",
"status": "string"
}
}
}GET/api/incidents/:incident_id🔒 auth
Fetch a single incident by id, tenant-scoped via the required tenant_id query param. Returns the full record including root_cause/recovery/verification notes and lifecycle timestamps. 404 when the incident is not found for the tenant.
[ "POST /api/auth/signup-tenant", "POST /api/incidents" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
| 404 | NotFound | NotFound | incident_id not found for the tenant |
{
"incident_id": "{{cache:incident.incidents.create.response.data.incident.incident_id}}"
}{
"success": true,
"data": {
"incident_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"incident": {
"incident_id": "string",
"status": "string"
}
}
}PATCH/api/incidents/:incident_id🔒 auth
Update editable incident fields (title, description, severity, affected_records, root_cause, recovery, verification, owner_persona_id assignment, subject_ref, sla_due_at, metadata). Status is NOT editable here — use the transition endpoint. tenant_id is required. Emits incident.updated.v1. 404 if not found.
[ "POST /api/auth/signup-tenant", "POST /api/incidents" ]
severity: low, medium, high, critical| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing from body |
| 404 | NotFound | NotFound | incident_id not found for the tenant |
{
"incident_id": "{{cache:incident.incidents.create.response.data.incident.incident_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"owner_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"root_cause": "Upstream feed schema drift",
"severity": "critical"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"root_cause": "Upstream feed schema drift",
"severity": "critical"
}{
"success": true,
"data": {
"incident_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"root_cause": "Upstream feed schema drift",
"severity": "critical",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"incident": {
"incident_id": "string",
"owner_persona_id": "string"
}
}
}GET/api/incidents/:incident_id/evidence🔒 auth
Read an incident's evidence timeline in chronological order (oldest first, ordered by occurred_at then created_at), optionally filtered to a single kind via the kind query param. Each entry carries its sdk-audit receipt (audit_entry_id, audit_seq, audit_entry_hash) so a reader can verify the entry against the immutable hash chain. Tenant-scoped via the required tenant_id query param. Edge cases: 400 when tenant_id is absent; an unknown or other-tenant incident_id is NOT a 404 here — it simply yields an empty evidence array, since the timeline is a filtered read rather than a record fetch; limit defaults to 200 and offset to 0 for paging a long timeline; entries whose audit emit was unavailable carry null receipt columns.
[ "POST /api/auth/signup-tenant", "POST /api/incidents", "POST /api/incidents/:incident_id/evidence" ]
kind: detected, root_cause, recovery, verification, note| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
| 401 | Unauthorized | Unauthorized | Authorization bearer token missing or invalid |
{
"incident_id": "{{cache:incident.incidents.create.response.data.incident.incident_id}}"
}{
"success": true,
"data": {
"evidence_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"evidence": [
{
"evidence_id": "string",
"incident_id": "string",
"kind": "string",
"body": "string",
"audit_entry_id": "string",
"audit_entry_hash": "string"
}
]
}
}POST/api/incidents/:incident_id/evidence🔒 auth
Append one entry to an incident's evidence timeline (detected / root_cause / recovery / verification / note). The entry is FIRST written to the sdk-audit hash-chained ledger and the receipt (audit_entry_id, audit_seq, audit_entry_hash) is stored on the row and returned, so the evidence is provably un-altered after recording. Evidence is APPEND-ONLY: a database trigger rejects every UPDATE and DELETE on the table, so there is deliberately no PATCH or DELETE counterpart to this endpoint, and the FK is ON DELETE RESTRICT so an incident that has evidence cannot be erased. Appending root_cause / recovery / verification also projects the body onto the matching incident summary column, and the first 'detected' entry stamps detected_at (COALESCE preserves an earlier detection time). Edge cases: 400 when tenant_id, kind or body is missing, or kind is outside the enum; 404 when the incident does not exist for that tenant (tenant-scoped, so another tenant's incident reads as not-found); occurred_at is optional and defaults to now(), letting back-dated evidence be recorded while created_at still records when it was filed; if the audit ledger is unavailable the entry is still recorded with null receipt columns rather than being lost.
[ "POST /api/auth/signup-tenant", "POST /api/incidents" ]
kind: detected, root_cause, recovery, verification, note| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, kind and body are required | tenant_id, kind or body missing from the request body |
| 400 | ValidationError | kind must be one of detected|root_cause|recovery|verification|note | kind is outside the allowed enum |
| 401 | Unauthorized | Unauthorized | Authorization bearer token missing or invalid |
| 404 | NotFound | [sdk-incident] incident <id> not found for tenant | incident_id does not exist for the authenticated tenant |
{
"incident_id": "{{cache:incident.incidents.create.response.data.incident.incident_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"kind": "detected",
"body": "Dedup monitor flagged 412 duplicate leads in the nightly ingest window.",
"evidence_ref": "s3://ops-logs/dedup-scan.json",
"recorded_by_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"occurred_at": "{{dynamic:pastdatetime}}",
"metadata": {
"detector": "dedup-monitor",
"duplicate_count": 412
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "detected",
"body": "Dedup monitor flagged 412 duplicate leads in the nightly ingest window.",
"evidence_ref": "s3://ops-logs/dedup-scan.json",
"recorded_by_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"occurred_at": "2026-01-15T10:30:00Z",
"metadata": {
"detector": "dedup-monitor",
"duplicate_count": 412
}
}{
"success": true,
"data": {
"evidence_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "detected",
"body": "Dedup monitor flagged 412 duplicate leads in the nightly ingest window.",
"evidence_ref": "s3://ops-logs/dedup-scan.json",
"recorded_by_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"occurred_at": "2026-01-15T10:30:00Z",
"metadata": {
"detector": "dedup-monitor",
"duplicate_count": 412
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"evidence": {
"evidence_id": "string",
"incident_id": "string",
"kind": "string",
"body": "string",
"audit_entry_id": "string",
"audit_entry_hash": "string"
}
}
}POST/api/incidents/:incident_id/transition🔒 auth
Advance an incident's status. Valid transitions: open->investigating|mitigated, investigating->mitigated|resolved, mitigated->resolved|investigating, resolved->closed|investigating (regression re-open); an incident may be cancelled while pre-resolved. resolved stamps resolved_at, closed/cancelled stamp closed_at. Emits incident.transitioned.v1 (from/to). tenant_id and status required. 409 InvalidTransition when not allowed from the current state; 404 if not found.
[ "POST /api/auth/signup-tenant", "POST /api/incidents" ]
status: open, investigating, mitigated, resolved, closed, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and status are required | tenant_id or status missing from body |
| 400 | ValidationError | invalid status | status is not one of open|investigating|mitigated|resolved|closed|cancelled |
| 404 | NotFound | NotFound | incident_id not found for the tenant |
| 409 | InvalidTransition | invalid transition <from> -> <to> | the requested transition is not allowed from the current status |
{
"incident_id": "{{cache:incident.incidents.create.response.data.incident.incident_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"status": "investigating"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "investigating"
}{
"success": true,
"data": {
"transition_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "investigating",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"incident": {
"incident_id": "string",
"status": "string"
}
}
}GET/api/incidents/sla-breaches🔒 auth
SLA-breach scan: returns tenant incidents whose sla_due_at is past AND whose status is still active (not resolved/closed/cancelled), ordered by deadline ascending. Powered by the incident_sla_idx partial index. Tenant-scoped via the required tenant_id query param; optional limit (default 100). Returns an empty list when nothing is overdue.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
{
"success": true,
"data": [
{
"sla_breach_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"incidents": []
}
}sdk-ingest
POST/api/ingest/:entity/batch🔒 auth
Generic ETL landing endpoint: accepts a batch of records for the :entity named in the path, keyed by a caller-supplied idempotency_key, with an optional mode. Gated by the gateway default-deny authGate, so a valid tenant JWT is required; tenant_id is taken from req.auth.tenant (the JWT) and never from the body, so a batch cannot be written into another tenant. Edge cases: idempotency_key is mandatory and is what makes a replayed batch a no-op rather than a duplicate insert - re-POSTing the same key must not double-write; records must be a non-empty array, so an empty array and a missing records field both 400; the :entity segment is not checked against an allowlist in the route, so an unknown entity fails inside the service; there is no batch-size cap in the handler, so oversized batches are bounded only downstream; every service failure is caught and flattened to a 400 with the raw message, so this route never returns 404 or 500.
[ "POST /api/auth/register" ]
mode: upsert, insert| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 400 | ValidationError | entity is required | the :entity path segment resolves empty |
| 400 | ValidationError | idempotency_key is required | body.idempotency_key is absent or an empty string |
| 400 | ValidationError | records must be a non-empty array | body.records is absent, not an array, or an empty array |
| 400 | IngestFailed | <service error message> | ingestBatch throws for any other reason - unknown entity/table, malformed record shape, or a sink/DB error; the catch flattens every failure to 400 |
{
"entity": "customer"
}{
"mode": "upsert",
"idempotency_key": "{{dynamic:uuid}}",
"records": [
{
"external_id": "EXT-1001",
"name": "Demo Record",
"value": 42
}
]
}{
"mode": "upsert",
"idempotency_key": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"records": [
{
"external_id": "EXT-1001",
"name": "Demo Record",
"value": 42
}
]
}{
"success": true,
"data": {
"batch_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"mode": "upsert",
"idempotency_key": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"records": [
{
"external_id": "EXT-1001",
"name": "Demo Record",
"value": 42
}
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/ingest/customer/batch🔒 auth
sdk-ingest batch front door. Accepts an envelope { entity, mode: upsert|insert, idempotency_key, records[] } and imports records into the target entity. Records provenance via sdk-lineage and an append-only entry via sdk-audit. Returns per-record results { imported, skipped, errors[] }.
[ "POST /api/auth/register" ]
mode: upsert, insert| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 400 | ValidationError | entity is required | the :entity path segment resolves empty |
| 400 | ValidationError | idempotency_key is required | body.idempotency_key is absent or an empty string (it defaults to "" which fails the check) |
| 400 | ValidationError | records must be a non-empty array | body.records is absent, not an array, or an empty array |
| 400 | IngestFailed | <service error message> | ingestBatch throws for any other reason - unknown entity/table, malformed record shape, or a sink/DB error; the catch flattens every failure to 400, so this route never returns 404 or 500 |
{
"entity": "customer",
"mode": "upsert",
"idempotency_key": "{{dynamic:uuid}}",
"records": [
{
"external_id": "C-1001",
"name": "Acme Corp",
"email": "ops@acme.test"
},
{
"external_id": "C-1002",
"name": "Globex",
"email": "ap@globex.test"
}
]
}{
"entity": "customer",
"mode": "upsert",
"idempotency_key": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"records": [
{
"external_id": "C-1001",
"name": "Acme Corp",
"email": "ops@acme.test"
},
{
"external_id": "C-1002",
"name": "Globex",
"email": "ap@globex.test"
}
]
}{
"success": true,
"data": {
"batch_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"entity": "customer",
"mode": "upsert",
"idempotency_key": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"records": [
{
"external_id": "C-1001",
"name": "Acme Corp",
"email": "ops@acme.test"
},
{
"external_id": "C-1002",
"name": "Globex",
"email": "ap@globex.test"
}
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"imported": 2
}{
"entity": "customer",
"mode": "upsert",
"idempotency_key": "{{dynamic:uuid}}",
"records": []
}{
"entity": "customer",
"mode": "upsert",
"idempotency_key": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"records": []
}{
"success": true,
"data": {
"batch_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"entity": "customer",
"mode": "upsert",
"idempotency_key": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"records": [],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 400.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/api/ingest/sensor-readings/batch🔒 auth
Typed P12 time-series intake: writes a batch of sensor readings keyed by a caller-supplied idempotency_key and answers 201 with the ingest summary. Gated by the gateway default-deny authGate, so a valid tenant JWT is required; tenant_id prefers req.auth.tenant (the JWT) and only falls back to body.tenant_id when the JWT carries no tenant claim. Edge cases: idempotency_key is mandatory and is the replay guard - re-POSTing the same key must not double-write the series; readings must be a non-empty array, so both an empty array and an omitted field 400; if the time-series sink is not configured the route returns 400 "sensor-reading sink not configured" rather than a 503, which makes an infrastructure outage look like a client error; malformed reading shapes, bad timestamps and unknown sensor ids all surface as 400 with the raw service message; there is no batch-size cap in the handler; this route never returns 404 or 500 because every throw is flattened to 400.
[ "POST /api/auth/signup-tenant", "POST /api/assets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 400 | ValidationError | idempotency_key is required | body.idempotency_key is absent or an empty string |
| 400 | ValidationError | readings must be a non-empty array | body.readings is absent, not an array, or an empty array |
| 400 | SinkNotConfigured | sensor-reading sink not configured | the sensor time-series sink is not wired in this deployment |
| 400 | IngestFailed | <service error message> | ingestSensorReadingsBatch throws for any other reason - malformed reading shape, bad timestamp, unknown sensor, or a sink/DB error; the catch flattens every failure to 400 |
{
"idempotency_key": "{{dynamic:uuid}}",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"readings": [
{
"sensor_id": "{{var:sensor_id}}",
"asset_id": "{{cache:assets.create.response.data.asset_id}}",
"component_id": "{{var:component_id}}",
"ts": "{{dynamic:pastdatetime}}",
"value": 12.5,
"unit": "N",
"quality": "good"
}
]
}{
"idempotency_key": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"readings": [
{
"sensor_id": "{{var:sensor_id}}",
"asset_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"component_id": "{{var:component_id}}",
"ts": "2026-01-15T10:30:00Z",
"value": 12.5,
"unit": "N",
"quality": "good"
}
]
}{
"success": true,
"data": {
"batch_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"idempotency_key": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"readings": [
{
"sensor_id": "{{var:sensor_id}}",
"asset_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"component_id": "{{var:component_id}}",
"ts": "2026-01-15T10:30:00Z",
"value": 12.5,
"unit": "N",
"quality": "good"
}
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-lead-scoring
POST/api/lead-scoring/models🔒 auth
Creates a lead-scoring model for a (tenant_id, vertical) pair and, in the same transaction, seeds one lead_scoring.feature_weight row per entry in weights — defaulting to DEFAULT_FEATURE_WEIGHTS (proximity 0.30, expertise 0.25, intent 0.30, storm_impact 0.15) when weights is omitted. Status is 'active' with trained_at = now() when activate is true, otherwise 'training' with trained_at null. QA edge cases: creation is NOT idempotent and nothing enforces one model per (tenant, vertical), so repeated POSTs with activate:true leave several active models and the /models/active lookup then resolves the most recently trained one; tenant_id is cast to ::uuid in SQL, so a non-UUID tenant_id fails inside the transaction and surfaces as 400 rather than a distinct validation error; passing an empty weights object ({}) is accepted and creates a model with zero weight rows, which later makes scoring fall back to DEFAULT_FEATURE_WEIGHTS; vertical is a free-text string with no allowlist.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and vertical are required | tenant_id or vertical is missing or empty in the body |
| 400 | CreateModelFailed | <underlying database error message> | createModel throws inside the transaction — e.g. tenant_id is not a valid UUID for the ::uuid cast, feature_set is not valid JSON, or a duplicate (model_id, feature) weight is supplied |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"vertical": "solar",
"feature_set": {
"proximity": {},
"expertise": {},
"intent": {},
"storm_impact": {}
},
"weights": {
"proximity": 0.3,
"expertise": 0.25,
"intent": 0.3,
"storm_impact": 0.15
},
"activate": true
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"vertical": "solar",
"feature_set": {
"proximity": {},
"expertise": {},
"intent": {},
"storm_impact": {}
},
"weights": {
"proximity": 0.3,
"expertise": 0.25,
"intent": 0.3,
"storm_impact": 0.15
},
"activate": true
}{
"success": true,
"data": {
"model_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"vertical": "solar",
"feature_set": {
"proximity": {},
"expertise": {},
"intent": {},
"storm_impact": {}
},
"weights": {
"proximity": 0.3,
"expertise": 0.25,
"intent": 0.3,
"storm_impact": 0.15
},
"activate": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"model_id": "string",
"tenant_id": "string",
"vertical": "string",
"trained_at": "string",
"feature_set": "object",
"status": "string"
}
}GET/api/lead-scoring/models/:id🔒 auth
Fetches one lead-scoring model by model_id, returning model_id, tenant_id, vertical, trained_at (ISO or null), feature_set and status. QA edge cases: an unknown model_id returns 404 'not found'; the query is not tenant-scoped, so any caller with a model_id reads that tenant's model metadata; trained_at is null for models still in 'training' status, so consumers must handle the null; the response contains the model row only — feature weights require the separate /weights endpoint.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/lead-scoring/models" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | NotFound | not found | no lead_scoring.model row matches the :id path param |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"id": "{{cache:lead-scoring.create.response.data.model_id}}"
}{
"success": true,
"data": {
"model_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"model_id": "string",
"tenant_id": "string",
"vertical": "string",
"trained_at": "string",
"feature_set": "object",
"status": "string"
}
}POST/api/lead-scoring/models/:id/activate🔒 auth
Flips a lead-scoring model to status='active' and stamps trained_at = now(), returning the updated model. QA edge cases: the UPDATE has no state precondition — activating an already-active model succeeds and simply re-stamps trained_at (idempotent in status, NOT in trained_at, which moves on every call, and trained_at is the tiebreaker used by /models/active); a retired model can be re-activated with no guard; activating does not deactivate any sibling model for the same (tenant, vertical), so this endpoint can leave multiple active models; an unknown model_id yields no updated row and the thrown error is mapped to 404.
[ "POST /api/auth/signup-tenant", "POST /api/lead-scoring/models" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | NotFound | [sdk-lead-scoring] model <id> not found | the UPDATE matches no lead_scoring.model row for the :id path param |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"entity": "lead_scoring.model",
"field": "status",
"flow": [
"training",
"active",
"retired"
],
"transitions": [
{
"from": "training",
"to": "active",
"via": "POST /api/lead-scoring/models/:id/activate"
}
]
}{
"id": "{{cache:lead-scoring.create.response.data.model_id}}"
}{}{
"success": true,
"data": {
"activate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"model_id": "string",
"tenant_id": "string",
"vertical": "string",
"trained_at": "string",
"feature_set": "object",
"status": "string"
}
}POST/api/lead-scoring/models/:id/retire🔒 auth
Flips a lead-scoring model to status='retired', returning the updated model. Unlike activate, trained_at is left untouched. QA edge cases: there is no state precondition — retiring an already-retired model succeeds and is fully idempotent; retiring the only active model for a (tenant, vertical) is permitted and leaves that pair with no active model, which then makes /models/active return 404 and every /score call for that pair fail with 400 'no active model' — that downstream blast radius is the important regression to cover; an unknown model_id maps to 404.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/lead-scoring/models" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | NotFound | [sdk-lead-scoring] model <id> not found | the UPDATE matches no lead_scoring.model row for the :id path param |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"entity": "lead_scoring.model",
"field": "status",
"flow": [
"training",
"active",
"retired"
],
"transitions": [
{
"from": "active",
"to": "retired",
"via": "POST /api/lead-scoring/models/:id/retire"
}
]
}{
"id": "{{cache:lead-scoring.create.response.data.model_id}}"
}{}{
"success": true,
"data": {
"retire_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"model_id": "string",
"tenant_id": "string",
"vertical": "string",
"trained_at": "string",
"feature_set": "object",
"status": "string"
}
}GET/api/lead-scoring/models/:id/weights🔒 auth
Lists every lead_scoring.feature_weight row for a model, ordered by feature name, with weight coerced from the database numeric to a JS number. QA edge cases: an unknown model_id does NOT 404 — it returns 200 with an empty array, so "model does not exist" and "model has no weights" are indistinguishable through this endpoint (use GET /models/:id to disambiguate); an empty array is meaningful, because the scoring engine falls back to DEFAULT_FEATURE_WEIGHTS whenever no weight rows exist; weights are returned raw (as tuned), NOT normalised — normalisation to sum 1.0 happens inside scoreContact, so these values will not necessarily add up to 1; the listing is unpaginated and not tenant-scoped.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/lead-scoring/models" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"id": "{{cache:lead-scoring.create.response.data.model_id}}"
}{
"success": true,
"data": {
"weight_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": "array"
}PUT/api/lead-scoring/models/:id/weights/:feature🔒 auth
Tunes a single feature weight for a model. Implemented as an INSERT ... ON CONFLICT (model_id, feature) DO UPDATE, so it upserts: the first call creates the weight row, later calls overwrite the value and re-stamp last_tuned_at. QA edge cases: the feature name comes from the URL path and is NOT validated against DEFAULT_FEATURE_WEIGHTS, so tuning an arbitrary feature like 'made_up_signal' succeeds and adds a row that scoreContact will include in its normalisation total but never multiply by a subscore — silently diluting every real subscore, which is the highest-value bug to test for; weight must be a finite number >= 0, so 0 is accepted (and setting every weight to 0 makes normalisation skip and produce a composite of 0) while negative, NaN, Infinity, null and non-numeric values are rejected; an unknown model_id violates the feature_weight foreign key and surfaces as 400, not 404.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/lead-scoring/models" ]
feature: proximity, expertise, intent, storm_impact| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | weight must be a non-negative finite number | body.weight is null/undefined, not a number, NaN, Infinity, or negative |
| 400 | SetWeightFailed | <underlying database error message> | [sdk-lead-scoring] setFeatureWeight failed | the upsert throws or returns no row — most commonly the :id path param is not an existing model_id (foreign-key violation) or is not a valid UUID |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"id": "{{cache:lead-scoring.create.response.data.model_id}}",
"feature": "proximity"
}{
"weight": 0.4
}{
"weight": 0.4
}{
"success": true,
"data": {
"weight_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"weight": 0.4,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"weight_id": "string",
"model_id": "string",
"feature": "string",
"weight": "number",
"last_tuned_at": "string"
}
}GET/api/lead-scoring/models/active🔒 auth
Resolves the currently active model for a (tenant_id, vertical) pair, ordered by trained_at DESC NULLS LAST and limited to one row. Both query params are required. QA edge cases: a tenant/vertical pair with no active model returns 404 'no active model' — distinct from the 400 you get when the params are simply absent; because model creation does not enforce a single active model per pair, several rows can be active at once and this endpoint deterministically returns only the most recently trained one; tenant_id is cast to ::uuid, so a non-UUID value raises a database error rather than a clean 400; vertical matching is exact and case-sensitive.
[ "POST /api/auth/signup-tenant", "POST /api/lead-scoring/models", "POST /api/lead-scoring/models/:id/activate" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and vertical query params required | either the tenant_id or the vertical query param is absent or empty |
| 404 | NotFound | no active model | no lead_scoring.model row exists with status='active' for the given (tenant_id, vertical) |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"success": true,
"data": [
{
"active_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"model_id": "string",
"tenant_id": "string",
"vertical": "string",
"trained_at": "string",
"feature_set": "object",
"status": "string"
}
}POST/api/lead-scoring/next-best-action🔒 auth
Scores a contact and recommends the next action in one round trip: it runs the same scoreContact pipeline as /score (so it also persists a lead_scoring.score row and emits the audit event) and then maps the result to an action. The default resolver applies storm_impact >= 0.5 as an override returning 'storm_response' regardless of any higher subscore; otherwise the dominant subscore maps proximity -> schedule_visit, expertise -> send_offer, intent -> reach_out, and anything else to nurture; when every subscore is <= 0 it returns 'nurture'. QA edge cases: the storm override outranking a strictly higher proximity/intent score is the key branch to cover, as is the 0.5 boundary itself (0.49 vs 0.50); this endpoint has the same side effects as /score, so calling it twice writes two score rows — it is not a read-only preview; every failure mode of /score applies identically here (missing required fields and 'no active model' both surface as 400); the resolver is swappable at runtime via setNextBestActionResolver, so in an environment where a custom resolver is installed the action mapping above no longer holds.
[ "POST /api/auth/signup-tenant", "POST /api/lead-scoring/models", "POST /api/lead-scoring/models/:id/activate" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, vertical, contact_id, trace_id are required | any of tenant_id, vertical, contact_id or trace_id is missing or empty in the body |
| 400 | NoActiveModel | [sdk-lead-scoring] no active model for tenant <tenant_id> vertical <vertical> | no lead_scoring.model row with status='active' exists for the (tenant_id, vertical) pair |
| 400 | NextBestActionFailed | [sdk-lead-scoring] score insert failed | <underlying error message> | the underlying scoreContact call fails (score insert returns no row, subscore backend throws, invalid tenant UUID) or the installed next-best-action resolver throws |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"tenant_id": "{{cache:lead-scoring.create.response.data.tenant_id}}",
"vertical": "solar",
"contact_id": "{{var:contact_id}}",
"trace_id": "{{var:trace_id}}",
"proximity": {
"distance_km": 8
},
"expertise": {
"persona_kinds": [
"homeowner",
"insurance-claimant"
],
"vertical_specialties": [
"roof",
"flood"
]
},
"intent": {
"days_since_last_engagement": 1,
"emails_opened": 6,
"replies": 2
},
"storm_impact": {
"overlapping_storm_events": 3
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"vertical": "solar",
"contact_id": "{{var:contact_id}}",
"trace_id": "{{var:trace_id}}",
"proximity": {
"distance_km": 8
},
"expertise": {
"persona_kinds": [
"homeowner",
"insurance-claimant"
],
"vertical_specialties": [
"roof",
"flood"
]
},
"intent": {
"days_since_last_engagement": 1,
"emails_opened": 6,
"replies": 2
},
"storm_impact": {
"overlapping_storm_events": 3
}
}{
"success": true,
"data": {
"next_best_action_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"vertical": "solar",
"contact_id": "{{var:contact_id}}",
"trace_id": "{{var:trace_id}}",
"proximity": {
"distance_km": 8
},
"expertise": {
"persona_kinds": [
"homeowner",
"insurance-claimant"
],
"vertical_specialties": [
"roof",
"flood"
]
},
"intent": {
"days_since_last_engagement": 1,
"emails_opened": 6,
"replies": 2
},
"storm_impact": {
"overlapping_storm_events": 3
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"scoring": "object",
"action": "object"
}
}POST/api/lead-scoring/score🔒 auth
Scores one contact: resolves the active model for (tenant_id, vertical), loads and normalises its feature weights, runs the four subscore backends (proximity, expertise, intent, storm_impact) in parallel, computes the weighted composite scaled to 0-100, persists a lead_scoring.score row with the component breakdown, and emits a lead-scoring.scored.v1 audit entry. QA edge cases: the single most common failure is a 400 carrying 'no active model for tenant ... vertical ...' — every one of tenant_id, vertical, contact_id and trace_id is required, so a missing-field 400 and a no-model 400 share a status and must be distinguished by message; the audit append is wrapped in try/catch and only warns, so an audit outage still returns 200 (do not assert that scoring fails when audit is down); each of proximity/expertise/intent/storm_impact is optional and defaults to {}, which yields a 0 subscore rather than an error, so a body with only the four required ids scores 0 successfully; the call is not idempotent — the same trace_id can be posted repeatedly and produces a new score row each time; if the model has no weight rows the engine silently falls back to DEFAULT_FEATURE_WEIGHTS.
[ "POST /api/auth/signup-tenant", "POST /api/lead-scoring/models", "POST /api/lead-scoring/models/:id/activate" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, vertical, contact_id, trace_id are required | any of tenant_id, vertical, contact_id or trace_id is missing or empty in the body |
| 400 | NoActiveModel | [sdk-lead-scoring] no active model for tenant <tenant_id> vertical <vertical> | no lead_scoring.model row with status='active' exists for the (tenant_id, vertical) pair |
| 400 | ScoreFailed | [sdk-lead-scoring] score insert failed | <underlying database error message> | the INSERT into lead_scoring.score returns no row, a subscore backend throws, or tenant_id is not a valid UUID for the ::uuid cast during model resolution |
| 401 | Unauthorized | Missing bearer token | no Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | the bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce) |
{
"tenant_id": "{{cache:lead-scoring.create.response.data.tenant_id}}",
"vertical": "solar",
"contact_id": "{{var:contact_id}}",
"trace_id": "{{var:trace_id}}",
"proximity": {
"distance_km": 12
},
"expertise": {
"persona_kinds": [
"homeowner"
],
"vertical_specialties": [
"roof",
"flood"
]
},
"intent": {
"days_since_last_engagement": 3,
"emails_opened": 4,
"replies": 1
},
"storm_impact": {
"overlapping_storm_events": 2
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"vertical": "solar",
"contact_id": "{{var:contact_id}}",
"trace_id": "{{var:trace_id}}",
"proximity": {
"distance_km": 12
},
"expertise": {
"persona_kinds": [
"homeowner"
],
"vertical_specialties": [
"roof",
"flood"
]
},
"intent": {
"days_since_last_engagement": 3,
"emails_opened": 4,
"replies": 1
},
"storm_impact": {
"overlapping_storm_events": 2
}
}{
"success": true,
"data": {
"score_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"vertical": "solar",
"contact_id": "{{var:contact_id}}",
"trace_id": "{{var:trace_id}}",
"proximity": {
"distance_km": 12
},
"expertise": {
"persona_kinds": [
"homeowner"
],
"vertical_specialties": [
"roof",
"flood"
]
},
"intent": {
"days_since_last_engagement": 3,
"emails_opened": 4,
"replies": 1
},
"storm_impact": {
"overlapping_storm_events": 2
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"score": "object",
"components": "object",
"weights": "object",
"model_id": "string"
}
}sdk-mcp-bridge
GET/api/mcp/healthpublic
Liveness probe for sdk-mcp-bridge; returns 200 with { sdk: "sdk-mcp-bridge", status: "ok" }. It is a constant-response handler with no database, no body parsing and no branches, so it cannot fail with a 4xx/5xx. The path ends in /health, so the api-gateway default-deny authGate treats it as public - no Authorization header is required and sending an invalid one does not cause a 401. There are therefore no error cases.
{
"success": true,
"data": [
{
"health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"sdk": "string",
"status": "string"
}GET/api/mcp/server-registrations🔒 auth
Lists the MCP server registrations for one tenant. The tenant_id query parameter is mandatory and is the only scoping mechanism - the handler does NOT derive the tenant from the JWT, so any authenticated caller can list another tenant's registrations by passing that tenant_id. Edge cases: tenant_id omitted or an empty string is a 400; an unknown tenant_id returns 200 with an empty array; disabled registrations are included in the listing; the listing is unpaginated; requires a valid JWT.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | Missing query param: tenant_id | Missing query param: tenant_id | tenant_id query parameter is absent or an empty string |
| 500 | List failed | List failed | listMcpServers throws (database unavailable, malformed tenant_id reaching a UUID cast, or query error) |
{
"success": true,
"data": [
{
"server_registration_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true,
"data": "array"
}POST/api/mcp/server-registrations🔒 auth
Register an external MCP server. Opens transport, calls tools/list to auto-register tools, emits mcp.server.registered.v1. Probe failure rolls back to status='disabled'. FR-MCP-1/2/7.
[ "POST /api/auth/signup-tenant" ]
transport: http, sse, stdio| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | Required: tenant_id, display_name, transport, endpoint_url, credential_envelope_b64 | Required: tenant_id, display_name, transport, endpoint_url, credential_envelope_b64 | Body is absent or any of tenant_id, display_name, transport, endpoint_url, credential_envelope_b64 is missing/empty |
| 502 | <probe failure message> | MCP tool-discovery probe failed | registerMcpServer throws with a message containing "probe failed" - endpoint_url is unreachable, is not an MCP server, or the supplied credential envelope was rejected |
| 500 | Register failed | Register failed | Any other registerMcpServer failure (database error, envelope decode/storage failure) |
{
"entity": "mcp.server_registration",
"field": "status",
"flow": [
"active",
"degraded",
"disabled"
],
"transitions": [
{
"from": "",
"to": "active",
"via": "POST /api/mcp/server-registrations"
},
{
"from": "active",
"to": "disabled",
"via": "POST /api/mcp/server-registrations/:id/disable"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"display_name": "Slack MCP",
"transport": "http",
"endpoint_url": "http://localhost:9401/mcp",
"credential_envelope_b64": "{{static:eyJ0b2tlbiI6InRlc3QtY3JlZCJ9}}",
"allowed_agent_ids": []
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"display_name": "Slack MCP",
"transport": "http",
"endpoint_url": "http://localhost:9401/mcp",
"credential_envelope_b64": "eyJ0b2tlbiI6InRlc3QtY3JlZCJ9",
"allowed_agent_ids": []
}{
"success": true,
"data": {
"server_registration_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"display_name": "Slack MCP",
"transport": "http",
"endpoint_url": "http://localhost:9401/mcp",
"credential_envelope_b64": "eyJ0b2tlbiI6InRlc3QtY3JlZCJ9",
"allowed_agent_ids": [],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true,
"data": {
"server": {
"registration_id": "string",
"tenant_id": "string",
"display_name": "string",
"transport": "string",
"endpoint_url": "string",
"status": "string"
},
"tools": "array"
}
}GET/api/mcp/server-registrations/:id🔒 auth
Reads one MCP server registration by its registration id and returns 200 with the row. Read-only; the handler does not compare the row's tenant_id against the JWT, so any authenticated caller can read any registration id. Edge cases: an unknown but well-formed UUID returns 404; a non-UUID id fails the Postgres UUID cast (22P02) inside the try block and is reported as a generic 500 "Lookup failed" rather than a 400; a registration that has been disabled is still returned (with its disabled state) rather than 404ing; requires a valid JWT.
[ "POST /api/auth/signup-tenant", "POST /api/mcp/server-registrations" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 404 | server_registration not found | server_registration not found | No mcp server_registration row exists for the supplied id |
| 500 | Lookup failed | Lookup failed | getMcpServer throws - includes a non-UUID id (Postgres 22P02) and database errors |
{
"id": "{{cache:mcp-server-registrations.create.response.data.server.registration_id}}"
}{
"success": true,
"data": {
"server_registration_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true,
"data": {
"registration_id": "string",
"tenant_id": "string",
"display_name": "string",
"transport": "string",
"endpoint_url": "string",
"status": "string"
}
}POST/api/mcp/server-registrations/:id/disable🔒 auth
Disables an MCP server registration, recording the acting subject and the supplied reason on the audit trail; returns 200 with { registration_id, disabled: true }. reason is mandatory. The actor defaults to the literal "system" when the JWT carries no sub claim. Edge cases: reason missing or an empty string is a 400; an unknown registration id is a 404 (the service throws a "not found" error which the handler maps); disabling an already-disabled registration succeeds again (idempotent - no conflict check); the handler does not verify the registration belongs to the caller's tenant; a non-UUID id fails the Postgres UUID cast and is reported as a generic 500; requires a valid JWT.
[ "POST /api/auth/signup-tenant", "POST /api/mcp/server-registrations" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | reason is required | reason is required | Body is absent or reason is missing/empty |
| 404 | <service "not found" message> | server_registration not found | disableMcpServer throws an error whose message contains "not found" - no registration exists for the supplied id |
| 500 | Disable failed | Disable failed | Any other disableMcpServer failure, including a non-UUID id that fails the Postgres UUID cast |
{
"entity": "mcp.server_registration",
"field": "status",
"flow": [
"active",
"disabled"
],
"transitions": [
{
"from": "active",
"to": "disabled",
"via": "POST /api/mcp/server-registrations/:id/disable"
}
]
}{
"id": "{{cache:mcp-server-registrations.create.response.data.server.registration_id}}"
}{
"reason": "Decommissioned by automated test"
}{
"reason": "Decommissioned by automated test"
}{
"success": true,
"data": {
"disable_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "Decommissioned by automated test",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true,
"data": {
"registration_id": "string",
"disabled": true
}
}POST/api/mcp/tools/:tool_id/invoke🔒 auth
Capability-token-gated MCP tool invocation. Validates token against args, marks single-use, opens transport, calls /tools/call, persists mcp.tool_invocation + mcp.tool.invoked.v1. AC-12, FR-MCP-3.
[ "POST /api/auth/signup-tenant", "POST /api/mcp/server-registrations", "POST /api/agent-runtime/runs", "POST /api/agent-runtime/tokens" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | tool_id must be a valid UUID | tool_id must be a valid UUID | The :tool_id path segment does not match the UUID regex (e.g. an unsubstituted ":tool_id" placeholder) - guarded up front so it cannot reach the query and 500 |
| 400 | Required: agent_run_id, capability_token_id, args, trace_id | Required: agent_run_id, capability_token_id, args, trace_id | Body is absent or any of agent_run_id, capability_token_id, trace_id is missing/empty, or args is undefined (args may legitimately be null, 0 or an empty object) |
| 403 | <invocation result payload> | Invocation outcome was not "succeeded" | invokeMcpTool returned normally with outcome !== "succeeded" (policy denial, capability-token rejection, upstream tool error) - the body still carries the full result object under data |
| 403 | <service message> | tenant opted out / registration status not enabled | invokeMcpTool throws with a message containing "opted out" or "status=" - the tenant has opted out of the tool or the server registration is disabled |
| 404 | <service "not found" message> | tool not found | invokeMcpTool throws with a message containing "not found" - the tool_id, agent run or capability token does not exist |
| 500 | Invoke failed | Invoke failed | Any other invocation failure (transport error, database error) |
{
"tool_id": "{{cache:mcp-server-registrations.create.response.data.tools.0.tool_id}}"
}{
"agent_run_id": "{{cache:agent-runtime-runs.create.response.data.run_id}}",
"capability_token_id": "{{cache:agent-runtime-tokens.mint.response.data.token_id}}",
"args": {
"channel": "#general",
"text": "hello from automated test"
},
"trace_id": "{{var:trace_id}}"
}{
"agent_run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"capability_token_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"args": {
"channel": "#general",
"text": "hello from automated test"
},
"trace_id": "{{var:trace_id}}"
}{
"success": true,
"data": {
"invoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"agent_run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"capability_token_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"args": {
"channel": "#general",
"text": "hello from automated test"
},
"trace_id": "{{var:trace_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true,
"data": {
"invocation_id": "string",
"outcome": "string",
"latency_ms": "number"
}
}sdk-media
GET/api/media/:blob_id/playback-url🔒 auth
Issues a presigned, time-limited playback/download URL for a ready blob (FR-MED-4) and returns 200 with the URL and expiry. Both a tenant_id claim and a sub claim are required on the JWT - a missing tenant_id is a 403 while a missing sub is a 401 - and the blob's tenant is asserted against the caller's. Edge cases: the optional ttl_seconds query parameter must parse as a positive finite number (0, negative and non-numeric values are 400s) and any oversized value is silently clamped down to the service maximum rather than rejected; an unknown blob_id is a 404; a blob owned by another tenant is a 403; a blob whose key material has been shredded is a 410 Gone; the access is attributed to the caller's persona, so this route is not side-effect free.
[ "POST /api/auth/signup-tenant", "POST /api/media/upload-url" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 403 | Forbidden | JWT missing tenant_id claim | The token verifies but carries no tenant_id claim |
| 401 | Unauthorized | Missing person_id in JWT | The token carries a tenant_id but no sub claim to attribute the playback access to |
| 400 | ValidationError | ttl_seconds must be a positive number | ttl_seconds is supplied but is non-numeric, NaN/Infinity, zero or negative |
| 403 | TenantOwnership | issuePlaybackUrl: caller tenant <tid> does not own this blob | The blob's tenant_id differs from the JWT tenant_id claim |
| 404 | BlobNotFound | Blob <blob_id> not found | No blob row exists for the supplied blob_id |
| 410 | Gone | Blob <blob_id> has been cryptographically shredded | The blob key material was shredded, so it can no longer be played back |
| 500 | InternalError | InternalError | Any other issuePlaybackUrl failure - notably no S3 signer registered in production, or a database error |
{
"blob_id": "{{cache:media.upload-url.create.response.data.blob_id}}"
}{
"success": true,
"data": {
"playback_url_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"url_id": "string",
"playback_url": "string",
"expires_at": "string"
}
}POST/api/media/:blob_id/ready🔒 auth
Commits an upload: the client reports the SHA-256 it computed and the blob transitions to ready. Returns 200 with the updated blob. checksum_hex must be exactly 64 hex characters (an optional 0x prefix is stripped before the check). The blob's tenant is asserted against the JWT tenant_id claim, so committing another tenant's blob is a 403. Edge cases: a malformed or truncated checksum is a 400; an unknown blob_id is a 404 BlobNotFound; a blob whose bytes have already been cryptographically shredded is a 410 Gone; a blob attached to a sealed encounter is a 409; a JWT with no tenant_id claim is a 403 rather than a 401; re-posting ready for an already-ready blob is not specially guarded and simply repeats the state write.
[ "POST /api/auth/signup-tenant", "POST /api/media/upload-url" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 403 | Forbidden | JWT missing tenant_id claim | The token verifies but carries no tenant_id claim |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | checksum_hex must be a 64-char hex SHA-256 | checksum_hex is missing, or is not 64 hexadecimal characters after stripping an optional 0x prefix |
| 403 | TenantOwnership | markBlobReady: caller tenant <tid> does not own this blob | The blob's tenant_id differs from the JWT tenant_id claim |
| 404 | BlobNotFound | Blob <blob_id> not found | No blob row exists for the supplied blob_id |
| 409 | SealedEncounter | Encounter <encounter_id> is sealed; new evidence blocked per FR-MED-5 | The blob belongs to an encounter that has since been sealed |
| 410 | Gone | Blob <blob_id> has been cryptographically shredded | The blob key material was shredded, so the object can no longer be committed or read |
| 500 | InternalError | InternalError | Any other markBlobReady failure (storage or database error) |
{
"entity": "media.blob",
"field": "status",
"flow": [
"uploading",
"ready",
"transcoded",
"shredded"
],
"transitions": [
{
"from": "uploading",
"to": "ready",
"via": "POST /api/media/:blob_id/ready"
}
]
}{
"blob_id": "{{cache:media.upload-url.create.response.data.blob_id}}"
}{
"checksum_hex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}{
"checksum_hex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}{
"success": true,
"data": {
"ready_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"checksum_hex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"blob": {
"blob_id": "string",
"tenant_id": "string",
"persona_id": "string",
"s3_key": "string",
"content_type": "string",
"byte_size": 0,
"status": "string"
}
}
}POST/api/media/:blob_id/transcode🔒 auth
Enqueues a transcode job for an existing blob (FR-MED-3) and returns 201 with the job record, whose status is then polled via GET /api/media/transcode-jobs/:job_id. pipeline is the only body field and must be one of video-mp4-hls | image-optimize | pdf-thumbnail. The blob's tenant is asserted against the JWT tenant_id claim. Edge cases: a missing or unrecognised pipeline is a 400 with the valid values echoed back; an unknown blob_id is a 404; a blob owned by another tenant is a 403; a shredded blob is a 410 Gone and cannot be transcoded; there is no duplicate-job guard, so requesting the same pipeline twice enqueues a second independent job rather than returning the first; a JWT with no tenant_id claim is a 403 rather than a 401.
[ "POST /api/auth/signup-tenant", "POST /api/media/upload-url" ]
pipeline: video-mp4-hls, image-optimize, pdf-thumbnail| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 403 | Forbidden | JWT missing tenant_id claim | The token verifies but carries no tenant_id claim |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | pipeline must be one of video-mp4-hls, image-optimize, pdf-thumbnail | pipeline is missing or outside the closed set |
| 403 | TenantOwnership | requestTranscode: caller tenant <tid> does not own blob <blob_id> | The blob's tenant_id differs from the JWT tenant_id claim |
| 404 | BlobNotFound | Blob <blob_id> not found | No blob row exists for the supplied blob_id |
| 410 | Gone | Blob <blob_id> has been shredded; cannot transcode | The blob key material was shredded, so the source object is unrecoverable |
| 500 | InternalError | InternalError | Any other requestTranscode failure (queue or database error) |
{
"entity": "media.transcode_job",
"field": "status",
"flow": [
"queued",
"running",
"succeeded",
"failed"
],
"transitions": [
{
"from": null,
"to": "queued",
"via": "POST /api/media/:blob_id/transcode"
}
]
}{
"blob_id": "{{cache:media.upload-url.create.response.data.blob_id}}"
}{
"pipeline": "image-optimize"
}{
"pipeline": "image-optimize"
}{
"success": true,
"data": {
"transcode_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"pipeline": "image-optimize",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"job": {
"job_id": "string",
"blob_id": "string",
"pipeline": "string",
"status": "string",
"output_blob_ids": [],
"billed_units": 0
}
}
}GET/api/media/transcode-jobs/:job_id🔒 auth
Polls the status of a transcode job by job_id and returns 200 with the job record (pipeline, state, output refs, timings). The route is behind requireAuth, but unlike the other media handlers this one does NOT call the tenant guard - it neither requires a tenant_id claim nor checks that the job belongs to the caller's tenant, so any authenticated caller can poll any job_id. Edge cases: an unknown job_id is a 404; a job that is still queued or running returns 200 with its in-progress state rather than an error, so callers must poll; a failed job likewise returns 200 with a failed state; a non-UUID job_id fails the Postgres UUID cast and, matching none of the mapped error classes, surfaces as a generic 500 InternalError.
[ "POST /api/auth/signup-tenant", "POST /api/media/upload-url", "POST /api/media/:blob_id/transcode" ]
pipeline: video-mp4-hls, image-optimize, pdf-thumbnailstatus: queued, running, succeeded, failed| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 404 | NotFound | No transcode job <job_id> | No transcode job row exists for the supplied job_id |
| 500 | InternalError | InternalError | getTranscodeJob throws - includes a non-UUID job_id (Postgres 22P02) and database errors |
{
"job_id": "{{cache:media.transcode.response.data.job.job_id}}"
}{
"success": true,
"data": {
"transcode_job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"job": {
"job_id": "string",
"blob_id": "string",
"pipeline": "string",
"status": "string",
"output_blob_ids": [],
"billed_units": 0,
"error_message": null
}
}
}POST/api/media/upload-url🔒 auth
Issues a presigned S3 upload URL for a new media blob (FR-MED-1) and returns 201 with the blob id, URL and expiry. The tenant is forced from the JWT tenant_id claim and overwrites any tenant_id in the body, so cross-tenant uploads are impossible. persona_id, content_type and a positive byte_size are mandatory. Edge cases: byte_size must be a positive number and is capped at 5 GiB (5368709120) - zero, negative, non-numeric or oversized values are 400s; ttl_seconds is optional and is clamped down to the service maximum, so an oversized TTL is silently reduced rather than rejected; a JWT with no tenant_id claim is a 403, not a 401; supplying an encounter_id that has already been sealed is a 409 (FR-MED-5); a tenant with no active vault key is a 400 VaultKeyMissing and must be provisioned via sdk-vault first.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 403 | Forbidden | JWT missing tenant_id claim | The token verifies but carries no tenant_id claim, so no tenant context can be established |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | persona_id / content_type is required | persona_id or content_type is absent or whitespace-only |
| 400 | ValidationError | byte_size must be a positive number | byte_size is missing, not a number, NaN/Infinity, zero or negative |
| 400 | ValidationError | byte_size cannot exceed 5368709120 bytes | byte_size exceeds the 5 GiB single-upload ceiling |
| 400 | VaultKeyMissing | No active vault tenant key for tenant <tenant_id>; provision via sdk-vault first | The tenant has no active vault key to wrap the blob DEK |
| 409 | SealedEncounter | Encounter <encounter_id> is sealed; new evidence blocked per FR-MED-5 | The supplied encounter_id has a vault.encounter_key_seal row - the encounter is sealed and no new evidence may be attached |
| 500 | InternalError | InternalError | Any other issueUploadUrl failure - notably no S3 signer registered in production, or a database error |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"content_type": "image/jpeg",
"byte_size": 1048576,
"ttl_seconds": 900
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"content_type": "image/jpeg",
"byte_size": 1048576,
"ttl_seconds": 900
}{
"success": true,
"data": {
"upload_url_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"content_type": "image/jpeg",
"byte_size": 1048576,
"ttl_seconds": 900,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"blob_id": "string",
"url_id": "string",
"upload_url": "string",
"expires_at": "string",
"s3_key": "string"
}
}sdk-meter
GET/api/meter/assets/:asset_id/usage🔒 auth
Returns the per-day metered usage rollup for one robot/sensor asset from meter.robot_usage_day, scoped to the tenant on the caller's JWT: rows of asset_id, sensor_id (NULL for asset-level usage), day, sku and units, ordered day DESC then sku ASC, wrapped in a {success, data} envelope. Edge cases: the tenant is read from req.auth.tenant_id and never from the request - a token with no tenant claim is rejected 400 before any query runs, and an asset owned by another tenant simply returns an empty array instead of 403; an unknown or never-metered asset_id likewise returns 200 with data: [] and no 404; results are hard-capped at LIMIT 5000 with no pagination cursor, so a long-lived asset silently truncates; both tenant_id and asset_id are cast ::uuid inside the SQL, so a non-UUID asset_id surfaces through the catch block as a 500 rather than a 400.
[ "POST /api/auth/signup-tenant", "POST /api/assets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in "Bearer <token>" form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler) |
| 400 | BadRequest | tenant context required | the verified JWT carries no tenant_id claim (req.auth.tenant_id falsy) |
| 500 | InternalServerError | <propagated database error message> | getRobotUsage throws - asset_id is not a valid UUID so the ::uuid cast fails, or the Postgres pool / meter schema is unavailable; the catch block returns {success:false, error:<message>} |
{
"asset_id": "{{cache:assets.create.response.data.asset_id}}"
}{
"success": true,
"data": {
"usage_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/meter/healthpublic
Liveness probe for the sdk-meter surface. Returns 200 with the static object {sdk: 'sdk-meter', mode: 'emit-only', status: 'ok'}; the handler takes no arguments, reads no request state and touches neither Postgres nor Kafka, so it confirms the meter routes are mounted rather than that its dependencies are healthy. Edge cases: the path ends in /health, so the gateway's default-deny authGate treats it as public - it answers identically with no Authorization header, an expired token or a garbage token and never returns 401; it accepts no query parameters and no body, so there is no validation, tenant-scoping, pagination or state precondition to exercise, and the handler has no client-error branch at all.
{
"success": true,
"data": [
{
"health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"sdk": "string",
"mode": "string",
"status": "string"
}sdk-notification
GET/admin/notifications/providerspublic
Reads the currently configured platform-default email provider as { success: true, data: <provider> }. QA edge cases: when no platform provider has ever been configured, getPlatformEmailProvider() resolves to null/undefined and the route still returns 200 with a null data field — it does NOT return 404, so tests must assert on data being null rather than on the status code; the stored credential is never returned in plaintext (it lives behind the secrets envelope), so the response exposes kind, from_address and config only; this is a singleton read with no parameters, and any query string is ignored; the route is ops-token gated and returns 401 before touching the store.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 500 | InternalServerError | <provider read error message> | getPlatformEmailProvider() throws — the secrets/credential store being unreachable or Postgres unavailable |
{
"success": true,
"data": [
{
"provider_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/admin/notifications/providerspublic
Sets the platform-default email provider that the tenant-first send resolver falls back to when a tenant has configured no BYO provider of its own. Accepts kind (smtp | sendgrid | ses) plus an optional provider config object, from_address and credential, stamps created_by as "admin-ops", and returns 201 { success: true, data: <provider> }. QA edge cases: kind is the ONLY validated field and it IS checked against the closed set smtp|sendgrid|ses, so an unknown kind is a proper 400 (unlike most sibling admin routes); config, from_address and credential are all optional and unvalidated — a provider can be created with no from_address and no credential and will only fail later at send time, not here; the credential is stored via the secrets envelope and is not echoed back in plaintext; this is a singleton platform setting, so posting again replaces the current default rather than creating a second provider.
kind: smtp, sendgrid, ses| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | admin token required | x-admin-ops-token header is missing, empty, or matches neither the env ADMIN_OPS_TOKEN break-glass secret (constant-time compare) nor the SHA-256 hash of any active row in admin.ops_token; revoked/expired DB tokens keep working until the per-replica cache expires (ADMIN_OPS_TOKEN_CACHE_TTL_MS, default 30s) unless a Redis invalidation broadcast lands first |
| 400 | ValidationError | kind is required (smtp|sendgrid|ses) | kind is missing, empty, or not one of smtp, sendgrid, ses |
| 500 | InternalServerError | <provider error message> | setPlatformEmailProvider() throws — credential encryption/secret storage failing, a malformed config failing to store, or Postgres unavailable |
{
"kind": "smtp",
"from_address": "welcome@projexlight.com",
"config": {
"host": "smtp.zoho.com",
"port": 465,
"secure": true,
"user": "welcome@projexlight.com"
},
"credential": "{{static:TestSmtpPass123!}}"
}{
"kind": "smtp",
"from_address": "welcome@projexlight.com",
"config": {
"host": "smtp.zoho.com",
"port": 465,
"secure": true,
"user": "welcome@projexlight.com"
},
"credential": "TestSmtpPass123!"
}{
"success": true,
"data": {
"provider_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "smtp",
"from_address": "welcome@projexlight.com",
"config": {
"host": "smtp.zoho.com",
"port": 465,
"secure": true,
"user": "welcome@projexlight.com"
},
"credential": "TestSmtpPass123!",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/api/notifications/delivery-receipts🔒 auth
List a tenant's provider delivery-status receipts (newest first), optionally filtered by status. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/notifications/webhooks/delivery/:provider" ]
status: queued, sent, delivered, failed, bounced, undelivered, complaint| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"success": true,
"data": [
{
"delivery_receipt_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"receipts": "array"
}
}POST/api/notifications/dispatch🔒 auth
Unified send transport that routes a message to the channel's provider chain with failover (email SES/SMTP, SMS Twilio) and defers on per-persona quiet hours. This is the single transport the sdk-sequence step sender uses. Returns {status: sent|deferred|failed, provider, provider_message_id}. tenant_id, channel, destination and body are required.
[ "POST /api/auth/signup-tenant" ]
channel: email, sms, whatsapp, push, slack| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | tenant_id, channel, destination and body are required | required field missing |
| 400 | ValidationError | channel must be one of email, sms, whatsapp, push, slack | invalid channel |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "email",
"destination": "{{dynamic:email}}",
"subject": "Your follow-up",
"body": "Thanks for your time — here are the next steps.",
"respect_quiet_hours": false,
"metadata": {}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"destination": "qa.user@example.com",
"subject": "Your follow-up",
"body": "Thanks for your time — here are the next steps.",
"respect_quiet_hours": false,
"metadata": {}
}{
"success": true,
"data": {
"dispatch_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"destination": "qa.user@example.com",
"subject": "Your follow-up",
"body": "Thanks for your time — here are the next steps.",
"respect_quiet_hours": false,
"metadata": {},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"dispatch": {
"status": "string"
}
}
}GET/api/notifications/frequency-policy🔒 auth
Lists the frequency policies visible to a tenant — its own overrides AND the platform defaults they replace, each tagged source: tenant|platform — and, when a channel is named, the RESOLVED policy plus current usage (used_last_24h and remaining). Proves AC1 and AC4. Usage is included because the question a caller actually has is 'may I send now', not 'what rows exist'; making them fetch the policy and then count themselves would duplicate the precedence logic on the client, where it would drift. Returning platform rows alongside tenant ones lets a tenant see what it is overriding and what it would fall back to if the override were removed — the question asked immediately before deleting one. QA edge cases: requiresAuth applies to this GET exactly as to the PUT (MUST-52), so no Bearer is 401; a missing tenant_id is 400; remaining is null when the resolved policy is uncapped, which is distinct from 0 (blocked); usage counts only rows whose outcome is 'sent', so a deduped retry never inflates it — otherwise a retry storm would appear to exhaust the allowance without a single extra message having been delivered; purpose defaults to the catch-all '*' when omitted.
[ "POST /api/auth/signup-tenant", "PUT /api/notifications/frequency-policy" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query parameter is absent |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token — GET is gated exactly as PUT |
{
"success": true,
"data": [
{
"frequency_policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}PUT/api/notifications/frequency-policy🔒 auth
Upserts a tenant's frequency policy for one channel + purpose (either may be '*', the catch-all). An upsert, so 200 rather than 201. Proves AC1 and AC4. Two distinct settings, deliberately not merged: max_per_day is a RATE answered by counting, and dedup_window_seconds is an IDENTITY answered by a unique key — counting cannot express dedup (two retries are two rows, so they would burn two units of the cap and still both be delivered) and a unique key cannot express a rate, so conflating them produces a system that is wrong in one direction or the other. Caps are evaluated per channel AND per purpose (AC4), so an OTP is never throttled because marketing exhausted the allowance. QA edge cases: max_per_day null means UNCAPPED and is explicitly NOT the same as 0, which blocks everything — the shipped platform default is uncapped precisely because turning caps on for every existing tenant during a migration would silently start dropping their traffic, the one outcome an additive change must not cause; a negative max_per_day or a dedup window outside 0..604800 (7 days) is a 400; the PLATFORM row (tenant_id NULL) is unreachable from this endpoint, since a tenant editing the shared default would change every other tenant's limits; precedence on read is tenant before platform and then most-specific-first, but a tenant catch-all still beats a platform-specific rule because the tenant has stated a house policy.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | max_per_day must be a non-negative integer, or null for uncapped | max_per_day is negative or not an integer; null is accepted and means uncapped |
| 400 | VALIDATION_ERROR | dedup_window_seconds must be an integer between 0 and 604800 (7 days) | dedup_window_seconds is negative or exceeds 604800 |
| 400 | VALIDATION_ERROR | tenant_id is required | tenant_id is absent, so the policy could not be scoped to a tenant |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token or tenant-scoped API key on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "sms",
"purpose": "marketing",
"max_per_day": 5,
"dedup_window_seconds": 900,
"updated_by": "api-regression"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "sms",
"purpose": "marketing",
"max_per_day": 5,
"dedup_window_seconds": 900,
"updated_by": "api-regression"
}{
"success": true,
"data": {
"frequency_policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "sms",
"purpose": "marketing",
"max_per_day": 5,
"dedup_window_seconds": 900,
"updated_by": "api-regression",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/notifications/providers🔒 auth
Lists the email provider bindings configured for the caller's tenant. The tenant is derived from the JWT, never from a query param, so this endpoint is inherently tenant-scoped and cannot be made to read another tenant's providers. Secrets are never returned — only binding metadata. Edge cases: a JWT with no tenant_id claim is 403 rather than 401; a tenant with no providers configured returns 200 with data.providers = [], not 404; there are no filter, limit or paging parameters, so every binding the service query selects is returned in one response.
[ "POST /api/auth/signup-tenant", "POST /api/notifications/providers" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 403 | Forbidden | JWT missing tenant_id claim | the verified JWT carries no tenant_id claim |
| 400 | ValidationError | <service error matching unsupported|too short|must be at least|not found|invalid> | listEmailProviders threw an error whose message matches the fail() mapper pattern |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"success": true,
"data": [
{
"provider_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"providers": []
}
}POST/api/notifications/providers🔒 auth
Binds the tenant's outbound email provider (BYO smtp | sendgrid | ses). The tenant comes from the JWT — there is NO tenant_id body field — and the credential is envelope-encrypted by the service and never echoed back in any response. Edge cases: a JWT with no tenant_id claim is 403 Forbidden, not 401; kind is whitelist-checked and credential must be at least 4 characters, and both failures are collected into a single 400; config defaults to {} and from_address is optional, so an SMTP binding with no host can be created and only fails later at verify/send time; fallback_on_error is honoured only when it is a real boolean; service rejections whose message matches unsupported|too short|must be at least|not found|invalid are mapped to 400 ValidationError.
[ "POST /api/auth/signup-tenant" ]
kind: smtp, sendgrid, ses| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | kind must be one of smtp, sendgrid, ses / credential is required (min 4 chars) | kind is not whitelisted or credential is missing/shorter than 4 chars; both are returned together in details[] |
| 400 | ValidationError | <service error matching unsupported|too short|must be at least|not found|invalid> | bindEmailProvider threw an error whose message matches the fail() mapper pattern (e.g. unsupported provider config, secret-backend rejection) |
| 403 | Forbidden | JWT missing tenant_id claim | the verified JWT carries no tenant_id claim |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"kind": "sendgrid",
"from_address": "{{dynamic:email}}",
"credential": "{{static:SG.qa-test-api-key-abcd1234}}",
"config": {},
"fallback_on_error": true
}{
"kind": "sendgrid",
"from_address": "qa.user@example.com",
"credential": "SG.qa-test-api-key-abcd1234",
"config": {},
"fallback_on_error": true
}{
"success": true,
"data": {
"provider_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "sendgrid",
"from_address": "qa.user@example.com",
"credential": "SG.qa-test-api-key-abcd1234",
"config": {},
"fallback_on_error": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"provider": {
"status": "active",
"kind": "sendgrid"
}
}
}DELETE/api/notifications/providers/:provider_id🔒 auth
Revokes an email provider binding for the caller's tenant, recording an audit reason. The body is optional — an absent reason defaults to 'tenant revoked email provider'. The binding is resolved by (binding_id, tenant_id from the JWT), so another tenant's provider_id is unreachable. Edge cases: a JWT with no tenant_id claim is 403; an unknown, cross-tenant or ALREADY-revoked :provider_id raises a service 'not found' error that fail() turns into 400 ValidationError rather than 404, so a repeat delete is a 400 and this route is not idempotent; revoking the tenant's only provider leaves outbound email with no binding and is not blocked.
[ "POST /api/auth/signup-tenant", "POST /api/notifications/providers" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | <service error matching unsupported|too short|must be at least|not found|invalid> | :provider_id is unknown, belongs to another tenant, or is already revoked — the service not-found error is mapped to 400 by fail() |
| 403 | Forbidden | JWT missing tenant_id claim | the verified JWT carries no tenant_id claim |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"provider_id": "{{cache:notifications.providers.create.response.data.provider.binding_id}}"
}{
"reason": "{{static:QA revoke test for email provider}}"
}{
"reason": "QA revoke test for email provider"
}{
"success": true
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"provider": {
"status": "revoked"
}
}
}PATCH/api/notifications/providers/:provider_id🔒 auth
Rotates an email provider binding's credential (and optionally its config). credential is MANDATORY on every call — this route cannot patch config alone — and must be at least 4 characters. The binding is resolved by (binding_id, tenant_id from the JWT), so a provider_id belonging to another tenant behaves as not-found. Edge cases: a JWT with no tenant_id claim is 403; an unknown or cross-tenant :provider_id raises a service 'not found' error that the fail() mapper pattern-matches to 400 ValidationError, NOT 404 — the surprising mapping to test; omitting config leaves the stored config untouched; the new credential is envelope-encrypted and never returned.
[ "POST /api/auth/signup-tenant", "POST /api/notifications/providers" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | credential is required (min 4 chars) | body.credential is absent or shorter than 4 characters |
| 400 | ValidationError | <service error matching unsupported|too short|must be at least|not found|invalid> | :provider_id is unknown or belongs to another tenant (service not-found mapped to 400 by fail()), or the secret backend rejects the new credential |
| 403 | Forbidden | JWT missing tenant_id claim | the verified JWT carries no tenant_id claim |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"provider_id": "{{cache:notifications.providers.create.response.data.provider.binding_id}}"
}{
"credential": "{{static:SG.qa-rotated-api-key-wxyz9876}}"
}{
"credential": "SG.qa-rotated-api-key-wxyz9876"
}{
"success": true,
"data": {
"provider_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"credential": "SG.qa-rotated-api-key-wxyz9876",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"provider": {
"status": "active"
}
}
}POST/api/notifications/providers/:provider_id/verify🔒 auth
Verifies an email provider binding by sending a real test message to the `to` address. CRITICAL for testing: a FAILED test send is treated as an expected, informative outcome and STILL returns 200 — with data.verified=false and data.error carrying the provider's message — so callers must inspect data.verified, not the status code. Only the missing-recipient and missing-tenant-claim cases produce non-200 responses. Edge cases: `to` is required and trimmed but is NOT format-validated, so a syntactically invalid address reaches the provider and returns verified:false; an unknown or cross-tenant :provider_id likewise surfaces as verified:false rather than 404; the call sends actual email, so it is not free to retry.
[ "POST /api/auth/signup-tenant", "POST /api/notifications/providers" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | to (recipient email) is required | body.to is absent or empty after trimming |
| 403 | Forbidden | JWT missing tenant_id claim | the verified JWT carries no tenant_id claim |
{
"provider_id": "{{cache:notifications.providers.create.response.data.provider.binding_id}}"
}{
"to": "{{dynamic:email}}"
}{
"to": "qa.user@example.com"
}{
"success": true,
"data": {
"status": "completed",
"to": "qa.user@example.com",
"verify_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"verified": false
}
}POST/api/notifications/quiet-hours🔒 auth
Upserts a persona's quiet-hours windows and do-not-disturb flag; the sender defers (suppresses) messages that fall inside a window. Idempotent by persona — re-posting REPLACES the previous window set, so posting windows:[] clears quiet hours entirely rather than erroring. Edge cases: a JWT with no tenant_id claim is 403, not 401; every window must be {dow:0-6, start:'HH:MM', end:'HH:MM', tz} and ONE malformed element rejects the whole array; start/end are regex-checked for HH:MM SHAPE only, so '99:99' passes validation; tz is only checked for non-empty, so an invalid IANA zone is not caught here; an overnight window (start > end) is accepted; dnd is optional and independent of the windows.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | persona_id is required / windows must be an array / each window needs {dow:0-6, start:HH:MM, end:HH:MM, tz} | any validateSetQuietHours check fails; all failures are returned together in details[] |
| 403 | Forbidden | JWT missing tenant_id claim | the verified JWT carries no tenant_id claim |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"windows": [
{
"dow": 0,
"start": "22:00",
"end": "06:00",
"tz": "America/Los_Angeles"
},
{
"dow": 1,
"start": "22:00",
"end": "06:00",
"tz": "America/Los_Angeles"
}
],
"dnd": false
}{
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"windows": [
{
"dow": 0,
"start": "22:00",
"end": "06:00",
"tz": "America/Los_Angeles"
},
{
"dow": 1,
"start": "22:00",
"end": "06:00",
"tz": "America/Los_Angeles"
}
],
"dnd": false
}{
"success": true,
"data": {
"quiet_hour_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"windows": [
{
"dow": 0,
"start": "22:00",
"end": "06:00",
"tz": "America/Los_Angeles"
},
{
"dow": 1,
"start": "22:00",
"end": "06:00",
"tz": "America/Los_Angeles"
}
],
"dnd": false,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"quiet_hours": {
"persona_id": "string",
"dnd": "boolean"
}
}
}POST/api/notifications/send🔒 auth
Sends a templated notification through the full pipeline: consent check -> quiet-hours check -> template render -> queue. tenant_id is OVERWRITTEN from the caller's JWT, so a tenant_id in the body is ignored and cross-tenant sends are impossible here. The status code encodes the outcome: 201 when queued but 200 when the message was SUPPRESSED (consent absent/revoked, inside a quiet-hours window, or dnd) — a 200 therefore means nothing was sent and the reason is in data.suppression_reason. Edge cases: a JWT with no tenant_id claim is 403, not 401; template_code must resolve to a template for that (tenant, channel) or the send is 404 TemplateNotFound; enforce_consent and honor_quiet_hours let the caller opt out of those gates; scheduled_at defers delivery.
[ "POST /api/auth/signup-tenant", "POST /api/notifications/templates" ]
channel: email, sms, whatsapp, push, slack| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | tenant_id is required / template_code is required / person_id is required / channel must be one of email, sms, whatsapp, push, slack / destination is required | any validateSendNotification check fails; all failures are returned together in details[] |
| 403 | Forbidden | JWT missing tenant_id claim | the verified JWT carries no tenant_id claim, so the handler cannot scope the send |
| 404 | TemplateNotFound | Template <code> not found for channel <channel> | template_code (+ channel/locale) resolves to no tenant template and no platform default exists |
| 409 | Conflict | Template already exists for that (tenant, code, channel, version) | a duplicate-key error escapes the send path and is mapped by the shared fail() handler |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"template_code": "{{cache:notifications.templates.create.response.data.template.code}}",
"person_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"app_identity_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"channel": "email",
"destination": "{{dynamic:email}}",
"payload": {
"name": "Alice"
},
"locale": "en-US",
"scheduled_at": "{{dynamic:futuredatetime}}",
"enforce_consent": false,
"honor_quiet_hours": false
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"template_code": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_identity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"destination": "qa.user@example.com",
"payload": {
"name": "Alice"
},
"locale": "en-US",
"scheduled_at": "2026-01-15T10:30:00Z",
"enforce_consent": false,
"honor_quiet_hours": false
}{
"success": true,
"data": {
"send_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"template_code": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_identity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"destination": "qa.user@example.com",
"payload": {
"name": "Alice"
},
"locale": "en-US",
"scheduled_at": "2026-01-15T10:30:00Z",
"enforce_consent": false,
"honor_quiet_hours": false,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"message": {
"message_id": "string",
"status": "string"
},
"status": "string",
"rendered_body": "string"
}
}POST/api/notifications/send-to-audience🔒 auth
Sends to an audience named by REFERENCE — a role template or an explicit persona list — never by address, and the response reports per-recipient status keyed by persona_id without ever returning the destination used. This is what lets a consuming app address 'everyone holding this role' when it holds no persona ids and no addresses, without inheriting an erasure surface. `authorization` is a required tagged union: `platform` (the platform decides, against a tenant-registered purpose, and applies the frequency cap), `delegated` (the app already decided; decision_ref and expires_at required), or `exempt` (basis and justification required — an exemption is recorded as a decision, never skipped). Edge cases: a delegated decision whose expires_at has passed is 403 DecisionExpired rather than being honoured, because between deciding and dispatching a consent can be revoked or an address suppressed and every such change runs in the restrictive direction; the platform may only ever NARROW a delegated decision (send -> suppressed/deferred) and never upgrade a denial; `no_destination` is returned per recipient with attempted:false and is NOT a failure and NOT a delivery attempt — retrying it returns the same answer, so a caller's retry ledger must key on `attempted` rather than on the status string; the persona->destination seam is unwired by default, so a platform with no resolver wired reports no_destination for every recipient rather than inventing an address; duplicate persona_ids in an explicit audience are de-duplicated so no caller can cause a double-send; exceeding the recipient ceiling (NOTIFICATION_MAX_AUDIENCE, default 50000) is a 400 naming the ceiling rather than a truncated send.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | authorization is required — a send with no recorded decision is not permitted | authorization absent, or mode not one of platform/delegated/exempt; also covers missing tenant_id, body, channels, or audience |
| 400 | ValidationError | audience exceeds the 50000 recipient ceiling — narrow the audience rather than relying on truncation | an explicit persona audience is larger than NOTIFICATION_MAX_AUDIENCE — a 400, never a truncated send |
| 403 | DecisionExpired | delegated decision <ref> expired at <ts>; re-decide rather than re-send | authorization.mode is delegated and expires_at is in the past — an expired decision has inherited nothing |
| 403 | PurposeRequired | platform authorization requires a registered purpose | authorization.mode is platform with no purpose — never defaulted, because a resolver with an optional purpose cannot be called safely on a path that reaches a customer |
| 403 | ExemptionUnjustified | exempt authorization requires both basis and justification — an exemption is recorded, not skipped | authorization.mode is exempt with basis or justification missing |
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"audience": {
"kind": "role",
"role_template_id": "{{var:role_template_id}}"
},
"channels": [
"email"
],
"body": "An SLA has breached and needs attention.",
"subject": "SLA breach",
"authorization": {
"mode": "delegated",
"decision_ref": "decision-smoke-0001",
"expires_at": "2099-01-01T00:00:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.POST/api/notifications/send-window🔒 auth
Read-only pre-flight: may a send go to this subject on this channel at this moment, and if not, when does the window open. Returns open, reasons (most-blocking first), next_open_at, a quiet_hours block (quiet, reason, and unconfigured so an absent record is distinguishable from an open one) and a frequency block (max_per_day, used_last_24h, remaining, policy_source). channel must be one of email, sms, whatsapp, push, slack. tenant_id falls back to the caller's credential. subject_persona_id is optional and selects whose quiet hours apply; purpose selects the frequency policy, since caps are per purpose so an OTP is not throttled by marketing. An optional ISO-8601 `at` evaluates a future moment for a scheduled campaign - an unparseable value is rejected rather than silently answered for now, because a verdict about the wrong moment is the one failure mode this check must not have. RESERVES NOTHING: it calls getSendUsage, never reserveSend, because a pre-flight that reserved would burn one unit of the daily cap per subject ASKED ABOUT, exhausting the allowance before a message went out and then suppressing the real sends as duplicates. Consequently `remaining` is a tenant-wide rolling-24h figure for the (channel, purpose), not a per-subject reservation - a caller planning a batch must compare it against its own batch size, which the returned capacity_note states. Requires a valid JWT, or a key holding notification.send-window.write.
[ "POST /api/auth/signup-tenant" ]
channel: email, sms, whatsapp, push, slackpolicy_source: tenant, platform, builtin| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 403 | Forbidden | Service token is missing required scope: notification.send-window.write | An API key or machine token that does not hold notification.send-window.write (or a covering wildcard) is presented |
| 400 | VALIDATION_ERROR | tenant_id is required (absent from the request and from the credential) | No tenant_id in the body and the credential carries none |
| 400 | VALIDATION_ERROR | channel must be one of email, sms, whatsapp, push, slack | channel is absent or not a supported dispatch channel |
| 400 | VALIDATION_ERROR | at must be an ISO-8601 timestamp | `at` is supplied but does not parse as a date |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "email",
"purpose": "marketing"
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"send_window": {
"open": "boolean",
"reasons": "array",
"evaluated_at": "string"
}
}
}POST/api/notifications/send-window/bulk🔒 auth
The send-window verdict for up to 1000 subjects in one request. The work divides on two different keys and this exploits both: quiet hours are per persona, so one query covers every DISTINCT persona in the batch, while the frequency policy and its usage count are per (tenant, channel, purpose), of which a campaign has one - so a ten-thousand-subject batch issues roughly two queries, not twenty thousand. Evaluating the windows themselves is pure and stays in process. Results are order-preserving with an explicit index; an item with a missing tenant or unsupported channel reports ok=false with error_code VALIDATION_ERROR in its own slot while every other subject still returns a verdict. Each verdict carries the same shape as the single endpoint, including capacity_note - `remaining` is a tenant-wide rolling-24h figure and NOT a per-subject reservation, so a caller must compare it against its own batch size before sending. Nothing is reserved and nothing is sent; the call is read-only and safely repeatable.
[ "POST /api/auth/signup-tenant" ]
channel: email, sms, whatsapp, push, slackpolicy_source: tenant, platform, builtinerror_code: VALIDATION_ERROR| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 403 | Forbidden | Service token is missing required scope: notification.send-window.write | An API key or machine token that does not hold notification.send-window.write (or a covering wildcard) is presented |
| 400 | ValidationError | body must be an object with an items[] array | Request body is absent, not a JSON object, or is itself an array |
| 400 | ValidationError | items must not be empty | items is an empty array |
| 400 | ValidationError | items exceeds the per-request maximum of 1000; page the batch | More than 1000 items are supplied |
{
"items": [
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "email",
"purpose": "marketing"
},
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "sms",
"purpose": "marketing"
},
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "smoke-signal"
}
]
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"results": "array",
"summary": {
"requested": "number",
"succeeded": "number",
"failed": "number"
}
}
}GET/api/notifications/sms-consent🔒 auth
List a tenant's SMS consent states (newest first), optionally filtered by status (opted_in/opted_out). Numbers are shown as last-4 only. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/notifications/sms-consent" ]
status: opted_in, opted_out| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"success": true,
"data": [
{
"sms_consent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"consents": "array"
}
}POST/api/notifications/sms-consent🔒 auth
Propagate an SMS opt-out (STOP) or opt-in (START) to BOTH the suppression list (reason-tagged, via sdk-deliverability) AND the local consent record, emitting a PII-safe opt-out/opt-in event. Idempotent per (tenant, number): a duplicate leaves state unchanged (changed=false). The number is stored only as a sha256 hash + last 4 digits (never plaintext). tenant_id, phone and action are required.
[ "POST /api/auth/signup-tenant" ]
action: opt_out, opt_in| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | tenant_id, phone and action are required | required field missing |
| 400 | ValidationError | action must be opt_out or opt_in | invalid action |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"phone": "+1555{{dynamic:number}}",
"action": "opt_out",
"source": "api",
"purpose": "marketing"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"phone": "+1555{{dynamic:number}}",
"action": "opt_out",
"source": "api",
"purpose": "marketing"
}{
"success": true,
"data": {
"sms_consent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"phone": "+1555{{dynamic:number}}",
"action": "opt_out",
"source": "api",
"purpose": "marketing",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"consent": {
"status": "string",
"phone_last4": "string"
}
}
}GET/api/notifications/sms-inbound🔒 auth
List a tenant's inbound SMS (newest first), optionally filtered by keyword intent (opt_out/opt_in/help/none). tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/notifications/webhooks/sms/inbound" ]
intent: opt_out, opt_in, help, none| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"success": true,
"data": [
{
"sms_inbound_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"messages": "array"
}
}POST/api/notifications/sms-settings🔒 auth
Configure a tenant's inbound-SMS settings: the HMAC signing secret used to verify Twilio webhooks and the HELP/opt-out/opt-in auto-reply text. Upsert per tenant. tenant_id required.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | tenant_id is required | tenant_id missing |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"help_reply": "Support: reply STOP to opt out, START to opt in. Call 1-800-555-0100."
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"help_reply": "Support: reply STOP to opt out, START to opt in. Call 1-800-555-0100."
}{
"success": true,
"data": {
"sms_setting_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"help_reply": "Support: reply STOP to opt out, START to opt in. Call 1-800-555-0100.",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"settings": {
"tenant_id": "string"
}
}
}POST/api/notifications/templates🔒 auth
Creates a notification template. tenant_id is FORCED from the caller's JWT unless the body sets platform_default:true, in which case the body is used verbatim to create a platform-wide default. Edge cases: a JWT with no tenant_id claim is 403 rather than 401; locale_bundles is required and must contain AT LEAST ONE locale — an empty object {} is rejected; channel is whitelist-checked against email/sms/whatsapp/push/slack; version is optional free text; the (tenant, code, channel, version) tuple is unique, so re-creating the same template returns 409 Conflict rather than upserting — bump version to publish a revision; template placeholders are not validated against any payload schema at create time.
[ "POST /api/auth/signup-tenant" ]
channel: email, sms, whatsapp, push, slack| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | code is required / channel must be one of email, sms, whatsapp, push, slack / locale_bundles is required and must include at least one locale | any validateCreateTemplate check fails; all failures are returned together in details[] |
| 403 | Forbidden | JWT missing tenant_id claim | the verified JWT carries no tenant_id claim |
| 409 | Conflict | Template already exists for that (tenant, code, channel, version) | a template already exists for the same (tenant_id, code, channel, version) — duplicate key |
| 404 | TemplateNotFound | Template not found | the service raises TemplateNotFoundError while resolving a base/parent template |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"code": "{{dynamic:slug}}",
"channel": "email",
"locale_bundles": {
"en-US": {
"subject": "Welcome",
"body": "Hi {name}, welcome!"
}
},
"required_consent_purpose": null,
"version": "1.0.0"
}{
"code": "sample-slug",
"channel": "email",
"locale_bundles": {
"en-US": {
"subject": "Welcome",
"body": "Hi {name}, welcome!"
}
},
"required_consent_purpose": null,
"version": "1.0.0"
}{
"success": true,
"data": {
"template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"code": "sample-slug",
"channel": "email",
"locale_bundles": {
"en-US": {
"subject": "Welcome",
"body": "Hi {name}, welcome!"
}
},
"required_consent_purpose": null,
"version": "1.0.0",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"template": {
"template_id": "string",
"code": "string",
"channel": "string"
}
}
}POST/api/notifications/webhooks/delivery/:providerpublic
PUBLIC provider delivery-status webhook (Twilio/SES/SendGrid, on the gateway allowlist). Normalizes the status (delivered/failed/bounced/undelivered/complaint), looks up the notification.message by provider_message_id and drives sent->delivered via markDelivered (emitting notification.delivered.v1 once), records an idempotent receipt (per provider+message_id+status), and feeds delivered/bounce counts to reputation. Unknown message ids are recorded gracefully (matched=false). tenant_id via ?tenant_id=. Signature-verified when a per-tenant secret is configured.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
| 401 | InvalidSignature | delivery callback signature verification failed | signing secret configured and signature mismatch |
{
"provider": "{{static:twilio}}"
}{
"MessageStatus": "delivered",
"MessageSid": "SM{{dynamic:uuid}}",
"To": "+15005550006"
}{
"MessageStatus": "delivered",
"MessageSid": "SM{{dynamic:uuid}}",
"To": "+15005550006"
}{
"success": true,
"data": {
"delivery_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"MessageStatus": "delivered",
"MessageSid": "SM{{dynamic:uuid}}",
"To": "+15005550006",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"processed": "number",
"matched": "number"
}
}POST/api/notifications/webhooks/sms/inboundpublic
PUBLIC inbound SMS webhook (Twilio, on the gateway allowlist). HMAC-verified when a signing secret is configured for the tenant, else accepted. Classifies the leading keyword case-insensitively: STOP/UNSUBSCRIBE/CANCEL/END/QUIT -> opt_out (suppresses the number), START/UNSTOP/YES -> opt_in (resubscribes), HELP/INFO -> returns the configured auto-reply; unknown text is a no-op. Idempotent per (provider, MessageSid). tenant_id via ?tenant_id=.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
| 400 | ValidationError | From is required | From missing |
| 401 | InvalidSignature | inbound SMS signature verification failed | signing secret configured and signature mismatch |
{
"From": "+1555{{dynamic:number}}",
"To": "+15005550006",
"Body": "STOP",
"MessageSid": "SM{{dynamic:uuid}}"
}{
"From": "+1555{{dynamic:number}}",
"To": "+15005550006",
"Body": "STOP",
"MessageSid": "SM{{dynamic:uuid}}"
}{
"success": true,
"data": {
"inbound_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"From": "+1555{{dynamic:number}}",
"To": "+15005550006",
"Body": "STOP",
"MessageSid": "SM{{dynamic:uuid}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"intent": "string"
}
}sdk-offer-catalog
POST/api/offers🔒 auth
Create a stable offer identity (name + unique slug); content lives in immutable versions. Slug is unique per tenant (duplicate -> 409). tenant_id, name and slug required.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, name and slug are required | required missing |
| 409 | Conflict | an offer with this slug already exists | slug reused |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"name": "{{dynamic:name}}",
"slug": "offer-{{dynamic:uuid}}",
"description": "Pro plan"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"slug": "offer-{{dynamic:uuid}}",
"description": "Pro plan"
}{
"success": true,
"data": {
"offer_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"slug": "offer-{{dynamic:uuid}}",
"description": "Pro plan",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"offer": {
"offer_id": "string"
}
}
}GET/api/offers/:offer_id🔒 auth
Fetches a single offer by id and returns 200 with data.offer. tenant_id is a REQUIRED QUERY PARAMETER, not a claim: getOffer scopes by (tenant_id, offer_id), so omitting it is 400 rather than defaulting to the caller's tenant. That also means the 404 is tenant-scoped - an offer_id that exists under a different tenant answers 404 NotFound, not 403, so this route never confirms the existence of another tenant's offer. Returns the offer envelope only; it does not include versions or the current live version - use GET /api/offers/:offer_id/versions and GET /api/offers/:offer_id/current for those.
[ "POST /api/auth/signup-tenant", "POST /api/offers" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it |
| 400 | ValidationError | tenant_id query param required | The tenant_id query parameter is absent - this route reads the tenant from the query, never from the JWT claim |
| 404 | NotFound | NotFound | No offer matches (tenant_id, offer_id) - including an offer that exists under a DIFFERENT tenant, which answers 404 rather than 403 so the route never confirms another tenant's data |
| 500 | InternalError | Fastify default error payload from the uncaught service throw | getOffer throws - a non-UUID offer_id that fails the Postgres uuid cast, or any database error |
{
"offer_id": "{{cache:offers.create.response.data.offer.offer_id}}"
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"offer": {
"offer_id": "string",
"name": "string",
"slug": "string"
}
}
}POST/api/offers/:offer_id/check-reference🔒 auth
The stale-reference guard: given a pinned offer_version_id, report whether it is still the current version. A reference to a superseded (retired/older) version is stale=true. Current references pass (stale=false). Callable by CRM at quote/version-stamp time. tenant_id and offer_version_id required.
[ "POST /api/auth/signup-tenant", "POST /api/offers", "POST /api/offers/:offer_id/versions" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and offer_version_id are required | required missing |
{
"offer_id": "{{cache:offers.create.response.data.offer.offer_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"offer_version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"offer_version_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"check_reference_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"offer_version_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"reference": {
"stale": "boolean"
}
}
}GET/api/offers/:offer_id/current🔒 auth
Resolve the current version for an offer with a fallback chain: the live version if present, else the most recent beta, else the most recent draft. Returns {version, source} (source=live|beta|draft|none). tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/offers", "POST /api/offers/:offer_id/versions/:version_id/activate" ]
source: live, beta, draft, none| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"offer_id": "{{cache:offers.create.response.data.offer.offer_id}}"
}{
"success": true,
"data": {
"current_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"source": "string"
}
}GET/api/offers/:offer_id/version-stamp🔒 auth
Return the offer version a consumer should pin (version-stamp): the current version via resolve (live, else beta, else draft). A record stores offer_version_id and later revalidates with check-reference. 404 if the offer has no version. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/offers", "POST /api/offers/:offer_id/versions" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
| 404 | NotFound | offer has no current version to stamp | no version |
{
"offer_id": "{{cache:offers.create.response.data.offer.offer_id}}"
}{
"success": true,
"data": {
"version_stamp_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"stamp": {
"offer_version_id": "string"
}
}
}GET/api/offers/:offer_id/versions🔒 auth
Every version of one offer, returned as data.versions. tenant_id is a REQUIRED QUERY PARAMETER - listOfferVersions scopes by (tenant_id, offer_id) - so omitting it is 400. Unlike GET /api/offers/:offer_id this route does NOT 404 on an unknown offer_id: an offer that does not exist and an offer with no versions yet both answer 200 with an empty array, because the handler never checks the parent exists. A caller that needs to distinguish those two must read the offer first. The list is the full version history including superseded ones, not just the live version - exactly one version is live at a time and activate atomically demotes the prior one, so read status rather than assuming order.
[ "POST /api/auth/signup-tenant", "POST /api/offers", "POST /api/offers/:offer_id/versions" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it |
| 400 | ValidationError | tenant_id query param required | The tenant_id query parameter is absent - this route reads the tenant from the query, never from the JWT claim |
| 500 | InternalError | Fastify default error payload from the uncaught service throw | listOfferVersions throws - a non-UUID offer_id that fails the Postgres uuid cast, or any database error. Note there is NO 404 path: an unknown offer_id returns 200 with an empty array |
{
"offer_id": "{{cache:offers.create.response.data.offer.offer_id}}"
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"versions": "array"
}
}POST/api/offers/:offer_id/versions🔒 auth
Create an immutable offer version (starts in status 'draft'). version is unique per (tenant, offer). tenant_id and version required.
[ "POST /api/auth/signup-tenant", "POST /api/offers" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and version are required | required missing |
| 409 | Conflict | this version already exists for the offer | version reused |
{
"offer_id": "{{cache:offers.create.response.data.offer.offer_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"version": "v-{{dynamic:uuid}}",
"title": "Pro v1",
"price": 49,
"currency": "USD",
"body": {}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"version": "v-{{dynamic:uuid}}",
"title": "Pro v1",
"price": 49,
"currency": "USD",
"body": {}
}{
"success": true,
"data": {
"version_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"version": "v-{{dynamic:uuid}}",
"title": "Pro v1",
"price": 49,
"currency": "USD",
"body": {},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"version": {
"offer_version_id": "string",
"status": "string"
}
}
}GET/api/offers/:offer_id/versions/:version_id🔒 auth
Fetches one offer version by id and returns 200 with data.version. tenant_id is a REQUIRED QUERY PARAMETER, so omitting it is 400. NOTE the resolution asymmetry: getOfferVersion is called with (tenant_id, version_id) ONLY - offer_id is in the path for URL shape but is NOT used to scope the lookup, so a version_id belonging to a different offer under the same tenant resolves 200 through a mismatched offer_id in the path. Do not treat this route as a check that the version belongs to the offer. A version_id under a different TENANT is correctly 404, and 404 is used rather than 403 so the route never confirms another tenant's data. Route ordering matters here: this pattern is registered AFTER /versions/:version_id/features, so 'features' is matched by that route rather than being read as a version_id.
[ "POST /api/auth/signup-tenant", "POST /api/offers", "POST /api/offers/:offer_id/versions" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it |
| 400 | ValidationError | tenant_id query param required | The tenant_id query parameter is absent - this route reads the tenant from the query, never from the JWT claim |
| 404 | NotFound | NotFound | No version matches (tenant_id, version_id) - including a version under a DIFFERENT tenant. A version under a different OFFER in the same tenant does NOT 404, because offer_id does not scope the lookup |
| 500 | InternalError | Fastify default error payload from the uncaught service throw | getOfferVersion throws - a non-UUID version_id that fails the Postgres uuid cast, or any database error |
{
"offer_id": "{{cache:offers.create.response.data.offer.offer_id}}",
"version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"version": {
"offer_version_id": "string",
"version": "string"
}
}
}POST/api/offers/:offer_id/versions/:version_id/activate🔒 auth
Activate/publish a version: atomically demote the prior live version for this offer (-> retired) and promote the target (-> live, activated_at now), emitting offer_catalog.version.activated.v1. At most one live version per offer. tenant_id required.
[ "POST /api/auth/signup-tenant", "POST /api/offers", "POST /api/offers/:offer_id/versions" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing |
| 404 | NotFound | offer version not found | no version |
{
"entity": "offer_catalog.offer_version",
"field": "status",
"flow": [
"draft",
"live"
],
"transitions": [
{
"from": "draft",
"to": "live",
"via": "POST /api/offers/:offer_id/versions/:version_id/activate"
},
{
"from": "live",
"to": "retired",
"via": "activation of a newer version (auto-demote)"
}
]
}{
"offer_id": "{{cache:offers.create.response.data.offer.offer_id}}",
"version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"activate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"version": {
"status": "string"
}
}
}GET/api/offers/:offer_id/versions/:version_id/features🔒 auth
Read a version's feature-status matrix (by sort order then key). Each row is one feature's status within that immutable version. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/offers", "POST /api/offers/:offer_id/versions", "POST /api/offers/:offer_id/versions/:version_id/features" ]
status: included, excluded, beta, roadmap, add_on, deprecated| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"offer_id": "{{cache:offers.create.response.data.offer.offer_id}}",
"version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}{
"success": true,
"data": {
"feature_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"features": "array"
}
}POST/api/offers/:offer_id/versions/:version_id/features🔒 auth
Set (upsert) one feature's status within an immutable offer version — the feature-status matrix cell. Idempotent per (version, feature_key). tenant_id, feature_key and name required; status defaults to 'included'.
[ "POST /api/auth/signup-tenant", "POST /api/offers", "POST /api/offers/:offer_id/versions" ]
status: included, excluded, beta, roadmap, add_on, deprecated| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, feature_key and name are required | required missing |
| 400 | ValidationError | status must be one of included, excluded, beta, roadmap, add_on, deprecated | invalid status |
| 404 | NotFound | offer version not found | no version |
{
"offer_id": "{{cache:offers.create.response.data.offer.offer_id}}",
"version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"feature_key": "api-access",
"name": "API Access",
"status": "included",
"value": "unlimited",
"sort_order": 1
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"feature_key": "api-access",
"name": "API Access",
"status": "included",
"value": "unlimited",
"sort_order": 1
}{
"success": true,
"data": {
"feature_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"feature_key": "api-access",
"name": "API Access",
"status": "included",
"value": "unlimited",
"sort_order": 1,
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"feature": {
"offer_feature_id": "string",
"status": "string"
}
}
}POST/api/offers/:offer_id/versions/:version_id/publish-decision🔒 auth
Record the sdk-approval decision for a version's publish request: approved (activation now permitted) or rejected (activation stays blocked). tenant_id and decision required.
[ "POST /api/auth/signup-tenant", "POST /api/offers", "POST /api/offers/:offer_id/versions", "POST /api/offers/:offer_id/versions/:version_id/publish-request" ]
decision: approved, rejected| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and decision are required | required missing |
| 400 | ValidationError | decision must be approved or rejected | invalid decision |
| 404 | NotFound | offer version not found | no version |
{
"entity": "offer_catalog.offer_version",
"field": "approval_status",
"flow": [
"pending",
"approved"
],
"transitions": [
{
"from": "pending",
"to": "approved",
"via": "POST .../publish-decision {approved}"
},
{
"from": "pending",
"to": "rejected",
"via": "POST .../publish-decision {rejected}"
}
]
}{
"offer_id": "{{cache:offers.create.response.data.offer.offer_id}}",
"version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"decision": "approved"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"decision": "approved"
}{
"success": true,
"data": {
"publish_decision_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"decision": "approved",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"version": {
"approval_status": "string"
}
}
}POST/api/offers/:offer_id/versions/:version_id/publish-request🔒 auth
File a publish-approval request for an offer version (delegated to sdk-approval; subject = offer_version_id). Sets approval_status='pending' and stores the approval_ref. While pending (or rejected) the version cannot be activated (409). tenant_id required.
[ "POST /api/auth/signup-tenant", "POST /api/offers", "POST /api/offers/:offer_id/versions" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing |
| 404 | NotFound | offer version not found | no version |
{
"offer_id": "{{cache:offers.create.response.data.offer.offer_id}}",
"version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"publish_request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"publish": {
"approval_ref": "string",
"approval_status": "string"
}
}
}sdk-parsing
POST/api/parsing/contact/extract🔒 auth
Extracts contact-field PROPOSALS from one captured input. An action endpoint, so 200 rather than 201 — nothing is persisted; extraction guesses and a human confirms, which is why the response is proposals and never a finished contact. Eight capture surfaces are supported via source_kind: SMART_PASTE, EMAIL_SIGNATURE, BUSINESS_CARD_OCR, VCARD, VCARD_MULTI, MOBILE_CONTACTS, BROWSER_SELECTION, VOICE_TRANSCRIPT. Proves all four criteria. AC1: the deterministic local parser ALWAYS runs first and unconditionally, and the sdk-ai-gateway LLM adjunct is reached only when required fields remain unresolved after it AND allow_llm is explicitly true — an absent allow_llm is opt-OUT, because sending tenant text to a model must be the caller's decision, not this SDK's; llm_reason is always populated so a skip is auditable rather than invisible. AC2: every proposal carries confidence 0..1 plus an evidence span {start,end,snippet} indexing into raw. AC3: nothing is fabricated — each proposal is re-verified by slicing its span out of raw and confirming the normalised slice contains the normalised value; failures are DROPPED into rejected[] with a stated reason, never silently kept, and the guard applies to LLM output identically, which is what stops a model's plausible-but-absent company name from reaching the caller. AC4: the schema resolves tenant-first then platform via sdk-taxonomy lookupExtractionSchema, with a builtin last resort so a fresh install still extracts; schema.source reports which of the three answered. QA edge cases: raw is required even for MOBILE_CONTACTS, because evidence spans index into it and without it the guard would be blind — the orchestrator serialises the structured payload to JSON and passes that as raw, so device contacts are verified by the same rule as everything else; values are returned VERBATIM, since a phone reformatted to E.164 would no longer appear in the source and would be indistinguishable from an invented one; a taxonomy outage falls back to the builtin rather than failing the paste; VOICE_TRANSCRIPT reconstructs 'x at y dot com' into an address but its evidence points at the ORIGINAL spoken characters and confidence is capped well below typed input; EMAIL_SIGNATURE keeps every handle and qualifies them work/mobile/home/fax rather than collapsing to one, and offers the email domain only as org_candidate — a domain is evidence of a company, not its name — never promoted to organization; BUSINESS_CARD_OCR scales every confidence down because the same regex hit is genuinely less trustworthy on OCR output than on pasted text.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | raw is required — evidence spans index into it | raw is absent or empty for any source_kind, including MOBILE_CONTACTS, where the structured payload is serialised into raw so the fabrication guard can still verify it |
| 400 | VALIDATION_ERROR | source_kind must be one of: SMART_PASTE, EMAIL_SIGNATURE, BUSINESS_CARD_OCR, VCARD, VCARD_MULTI, MOBILE_CONTACTS, BROWSER_SELECTION, VOICE_TRANSCRIPT | source_kind is not one of the eight registered capture surfaces |
| 400 | VALIDATION_ERROR | tenant_id is required | tenant_id is absent, so the schema cannot be resolved tenant-first |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token — extraction reads customer text |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"source_kind": "EMAIL_SIGNATURE",
"raw": "Jane Okonkwo\nHead of Platform Engineering\nAcme Technologies Ltd\nWork: +44 20 7946 0958\nMobile: 07700 900123\njane.okonkwo@acme-tech.com"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"source_kind": "EMAIL_SIGNATURE",
"raw": "Jane Okonkwo\nHead of Platform Engineering\nAcme Technologies Ltd\nWork: +44 20 7946 0958\nMobile: 07700 900123\njane.okonkwo@acme-tech.com"
}{
"success": true,
"data": {
"extract_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"source_kind": "EMAIL_SIGNATURE",
"raw": "Jane Okonkwo\nHead of Platform Engineering\nAcme Technologies Ltd\nWork: +44 20 7946 0958\nMobile: 07700 900123\njane.okonkwo@acme-tech.com",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/parsing/contact/extract-batch🔒 auth
Runs contact extraction over up to 100 captures in one request, each with its own source_kind and raw. An action endpoint returning 200; nothing is persisted. Per-item isolation is the point: ONE malformed item does not fail the batch, because a caller pasting forty signatures should not lose thirty-nine to one bad entry. Each entry returns {id, ok, result|error} and the id is echoed back so a caller correlates by id rather than relying on array order, then retries exactly the failures. The response is 200 even when some items failed — the batch itself succeeded, and a 4xx would hide the items that extracted cleanly, so read ok_count/failed_count rather than the status code. allow_llm applies to the whole batch and defaults to opt-OUT exactly as on the single endpoint (AC1); every proposal carries confidence and a verified evidence span (AC2). QA edge cases: an empty items array is a 400 rather than a meaningless success; more than 100 items is a 400 naming the cap, so a caller learns the limit instead of silently having the tail dropped; per-item validation failures are reported with the offending INDEX (items[1].source_kind ...) so they can be found in a large payload; items[].raw is required for every item for the same evidence-span reason as the single endpoint.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | items must be a non-empty array | items is absent, not an array, or empty |
| 400 | VALIDATION_ERROR | items may not exceed 100 per request | more than 100 items are sent — the excess is refused loudly rather than silently truncated |
| 400 | VALIDATION_ERROR | items[1].source_kind must be one of: SMART_PASTE, EMAIL_SIGNATURE, BUSINESS_CARD_OCR, VCARD, VCARD_MULTI, MOBILE_CONTACTS, BROWSER_SELECTION, VOICE_TRANSCRIPT | any item has a missing raw or an unknown source_kind |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"items": [
{
"id": "a",
"source_kind": "SMART_PASTE",
"raw": "jane@acme.com"
},
{
"id": "b",
"source_kind": "EMAIL_SIGNATURE",
"raw": "Bob Smith\nbob@acme.com"
}
]
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"items": [
{
"id": "a",
"source_kind": "SMART_PASTE",
"raw": "jane@acme.com"
},
{
"id": "b",
"source_kind": "EMAIL_SIGNATURE",
"raw": "Bob Smith\nbob@acme.com"
}
]
}{
"success": true,
"data": {
"extract_batch_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"items": [
{
"id": "a",
"source_kind": "SMART_PASTE",
"raw": "jane@acme.com"
},
{
"id": "b",
"source_kind": "EMAIL_SIGNATURE",
"raw": "Bob Smith\nbob@acme.com"
}
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/parsing/contact/schemas🔒 auth
Returns the contact extraction schema that WOULD be applied for this tenant, plus the list of supported capture surfaces. Proves AC4. Resolution is tenant-first with platform fallback, delegated to the existing sdk-taxonomy lookupExtractionSchema — whose query already ranks tenant versions above platform ones via ORDER BY (v.tenant_id IS NOT NULL) DESC — rather than reimplemented here, since a second resolver would be a second thing to keep correct. A builtin field set is the last resort so a fresh install can extract contacts before anyone has seeded a taxonomy; failing hard there would make the feature undemonstrable on day one. schema.source is returned as tenant|platform|builtin so a caller can TELL which of the three answered instead of inferring it from the field list. source_kinds is returned so a client builds its capture UI from the server response rather than hard-coding a list that drifts the moment a backend is registered. QA edge cases: requiresAuth applies to this GET exactly as to the POSTs (MUST-52), so no Bearer is 401; a missing tenant_id is 400 because without it the tenant-first half of the resolution is meaningless; a taxonomy outage returns the builtin with source='builtin' and a 200 rather than a 5xx, matching the extract endpoint's degrade-don't-fail behaviour; taxonomy_version_id is optional and is only echoed back on the builtin path.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | tenant_id is absent, which makes tenant-first resolution meaningless |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token — GET is gated exactly as POST |
{
"success": true,
"data": [
{
"schema_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-payment
POST/api/payments/:charge_id/distribute🔒 auth
Appends immutable, hash-chained distribution ledger entries splitting a captured charge among parties. The caller tenant is taken from the JWT (never the body); the body charge_id is overwritten with the path param. Requires a 3-letter ISO-4217 currency and a non-empty splits[] where each split has a string party_persona_id and a positive-number share. The charge must exist, be owned by the caller tenant, and be in status "captured"; the batch share sum and the cumulative-of-all-prior-distributions must not exceed the charge amount. Edge cases: cross-tenant charge, missing charge, non-captured status, zero/negative shares, empty splits, wrong-length currency, over-subscription.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/payments/methods", "POST /api/payments/charge" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 403 | Forbidden | JWT missing tenant_id claim | req.auth.tenant_id absent after auth |
| 400 | ValidationError | charge_id required / currency must be ISO-4217 3-letter / splits must be a non-empty array / splits[i].party_persona_id required / splits[i].share must be a positive number | validateDistribute fails |
| 404 | ChargeNotFound | Charge <charge_id> not found | no charge row for the path charge_id |
| 403 | TenantOwnership | caller tenant does not own resource tenant | charge belongs to a different tenant than the JWT tenant |
| 409 | DistributionOversubscribed | cumulative distribution would exceed charge amount | prior distributions + this batch exceed the charge amount |
| 500 | InternalError | InternalError | default fallthrough (also, due to a fail()-matcher case mismatch, a non-captured status or batch-sum-exceeds-amount currently lands here instead of 409) |
{
"charge_id": "{{cache:payments.charge.response.data.charge.charge_id}}"
}{
"currency": "USD",
"splits": [
{
"party_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"share": 20
},
{
"party_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"share": 5
}
]
}{
"currency": "USD",
"splits": [
{
"party_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"share": 20
},
{
"party_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"share": 5
}
]
}{
"success": true,
"data": {
"distribute_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"currency": "USD",
"splits": [
{
"party_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"share": 20
},
{
"party_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"share": 5
}
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"distributions": [
{
"distribution_id": "string",
"charge_id": "string",
"party_persona_id": "string",
"share": "string",
"seq": "string"
}
]
}
}POST/api/payments/:charge_id/refund🔒 auth
Issues a refund against the charge named by the charge_id path param; high-value refunds are gated by sdk-approval. Requires a valid tenant JWT (requireAuth); the caller tenant comes from the JWT and is passed to the service, which enforces ownership. Edge cases: a JWT without a tenant_id claim is a 403 rather than a 500; amount must be a finite positive number and reason is mandatory, so a 0 or negative amount and a missing reason each 400; refunding more than the remaining refundable balance (including the cumulative effect of earlier partial refunds) is a 422 InsufficientRefundableAmount, distinct from the 400 validation path; an unknown charge_id is a 404 ChargeNotFound and a charge owned by a different tenant is a 403 TenantOwnership, not a 404; a charge in a status that cannot be refunded (never captured, already fully refunded, voided) is a 409 InvalidState; the endpoint is not idempotent - repeating the same request issues a second refund until the refundable balance is exhausted.
[ "POST /api/auth/signup-tenant", "POST /api/payments/methods", "POST /api/payments/charge" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 403 | Forbidden | JWT missing tenant_id claim | req.auth.tenant_id is absent after auth |
| 400 | ValidationError | amount must be a positive number / reason is required | validateRefund fails; details[] carries every failed rule |
| 404 | ChargeNotFound | Charge <charge_id> not found | refund raises ChargeNotFoundError for the path charge_id |
| 403 | TenantOwnership | caller tenant does not own the charge | refund raises TenantOwnershipError because the charge belongs to a different tenant than the JWT tenant |
| 422 | InsufficientRefundableAmount | refund amount exceeds the remaining refundable balance | refund raises InsufficientRefundableAmountError - the requested amount plus prior refunds exceeds the charge amount |
| 409 | InvalidState | <state error message> | the service throws a state error matched by fail() (message contains "cannot be refunded") - the charge was never captured, is voided, or is already fully refunded |
| 500 | InternalError | InternalError | refund throws an unmapped error - provider/gateway failure, approval-gate failure, or any DB error |
{
"entity": "refund",
"field": "status",
"flow": [
"pending",
"awaiting_approval",
"approved",
"rejected",
"succeeded",
"failed"
],
"transitions": [
{
"from": "pending",
"to": "succeeded",
"via": "POST /api/payments/:charge_id/refund"
},
{
"from": "pending",
"to": "awaiting_approval",
"via": "POST /api/payments/:charge_id/refund"
},
{
"from": "awaiting_approval",
"to": "approved",
"via": "POST /api/payments/:charge_id/refund"
}
]
}{
"charge_id": "{{cache:payments.charge.response.data.charge.charge_id}}"
}{
"amount": 5,
"reason": "customer requested partial refund",
"approval_threshold": 10000
}{
"amount": 5,
"reason": "customer requested partial refund",
"approval_threshold": 10000
}{
"success": true,
"data": {
"refund_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"amount": 5,
"reason": "customer requested partial refund",
"approval_threshold": 10000,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"refund": {
"refund_id": "string",
"status": "string",
"amount": "string"
}
}
}POST/api/payments/charge🔒 auth
Captures a charge against a stored payment method via the provider. Requires a valid tenant JWT (requireAuth); tenant_id is forced from the JWT and any body tenant_id is ignored. The success status is conditional: 201 when the provider result is "captured", 200 for any other terminal status (for example pending or authorized) - do not assert 201 unconditionally. Edge cases: a JWT with no tenant_id claim is a 403, not a 500; amount must be a finite positive number, so 0, a negative, a string and NaN all 400; currency is length-checked only (exactly 3 characters) and is not validated against the real ISO-4217 list, so "XXX" passes; method_id is presence-checked only, so an unknown method_id becomes a 404 PaymentMethodNotFound from the service, and a method owned by another tenant is a 403 TenantOwnership rather than a 404; a method or charge in a state that cannot be charged surfaces as a 409 InvalidState.
[ "POST /api/auth/signup-tenant", "POST /api/payments/methods" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 403 | Forbidden | JWT missing tenant_id claim | req.auth.tenant_id is absent after auth |
| 400 | ValidationError | tenant_id is required / method_id is required / amount must be a positive number / currency must be ISO-4217 3-letter | validateCharge fails; details[] carries every failed rule |
| 404 | PaymentMethodNotFound | Payment method <method_id> not found | charge raises PaymentMethodNotFoundError for the supplied method_id |
| 403 | TenantOwnership | caller tenant does not own the payment method | charge raises TenantOwnershipError because the method belongs to a different tenant than the JWT tenant |
| 409 | InvalidState | <state error message> | the service throws a state error matched by fail() (message contains "cannot distribute", "cannot be refunded" or " is ") - e.g. the method is detached or the charge is in a non-chargeable state |
| 500 | InternalError | InternalError | charge throws an unmapped error - provider/gateway failure or any DB error |
{
"entity": "charge",
"field": "status",
"flow": [
"requires_action",
"authorized",
"captured",
"failed",
"refunded",
"disputed"
],
"transitions": [
{
"from": "requires_action",
"to": "captured",
"via": "POST /api/payments/charge"
},
{
"from": "requires_action",
"to": "failed",
"via": "POST /api/payments/charge"
},
{
"from": "captured",
"to": "refunded",
"via": "POST /api/payments/:charge_id/refund"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"method_id": "{{cache:payments.methods.create.response.data.method.method_id}}",
"amount": 25,
"currency": "USD",
"encounter_id": "{{var:encounter_id}}",
"idempotency_key": "{{dynamic:slug}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"method_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"amount": 25,
"currency": "USD",
"encounter_id": "{{var:encounter_id}}",
"idempotency_key": "sample-slug"
}{
"success": true,
"data": {
"charge_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"method_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"amount": 25,
"currency": "USD",
"encounter_id": "{{var:encounter_id}}",
"idempotency_key": "sample-slug",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"charge": {
"charge_id": "string",
"status": "string",
"amount": "string"
}
}
}POST/api/payments/methods🔒 auth
Attaches a tokenized payment method to a persona and returns 201 with the stored method. Requires a valid tenant JWT (requireAuth); tenant_id is forced from the JWT tenant_id claim and any body tenant_id is overwritten, so a method cannot be attached to another tenant. Edge cases: a JWT without a tenant_id claim (for example a login with no tenant selected) is a 403 rather than a 500; provider must be one of the allowed providers and kind one of the allowed kinds, so an unknown value is a 400 listing the permitted set; provider_token is required and is additionally screened for raw-card-number shape - sending a PAN instead of a provider token is refused per FR-PAY-2 as a 400 and nothing is stored; persona_id is presence-checked only, so a non-existent persona surfaces from the service rather than the validator; validation accumulates, so one 400 may list several messages in details[]; the endpoint is not idempotent - re-posting the same provider_token attaches another method.
[ "POST /api/auth/signup-tenant" ]
provider: stripe, razorpay, plaid, achkind: card, bank-account, upi, wallet| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 403 | Forbidden | JWT missing tenant_id claim | req.auth.tenant_id is absent after auth - the token carries no tenant scope |
| 400 | ValidationError | tenant_id is required / persona_id is required / provider must be one of <list> / provider_token is required / kind must be one of <list> / provider_token looks like a raw card number; refusing per FR-PAY-2 (use the provider token only) | validateAttachMethod fails; details[] carries every failed rule |
| 500 | InternalError | InternalError | attachPaymentMethod throws an unmapped error - provider API failure, an unsatisfied persona foreign key, or any DB error |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"provider": "stripe",
"provider_token": "pm_card_visa_{{dynamic:slug}}",
"kind": "card",
"last4": "4242",
"brand": "visa",
"exp_month": 12,
"exp_year": 2030,
"secure_data_field_ref": "{{var:secure_data_field_ref}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider": "stripe",
"provider_token": "pm_card_visa_{{dynamic:slug}}",
"kind": "card",
"last4": "4242",
"brand": "visa",
"exp_month": 12,
"exp_year": 2030,
"secure_data_field_ref": "{{var:secure_data_field_ref}}"
}{
"success": true,
"data": {
"method_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider": "stripe",
"provider_token": "pm_card_visa_{{dynamic:slug}}",
"kind": "card",
"last4": "4242",
"brand": "visa",
"exp_month": 12,
"exp_year": 2030,
"secure_data_field_ref": "{{var:secure_data_field_ref}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"method": {
"method_id": "string",
"provider": "string",
"last4": "string"
}
}
}GET/api/payments/provider🔒 auth
Two-level payment-provider resolution (EP-341) via the config plane. level='collect' (default) resolves payment.provider along the caller's tenant->platform chain (how a tenant collects from its end-users); level='billing' resolves platform-scope only (how the tenant pays ProjexLight, no tenant override). Returns 200 { data: { level, configured, provider, scope, value } } — configured=false with nulls when no provider is set at any scope in range. Tenant JWT required.
[ "POST /api/auth/signup-tenant" ]
level: collect, billing| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | no Authorization header or an invalid/expired tenant JWT (api-gateway default-deny authGate) |
{
"success": true,
"data": [
{
"provider_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"level": "string",
"configured": "boolean",
"provider": "string",
"scope": "string",
"value": "object"
}
}sdk-persona
POST/api/app-identities🔒 auth
Creates an L2 app_identity binding a canonical person_id to an app_id, returning 201 with the new row. Requires a valid tenant JWT (requireAuth). Edge cases: only presence of person_id and app_id is validated - no UUID-format check, no existence check and no tenant check - so a non-existent person_id/app_id or a malformed UUID escapes validation and fails inside the service; the route has no try/catch, so that failure is answered by the Fastify default error handler as 500 rather than 404 or 400. Empty strings count as missing and yield 400. The caller JWT tenant is never consulted, so cross-tenant creation is not blocked here. The endpoint is not idempotent: repeating the same person_id+app_id either creates another row or trips a unique constraint (again a 500) depending on the schema.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | missing fields | person_id or app_id is absent or an empty string |
| 500 | InternalServerError | Internal Server Error | createAppIdentity throws and the route has no try/catch, so the Fastify default error handler responds - non-UUID person_id/app_id failing the uuid cast, an unsatisfied person/app foreign key, a duplicate unique-constraint violation, or any DB error |
{
"person_id": "{{cache:auth.register.response.data.userId}}",
"app_id": "{{cache:auth.signup-tenant.response.data.app_id}}"
}{
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"app_identity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/app-identities/:app_identity_id🔒 auth
Fetches one L2 app_identity row by its app_identity_id path param. Requires a valid tenant JWT (requireAuth). Edge cases: the lookup is by primary key only - the caller JWT tenant is never compared to the row, so any authenticated caller can read any app_identity and tenant scoping is not enforced here; a well-formed UUID that does not exist returns 404 NotFound, whereas a malformed non-UUID id fails the Postgres uuid cast inside an untried service call and surfaces as a Fastify 500, not a 400 or 404.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/app-identities" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 404 | NotFound | NotFound | no app_identity row matches the app_identity_id |
| 500 | InternalServerError | Internal Server Error | getAppIdentity throws and the route has no try/catch, so the Fastify default error handler responds - chiefly a non-UUID app_identity_id failing the uuid cast, or any DB error |
{
"app_identity_id": "{{cache:app-identities.create.response.data.app_identity.app_identity_id}}"
}{
"success": true,
"data": {
"app_identity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/app-identities/:app_identity_id/memberships🔒 auth
Lists the L3 memberships attached to one app_identity. Requires a valid tenant JWT (requireAuth). Edge cases: this is a list, so an unknown but well-formed app_identity_id returns 200 with an empty memberships array - never a 404; there is no limit/offset paging, so an app_identity with many memberships returns them all in one payload; the caller JWT tenant is not compared to the rows, so membership rows for other tenants are visible to any authenticated caller - test tenant scoping explicitly; a malformed non-UUID app_identity_id fails the uuid cast in an untried service call and surfaces as a Fastify 500.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/app-identities" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 500 | InternalServerError | Internal Server Error | listMembershipsForAppIdentity throws and the route has no try/catch, so the Fastify default error handler responds - chiefly a non-UUID app_identity_id failing the uuid cast, or any DB error |
{
"app_identity_id": "{{cache:app-identities.create.response.data.app_identity.app_identity_id}}"
}{
"success": true,
"data": {
"membership_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/memberships🔒 auth
Creates an L3 membership joining an existing L2 app_identity to a tenant, returning 201 with the new row. Requires a valid tenant JWT (requireAuth). Edge cases: only presence of app_identity_id and tenant_id is validated - there is no UUID-format check, no existence check and no comparison against the caller JWT tenant, so a caller can create a membership in a tenant they do not belong to; empty strings count as missing and yield 400; a non-existent app_identity_id or tenant_id passes validation and then trips a foreign-key violation inside an untried service call, which the Fastify default error handler reports as 500 rather than 404 or 409; a malformed non-UUID id likewise becomes a 500; the endpoint is not idempotent - repeating the same pair either duplicates the membership or trips a unique constraint (again a 500) depending on the schema.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/app-identities" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | missing fields | app_identity_id or tenant_id is absent or an empty string |
| 500 | InternalServerError | Internal Server Error | createMembership throws and the route has no try/catch, so the Fastify default error handler responds - a non-existent app_identity_id/tenant_id foreign key, a non-UUID id failing the uuid cast, a duplicate unique-constraint violation, or any DB error |
{
"app_identity_id": "{{cache:app-identities.create.response.data.app_identity.app_identity_id}}",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"app_identity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"membership_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_identity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/memberships/:membership_id/personas🔒 auth
Lists the L4 personas attached to one membership. Requires a valid tenant JWT (requireAuth). Edge cases: this is a list, so an unknown but well-formed membership_id returns 200 with an empty personas array - never a 404; there is no limit/offset paging, so every persona is returned in one payload; shredded personas are not filtered by the route, so the caller must inspect the returned state; the caller JWT tenant is never compared to the membership, so personas under another tenant membership are readable - test tenant scoping explicitly; a malformed non-UUID membership_id fails the uuid cast in an untried service call and surfaces as a Fastify 500 rather than a 400.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/app-identities", "POST /api/memberships" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 500 | InternalServerError | Internal Server Error | listPersonasForMembership throws and the route has no try/catch, so the Fastify default error handler responds - chiefly a non-UUID membership_id failing the uuid cast, or any DB error |
{
"membership_id": "{{cache:memberships.create.response.data.membership.membership_id}}"
}{
"success": true,
"data": {
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/memberships/:membership_id/terminate🔒 auth
Terminates the L3 membership named by the membership_id path param and returns the updated row. Requires a valid tenant JWT (requireAuth). Edge cases: no body is read, so any payload is ignored; there is no state precondition - an already-terminated membership can be terminated again and still returns 200 with the row (repeatable, though the termination stamp is rewritten), whereas an unknown membership_id returns 404 NotFound; the caller JWT tenant is never compared to the membership, so any authenticated caller can terminate any membership - verify tenant scoping deliberately; the route does not cascade to or check dependent L4 personas; a malformed non-UUID membership_id fails the uuid cast in an untried service call and surfaces as a Fastify 500 rather than a 404.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/app-identities", "POST /api/memberships" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 404 | NotFound | NotFound | terminateMembership returns no row for the membership_id |
| 500 | InternalServerError | Internal Server Error | terminateMembership throws and the route has no try/catch, so the Fastify default error handler responds - chiefly a non-UUID membership_id failing the uuid cast, or any DB error |
{
"entity": "membership",
"field": "status",
"flow": [
"active",
"suspended",
"terminated"
],
"transitions": [
{
"from": "active",
"to": "terminated",
"via": "POST /api/memberships/:membership_id/terminate"
}
]
}{
"membership_id": "{{cache:memberships.create.response.data.membership.membership_id}}"
}{}{
"success": true,
"data": {
"terminate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/personas🔒 auth
Gateway-composed member list for a tenant: reads identity.tenant_membership (status='active' only) and resolves each member's display name from the L2 profile band, falling back to the person's email alias and finally the literal 'Member'. Returns membership_id as persona_id plus role_template_id, bu_id and status. Edge cases: ?tenant_id= is REQUIRED (400 if absent) and is caller-asserted from the query string rather than the JWT; suspended/terminated members are silently excluded; results are hard-capped at LIMIT 500 with no paging cursor, so tenants above 500 active members are truncated with no indication; a tenant_id that is not a UUID fails the ::uuid cast and returns 500.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Gateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent |
| 401 | Unauthorized | Invalid or expired token | authGate ran requireAuth and the JWT failed verification or had expired |
| 400 | ValidationError | tenant_id required | ?tenant_id= query param is absent or empty |
| 500 | InternalError | <postgres error text> | tenant_id is not a valid UUID (::uuid cast fails), or the membership/profile query errors |
{
"success": true,
"data": [
{
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true,
"data": [
{
"persona_id": "string",
"display_name": "string",
"role": "string",
"bu_id": "string",
"status": "string"
}
]
}POST/api/personas🔒 auth
Creates an L4 persona under an existing L3 membership. membership_id and kind are mandatory; primary_role_template_id, bu_id and persona_key_ref are optional. Edge cases: only presence is validated — a membership_id that is not a UUID, or a valid UUID matching no membership row, is not caught by the handler and fails downstream in Postgres (uuid cast / FK violation) as a 500, not a 400/404; the same applies to primary_role_template_id and bu_id; kind is free text at this layer, so an unknown kind is only rejected if the DB enum rejects it; there is no duplicate-persona guard.
[ "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | missing fields | membership_id or kind is absent/empty |
| 500 | Internal Server Error | Internal Server Error | membership_id/bu_id/primary_role_template_id is not a UUID or violates a foreign key — the route has no try/catch, so the rejection surfaces as Fastify's default 500 |
{
"membership_id": "{{cache:memberships.create.response.data.membership.membership_id}}",
"kind": "patient",
"primary_role_template_id": "{{var:role_template_id}}",
"bu_id": "{{var:bu_id}}",
"persona_key_ref": "{{var:persona_key_ref}}"
}{
"membership_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "patient",
"primary_role_template_id": "{{var:role_template_id}}",
"bu_id": "{{var:bu_id}}",
"persona_key_ref": "{{var:persona_key_ref}}"
}{
"success": true,
"data": {
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"membership_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "patient",
"primary_role_template_id": "{{var:role_template_id}}",
"bu_id": "{{var:bu_id}}",
"persona_key_ref": "{{var:persona_key_ref}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/personas/:persona_id🔒 auth
Fetches a single persona by id. Edge cases: an unknown persona_id returns 404 NotFound; a :persona_id that is not a valid UUID is not validated by the handler and fails in Postgres as a 500; the lookup is by id alone and is NOT filtered by the caller's tenant, so any authenticated caller holding a persona_id can read it.
[ "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 404 | NotFound | NotFound | no persona row exists for :persona_id |
| 500 | Internal Server Error | Internal Server Error | :persona_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500 |
{
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}{
"success": true,
"data": {
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/personas/:persona_id/bu🔒 auth
Assigns (or clears) the business unit on a persona. bu_id is optional: omitting it or sending null CLEARS the persona's BU rather than erroring — there is no validation branch on this route at all. Edge cases: an unknown :persona_id updates zero rows and still returns {success:true}, so this endpoint never reports 404 and callers cannot detect a typo'd persona; a non-UUID :persona_id or a bu_id matching no BU row fails in Postgres (cast / FK) and returns 500; the BU is not checked to belong to the persona's tenant.
[ "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Gateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent |
| 401 | Unauthorized | Invalid or expired token | authGate ran requireAuth and the JWT failed verification or had expired |
| 500 | InternalError | <postgres error text> | :persona_id or bu_id is not a valid UUID, or bu_id violates the business-unit foreign key |
{
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}{
"bu_id": "{{var:bu_id}}"
}{
"bu_id": "{{var:bu_id}}"
}{
"success": true,
"data": {
"bu_id": "{{var:bu_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/personas/:persona_id/deactivate🔒 auth
Deactivates a persona by setting persona.status = 'suspended'. Takes no body. Edge cases: idempotent — suspending an already-suspended persona is a no-op that still returns {success:true}; the UPDATE row count is not checked, so an unknown :persona_id ALSO returns {success:true} and this endpoint can never report 404; a non-UUID :persona_id fails the query and returns 500; there is no re-activation counterpart on this route.
[ "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Gateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent |
| 401 | Unauthorized | Invalid or expired token | authGate ran requireAuth and the JWT failed verification or had expired |
| 500 | InternalError | <postgres error text> | :persona_id is not a valid UUID, or the status UPDATE fails |
{
"entity": "persona.persona",
"field": "status",
"flow": [
"active",
"inactive"
],
"transitions": [
{
"from": "active",
"to": "inactive",
"via": "POST /api/personas/:persona_id/deactivate"
}
]
}{
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}{}{
"success": true,
"data": {
"deactivate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/personas/:persona_id/role🔒 auth
Sets a persona's primary role. IMPORTANT: persona.persona stores the primary role as a role_template_id UUID — there is no free-text role column — so the `role` body field must be a role_template_id and a human-readable label such as 'admin' is rejected 400. Edge cases: a missing/empty role is 400 'role required'; a non-UUID role is a 400 with a distinct message; a well-formed UUID matching no role_template row violates the FK and surfaces as 500; the UPDATE row count is not checked, so an unknown :persona_id still returns {success:true} — this endpoint can never report 404.
[ "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/role-templates" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Gateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent |
| 401 | Unauthorized | Invalid or expired token | authGate ran requireAuth and the JWT failed verification or had expired |
| 400 | ValidationError | role required | body.role is absent or empty |
| 400 | ValidationError | role must be a role_template_id (uuid) | body.role is present but does not match the UUID pattern (e.g. a role label was sent) |
| 500 | InternalError | <postgres error text> | :persona_id is not a valid UUID, or role references a role_template_id that does not exist (FK violation) |
{
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}{
"role": "{{cache:role-templates.create.response.data.role_template.role_template_id}}"
}{
"role": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"role_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"role": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/personas/:persona_id/roles🔒 auth
Lists the role assignments held by a persona. Edge cases: this is a list endpoint, so an unknown persona_id returns 200 with data.roles = [] rather than 404 — a typo'd persona is indistinguishable from a persona with no roles; a non-UUID :persona_id is not validated and fails in Postgres as a 500; there is no paging or limit parameter and no tenant filter on the read.
[ "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 500 | Internal Server Error | Internal Server Error | :persona_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500 |
{
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}{
"success": true,
"data": {
"role_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/personas/:persona_id/shred🔒 auth
Crypto-shreds a persona (GDPR erasure): destroys the persona key reference so persona-scoped ciphertext becomes unreadable, returning the resulting persona row. Edge cases: an unknown :persona_id returns 404; a non-UUID :persona_id fails in Postgres as a 500; shredding is destructive and NOT reversible, yet the route has no already-shredded guard, so a repeat call still returns 200 with the row (idempotent in effect, not by check); the persona row itself is retained — only the key material is destroyed.
[ "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 404 | NotFound | NotFound | no persona row exists for :persona_id |
| 500 | Internal Server Error | Internal Server Error | :persona_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500 |
{
"entity": "persona.persona",
"field": "status",
"flow": [
"active",
"suspended",
"shredded"
],
"transitions": [
{
"from": "active",
"to": "shredded",
"via": "POST /api/personas/:persona_id/shred"
},
{
"from": "suspended",
"to": "shredded",
"via": "POST /api/personas/:persona_id/shred"
}
]
}{
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}{}{
"success": true,
"data": {
"shred_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/persons/:person_id/app-identities🔒 auth
Lists every L2 app identity linked to one L1 person from persona.app_identity - app_identity_id, person_id, app_id, status, merged_into_app_identity_id and created_at - ordered by created_at ascending. Edge cases: an unknown person_id returns 200 with app_identities: [] rather than 404, so a non-existent person is indistinguishable from a person with no apps; person_id is a UUID column, so a non-UUID path segment fails at the database and surfaces as a 500 rather than a 400; merged identities are NOT filtered out - rows carrying a non-null merged_into_app_identity_id are still returned and callers must follow the merge pointer themselves; the result is unpaginated and the query filters on person_id alone, so the caller's JWT tenant does not narrow the rows.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in "Bearer <token>" form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler) |
| 500 | InternalServerError | Internal Server Error | listAppIdentitiesForPerson SELECT fails - person_id is not a valid UUID (persona.app_identity.person_id is UUID NOT NULL) or the Postgres pool is unavailable; the route has no try/catch so the rejection becomes Fastify default 500 |
{
"person_id": "{{cache:auth.register.response.data.userId}}"
}{
"success": true,
"data": {
"app_identity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/persons/:person_id/devices🔒 auth
Lists every device linked to one person from device.person_link - link_id, device_uuid, person_id, first_used_at, last_used_at and status - returned under data.links. Edge cases: an unknown person_id returns 200 with links: [] rather than 404; person_id is a UUID column, so a non-UUID path segment fails at the database and surfaces as a 500 rather than a 400; revoked links are NOT filtered out - every row is returned regardless of its status value, so callers must filter on status themselves; the result set is unpaginated and unordered, and the query filters on person_id alone with no tenant or device-status predicate, so the caller's JWT tenant does not narrow the rows.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in "Bearer <token>" form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler) |
| 500 | InternalServerError | Internal Server Error | listDevicesForPerson SELECT fails - person_id is not a valid UUID (device.person_link.person_id is UUID NOT NULL) or the Postgres pool is unavailable; the route has no try/catch so the rejection becomes Fastify default 500 |
{
"person_id": "{{cache:auth.register.response.data.userId}}"
}{
"success": true,
"data": {
"device_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/role-templates/:role_template_id/holders🔒 auth
The reverse of GET /api/personas/:persona_id/roles: returns the personas holding one role template, scoped to one tenant. Holders are the UNION of two sources — an active persona.role_assignment grant and the persona's own primary_role_template_id — because a tenant that provisions people with a starting template and never edits them would otherwise return an empty list for a role many people hold. Each persona appears AT MOST ONCE even when it holds the role both ways, so a caller fanning out a notification cannot double-send; held_via reports 'assignment' in preference to 'primary'. Suspended/terminated memberships and non-active personas are excluded. Edge cases: tenant_id is a REQUIRED query param and its absence is a 400, not an unscoped read — persona.role_assignment carries no tenant column (the tenant is reached by joining persona.membership), so defaulting it would expose every tenant's role holders; an unknown role_template_id returns 200 with holders = [] rather than 404, since a typo'd template is indistinguishable from a role nobody holds; include_primary is off ONLY for the literal string 'false' (any other value leaves it on, so ?include_primary=true behaves as expected); limit is clamped to 1..1000 with a default of 200; a non-UUID role_template_id or tenant_id fails in Postgres as a 500 because the route does not pre-validate UUID shape.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param is absent — the read is deliberately refused rather than run unscoped across tenants |
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 500 | Internal Server Error | Internal Server Error | :role_template_id or tenant_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500 |
{
"role_template_id": "{{var:role_template_id}}"
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.sdk-policy
GET/api/policies🔒 auth
Lists the ACTIVE policy bundles governing a scope, returned under data.policies. The tenant is taken from the caller's JWT and never from the query string - a policy names what a caller may do, so allowing one to be listed by naming somebody else's tenant would hand over their access model; a credential carrying no tenant answers 400 ValidationError. With no app_id the response is the tenant-wide rules only (app_id IS NULL). With ?app_id= it is that app's own rules FOLLOWED BY the tenant-wide rules the app inherits, ordered app-specific first. Edge cases: the ordering is for presentation and for callers that want the narrowest rule - it is NOT first-match-wins, because access rules COMPOSE and silently dropping an inherited tenant-wide rule because a more specific app rule exists would widen access at exactly the moment somebody added a restriction; only status='active' rows are returned, so draft and retired bundles are invisible here; an app_id that matches no policy is not an error and yields just the inherited tenant-wide rules.
[ "POST /api/auth/register" ]
status: draft, active, deprecated, retired| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | This credential carries no tenant context | The JWT carries no tenant_id - for example a person-level token minted by a login that named no tenant |
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 500 | InternalError | InternalError | listPoliciesForScope throws - any database error while reading policy.policy |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"policies": []
}
}POST/api/policies🔒 auth
Creates a versioned policy bundle (name + version + IQL source, with optional obligations) per FR-POL-4 and returns 201. Requires a valid tenant JWT (requireAuth). Edge cases: name, version and iql_source are all mandatory presence checks; obligations, when present, must be an object and each sub-field is type-checked - mask_fields must be an array of strings, row_filter must be an object, audit_level must be one of the allowed levels, and ttl_seconds must be a non-negative number (0 is allowed, -1 is a 400); a syntactically invalid IQL body is a 400 IQLParseError raised by the parser rather than the validator, so it is a distinct failure mode from ValidationError; re-creating the same name+version pair trips the unique constraint and is a 409 Conflict, so the endpoint is not idempotent - bump the version to publish a change. SCOPING: an optional app_id scopes the rule to ONE app; omit it for a tenant-wide rule that every app of the tenant is evaluated against (the pre-2026-08 behaviour, and what existing rows still are). tenant_id NULL with app_id NULL is a platform default. Uniqueness is enforced by two PARTIAL indexes - (tenant_id, name, version) WHERE app_id IS NULL and (tenant_id, app_id, name, version) WHERE app_id IS NOT NULL - so an app-scoped override may carry the SAME name and version as the tenant-wide rule it narrows; a single four-column UNIQUE would not have worked because Postgres treats NULLs as distinct and would have permitted unlimited duplicate tenant-wide rows.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant" ]
obligations.audit_level: none, standard, detailed, forensic| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | name is required / iql_source is required / version is required / obligations must be an object / obligations.mask_fields must be an array of strings / obligations.row_filter must be an object / obligations.audit_level must be one of <list> / obligations.ttl_seconds must be a non-negative number | validateCreatePolicy fails; details[] carries every failed rule |
| 400 | IQLParseError | <parser error message> | createPolicy throws a parse error whose message starts with "Unknown IQL" or "Unexpected", or otherwise contains "IQL" |
| 409 | Conflict | policy with this name+version already exists | the INSERT trips a duplicate-key constraint on name+version |
| 500 | InternalError | InternalError | createPolicy throws any other error - a DB failure or unmapped service error |
{
"name": "{{dynamic:name}}",
"version": "1.0.0",
"iql_source": "subject.persona(role=\"doctor\") and relationship(type=\"care-team\")",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"obligations": {
"mask_fields": [
"ssn",
"dob"
],
"row_filter": {
"care_team": true
},
"audit_level": "standard",
"ttl_seconds": 300
}
}{
"name": "Acme QA Sample",
"version": "1.0.0",
"iql_source": "subject.persona(role=\"doctor\") and relationship(type=\"care-team\")",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"obligations": {
"mask_fields": [
"ssn",
"dob"
],
"row_filter": {
"care_team": true
},
"audit_level": "standard",
"ttl_seconds": 300
}
}{
"success": true,
"data": {
"policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"version": "1.0.0",
"iql_source": "subject.persona(role=\"doctor\") and relationship(type=\"care-team\")",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"obligations": {
"mask_fields": [
"ssn",
"dob"
],
"row_filter": {
"care_team": true
},
"audit_level": "standard",
"ttl_seconds": 300
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"policy": {
"policy_id": "string",
"name": "string",
"version": "string"
}
}
}GET/api/policies/:policy_id🔒 auth
Reads one policy bundle by its policy_id path param and returns it under data.policy. Requires a valid tenant JWT (requireAuth). Edge cases: the lookup is by id alone - the caller JWT tenant is never compared to the policy, so any authenticated caller can read any policy bundle including its IQL source; a well-formed but unknown policy_id returns 404 NotFound with the id echoed in details[], while a malformed non-UUID id fails the Postgres uuid cast inside the try/catch and is reported as a 500 InternalError rather than a 404 or 400; the response returns exactly the version row the id addresses - there is no "latest version" resolution here.
[ "POST /api/auth/register", "POST /api/policies" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 404 | NotFound | No policy <policy_id> | getPolicy returns no row for the policy_id |
| 500 | InternalError | InternalError | getPolicy throws - a non-UUID policy_id failing the uuid cast, or any DB error |
{
"policy_id": "{{cache:policies.create.response.data.policy.policy_id}}"
}{
"success": true,
"data": {
"policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"policy": {
"policy_id": "string",
"iql_source": "string"
}
}
}POST/api/policies/evaluate🔒 auth
Evaluates a stored policy against a subject and context per FR-POL-1 and returns the decision plus any obligations. Requires a valid tenant JWT (requireAuth). Edge cases: policy_id and subject_id are mandatory presence checks; purpose is conditionally required - it must be supplied whenever purpose_bound is true, and omitting it then is a 400 even though purpose is otherwise optional; a well-formed policy_id that does not resolve is a 404 NotFound raised from the service (matched on the message containing "not found"), not a 400; a policy that evaluates to deny is still a 200 with a deny decision in the body, so a deny must not be treated as an error status; a non-UUID policy_id or subject_id fails the uuid cast and surfaces as a 500 rather than a 400.
[ "POST /api/auth/register", "POST /api/policies" ]
resource_class: sensitive, low_risk| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | policy_id is required / subject_id is required / purpose is required when purpose_bound is true | validateEvaluatePolicy fails; details[] carries every failed rule |
| 404 | NotFound | <service message containing "not found"> | evaluatePolicy throws for an unresolvable policy or a referenced entity it cannot find |
| 500 | InternalError | InternalError | evaluatePolicy throws any other error - IQL evaluation failure, a non-UUID id failing the uuid cast, or any DB error |
{
"policy_id": "{{cache:policies.create.response.data.policy.policy_id}}",
"subject_id": "{{cache:auth.register.response.data.userId}}",
"target_id": "{{var:target_id}}",
"context": {
"subject": {
"persona": {
"role": "doctor"
}
},
"rebac": {
"care-team:*": true
},
"projection_version": 1
},
"purpose": "treatment",
"purpose_bound": false,
"consent_receipts": [],
"resource_class": "sensitive"
}{
"policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_id": "{{var:target_id}}",
"context": {
"subject": {
"persona": {
"role": "doctor"
}
},
"rebac": {
"care-team:*": true
},
"projection_version": 1
},
"purpose": "treatment",
"purpose_bound": false,
"consent_receipts": [],
"resource_class": "sensitive"
}{
"success": true,
"data": {
"status": "completed",
"policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_id": "{{var:target_id}}",
"context": {
"subject": {
"persona": {
"role": "doctor"
}
},
"rebac": {
"care-team:*": true
},
"projection_version": 1
},
"purpose": "treatment",
"purpose_bound": false,
"consent_receipts": [],
"resource_class": "sensitive",
"evaluate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"decision": "string",
"reason": "string",
"layers_used": "array",
"cached": "boolean"
}
}POST/api/policies/evaluate/bulk🔒 auth
Evaluates up to 1000 (policy, subject) pairs in one request. Collapses the cost that made the per-subject form unusable at campaign scale: ONE policy read per DISTINCT policy_id rather than per item, and ONE batched INSERT for all decision rows. Cedar evaluation stays per item, in process, against an already-compiled term, and the consent gate is the same code path the single endpoint uses so the two cannot disagree about a DENY. Results are order-preserving with an explicit index. An item naming a policy that does not exist reports ok=false with error_code POLICY_NOT_FOUND in its own slot - the single endpoint's 404 is right when the request IS one evaluation, but failing ten thousand verdicts because one named a deleted policy is not. IMPORTANT DIFFERENCE FROM THE SINGLE ENDPOINT: the audit fan-out is aggregated - one policy.evaluated-bulk.v1 per (batch, policy) carrying the allow/deny split, rather than one policy.evaluated.v1 per subject, because N hash-chain appends would serialize the whole batch behind the audit writer. Every individual decision is still written to policy.decision; a caller that needs per-subject audit events must use POST /api/policies/evaluate.
[ "POST /api/auth/register", "POST /api/policies" ]
resource_class: sensitive, low_riskdecision: ALLOW, DENYerror_code: VALIDATION_ERROR, POLICY_NOT_FOUND| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | body must be an object with an items[] array | Request body is absent, not a JSON object, or is itself an array |
| 400 | ValidationError | items must not be empty | items is an empty array |
| 400 | ValidationError | items exceeds the per-request maximum of 1000; page the batch | More than 1000 items are supplied |
| 200 | POLICY_NOT_FOUND | Policy <policy_id> not found | An item names a policy that does not exist. Reported as ok=false IN THAT ITEM'S SLOT with the request still 200 — deliberately not the single endpoint's 404, because failing ten thousand verdicts because one item named a deleted policy is the failure this endpoint exists to avoid. Not expressible as a payload in the test case above, since a literal UUID there would violate the FK-via-cache rule; asserted in the route harness instead. |
| 200 | VALIDATION_ERROR | policy_id must be a uuid | subject_id must be a uuid | <field> is required | An item is malformed. Reported per item with the request still 200; the uuid guard matters here because an unchecked value would abort the batched INSERT and cost every other subject its verdict |
| 500 | InternalError | InternalError | evaluatePolicyBulk throws outside a single item (database unavailable, or the batched decision insert fails) |
{
"items": [
{
"policy_id": "{{cache:policies.create.response.data.policy.policy_id}}",
"subject_id": "{{cache:auth.register.response.data.userId}}",
"context": {
"subject": {
"persona": {
"role": "doctor"
}
},
"projection_version": 1
},
"resource_class": "sensitive"
},
{
"policy_id": "{{cache:policies.create.response.data.policy.policy_id}}",
"subject_id": "{{cache:auth.register.response.data.userId}}"
}
]
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"results": "array",
"summary": {
"requested": "number",
"succeeded": "number",
"failed": "number"
}
}
}sdk-pool-router
GET/api/router/resolve🔒 auth
Resolves a (tenant_id, app_id) tuple to the pool that serves it (P1 §8.1 / FR-PR-1), returning 200 {data:{pool_index, pool_family, region, primary_endpoint, status}}. Cache-first: the route cache is consulted before routing.tenant_pool_map, and a hit is written through on miss; routing latency is emitted per FR-PR-5. QA edge cases: both query params are trimmed and required as one branch — omitting either, or sending whitespace-only values, gives the same 400 with details ['tenant_id and app_id are required']. Two distinct misses collapse into the same 404: no tenant_pool_map row for the tenant at all, and a row that exists but has no pool assigned for that app_id. app_id has two reserved sentinel values — '__admin__' selects the tenant's admin_pool_index and '__evidence__' the evidence_pool_index; every other value is looked up as a key inside the app_pool_index JSON map, so an app_id the tenant has not onboarded is a 404 rather than a 400. The read is idempotent and unpaginated (single tuple in, single pool record out). Because results are cached, a freshly seeded or freshly revoked mapping may not be reflected immediately — seed via the setupScript before asserting. tenant_id is taken from the query string and is NOT cross-checked against the caller's JWT, so any authenticated caller can resolve another tenant's pool; tenant-isolation tests must not expect a 403. The path is not on the gateway public allowlist, so the default-deny auth gate requires a valid tenant JWT.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — authGate.ts default-deny gate rejects /api/router/resolve before the handler |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or expired) |
| 400 | ValidationError | tenant_id and app_id are required | Either query param is absent, empty, or whitespace-only after trim |
| 404 | NotFound | No active pool mapping for this tenant and app | resolveTenantPool returns null — no routing.tenant_pool_map row for tenant_id, or the row carries no pool index for the requested app_id (including the __admin__/__evidence__ sentinels being unset) |
| 500 | InternalError | InternalError | resolveTenantPool throws — route-cache backend error, malformed tenant_id rejected by the column type, or the routing schema / DB pool being unavailable |
{
"success": true,
"data": [
{
"resolve_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"pool_index": "string",
"pool_family": "string",
"region": "string",
"primary_endpoint": "string",
"status": "string"
}
}sdk-principal-token
POST/api/principal-token🔒 auth
Mints a short-TTL, audience-bound platform principal token (P10/E2) from the SERVER-RESOLVED identity context: every claim derives from the verified JWT / resolveIdentityContext, and the only caller-supplied values are audience, ttl_seconds and purpose. Returns 201 with { token, audience, sub }. When the JWT carries both app_id and tenant_id the full IdentityContext (persona ids, effective scopes, role closure, projection_version) is resolved from the DB; otherwise it falls back to the raw six-layer JWT claims with empty scope/closure arrays. Device posture is captured at the gateway from the x-device-trust and x-network-zone headers — non-string or empty header values are dropped. Edge cases: audience is mandatory and is not validated against a registry, so any string mints a token no downstream service may accept; ttl_seconds is optional and clamped by mintPrincipalToken's own bounds; the endpoint is NOT idempotent — each call mints a fresh token; minting fails if the signing key store cannot load or rotate a key.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 401 | Unauthorized | Unauthorized | requireAuth passed but req.auth.sub is absent, so there is no subject to bind the principal token to |
| 400 | ValidationError | audience is required | Body is missing audience, or audience is an empty/falsy value |
| 500 | InternalError | <error message from resolveIdentityContext or mintPrincipalToken> | Identity-context resolution fails (person/app/tenant lookup errors or non-UUID ids from the JWT) or the token cannot be signed — signing-key load/rotation or wrap-key failure |
{
"audience": "sdk-crm",
"ttl_seconds": 300,
"purpose": "delegated-read"
}{
"audience": "sdk-crm",
"ttl_seconds": 300,
"purpose": "delegated-read"
}{
"success": true,
"data": {
"principal_token_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"audience": "sdk-crm",
"ttl_seconds": 300,
"purpose": "delegated-read",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"token": "string",
"audience": "string",
"sub": "string"
}
}sdk-profile
PUT/api/profile/bands🔒 auth
Upserts an L2 profile band: inserts a new profile.band_l2 row or, on conflict with an existing (app_identity_id, band_kind), replaces its fields_envelope and bumps updated_at. Returns 200 in both cases, so the call is fully idempotent and repeat PUTs never conflict. app_identity_id, band_kind and tenant_id are all mandatory; fields_envelope is optional and defaults to an empty object, which effectively clears the band. Edge cases: unrecognised band_kind is a 400; an empty-string tenant_id is treated as missing; a non-existent app_identity_id or tenant_id trips a foreign-key violation that escapes as an unhandled 500; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/app-identities" ]
band_kind: profile, preference, notification_routing| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | missing required fields | app_identity_id, band_kind or tenant_id is absent or empty |
| 400 | ValidationError | invalid band_kind | band_kind is not one of profile, preference, notification_routing |
| 500 | Internal Server Error | insert or update on table "band_l2" violates foreign key constraint | app_identity_id or tenant_id references a row that does not exist - the upsert has no pre-check, so the FK violation escapes unhandled |
{
"app_identity_id": "{{cache:app-identities.create.response.data.app_identity.app_identity_id}}",
"band_kind": "profile",
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"fields_envelope": {
"display_name": "base64envelope=="
}
}{
"app_identity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"band_kind": "profile",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"fields_envelope": {
"display_name": "base64envelope=="
}
}{
"success": true,
"data": {
"band_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_identity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"band_kind": "profile",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"fields_envelope": {
"display_name": "base64envelope=="
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/profile/bands/:app_identity_id/:band_kind🔒 auth
Reads one L2 profile band (the encrypted fields_envelope) for an app identity and band kind, returning 200 with { data: { band } }. band_kind is validated against the closed set profile | preference | notification_routing before the query runs. Edge cases: an unrecognised band_kind is a 400 (not a 404); a valid band_kind for an app_identity_id that has never had that band upserted is a 404; a non-UUID app_identity_id reaches the Postgres UUID cast unguarded and surfaces as a 500; the band is keyed only by (app_identity_id, band_kind) so the handler performs no tenant check against the JWT; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/app-identities", "PUT /api/profile/bands" ]
band_kind: profile, preference, notification_routing| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | invalid band_kind | band_kind path segment is not one of profile, preference, notification_routing |
| 404 | NotFound | NotFound | No profile.band_l2 row exists for the (app_identity_id, band_kind) pair |
| 500 | Internal Server Error | invalid input syntax for type uuid | app_identity_id is not a valid UUID - no format guard, so Postgres 22P02 escapes as an unhandled 500 |
{
"app_identity_id": "{{cache:app-identities.create.response.data.app_identity.app_identity_id}}",
"band_kind": "profile"
}{
"success": true,
"data": {
"band_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/profile/secure-data/:person_id🔒 auth
Reads the secure-data record for a person - the per-field encrypted envelopes plus the per-field state map (active / shredded) - returning 200 with { data: { secure_data } }. The row is created lazily by set-field, so a person who has never had a secure field written has no row at all. Edge cases: a person with no secure_data row is a 404; a person whose only fields have been shredded still returns 200, with the envelopes removed but the field_states entries retained as shredded (shredding never deletes the row); a non-UUID person_id hits the Postgres UUID cast unguarded and surfaces as a 500; no tenant scoping is applied; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/profile/secure-data/set-field" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 404 | NotFound | NotFound | No profile.secure_data row exists for the person_id (no field has ever been set) |
| 500 | Internal Server Error | invalid input syntax for type uuid | person_id is not a valid UUID - no format guard, so Postgres 22P02 escapes as an unhandled 500 |
{
"person_id": "{{cache:auth.register.response.data.userId}}"
}{
"success": true,
"data": {
"secure_data_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/profile/secure-data/:person_id/shred-history🔒 auth
Returns the append-only per-field shred history for a person as { data: { history } } with status 200. Read-only and unpaginated - the full log is returned, so a person with a long shred history returns every row. Edge cases: an unknown person_id (or one that has never been shredded) returns 200 with an empty history array rather than a 404; the same field shredded repeatedly yields one entry per shred call; a non-UUID person_id reaches the Postgres UUID cast unguarded and surfaces as a 500; no tenant scoping is applied; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/profile/secure-data/set-field", "POST /api/profile/secure-data/shred-field" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 500 | Internal Server Error | invalid input syntax for type uuid | person_id is not a valid UUID - no format guard, so Postgres 22P02 escapes as an unhandled 500 |
{
"person_id": "{{cache:auth.register.response.data.userId}}"
}{
"success": true,
"data": {
"shred_history_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/profile/secure-data/set-field🔒 auth
Sets or replaces one named encrypted field on a person's secure-data record, creating the row on first use and merging the field into the existing field_envelopes / field_states JSONB on subsequent calls. Returns 200 with the full updated record. Idempotent: re-posting the same field overwrites in place and the field state is (re-)set to active - which means a previously shredded field can be resurrected by writing it again. Edge cases: person_id, field_name and envelope are all mandatory and an empty string counts as missing; envelope is stored verbatim with no format or size validation; a person_id that does not exist trips a foreign-key violation that escapes as an unhandled 500; requires a valid JWT.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | missing required fields | person_id, field_name or envelope is absent or an empty string |
| 500 | Internal Server Error | insert or update on table "secure_data" violates foreign key constraint | person_id does not reference an existing person (or is not a valid UUID) - the upsert has no pre-check, so the Postgres error escapes unhandled |
{
"person_id": "{{cache:auth.register.response.data.userId}}",
"field_name": "pan",
"envelope": "cGFuLXNlY3JldA=="
}{
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"field_name": "pan",
"envelope": "cGFuLXNlY3JldA=="
}{
"success": true,
"data": {
"set_field_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"field_name": "pan",
"envelope": "cGFuLXNlY3JldA==",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/profile/secure-data/shred-field🔒 auth
Cryptographically shreds one named field on a person's secure-data record: the envelope bytes are removed from field_envelopes, field_states is stamped { state: "shredded", shredded_at }, and an append-only shred-log row is written. Returns 200 with the log entry. reason must be one of retention-expiry | dsar-erasure | operator-request. Edge cases: the UPDATE is unconditional, so shredding a person or field that does not exist still returns 200 and still writes a log row (no 404 path); repeat shreds of the same field are idempotent but each appends another log entry; audit_entry_id is optional and, when omitted, is generated from the emitted profile.field.shredded.v1 event; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/profile/secure-data/set-field" ]
reason: retention-expiry, dsar-erasure, operator-request| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | missing required fields | person_id, field_name or reason is absent or an empty string |
| 400 | ValidationError | invalid reason | reason is not one of retention-expiry, dsar-erasure, operator-request |
| 500 | Internal Server Error | invalid input syntax for type uuid | person_id is not a valid UUID - Postgres 22P02 on the UPDATE escapes as an unhandled 500 |
{
"person_id": "{{cache:auth.register.response.data.userId}}",
"field_name": "pan",
"reason": "dsar-erasure",
"audit_entry_id": "{{var:audit_entry_id}}"
}{
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"field_name": "pan",
"reason": "dsar-erasure",
"audit_entry_id": "{{var:audit_entry_id}}"
}{
"success": true,
"data": {
"shred_field_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"field_name": "pan",
"reason": "dsar-erasure",
"audit_entry_id": "{{var:audit_entry_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-projection
POST/api/projection/replay🔒 auth
Rebuilds a subject's projection FROM the assertion log rather than patching it, and persists a snapshot plus a content hash. An action endpoint and idempotent, so 200 on the first call and on every repeat — a repeat is expected, not an error. Proves all four criteria. AC1: patching would require knowing what a retracted assertion contributed, i.e. trusting a delta computed against state you no longer hold, after which two patches applied in a different order disagree and nothing can say which is right; a replay derives the answer from the surviving assertions, so the result depends only on the log's CONTENT and never on the path taken — deterministic and idempotent are the same property here. The content_hash is taken over a CANONICAL projection with the wall-clock stamp stripped, so two identical replays hash equal instead of looking like a change; changed=false on a repeat. AC2: retract_assertion_id and supersede_assertion_id retract/link AND replay the affected subject in the SAME call, so a successful retraction has by definition already propagated — a scheduled follow-up would leave a window in which the projection still shows a formally withdrawn value, and that window is exactly when someone reads it. AC3: every replay appends projection.replay.completed.v1 to the audit chain with both hashes, and a retraction additionally appends projection.assertion.retracted.v1; a no-op replay is STILL recorded, because 'we replayed and it made no difference' is itself the auditable answer. AC4: measured, not asserted — a 10,000-assertion subject replays in ~450-550ms against a 3000ms budget, twice, with an identical hash. QA edge cases: the snapshot is a CACHE plus evidence, never a source of truth — deleting it and replaying reproduces the same hash exactly, which is the property that makes the rebuild trustworthy; scope=tenant is BOUNDED and returns `remaining` rather than sweeping unbounded, so a rule edit on a million-subject tenant cannot become an open-ended synchronous job, and silent truncation would wrongly read as 'done'; retracting or superseding an unknown assertion is 404 rather than a fake success; an assertion may not supersede itself; superseded_by is required whenever supersede_assertion_id is given; a tenant-wide replay emits ONE summary event instead of N, since each subject's snapshot already records its own hash and 900 events would bury the ledger.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | ASSERTION_NOT_FOUND | NotFound | retract_assertion_id names no assertion for this tenant |
| 400 | VALIDATION_ERROR | subject_ref, retract_assertion_id or supersede_assertion_id is required for scope=subject | scope defaults to subject but no subject_ref or assertion id is supplied |
| 400 | VALIDATION_ERROR | superseded_by is required when supersede_assertion_id is given | a supersede is requested without naming what supersedes it, which would record a dangling link |
| 400 | VALIDATION_ERROR | trigger must be one of: manual, retraction, supersede, rule_change, backfill | trigger is not one of the five recorded causes |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token — replay rewrites a tenant's projection cache |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"subject_ref": "lead:regression-subject",
"trigger": "manual",
"reason": "api regression"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_ref": "lead:regression-subject",
"trigger": "manual",
"reason": "api regression"
}{
"success": true,
"data": {
"status": "completed",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_ref": "lead:regression-subject",
"trigger": "manual",
"reason": "api regression",
"replay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/projection/subject/:subject_ref/explained🔒 auth
Returns, per attribute, the surviving value AND every losing assertion with a concrete reason it lost. Proves AC1, AC3 and AC4. AC1: the reason is a full sentence naming the deciding criterion, both compared values and the criterion's position in the rule order — e.g. "lost on origin_class (criterion 2 of 4): 'import' ranks below 'user_supplied' in this tenant's order [human_verified > user_supplied > import]" — never a bare status word like superseded or stale, because a status word tells a user nothing they can act on: they cannot see whether the import beat their correction through precedence they would agree with, or because someone set a confidence wrong. decided_by carries the same facts structurally for callers that would rather format their own text. Only the FIRST separating criterion is reported, because later criteria were genuinely never consulted. AC3: losing is COMPUTED on read, never written — no column changes on a losing row, so the same rows re-explain themselves under a changed rule set and a rule change is a projection change rather than a data migration; losers stay ordinary queryable rows. AC4: the ordering is total, with a final assertion_id tie-break, so repeated calls on identical inputs return byte-identical output apart from projected_at; without that tie-break two assertions equal on every criterion could come back in either order depending on how Postgres returned rows, and stability would hold only by luck. Attributes are returned sorted for the same reason. QA edge cases: subject_ref is URL-decoded because refs are '<kind>:<id>' and the colon is escaped in a path segment; retracted and rejected assertions are excluded from the CONTEST by default (a retracted claim should not win) but are counted in excluded_count and remain retrievable via include_retracted=true or include_all_assertions=true, so nothing is hidden either way; an exact tie on every criterion says so explicitly and names the tie-break rather than pretending it was decided on merit, which is usually the signal a rule set needs another criterion; an unknown origin_class sorts last instead of throwing, so a new source appearing in the data cannot break the whole projection; a subject with no assertions returns 200 with an empty attributes array rather than 404, since 'no data yet' is not an error.
[ "POST /api/auth/signup-tenant", "PUT /api/projection/survivorship-rules" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query parameter is absent, so the read cannot be tenant-scoped |
| 400 | VALIDATION_ERROR | subject_ref is required | the decoded path segment is empty or whitespace-only |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token — this reads a tenant's subject data |
{
"subject_ref": "lead%3Aregression-subject"
}{
"success": true,
"data": {
"explained_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/projection/survivorship-rules🔒 auth
Lists the survivorship rule sets visible to a tenant — its own overrides AND the platform defaults they replace — each tagged with source: 'tenant' or 'platform'. Proves AC2. Returning both is deliberate: a tenant that only saw its own rows could not tell what it is overriding, nor what it would fall back to if it deleted an override, which is exactly the question someone asks before removing one. Tenant rows sort first, then by attribute, so the response order is stable. QA edge cases: requiresAuth applies to this GET exactly as to the PUT (MUST-52), so no Bearer is 401; a missing tenant_id is 400, since without it the tenant half of the listing is undefined; a tenant with no overrides still gets a 200 listing the platform rows rather than an empty array, because 'no overrides' and 'no rules at all' are different situations and an empty list would imply the latter; the platform row is read-only through this API and is never returned as editable; source is the only reliable way to distinguish the two, since a tenant override and the default share their shape by design.
[ "POST /api/auth/signup-tenant", "PUT /api/projection/survivorship-rules" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query parameter is absent |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token — GET is gated exactly as PUT |
{
"success": true,
"data": [
{
"survivorship_rule_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}PUT/api/projection/survivorship-rules🔒 auth
Upserts a tenant's survivorship rule set for one attribute (or '*', the catch-all). An upsert, so 200 rather than 201 — the same call creates or replaces. Proves AC2. criteria is an ORDERED array, not a set of weights: order IS the rule, and 'a verified value beats an unverified one, and only if that ties does origin matter' cannot be expressed as weights without inventing magic numbers — worse, weights make a loss unexplainable ('it scored 0.62') where an ordered list makes it a sentence. Valid criteria are verification_state, origin_class (both requiring a non-empty 'order' array, best first) and confidence, recency (both taking an optional direction, default desc). Resolution elsewhere is tenant-first then platform then a code builtin. QA edge cases: the PLATFORM row (tenant_id NULL) is deliberately unreachable from this endpoint — a tenant editing the shared default would change every other tenant's precedence, so a tenant overrides by writing its own row and the platform row is never edited in place, which keeps 'what does the platform say' answerable; a repeated criterion is a 400 rather than being accepted, because the second occurrence can never be reached and silently accepting it would leave the author believing it applies; duplicate entries inside an 'order' array are a 400 since their precedence would be ambiguous; validation happens at WRITE time on purpose — a bad rule discovered during a projection produces a wrong winner nobody notices, whereas the same rule refused at PUT produces an error the author can act on; an attribute-specific tenant rule beats the tenant catch-all, but a tenant catch-all still beats a platform-specific rule, because the tenant has deliberately stated a house policy.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | criteria[0].criterion must be one of: verification_state, origin_class, confidence, recency | criteria contains a criterion name the comparator does not implement |
| 400 | VALIDATION_ERROR | criteria[1] repeats 'confidence' — the later one can never be reached | the same criterion appears twice; the second is dead configuration the author would wrongly believe applies |
| 400 | VALIDATION_ERROR | criteria[0] ('origin_class') requires a non-empty 'order' array, best first | verification_state or origin_class is given without an order array, leaving its precedence undefined |
| 400 | VALIDATION_ERROR | criteria must contain at least one criterion | criteria is an empty array, which would make every contest a bare tie-break |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token — this writes the tenant's precedence policy |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"attribute": "*",
"criteria": [
{
"criterion": "verification_state",
"order": [
"verified",
"unverified",
"rejected"
]
},
{
"criterion": "origin_class",
"order": [
"human_verified",
"user_supplied",
"import"
]
},
{
"criterion": "confidence",
"direction": "desc"
},
{
"criterion": "recency",
"direction": "desc"
}
],
"updated_by": "api-regression"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"attribute": "*",
"criteria": [
{
"criterion": "verification_state",
"order": [
"verified",
"unverified",
"rejected"
]
},
{
"criterion": "origin_class",
"order": [
"human_verified",
"user_supplied",
"import"
]
},
{
"criterion": "confidence",
"direction": "desc"
},
{
"criterion": "recency",
"direction": "desc"
}
],
"updated_by": "api-regression"
}{
"success": true,
"data": {
"survivorship_rule_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"attribute": "*",
"criteria": [
{
"criterion": "verification_state",
"order": [
"verified",
"unverified",
"rejected"
]
},
{
"criterion": "origin_class",
"order": [
"human_verified",
"user_supplied",
"import"
]
},
{
"criterion": "confidence",
"direction": "desc"
},
{
"criterion": "recency",
"direction": "desc"
}
],
"updated_by": "api-regression",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-rebac
POST/api/relationships🔒 auth
Creates a new relationship edge between two personas, returning 201 with the created relationship. Requires non-empty kind, persona_a and persona_b (trimmed), and persona_a must differ from persona_b (self-edges rejected). Optional fields (scope, consent_ref, expires_at, reattest_due_at, cross_tenant) are coerced/ignored if wrong type. A DB check-constraint violation maps to 400; other insert failures return 500. NOTE on persona_b: the endpoint needs TWO DISTINCT personas, but the dependency graph is one node per METHOD+ENDPOINT so it cannot call POST /api/personas twice; persona_a therefore comes from the real producer and persona_b uses the seeded {{var:persona_id}}. That is safe here because rebac.relationship has NO foreign keys on persona_a/persona_b (verified) — they are loose UUIDs and the only rule enforced is that the two differ. Using the same producer for both was a real defect: it always resolved to one id and the request 400'd on every run.
[ "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | details[] lists failing fields: kind/persona_a/persona_b required, persona_a and persona_b must differ, or body must be an object | body not an object, a required field empty/missing, or persona_a === persona_b |
| 400 | ValidationError | raw DB check-constraint message | createRelationship() throws an error whose message includes "check constraint" |
| 500 | InternalError | InternalError | createRelationship() throws any other error or an error escapes the controller |
{
"kind": "care-team",
"persona_a": "{{cache:personas.create.response.data.persona.persona_id}}",
"persona_b": "{{var:persona_id}}",
"scope": {
"encounter_kind": "primary-care"
},
"consent_ref": "{{var:customer_consent_ref}}",
"expires_at": "2027-01-01T00:00:00Z",
"reattest_due_at": "2026-12-01T00:00:00Z",
"cross_tenant": false
}{
"kind": "care-team",
"persona_a": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_b": "{{var:persona_id}}",
"scope": {
"encounter_kind": "primary-care"
},
"consent_ref": "{{var:customer_consent_ref}}",
"expires_at": "2027-01-01T00:00:00Z",
"reattest_due_at": "2026-12-01T00:00:00Z",
"cross_tenant": false
}{
"success": true,
"data": {
"relationship_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "care-team",
"persona_a": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_b": "{{var:persona_id}}",
"scope": {
"encounter_kind": "primary-care"
},
"consent_ref": "{{var:customer_consent_ref}}",
"expires_at": "2027-01-01T00:00:00Z",
"reattest_due_at": "2026-12-01T00:00:00Z",
"cross_tenant": false,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"relationship": {
"relationship_id": "string",
"kind": "string",
"status": "string"
}
}
}POST/api/relationships/:relationship_id/attest🔒 auth
Records the trust state of a relationship's contextual role and returns 200 with data.role. trust_state is required and must be one of CONFIRMED, CANDIDATE or DOCUMENTED. THE EVIDENCE RULE IS THE POINT: CONFIRMED and DOCUMENTED both mean 'somebody checked', so attestContextualRole refuses either without at least one non-empty evidence_ref - a claim that a check happened must say what was checked. CANDIDATE is the only state that may carry no evidence, because it asserts nothing was verified. The two 400s are distinguishable by code: VALIDATION_ERROR for a trust_state outside the enum (rejected in the controller before any service call), EVIDENCE_REQUIRED for a valid state missing its evidence (raised by the service). Re-attesting MERGES evidence_refs with what is already stored rather than replacing them, deduplicated, so promoting a CANDIDATE to CONFIRMED can supply only the new reference. An unknown relationship_id is 404 RELATIONSHIP_NOT_FOUND.
[ "POST /api/auth/signup-tenant", "POST /api/relationships" ]
trust_state: CONFIRMED, CANDIDATE, DOCUMENTED| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it |
| 400 | VALIDATION_ERROR | trust_state must be one of CONFIRMED, CANDIDATE, DOCUMENTED | trust_state is absent or outside the enum - rejected in the controller before attestContextualRole is called |
| 400 | EVIDENCE_REQUIRED | [sdk-rebac] trust_state '<state>' requires at least one evidence_ref | trust_state is CONFIRMED or DOCUMENTED and evidence_refs is absent, empty, or contains only blank strings - both states assert a check was made, so they must name what was checked |
| 404 | RELATIONSHIP_NOT_FOUND | NotFound | No relationship matches relationship_id |
| 500 | InternalError | InternalError | attestContextualRole throws anything other than the evidence error - a non-UUID relationship_id that fails the Postgres uuid cast, or any database error |
{
"relationship_id": "{{cache:relationships.create.response.data.relationship.relationship_id}}"
}{
"trust_state": "CONFIRMED",
"evidence_refs": [
"evidence://qa/attestation-{{dynamic:slug}}"
]
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"role": {
"trust_state": "string"
}
}
}PUT/api/relationships/:relationship_id/scope🔒 auth
Updates a relationship edge's scope object and/or lifecycle status (FR-REB-4), returning the full updated relationship; setting status='terminated' also stamps terminated_at and invalidates the cached decisions for both personas. Body must be an object carrying at least one of scope or status — an empty body is a 400, and status is constrained to open|active|suspended|terminated|expired. Edge cases: an unknown or non-existent relationship_id yields 404 (the UPDATE returns zero rows); a scope-only update replaces the jsonb scope wholesale rather than merging; re-terminating an already-terminated edge succeeds idempotently and simply re-stamps terminated_at; a non-UUID relationship_id makes Postgres reject the cast and surfaces as 500. Requires a valid tenant JWT (route preHandler requireAuth plus the gateway default-deny gate).
[ "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/relationships" ]
status: open, active, suspended, terminated, expired| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | body must be an object | Request body is missing, null, or not a JSON object |
| 400 | ValidationError | at least one of scope or status is required | Body is an object but carries neither a scope object nor a status string |
| 400 | ValidationError | status must be one of open, active, suspended, terminated, expired | status is a string outside the allowed RelationshipStatus set |
| 404 | NotFound | Relationship <relationship_id> not found | No rebac.relationship row matches relationship_id, so the UPDATE (or the no-op SELECT) returns zero rows |
| 500 | InternalError | InternalError | Any other failure in updateRelationshipScope — non-UUID relationship_id rejected by Postgres, jsonb cast failure, or cache-invalidation/DB error |
{
"entity": "relationship",
"field": "status",
"flow": [
"open",
"active",
"suspended",
"terminated",
"expired"
],
"transitions": [
{
"from": "active",
"to": "suspended",
"via": "PUT /api/relationships/:relationship_id/scope"
},
{
"from": "suspended",
"to": "active",
"via": "PUT /api/relationships/:relationship_id/scope"
},
{
"from": "active",
"to": "terminated",
"via": "PUT /api/relationships/:relationship_id/scope"
},
{
"from": "active",
"to": "expired",
"via": "PUT /api/relationships/:relationship_id/scope"
}
]
}{
"relationship_id": "{{cache:relationships.create.response.data.relationship.relationship_id}}"
}{
"status": "suspended",
"scope": {
"encounter_kind": "primary-care"
}
}{
"status": "suspended",
"scope": {
"encounter_kind": "primary-care"
}
}{
"success": true,
"data": {
"scope_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "suspended",
"scope": {
"encounter_kind": "primary-care"
},
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"relationship": {
"relationship_id": "string",
"status": "string"
}
}
}POST/api/relationships/check🔒 auth
Evaluates whether a subject_persona_id can reach a target_persona_id via a relationship of the given kind, running a bounded graph traversal and returning a decision plus budget usage. Requires subject_persona_id, target_persona_id and kind (all trimmed). Optional budget object, if present, MUST have numeric depth_cap and visit_cap. All validation failures collect into one 400 whose details[] lists every failing field; a traversal/DB failure returns 500.
[ "POST /api/auth/register", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | details[] lists failing fields: subject_persona_id/target_persona_id/kind required, or budget must have numeric depth_cap and visit_cap, or body must be an object | body not an object, a required field empty/missing, or a partial/non-numeric budget |
| 500 | InternalError | InternalError | checkRelationship() service throws or an error escapes the controller |
{
"subject_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"target_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"kind": "care-team",
"budget": {
"depth_cap": 4,
"visit_cap": 1024
}
}{
"subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "care-team",
"budget": {
"depth_cap": 4,
"visit_cap": 1024
}
}{
"success": true,
"data": {
"check_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"target_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "care-team",
"budget": {
"depth_cap": 4,
"visit_cap": 1024
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"decision": "string",
"reason": "string",
"traversal_depth": "number",
"budget_used": {
"visits": "number",
"depth": "number"
},
"cached": "boolean"
}
}GET/api/relationships/roles🔒 auth
Lists the contextual roles held by a persona, filtered by counterparty, kind, label or trust_state. Proves AC1 (several roles coexist per pair, each with its own trust and validity) and AC2 (closed roles are still retrievable). The as_of parameter is the bitemporal read — valid_from <= t AND (valid_to IS NULL OR valid_to > t) — which lets 'who was the carer last March' be answered from the SAME rows that answer 'who is the carer now'. That is the point of storing validity on the row: no snapshot table, no parallel history table, and therefore no way for the two to disagree. QA edge cases: the default view is live-as-of-now, so a closed role disappears from it while remaining fully retrievable with include_closed=true — losing is a date on the row, never a deletion; as_of correctly excludes a role whose valid_from is later than the instant asked about, which is what distinguishes a real bitemporal read from a naive 'not closed yet' filter; requiresAuth applies to this GET exactly as to the POST (MUST-52); a missing persona_a is 400 since the query would otherwise span every persona; limit is clamped to 1..1000; results are ordered by valid_from DESC then relationship_id so the response is stable across repeated reads.
[ "POST /api/auth/signup-tenant", "POST /api/relationships/roles" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | persona_a query param required | persona_a is absent, which would make the query span every persona |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token — GET is gated exactly as POST |
{
"success": true,
"data": [
{
"role_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/relationships/roles🔒 auth
Grants one contextual role between two personas. A collection-root create, so 201. Proves AC1 and AC3. A subject-object pair is no longer ONE relationship: someone can be a patient's daughter AND registered carer AND billing contact simultaneously, each with its own role_label, its own trust_state and its own valid_from/valid_to — collapsing them into a single edge would force a choice between losing the distinction and overwriting a role that is still true. TRUST AND VALIDITY ARE SEPARATE AXES on purpose: 'is this in force now' and 'how sure are we it is real' have different remedies (an expired carer role needs renewing, an unevidenced one needs a document), and a single status field answers 'inactive' to both. AC3: CONFIRMED and DOCUMENTED require at least one evidence_ref, enforced BOTH in the service (readable error) and by a CHECK constraint (so a backfill or direct insert cannot slip past); CANDIDATE requires none, which is also why it is the column default — any other default would have invalidated every pre-existing row the moment the column was added. QA edge cases: the evidence CHECK uses cardinality() rather than array_length(), because array_length('{}',1) returns NULL and a NULL CHECK PASSES — written the obvious way the constraint silently permits the exact row it exists to forbid; whitespace-only evidence refs are stripped and then count as absent; a persona cannot hold a role to itself; a duplicate LIVE role for the same (pair, kind, role_label) is a 409, but the same pair may hold unlimited DIFFERENT labels and unlimited CLOSED historical rows; the live-role uniqueness is scoped to labelled rows only, so pre-existing unlabelled edges — which the original schema legitimately permits in duplicate — keep working unchanged.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | EVIDENCE_REQUIRED | [sdk-rebac] trust_state 'CONFIRMED' requires at least one evidence_ref | trust_state is CONFIRMED or DOCUMENTED and evidence_refs is empty or whitespace-only — such a row reads as checked to every downstream reader while resting on nothing |
| 400 | VALIDATION_ERROR | trust_state must be one of CONFIRMED, CANDIDATE, DOCUMENTED | trust_state is outside the three recorded states |
| 400 | VALIDATION_ERROR | persona_b is required | kind, persona_a or persona_b is absent |
| 409 | ROLE_ALREADY_LIVE | a live role with this kind and label already exists for the pair | an identical (pair, kind, role_label) role is already open — a duplicate, not a second role; close the first or use a different label |
| 401 | Unauthorized | missing or invalid tenant token | no valid tenant Bearer token on the request |
{
"kind": "care-team",
"persona_a": "{{cache:personas.create.response.data.persona.persona_id}}",
"persona_b": "{{cache:personas.create-second.response.data.persona.persona_id}}",
"role_label": "registered_carer",
"trust_state": "DOCUMENTED",
"evidence_refs": [
"doc:poa-77"
],
"valid_from": "{{dynamic:pastdatetime}}"
}{
"kind": "care-team",
"persona_a": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_b": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"role_label": "registered_carer",
"trust_state": "DOCUMENTED",
"evidence_refs": [
"doc:poa-77"
],
"valid_from": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"role_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "care-team",
"persona_a": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_b": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"role_label": "registered_carer",
"trust_state": "DOCUMENTED",
"evidence_refs": [
"doc:poa-77"
],
"valid_from": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/role-assignments🔒 auth
Assigns a role template to an L4 persona, inserting a persona.role_assignment row and emitting the identity.role.assigned.v1 audit event; returns 201 with the created assignment (assignment_id, persona_id, role_template_id, assigned_at, revoked_at=null, assigned_by). Only persona_id and role_template_id are validated as present — assigned_by is optional and stored as NULL when omitted. Edge cases: the endpoint is NOT idempotent, so repeating the same body creates a second active assignment for the same persona/role pair; a persona_id or role_template_id that does not exist (or is not a UUID) is not pre-checked and fails at the Postgres FK/cast layer as an uncaught 500; empty-string values are falsy and are rejected as missing fields. Requires a valid tenant JWT.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | missing fields | persona_id or role_template_id is absent, empty, or otherwise falsy in the body |
| 500 | Internal Server Error | Fastify default error payload from the uncaught service throw | assignRole throws and is not caught by the route — persona_id/role_template_id violates the FK to persona.persona / role template, is not a valid UUID, or the audit emit / DB insert fails |
{
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"role_template_id": "{{var:role_template_id}}",
"assigned_by": "sdk-persona.qa-assign"
}{
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"role_template_id": "{{var:role_template_id}}",
"assigned_by": "sdk-persona.qa-assign"
}{
"success": true,
"data": {
"role_assignment_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"role_template_id": "{{var:role_template_id}}",
"assigned_by": "sdk-persona.qa-assign",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/role-assignments/:assignment_id/revoke🔒 auth
Revokes an active role assignment by stamping persona.role_assignment.revoked_at = now() and emitting identity.role.revoked.v1; returns 200 with the revoked assignment record. The UPDATE is guarded by `revoked_at IS NULL`, so it only affects assignments that are still active. Edge cases: revoking is NOT idempotent from the caller's view — a second revoke of the same assignment matches zero rows and returns 404, as does an assignment_id that never existed; a non-UUID assignment_id is rejected by Postgres and surfaces as an uncaught 500; there is no tenant/persona ownership check in this handler beyond the JWT gate, so scoping relies on the caller holding the assignment_id. Requires a valid tenant JWT.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /api/app-identities", "POST /api/memberships", "POST /api/personas", "POST /api/role-assignments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 404 | NotFound | NotFound | No persona.role_assignment row with that assignment_id and revoked_at IS NULL — unknown id, or the assignment was already revoked |
| 500 | Internal Server Error | Fastify default error payload from the uncaught service throw | revokeRoleAssignment throws — assignment_id is not a valid UUID, or the UPDATE / audit emit fails |
{
"assignment_id": "{{cache:role-assignments.create.response.data.assignment.assignment_id}}"
}{}{
"success": true,
"data": {
"status": "completed",
"revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/role-templates🔒 auth
Creates a per-app role template in tenant.role_template with an optional inheritance parent and an opaque JSONB permissions map. Despite the 'sdk-rebac' label the route is served by sdk-tenant (packages/sdk-tenant/src/server/routes.ts -> createRoleTemplateHandler). Requires app_id and name; tenant_id omitted means a platform-default template (tenant_id NULL), tenant_id present means a tenant override. QA edge cases: uniqueness is enforced by two PARTIAL unique indexes — (app_id, name) WHERE tenant_id IS NULL and (tenant_id, app_id, name) WHERE tenant_id IS NOT NULL — so re-POSTing the same triple returns 409 Conflict, while the same (app_id, name) is legitimately allowed once globally and once per tenant. app_id is a TEXT FK to tenant.app and tenant_id/parent_role_template_id are UUID FKs; an unknown value trips the FK and is reported as 400 ValidationError (never 404), whereas a malformed UUID for tenant_id or parent_role_template_id yields 500 because uncaught() only text-matches FK/duplicate/not-found. permissions is stored verbatim with no schema validation (unknown keys, non-boolean values and arbitrary nesting all persist); a non-object permissions value is silently coerced to {} rather than rejected. No cycle check exists on parent_role_template_id. Auth is a plain valid-JWT check — the caller's tenant claim is never compared to the body tenant_id, so cross-tenant template creation is not blocked at this layer.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization header or a non-Bearer scheme. Enforced by the gateway default-deny authGate (/api/role-templates is not on the public allowlist) and by the route's requireAuth preHandler. |
| 401 | Unauthorized | Invalid or expired token | verifyJwt() rejects the bearer token — bad signature, wrong secret, or expired exp. |
| 400 | ValidationError | body must be an object | Request body is null, absent, or not a JSON object. |
| 400 | ValidationError | app_id is required | app_id is missing, not a string, or empty. |
| 400 | ValidationError | name is required | name is missing, not a string, or empty. Both messages appear together in details[] when both are absent. |
| 400 | ValidationError | insert or update on table "role_template" violates foreign key constraint | app_id has no row in tenant.app, tenant_id has no row in tenant.tenant, or parent_role_template_id has no row in tenant.role_template. uncaught() maps all FK violations to 400. |
| 409 | Conflict | duplicate key value violates unique constraint "role_template_tenant_uniq" | A template with the same (tenant_id, app_id, name) already exists — or the same (app_id, name) among platform defaults, which violates role_template_global_uniq. This makes the endpoint non-idempotent: the second identical POST fails rather than returning the existing row. |
| 500 | InternalError | InternalError | Any DB error not matching the FK/duplicate/not-found text — chiefly tenant_id or parent_role_template_id being a non-UUID string ('invalid input syntax for type uuid'), or a connection-pool failure. Also emitted by the route-level try/catch in sdk-tenant registerRoutes when nothing has been sent yet. |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"app_id": "{{cache:auth.signup-tenant.response.data.app_id}}",
"name": "{{dynamic:name}}",
"permissions": {
"chart.read": true,
"chart.write": false
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"permissions": {
"chart.read": true,
"chart.write": false
}
}{
"success": true,
"data": {
"role_template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"permissions": {
"chart.read": true,
"chart.write": false
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-resource-registry
GET/api/resources🔒 auth
Lists resource-ownership registry records for the admin "who owns what" view, filterable by owner, status, environment, resource_type and team with limit/offset paging (P10/E5). Requires a valid tenant JWT (requireAuth). Edge cases: every filter is optional, so an unfiltered call returns the whole registry - this is a platform-wide registry and the caller JWT tenant is never applied as a filter, so tenant scoping is not enforced on this route; limit and offset are passed through parseInt, so a non-numeric or empty value becomes NaN and surfaces as a 500 InternalError rather than a 400; an unknown status or owner value simply matches nothing and returns 200 with an empty resources array, never a 404; filters combine with AND, so contradictory filters yield an empty list.
[ "POST /api/auth/register", "POST /api/resources" ]
status: registered, quarantined| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 500 | InternalError | InternalError | listResources throws - NaN limit/offset from a non-numeric query value, or any DB error |
{
"success": true,
"data": [
{
"resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"resources": []
}
}POST/api/resources🔒 auth
Registers or updates an owned resource in the ownership registry, driven by GitOps sync, returning 201 with the stored record. Requires a valid tenant JWT (requireAuth). Edge cases: five fields are mandatory presence checks (resource_id, resource_type, environment, owner, approved_by) and any one missing or empty produces the same single 400 message listing all five; values are not validated against any enum in the route, so an unknown resource_type or a typo in environment is accepted and stored; because this is a sync endpoint the same resource_id is expected to be re-posted - the upsert makes it effectively idempotent on resource_id with later fields overwriting earlier ones, so a repeat call is a 201 rather than a 409; the caller JWT tenant is neither recorded nor checked, so ownership rows are platform-wide; a constraint or DB failure is a 500 that echoes the underlying error message in details[].
[ "POST /api/auth/register" ]
environment: dev, staging, prodstatus: registered, quarantined| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | resource_id, resource_type, environment, owner, approved_by are required | any of resource_id, resource_type, environment, owner or approved_by is absent or empty |
| 500 | InternalError | InternalError | registerResource throws - a constraint violation or any DB error; the underlying message is echoed in details[] |
{
"resource_id": "{{var:resource_id}}",
"resource_type": "s3_bucket",
"environment": "dev",
"owner": "platform-team",
"approved_by": "cto",
"team": "platform",
"repo": "org/infra-platform",
"terraform_module": "modules/s3-bucket",
"cloud_account": "123456789012",
"cost_center": "CC-1001",
"data_classification": "internal",
"network_zone": "private",
"created_by": "gitops-sync",
"expires_at": "{{dynamic:futuredatetime}}"
}{
"resource_id": "{{var:resource_id}}",
"resource_type": "s3_bucket",
"environment": "dev",
"owner": "platform-team",
"approved_by": "cto",
"team": "platform",
"repo": "org/infra-platform",
"terraform_module": "modules/s3-bucket",
"cloud_account": "123456789012",
"cost_center": "CC-1001",
"data_classification": "internal",
"network_zone": "private",
"created_by": "gitops-sync",
"expires_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"resource_id": "{{var:resource_id}}",
"resource_type": "s3_bucket",
"environment": "dev",
"owner": "platform-team",
"approved_by": "cto",
"team": "platform",
"repo": "org/infra-platform",
"terraform_module": "modules/s3-bucket",
"cloud_account": "123456789012",
"cost_center": "CC-1001",
"data_classification": "internal",
"network_zone": "private",
"created_by": "gitops-sync",
"expires_at": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"resource": {
"resource_id": "string",
"owner": "string",
"approved_by": "string"
}
}
}GET/api/resources/:resource_id🔒 auth
Ownership lookup for a single registry entry by its resource_id path param, used by the admin app. Requires a valid tenant JWT (requireAuth). Edge cases: resource_id is an opaque registry key rather than a generated UUID, so an unknown id returns 404 NotFound with the id echoed in details[]; the caller JWT tenant is never compared to the record - this is a platform-wide registry, so any authenticated caller can read any ownership row; a resource in a decommissioned or retired status is still returned as 200, so the caller must inspect the status field rather than expecting a 404; any DB failure is caught and reported as 500 InternalError.
[ "POST /api/auth/register", "POST /api/resources" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 404 | NotFound | No registry row for <resource_id> | getOwnership returns no record for the resource_id |
| 500 | InternalError | InternalError | getOwnership throws - any DB error |
{
"resource_id": "{{cache:resources.create.response.data.resource.resource_id}}"
}{
"success": true,
"data": {
"resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"resource": {
"resource_id": "string",
"owner": "string"
}
}
}sdk-scheduling
GET/api/scheduling/appointments🔒 auth
List a tenant's appointments, ordered by start_time, with optional host_persona_id / subject_persona_id / status / start_after / start_before filters. Only tenant_id is required.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/appointments" ]
status: pending, confirmed, cancelled, completed, no_show| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"success": true,
"data": [
{
"appointment_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"appointments": "array"
}
}POST/api/scheduling/appointments🔒 auth
Book an appointment on a host's calendar with double-booking prevention: the overlap check and insert run in one transaction, so a window that overlaps an existing non-cancelled appointment for the same host is rejected with 409. end_time must be strictly after start_time (else 400). subject_persona_id is the invitee/lead persona. Required: tenant_id, host_persona_id, title, start_time, end_time.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/meeting-types" ]
status: pending, confirmed, cancelled, completed, no_showlocation_type: video, phone, in_person, customsource: internal, public_link, sequence, import, provider_sync| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, host_persona_id, title, start_time and end_time are required | any required field missing from body |
| 400 | ValidationError | end_time must be after start_time | end_time <= start_time |
| 409 | DoubleBooking | the host already has an appointment overlapping this window | the requested window overlaps an existing non-cancelled appointment for the same host |
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"entity": "scheduling.appointment",
"field": "status",
"flow": [
"confirmed",
"completed"
],
"transitions": [
{
"from": "confirmed",
"to": "cancelled",
"via": "cancel (TK-3624 booking lifecycle)"
},
{
"from": "confirmed",
"to": "completed",
"via": "complete (TK-3624 booking lifecycle)"
},
{
"from": "confirmed",
"to": "no_show",
"via": "no-show detection (TK-3625)"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"host_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"subject_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"meeting_type_id": "{{cache:scheduling.meeting-type.create.response.data.meeting_type.meeting_type_id}}",
"title": "{{dynamic:text}}",
"description": "Discovery call booked from availability",
"start_time": "{{dynamic:futuredatetime}}",
"end_time": "{{dynamic:futuredatetime+30m}}",
"timezone": "America/New_York",
"location_type": "video",
"location_detail": "Google Meet",
"meeting_url": "https://meet.example.com/abc",
"attendees": [],
"notes": "Booked via QA",
"entity_ref": "lead:{{dynamic:uuid}}",
"source": "internal"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"host_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"meeting_type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"title": "sample-text",
"description": "Discovery call booked from availability",
"start_time": "2026-01-15T10:30:00Z",
"end_time": "2026-01-15T10:30:00Z",
"timezone": "America/New_York",
"location_type": "video",
"location_detail": "Google Meet",
"meeting_url": "https://meet.example.com/abc",
"attendees": [],
"notes": "Booked via QA",
"entity_ref": "lead:{{dynamic:uuid}}",
"source": "internal"
}{
"success": true,
"data": {
"appointment_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"host_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"meeting_type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"title": "sample-text",
"description": "Discovery call booked from availability",
"start_time": "2026-01-15T10:30:00Z",
"end_time": "2026-01-15T10:30:00Z",
"timezone": "America/New_York",
"location_type": "video",
"location_detail": "Google Meet",
"meeting_url": "https://meet.example.com/abc",
"attendees": [],
"notes": "Booked via QA",
"entity_ref": "lead:{{dynamic:uuid}}",
"source": "internal",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"appointment": {
"appointment_id": "string",
"status": "string"
}
}
}GET/api/scheduling/appointments/:appointment_id🔒 auth
Fetch a single appointment by id (tenant-scoped). Returns 404 if no appointment with that id exists for the tenant. tenant_id query param is required.
[ "POST /api/auth/signup-tenant", "POST /api/scheduling/appointments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
| 404 | NotFound | appointment not found | no appointment with that id for the tenant |
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}{
"success": true,
"data": {
"appointment_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"appointment": {
"appointment_id": "string",
"status": "string"
}
}
}POST/api/scheduling/appointments/:appointment_id/calendar-push🔒 auth
Push a single appointment to a connection's external calendar, creating the external event or updating/cancelling it if already mapped (this is how reschedule/cancel propagate to the provider). Records the appointment <-> external event mapping. tenant_id and connection_id required.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/meeting-types", "POST /api/scheduling/appointments", "POST /api/scheduling/calendar-connections" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and connection_id are required | required field missing |
| 404 | NotFound | connection/appointment not found | no connection or appointment for tenant |
{
"appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"connection_id": "{{cache:scheduling.calendar-connection.create.response.data.connection.connection_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"connection_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"calendar_push_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"connection_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"external_event_id": "string",
"operation": "string"
}
}POST/api/scheduling/appointments/:appointment_id/cancel🔒 auth
Cancel an appointment (idempotent - re-cancelling is a no-op), record the reason, bump the ICS SEQUENCE, and fire a cancellation notice (ICS METHOD:CANCEL) to both parties. Appends a booking_event.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/meeting-types", "POST /api/scheduling/appointments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing |
| 404 | NotFound | appointment not found | no appointment for tenant |
{
"entity": "scheduling.appointment",
"field": "status",
"flow": [
"confirmed",
"cancelled"
],
"transitions": [
{
"from": "confirmed",
"to": "cancelled",
"via": "POST /api/scheduling/appointments/:appointment_id/cancel"
}
]
}{
"appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"reason": "Customer requested cancellation"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "Customer requested cancellation"
}{
"success": true,
"data": {
"cancel_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "Customer requested cancellation",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"appointment": {
"appointment_id": "string",
"status": "string"
}
}
}POST/api/scheduling/appointments/:appointment_id/confirm🔒 auth
Confirm a pending/confirmed appointment (idempotent) and fire the confirmation notice to both parties (pluggable booking notifier -> sdk-notification). Sets confirmed_at and appends a booking_event. Confirming a cancelled/completed appointment returns 409.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/meeting-types", "POST /api/scheduling/appointments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing |
| 404 | NotFound | appointment not found | no appointment for tenant |
| 409 | InvalidTransition | cannot confirm an appointment in status 'cancelled' | appointment already cancelled/completed |
{
"entity": "scheduling.appointment",
"field": "status",
"flow": [
"confirmed"
],
"transitions": [
{
"from": "pending",
"to": "confirmed",
"via": "POST /api/scheduling/appointments/:appointment_id/confirm"
}
]
}{
"appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"confirm_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"appointment": {
"appointment_id": "string",
"status": "string"
}
}
}GET/api/scheduling/appointments/:appointment_id/events🔒 auth
Return the append-only booking lifecycle timeline for an appointment (created/confirmed/rescheduled/cancelled/notified), oldest first. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/meeting-types", "POST /api/scheduling/appointments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}{
"success": true,
"data": {
"event_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"events": "array"
}
}GET/api/scheduling/appointments/:appointment_id/ics🔒 auth
Return the RFC 5545 iCalendar (.ics) invite for the appointment as text/calendar. STATUS/METHOD reflect the current state (CONFIRMED/REQUEST, or CANCELLED/CANCEL once cancelled); SEQUENCE advances across reschedules/cancels so calendar clients accept updates.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/meeting-types", "POST /api/scheduling/appointments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
| 404 | NotFound | appointment not found | no appointment for tenant |
{
"appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}{
"success": true,
"data": {
"ics_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/scheduling/appointments/:appointment_id/rebook🔒 auth
Rescue/rebook a (no-show or cancelled) appointment into a NEW confirmed appointment at a new window, cloning host/subject/meeting-type/title and linking back via rescheduled_from. Double-book prevention runs on the new window (409 on overlap; 400 if end<=start). Reminders are scheduled for the new slot. tenant_id, start_time, end_time required.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/meeting-types", "POST /api/scheduling/appointments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, start_time and end_time are required | required field missing |
| 400 | ValidationError | end_time must be after start_time | end<=start |
| 409 | DoubleBooking | the host already has an appointment overlapping this window | new window overlaps another appointment |
| 404 | NotFound | appointment not found | no appointment for tenant |
{
"appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"start_time": "{{dynamic:futuredatetime+180m}}",
"end_time": "{{dynamic:futuredatetime+210m}}",
"timezone": "America/New_York"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"start_time": "2026-01-15T10:30:00Z",
"end_time": "2026-01-15T10:30:00Z",
"timezone": "America/New_York"
}{
"success": true,
"data": {
"rebook_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"start_time": "2026-01-15T10:30:00Z",
"end_time": "2026-01-15T10:30:00Z",
"timezone": "America/New_York",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"appointment": {
"appointment_id": "string"
}
}
}GET/api/scheduling/appointments/:appointment_id/reminders🔒 auth
List an appointment's scheduled reminders (soonest first). tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/meeting-types", "POST /api/scheduling/appointments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}{
"success": true,
"data": {
"reminder_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"reminders": "array"
}
}POST/api/scheduling/appointments/:appointment_id/reminders🔒 auth
Schedule the pre-meeting reminder fan-out for an appointment (default 24h/2h/15m before, override via offsets_minutes). One reminder row per offset at start_time - offset; offsets already in the past are skipped; idempotent per (appointment, offset). tenant_id required.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/meeting-types", "POST /api/scheduling/appointments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing |
| 404 | NotFound | appointment not found | no appointment for tenant |
{
"appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"offsets_minutes": [
1440,
120,
15
]
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"offsets_minutes": [
1440,
120,
15
]
}{
"success": true,
"data": {
"reminder_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"offsets_minutes": [
1440,
120,
15
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"reminders": "array"
}
}POST/api/scheduling/appointments/:appointment_id/reschedule🔒 auth
Move an appointment to a new window. Double-book prevention runs on the new window (excluding this appointment); ICS SEQUENCE is bumped so calendar clients accept the change; a reschedule notice fires. end_time must be after start_time (400). Cancelled/completed appointments cannot be moved (409).
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/meeting-types", "POST /api/scheduling/appointments" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, start_time and end_time are required | required field missing |
| 400 | ValidationError | end_time must be after start_time | end<=start |
| 404 | NotFound | appointment not found | no appointment |
| 409 | DoubleBooking | the host already has an appointment overlapping this window | new window overlaps another appointment |
{
"appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"start_time": "{{dynamic:futuredatetime+120m}}",
"end_time": "{{dynamic:futuredatetime+150m}}",
"timezone": "America/New_York"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"start_time": "2026-01-15T10:30:00Z",
"end_time": "2026-01-15T10:30:00Z",
"timezone": "America/New_York"
}{
"success": true,
"data": {
"reschedule_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"start_time": "2026-01-15T10:30:00Z",
"end_time": "2026-01-15T10:30:00Z",
"timezone": "America/New_York",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"appointment": {
"appointment_id": "string"
}
}
}GET/api/scheduling/availability🔒 auth
Compute a host's bookable time slots for a given ISO date, honoring the host's per-weekday business hours (in the rule's IANA timezone) and marking slots that overlap an existing non-cancelled appointment as unavailable. A weekday with no active rule returns zero slots (still HTTP 200). Slot length is the meeting type's duration when meeting_type_id is given, else slot_minutes, else the rule interval. Required query params: tenant_id, host_persona_id, date (YYYY-MM-DD).
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/meeting-types", "POST /api/scheduling/availability-rules" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, host_persona_id and date query params are required | any of tenant_id / host_persona_id / date missing |
| 400 | ValidationError | date must be an ISO date (YYYY-MM-DD) | date not in YYYY-MM-DD format |
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"success": true,
"data": [
{
"availability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"availability": {
"date": "string",
"slots": "array",
"total_slots": "number"
}
}
}GET/api/scheduling/availability-rules🔒 auth
The recurring availability rules for ONE host, returned as data.rules. BOTH tenant_id AND host_persona_id are required query parameters - listAvailabilityRules takes the pair, so this route cannot list rules across hosts and omitting either is the same single 400. Returns the rules as authored (weekday plus start/end local times), NOT bookable slots: GET /api/scheduling/availability is the route that intersects these rules with existing appointments, meeting-type duration and time-off to produce concrete slots. Reading rules and computing slots client-side duplicates that logic and misses the exclusions. An unknown host_persona_id is 200 with an empty array rather than 404, because the handler never checks the persona exists.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/availability-rules" ]
weekday: 0, 1, 2, 3, 4, 5, 6| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it |
| 400 | ValidationError | tenant_id and host_persona_id query params required | Either query parameter is absent - both are required together and produce this same single message |
| 500 | InternalError | Fastify default error payload from the uncaught service throw | listAvailabilityRules throws - a non-UUID tenant_id or host_persona_id that fails the Postgres uuid cast, or any database error |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"rules": "array"
}
}POST/api/scheduling/availability-rules🔒 auth
Set (upsert) a host's bookable business hours for one weekday, in an IANA timezone. Idempotent — re-posting the same (tenant, host, weekday) overwrites the existing rule (one rule per weekday). A weekday with no active rule is treated as a closed day by slot generation. weekday is 0 (Sunday) through 6 (Saturday); start_time/end_time are wall-clock 'HH:MM' in the given timezone and end must be after start.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
weekday: 0, 1, 2, 3, 4, 5, 6| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, host_persona_id and weekday are required | tenant_id, host_persona_id or weekday missing from body |
| 400 | ValidationError | weekday must be 0 (Sunday) through 6 (Saturday) | weekday outside 0-6 |
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"host_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"weekday": 1,
"start_time": "09:00",
"end_time": "17:00",
"timezone": "America/New_York",
"slot_interval_minutes": 30,
"is_active": true
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"host_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"weekday": 1,
"start_time": "09:00",
"end_time": "17:00",
"timezone": "America/New_York",
"slot_interval_minutes": 30,
"is_active": true
}{
"success": true,
"data": {
"availability_rule_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"host_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"weekday": 1,
"start_time": "09:00",
"end_time": "17:00",
"timezone": "America/New_York",
"slot_interval_minutes": 30,
"is_active": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"rule": {
"rule_id": "string",
"weekday": "number"
}
}
}GET/api/scheduling/calendar-connections🔒 auth
List a tenant's calendar connections (optionally filtered by host_persona_id), newest first. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/scheduling/calendar-connections" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"success": true,
"data": [
{
"calendar_connection_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"connections": "array"
}
}POST/api/scheduling/calendar-connections🔒 auth
Bind a host to an external calendar provider (google/microsoft/caldav) via an sdk-connectors install. Upsert per (host, provider, external calendar). tenant_id, host_persona_id and provider are required. direction defaults to both.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
provider: google, microsoft, caldavdirection: inbound, outbound, both| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, host_persona_id and provider are required | required field missing |
| 400 | ValidationError | provider must be google, microsoft or caldav | invalid provider |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"host_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"provider": "google",
"connector_install_id": null,
"external_calendar_id": "primary-{{dynamic:uuid}}",
"direction": "both",
"metadata": {}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"host_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider": "google",
"connector_install_id": null,
"external_calendar_id": "primary-{{dynamic:uuid}}",
"direction": "both",
"metadata": {}
}{
"success": true,
"data": {
"calendar_connection_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"host_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider": "google",
"connector_install_id": null,
"external_calendar_id": "primary-{{dynamic:uuid}}",
"direction": "both",
"metadata": {},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"connection": {
"connection_id": "string",
"provider": "string"
}
}
}GET/api/scheduling/calendar-connections/:connection_id🔒 auth
Fetch a single calendar connection by id (tenant-scoped). 404 if not found. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/scheduling/calendar-connections" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
| 404 | NotFound | connection not found | no connection for tenant |
{
"connection_id": "{{cache:scheduling.calendar-connection.create.response.data.connection.connection_id}}"
}{
"success": true,
"data": {
"calendar_connection_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"connection": {
"connection_id": "string"
}
}
}POST/api/scheduling/calendar-connections/:connection_id/sync🔒 auth
Run a two-way sync for a connection: push unmapped non-cancelled appointments to the external calendar (outbound) and pull provider changes to apply reschedules/cancellations (inbound), advancing the sync token. Idempotent. Returns {pushed, pulled, applied}. tenant_id required.
[ "POST /api/auth/signup-tenant", "POST /api/scheduling/calendar-connections" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing |
| 404 | NotFound | connection not found | no connection for tenant |
{
"connection_id": "{{cache:scheduling.calendar-connection.create.response.data.connection.connection_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"status": "completed",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"sync_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"pushed": "number",
"pulled": "number",
"applied": "number"
}
}GET/api/scheduling/meeting-types🔒 auth
List all meeting types for a tenant, active first then newest first. tenant_id query param is required. Returns the reusable meeting kinds a host can offer for booking.
[ "POST /api/auth/signup-tenant", "POST /api/scheduling/meeting-types" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"success": true,
"data": [
{
"meeting_type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"meeting_types": "array"
}
}POST/api/scheduling/meeting-types🔒 auth
Create a reusable meeting type (bookable meeting kind) for a tenant — name, unique slug, duration (15/30/45/60 min are the common presets), booking buffers and location type. Slug is unique per tenant (a duplicate returns 409). Referenced by availability slotting, appointments and public scheduling links. tenant_id, name and slug are required.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
location_type: video, phone, in_person, custom| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, name and slug are required | tenant_id, name or slug missing from body |
| 409 | Conflict | a meeting type with this slug already exists for the tenant | slug already used by another meeting type in the tenant (UNIQUE tenant_id, slug) |
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"host_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"name": "{{dynamic:name}}",
"slug": "discovery-{{dynamic:uuid}}",
"description": "30-minute discovery call",
"duration_minutes": 30,
"buffer_before_minutes": 5,
"buffer_after_minutes": 5,
"location_type": "video",
"location_detail": "Google Meet",
"metadata": {}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"host_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"slug": "discovery-{{dynamic:uuid}}",
"description": "30-minute discovery call",
"duration_minutes": 30,
"buffer_before_minutes": 5,
"buffer_after_minutes": 5,
"location_type": "video",
"location_detail": "Google Meet",
"metadata": {}
}{
"success": true,
"data": {
"meeting_type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"host_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"slug": "discovery-{{dynamic:uuid}}",
"description": "30-minute discovery call",
"duration_minutes": 30,
"buffer_before_minutes": 5,
"buffer_after_minutes": 5,
"location_type": "video",
"location_detail": "Google Meet",
"metadata": {},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"meeting_type": {
"meeting_type_id": "string",
"slug": "string",
"duration_minutes": "number"
}
}
}POST/api/scheduling/no-show/scan🔒 auth
Mark confirmed appointments whose end_time passed by grace_minutes (default 10) and that were never completed as no_show, appending a booking_event for each. Returns {marked, appointment_ids} so a caller can offer a rescue/rebook. Also runs on the scheduling worker timer.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"grace_minutes": 10,
"batch_size": 100
}{
"grace_minutes": 10,
"batch_size": 100
}{
"success": true,
"data": {
"scan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"grace_minutes": 10,
"batch_size": 100,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"marked": "number",
"appointment_ids": "array"
}
}POST/api/scheduling/public/appointments/:public_token/cancelpublic
PUBLIC: the invitee cancels a booking they made through a shared link, freeing the host's slot, stamping cancelled_at with the optional reason, recording a booking_event and firing the cancellation notice. Like confirm, AUTHORISATION IS THE TOKEN — the path parameter is the high-entropy public_token issued at booking, never the appointment_id, because accepting an id would let anyone cancel a stranger's meeting by guessing UUIDs. Edge cases: an unknown or wrong token is a flat 404 revealing nothing; cancelling an already-cancelled appointment fails the lifecycle guard rather than silently succeeding; the freed window becomes bookable again immediately, since the double-book check ignores cancelled rows; reason is optional and is persisted to cancel_reason for the host's records.
[ "POST /api/auth/signup-tenant", "POST /api/scheduling/meeting-types", "POST /api/scheduling/scheduling-links", "POST /api/scheduling/public/links/:slug/book", "POST /api/scheduling/public/appointments/:public_token/confirm" ]
status: pending, confirmed, cancelled, completed, no_show| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | NotFound | NotFound | the token is unknown or wrong — including when a raw appointment_id is passed instead of the token |
| Transition | Triggered by |
|---|---|
pending|confirmed -> cancelled | POST /api/scheduling/public/appointments/:public_token/cancel (stamps cancelled_at + cancel_reason; frees the slot) |
{
"public_token": "{{cache:scheduling.public.book.response.data.public_token}}"
}{
"reason": "Something came up - will rebook next week"
}{
"reason": "Something came up - will rebook next week"
}{
"success": true,
"data": {
"cancel_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "Something came up - will rebook next week",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"appointment": {
"appointment_id": "string",
"status": "string"
}
}
}POST/api/scheduling/public/appointments/:public_token/confirmpublic
PUBLIC: the invitee confirms a booking they made through a shared link, moving it pending -> confirmed, stamping confirmed_at, recording a booking_event and firing the confirmation notice. This is the double opt-in step: a public booking is created 'pending' because the invitee's email is unverified at booking time, so an unverified address never holds a confirmed slot on the host's calendar. AUTHORISATION IS THE TOKEN: the path parameter is the high-entropy public_token issued at booking, NOT the appointment_id — accepting a raw appointment_id here would be an IDOR (guess an id, confirm or cancel a stranger's meeting), so passing one returns 404. Edge cases: an unknown, wrong or already-used-then-cancelled token is a flat 404 that reveals nothing; confirming an already-confirmed appointment is idempotent (confirmed_at is preserved via COALESCE) rather than an error; confirming a cancelled or completed appointment fails the lifecycle guard.
[ "POST /api/auth/signup-tenant", "POST /api/scheduling/meeting-types", "POST /api/scheduling/scheduling-links", "POST /api/scheduling/public/links/:slug/book" ]
status: pending, confirmed, cancelled, completed, no_show| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | NotFound | NotFound | the token is unknown or wrong — including when a raw appointment_id is passed instead of the token |
| Transition | Triggered by |
|---|---|
pending -> confirmed | POST /api/scheduling/public/appointments/:public_token/confirm (stamps confirmed_at, emits the confirmed booking_event) |
pending|confirmed -> cancelled | POST /api/scheduling/public/appointments/:public_token/cancel |
{
"public_token": "{{cache:scheduling.public.book.response.data.public_token}}"
}{}{
"success": true,
"data": {
"confirm_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"appointment": {
"appointment_id": "string",
"status": "string"
}
}
}GET/api/scheduling/public/links/:slugpublic
PUBLIC: resolve a shared booking slug into exactly what a booking page needs to render — title, description, the booking window (max_days_ahead / min_notice_minutes) and the meeting duration resolved from the link's meeting type. No auth: the visitor is an anonymous prospect, so the gateway allowlists /api/scheduling/public/. The projection is deliberately narrow — tenant_id, host_persona_id, link_id and meeting_type_id are NEVER returned, so a leaked slug cannot be used to enumerate a tenant's internals. Edge cases: an unknown slug, a deactivated link (is_active=false) and an expired link (expires_at in the past) ALL return an identical 404 rather than a 403, so an attacker cannot use the status code to discover which slugs exist; duration_minutes is null when the link pins no meeting type, in which case booking falls back to a 30-minute default.
[ "POST /api/auth/signup-tenant", "POST /api/scheduling/meeting-types", "POST /api/scheduling/scheduling-links" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 404 | NotFound | NotFound | slug unknown, link deactivated, or link expired — all three are indistinguishable by design |
{
"slug": "{{cache:scheduling.scheduling-link.create.response.data.link.slug}}"
}{
"success": true,
"data": {
"link_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "link not found"
}{
"data": {
"link": {
"slug": "string",
"title": "string",
"max_days_ahead": "number",
"min_notice_minutes": "number"
}
}
}GET/api/scheduling/public/links/:slug/availabilitypublic
PUBLIC: open slots on one date for a shared booking link, computed from the host's availability rules in their IANA timezone with buffers applied and existing appointments excluded. Slot length comes from the link's meeting type. No auth — the caller is an anonymous prospect — so the gateway allowlists /api/scheduling/public/. The requested date is clamped to the link's max_days_ahead window, so an anonymous caller cannot walk the host's calendar arbitrarily far into the future; a date far in the past is rejected too. The minimum-notice rule is NOT applied to the date probe itself (only to the actual booking), so today's page can still render today's later slots. Edge cases: 400 when the date query param is missing or outside the window; unknown/deactivated/expired slug is an indistinguishable 404; a date with no configured availability rule returns an empty slots array with total_available 0 rather than an error.
[ "POST /api/auth/signup-tenant", "POST /api/scheduling/meeting-types", "POST /api/scheduling/availability-rules", "POST /api/scheduling/scheduling-links" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | date query param required (YYYY-MM-DD) | the date query param is absent |
| 400 | BookingWindowError | start_time is beyond the link's <n>-day booking window | the requested date exceeds the link's max_days_ahead |
| 400 | BookingWindowError | date is in the past | the requested date is more than a day in the past |
| 404 | NotFound | NotFound | slug unknown, link deactivated, or link expired |
{
"slug": "{{cache:scheduling.scheduling-link.create.response.data.link.slug}}"
}{
"success": true,
"data": {
"availability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "availability not found"
}{
"data": {
"availability": {
"date": "string",
"timezone": "string",
"slot_minutes": "number",
"slots": "array",
"total_available": "number"
}
}
}POST/api/scheduling/public/links/:slug/bookpublic
PUBLIC (anonymous) booking: a prospect opens a shared scheduling link and books without any tenant login, so this route is deliberately outside requireAuth and the gateway allowlists the /api/scheduling/public/ prefix. It is written for an untrusted caller. end_time is DERIVED from the link's meeting type when omitted, so a client cannot book a 6-hour slot against a 30-minute meeting type. The link's own guardrails are enforced SERVER-side: a start_time beyond max_days_ahead or inside min_notice_minutes is rejected 400 even though the UI also shows them. The appointment is created as 'pending', NOT confirmed — the invitee's email is unverified at booking time, so double opt-in is what stops an unverified address from holding a confirmed slot on the host's calendar; confirming via the returned token moves it pending -> confirmed. The response carries a high-entropy public_token which is the ONLY key the public confirm/cancel routes accept (acting on a raw appointment_id would be an IDOR). Edge cases: an unknown, deactivated or expired slug is an indistinguishable 404 — never a 403 that would confirm the slug exists; a slot taken between rendering and submitting returns 409 via the transactional double-book check; 400 when start_time, invitee_name or invitee_email is missing.
[ "POST /api/auth/signup-tenant", "POST /api/scheduling/meeting-types", "POST /api/scheduling/scheduling-links" ]
status: pending, confirmed, cancelled, completed, no_show| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | start_time, invitee_name and invitee_email are required | any of the three required fields is missing |
| 400 | BookingWindowError | start_time is beyond the link's <n>-day booking window | start_time exceeds the link's max_days_ahead |
| 400 | BookingWindowError | start_time is inside the link's <n>-minute minimum notice | start_time is sooner than the link's min_notice_minutes |
| 404 | NotFound | NotFound | the slug is unknown, the link is deactivated, or it has expired — all indistinguishable by design |
| 409 | DoubleBooking | that slot was just taken | the host was booked for that window between page render and submit |
| Transition | Triggered by |
|---|---|
(none) -> pending | POST /api/scheduling/public/links/:slug/book (anonymous booking, email unverified) |
pending -> confirmed | POST /api/scheduling/public/appointments/:public_token/confirm (double opt-in; stamps confirmed_at) |
pending|confirmed -> cancelled | POST /api/scheduling/public/appointments/:public_token/cancel |
{
"slug": "{{cache:scheduling.scheduling-link.create.response.data.link.slug}}"
}{
"start_time": "{{dynamic:futuredatetime+48h}}",
"end_time": "{{dynamic:futuredatetime+49h}}",
"invitee_name": "{{dynamic:name}}",
"invitee_email": "{{dynamic:email}}",
"timezone": "America/New_York",
"notes": "Booked from the website pricing page"
}{
"start_time": "2026-01-15T10:30:00Z",
"end_time": "2026-01-15T10:30:00Z",
"invitee_name": "Acme QA Sample",
"invitee_email": "qa.user@example.com",
"timezone": "America/New_York",
"notes": "Booked from the website pricing page"
}{
"success": true,
"data": {
"book_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"start_time": "2026-01-15T10:30:00Z",
"end_time": "2026-01-15T10:30:00Z",
"invitee_name": "Acme QA Sample",
"invitee_email": "qa.user@example.com",
"timezone": "America/New_York",
"notes": "Booked from the website pricing page",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"appointment": {
"appointment_id": "string",
"status": "string",
"start_time": "string",
"end_time": "string"
},
"public_token": "string"
}
}POST/api/scheduling/reminders/tick🔒 auth
Drain due reminders on demand: claim pending reminder rows whose remind_at has passed (FOR UPDATE SKIP LOCKED), fire the reminder notice for each still-active appointment, and mark rows sent/skipped. Also runs on a timer when SCHEDULING_WORKER_ENABLED. batch_size optional. Returns {claimed, sent, skipped}.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"batch_size": 50
}{
"batch_size": 50
}{
"success": true,
"data": {
"tick_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"batch_size": 50,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"claimed": "number",
"sent": "number",
"skipped": "number"
}
}GET/api/scheduling/scheduling-links🔒 auth
List a tenant's public booking links (active first, newest first). tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/scheduling/scheduling-links" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
{
"success": true,
"data": [
{
"scheduling_link_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"links": "array"
}
}POST/api/scheduling/scheduling-links🔒 auth
Create a shareable public booking link (Calendly-style) binding a host + meeting type to a globally-unique slug. tenant_id, host_persona_id and slug are required; a duplicate slug returns 409. max_days_ahead / min_notice_minutes bound the self-book window.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/scheduling/meeting-types" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, host_persona_id and slug are required | required field missing |
| 409 | Conflict | a scheduling link with this slug already exists | slug already used (UNIQUE) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"host_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"meeting_type_id": "{{cache:scheduling.meeting-type.create.response.data.meeting_type.meeting_type_id}}",
"slug": "book-{{dynamic:uuid}}",
"title": "Book a discovery call",
"description": "30-minute discovery call",
"max_days_ahead": 30,
"min_notice_minutes": 120,
"expires_at": "{{dynamic:futuredatetime+30d}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"host_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"meeting_type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"slug": "book-{{dynamic:uuid}}",
"title": "Book a discovery call",
"description": "30-minute discovery call",
"max_days_ahead": 30,
"min_notice_minutes": 120,
"expires_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"scheduling_link_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"host_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"meeting_type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"slug": "book-{{dynamic:uuid}}",
"title": "Book a discovery call",
"description": "30-minute discovery call",
"max_days_ahead": 30,
"min_notice_minutes": 120,
"expires_at": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"link": {
"link_id": "string",
"slug": "string"
}
}
}GET/api/scheduling/scheduling-links/:link_id🔒 auth
Fetch a single public booking link by id (tenant-scoped). 404 if not found. tenant_id query param required.
[ "POST /api/auth/signup-tenant", "POST /api/scheduling/scheduling-links" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id missing |
| 404 | NotFound | link not found | no link for tenant |
{
"link_id": "{{cache:scheduling.scheduling-link.create.response.data.link.link_id}}"
}{
"success": true,
"data": {
"scheduling_link_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"link": {
"link_id": "string"
}
}
}sdk-search
GET/api/search🔒 auth
Executes an ABAC-filtered free-text search over an indexed entity_kind for the caller's tenant, reading q/entity_kind/size/from from the query string and returning the hit set. tenant_id is force-injected from the verified JWT and effective_scopes are server-resolved via resolveEffectiveScopes(req.auth) — a caller-supplied effective_scopes in the request is ignored, so scope escalation is not possible. Edge cases: the JWT tenant_id must be a UUID or validation fails even though the caller never supplied it; entity_kind is mandatory and an unregistered entity_kind returns IndexNotFound rather than an empty page; DSL `size` is clamped to 0..200 and `from` floored at 0, so oversized pagination requests are silently capped instead of erroring; `script`, `script_score` and `function_score` clauses are stripped from any supplied DSL to prevent ABAC bypass; results are always confined to the JWT tenant so a cross-tenant entity is invisible rather than 403.
[ "POST /api/auth/signup-tenant", "POST /api/search/index" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | JWT missing tenant_id claim | Token verifies but carries no tenant_id claim; authTenant() rejects before any query runs |
| 400 | ValidationError | tenant_id must be a UUID | The tenant_id claim injected from the JWT is not a well-formed UUID |
| 400 | ValidationError | entity_kind is required | entity_kind query param is missing or blank |
| 404 | IndexNotFound | No search index registered for the requested entity_kind | executeQuery throws IndexNotFoundError because no index definition exists for tenant_id + entity_kind |
| 500 | InternalError | InternalError | OpenSearch is unreachable or the query throws a non-IndexNotFoundError |
{
"success": true,
"data": [
{
"search_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"hits": "array",
"total": "number",
"took_ms": "number",
"index_used": "string"
}
}POST/api/search🔒 auth
POST form of the search query endpoint: accepts a full search DSL body (query/size/from/sort) instead of the flat query-string form, then runs the same ABAC-filtered execution as GET /api/search. tenant_id comes from the verified JWT and effective_scopes are resolved server-side from JWT claims — any effective_scopes in the body is discarded. Edge cases: an empty or non-object body fails validation before reaching OpenSearch; `size` is clamped into 0..200 and `from` floored at 0 so oversized page requests are capped, not rejected; `script`/`script_score`/`function_score` clauses are recursively stripped from the DSL (including inside arrays) so a crafted body cannot execute code or escape tenant filtering; querying an entity_kind with no registered index returns 404 IndexNotFound.
[ "POST /api/auth/signup-tenant", "POST /api/search/index" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | JWT missing tenant_id claim | Token verifies but carries no tenant_id claim; authTenant() rejects before any query runs |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | tenant_id must be a UUID | The tenant_id claim injected from the JWT is not a well-formed UUID |
| 400 | ValidationError | entity_kind is required | entity_kind is missing or blank in the body |
| 404 | IndexNotFound | No search index registered for the requested entity_kind | executeQuery throws IndexNotFoundError for tenant_id + entity_kind |
| 500 | InternalError | InternalError | OpenSearch is unreachable or query execution throws an unexpected error |
{
"entity_kind": "encounter",
"q": "open",
"dsl": {
"query": {
"term": {
"status": "open"
}
}
},
"size": 10,
"from": 0
}{
"entity_kind": "encounter",
"q": "open",
"dsl": {
"query": {
"term": {
"status": "open"
}
}
},
"size": 10,
"from": 0
}{
"success": true,
"data": {
"search_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"entity_kind": "encounter",
"q": "open",
"dsl": {
"query": {
"term": {
"status": "open"
}
}
},
"size": 10,
"from": 0,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"hits": "array",
"total": "number",
"took_ms": "number",
"index_used": "string"
}
}GET/api/search/healthpublic
Reports whether a search backend is actually wired, so a caller can tell 'search is unavailable here' from 'search ran and matched nothing'. Without it those two are indistinguishable: an unwired deployment 500s every query and a consuming app reasonably renders that as an empty result set, so a shipped feature reports '0 results' for a backend that was never connected — the most misleading answer available, because it looks like a fact about the data. Returns data.search_up (boolean), data.client ('registered' | 'synthetic' | 'fail-loud') and, when unavailable, data.reason. Edge cases: this returns 200 EVEN WHEN search is unavailable, deliberately — it reports a capability state and is not itself failing, so a caller must read data.search_up rather than the status code; answering 503 would make a caller's own health check flap and conflate 'the probe is broken' with 'the thing it probes is down'. It needs no auth (the gateway allowlists any path ending in /health) and discloses nothing tenant-specific, only whether a backend is configured. client='synthetic' means results are fabricated and must not be trusted in production.
{success,data} envelope derived from the request contract; assert shape + HTTP 200.POST/api/search/index🔒 auth
Registers a search index definition for an entity_kind in the caller's tenant and ensures the backing physical index/alias exists, returning the persisted definition. tenant_id is overwritten from the verified JWT, so a tenant_id in the body cannot target another tenant. Edge cases: entity_kind is mandatory; re-registering the same tenant_id + entity_kind is idempotent at the ensureIndex level rather than a duplicate error; omitting opensearch_alias lets the service derive one; field_mappings must be an object or it is dropped; if the search backend is unreachable the ensure step surfaces as 500, and a malformed mapping rejected by the engine also lands as 500 rather than 400.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | JWT missing tenant_id claim | Token verifies but carries no tenant_id claim; authTenant() rejects before any query runs |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | tenant_id must be a UUID | The tenant_id claim injected from the JWT is not a well-formed UUID |
| 400 | ValidationError | entity_kind is required | entity_kind is missing or blank in the body |
| 404 | IndexNotFound | Referenced index could not be resolved | ensureIndex throws IndexNotFoundError while resolving the target alias |
| 500 | InternalError | InternalError | OpenSearch index creation fails or the persistence write throws |
{
"entity": "index_definition",
"field": "status",
"flow": [
"building",
"active",
"deprecated",
"deleting"
],
"transitions": [
{
"from": null,
"to": "active",
"via": "POST /api/search/index"
},
{
"from": "active",
"to": "deprecated",
"via": "POST /api/search/index"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"entity_kind": "encounter",
"opensearch_alias": "ten-0-encounter",
"field_mappings": {
"properties": {
"tenant_id": {
"type": "keyword"
},
"_scope_tags": {
"type": "keyword"
},
"status": {
"type": "keyword"
}
}
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"entity_kind": "encounter",
"opensearch_alias": "ten-0-encounter",
"field_mappings": {
"properties": {
"tenant_id": {
"type": "keyword"
},
"_scope_tags": {
"type": "keyword"
},
"status": {
"type": "keyword"
}
}
}
}{
"success": true,
"data": {
"index_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"entity_kind": "encounter",
"opensearch_alias": "ten-0-encounter",
"field_mappings": {
"properties": {
"tenant_id": {
"type": "keyword"
},
"_scope_tags": {
"type": "keyword"
},
"status": {
"type": "keyword"
}
}
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"definition": {
"index_def_id": "string",
"opensearch_alias": "string"
}
}
}GET/api/search/saved-queries🔒 auth
Lists the saved queries belonging to one persona within the caller's tenant, scoped by the JWT tenant_id plus the required persona_id query param. Edge cases: persona_id is mandatory and a missing or blank value is a 400 — it is not treated as "list all"; a persona that exists but owns nothing returns 200 with an empty queries array rather than 404; a persona_id from another tenant yields an empty list because the tenant filter comes from the JWT, so this endpoint cannot be used to enumerate other tenants' saved queries; there is no pagination, so the full set for the persona is returned in one response.
[ "POST /api/auth/signup-tenant", "POST /api/search/saved-queries" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | JWT missing tenant_id claim | Token verifies but carries no tenant_id claim; authTenant() rejects before any query runs |
| 400 | ValidationError | persona_id required | persona_id query param is missing or empty |
| 404 | IndexNotFound | Referenced index could not be resolved | listSavedQueries throws IndexNotFoundError |
| 500 | InternalError | InternalError | The datastore read fails or throws a non-IndexNotFoundError |
{
"success": true,
"data": [
{
"saved_query_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"queries": "array"
}
}POST/api/search/saved-queries🔒 auth
Persists a named saved query (a reusable search DSL) for a persona inside the caller's tenant and returns the stored record. tenant_id is taken from the verified JWT, never the body. Edge cases: both tenant_id and persona_id must be UUIDs and name must be non-blank — all three are reported together in one 400 details array; the dsl key must be an object (a JSON string or array fails); the stored DSL is sanitized exactly like a live query, so script/script_score/function_score clauses are stripped and size is clamped to 0..200 before persistence, meaning a saved query can never be replayed with an escalated clause; names are not uniqueness-checked, so repeated submissions create duplicate saved queries rather than returning a conflict.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | JWT missing tenant_id claim | Token verifies but carries no tenant_id claim; authTenant() rejects before any query runs |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | tenant_id must be a UUID | The tenant_id claim injected from the JWT is not a well-formed UUID |
| 400 | ValidationError | persona_id must be a UUID | persona_id is missing or not a well-formed UUID |
| 400 | ValidationError | name is required | name is missing or blank after trimming |
| 400 | ValidationError | dsl object is required | dsl is missing or is not a JSON object |
| 404 | IndexNotFound | Referenced index could not be resolved | createSavedQuery throws IndexNotFoundError |
| 500 | InternalError | InternalError | The saved-query insert fails (e.g. persona_id violates a foreign key) or the datastore is unreachable |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"name": "my-open-encounters",
"dsl": {
"query": {
"term": {
"status": "open"
}
},
"size": 50
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "my-open-encounters",
"dsl": {
"query": {
"term": {
"status": "open"
}
},
"size": 50
}
}{
"success": true,
"data": {
"saved_query_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "my-open-encounters",
"dsl": {
"query": {
"term": {
"status": "open"
}
},
"size": 50
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"saved_query": {
"query_id": "string",
"tenant_id": "string",
"persona_id": "string",
"name": "string",
"dsl": "object",
"created_at": "string"
}
}
}sdk-secrets
GET/api/secrets🔒 auth
Looks up a SecretRef in the catalog by its ref string, passed as the `ref` QUERY parameter, and returns the catalog record (ref, scope, kms_key_id, rotation metadata) - never the secret material itself. Requires a valid tenant JWT (requireAuth). WHY THE QUERY FORM: secretRefCatalog validates every reference against /^secret:\/\/(app|pool|tenant)\/(.+)$/, so a conformant ref ALWAYS contains '://' and at least one further '/' - three or more path segments. A Fastify ':ref' parameter matches exactly ONE segment, so the sibling route GET /api/secrets/:ref can never match a real ref and 404s on every conformant value; it is retained in the code only as a harmless legacy path. The query string is where a slash is not a delimiter, so this is the form that actually works and the one QA must use. Edge cases: an absent or whitespace-only ref returns 400 ValidationError naming the expected shape; an unknown but well-formed ref returns 404 NotFound with the ref echoed in details[]; the caller JWT tenant is never compared to the record, so any authenticated caller can resolve any ref.
[ "POST /api/auth/register", "POST /api/secrets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | ref query parameter is required, e.g. ?ref=secret://tenant/my-key | the ref query parameter is absent or trims to an empty string |
| 404 | NotFound | No SecretRef registered for <ref> | retrieveSecret returns no record for that ref |
| 500 | InternalError | InternalError | retrieveSecret throws a DB error |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"ref": "string",
"scope": "string",
"provider": "string",
"kms_key_id": "string",
"created_at": "string",
"rotated_at": "string"
}
}POST/api/secrets🔒 auth
Registers a new SecretRef in the secrets catalog (ref, scope, kms_key_id) per P1-Foundation-Spine section 5 and returns 201 with the record. This stores only the reference and KMS key binding - no secret material is transmitted or persisted here. Requires a valid tenant JWT (requireAuth). Edge cases: ref, scope and kms_key_id are all mandatory, and scope is checked against a fixed allowlist so an unknown scope 400s with the permitted values echoed in the message; the ref string must also parse as a valid secret reference - a syntactically bad ref passes the presence check and is rejected later by storeSecret as a second, distinct 400 ("Invalid secret reference"); kms_key_id is presence-checked only, so a non-existent KMS key is not detected here; the endpoint is not idempotent - re-registering an existing ref trips a unique constraint and surfaces as a 500 InternalError rather than a 409.
[ "POST /api/auth/register" ]
scope: app, pool, tenant| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | ref is required / scope is required / scope must be one of <allowed scopes> / kms_key_id is required | validateRegisterInput fails; details[] carries every failed rule |
| 400 | ValidationError | Invalid secret reference <ref> | storeSecret throws a message starting with "Invalid secret reference" - the ref string does not parse as a valid secret reference |
| 500 | InternalError | InternalError | storeSecret throws any other error - a duplicate-ref unique-constraint violation, a KMS failure, or any DB error |
{
"ref": "secret://tenant/dev-test-key-001",
"scope": "tenant",
"kms_key_id": "mock-key-1"
}{
"ref": "secret://tenant/dev-test-key-001",
"scope": "tenant",
"kms_key_id": "mock-key-1"
}{
"success": true,
"data": {
"secret_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ref": "secret://tenant/dev-test-key-001",
"scope": "tenant",
"kms_key_id": "mock-key-1",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"ref": "string",
"scope": "string",
"provider": "string",
"kms_key_id": "string",
"created_at": "string",
"rotated_at": "string"
}
}GET/api/secrets/:ref🔒 auth
Looks up a SecretRef in the catalog by its ref string, which is URL-encoded in the path and decoded by the handler. Returns the catalog record (ref, scope, kms_key_id, rotation metadata) and never the secret material itself. Requires a valid tenant JWT (requireAuth). Edge cases: the ref path segment MUST be percent-encoded, since refs typically contain "/" and ":" characters that would otherwise split into extra path segments and route-miss to a 404; a malformed percent sequence (for example a bare "%") makes decodeURIComponent throw a URIError, which is caught and reported as a 500 InternalError rather than a 400; an unknown but well-formed ref returns 404 NotFound with the decoded ref echoed in details[]; the caller JWT tenant is never compared to the record, so any authenticated caller can resolve any ref.
[ "POST /api/auth/register", "POST /api/secrets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 404 | NotFound | No SecretRef registered for <ref> | retrieveSecret returns no record for the decoded ref |
| 500 | InternalError | InternalError | decodeURIComponent throws a URIError on a malformed percent-encoded ref, or retrieveSecret throws a DB error |
{
"ref": "secret%3A%2F%2Ftenant%2Fdev-test-key-001"
}{
"success": true,
"data": {
"secret_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"ref": "string",
"scope": "string",
"provider": "string",
"kms_key_id": "string",
"created_at": "string",
"rotated_at": "string"
}
}POST/api/secrets/:ref/rotate🔒 auth
Rotates the KMS key version backing a registered SecretRef and stamps rotated_at on the catalog row, returning the rotation result. The ref is URL-encoded in the path and decoded by the handler. Requires a valid tenant JWT (requireAuth). Edge cases: no body is read, so any payload is ignored; the ref MUST be percent-encoded because refs typically contain "/" and ":" which would otherwise split into extra path segments and route-miss to a 404, and a malformed percent sequence makes decodeURIComponent throw a URIError that is caught as a 500 rather than a 400; rotating an unregistered ref is a 404 NotFound, matched on the service message starting with "Secret reference not registered"; rotation is repeatable but not idempotent - each call mints a new key version and rewrites rotated_at, so callers must not retry blindly; a KMS-side failure is a 500 InternalError, indistinguishable in status from a DB failure.
[ "POST /api/auth/register", "POST /api/secrets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 404 | NotFound | Secret reference not registered: <ref> | rotateSecret throws a message starting with "Secret reference not registered" - the decoded ref has no catalog row |
| 500 | InternalError | InternalError | decodeURIComponent throws a URIError on a malformed percent-encoded ref, or rotateSecret throws any other error - KMS rotation failure or a DB error |
{
"ref": "secret%3A%2F%2Ftenant%2Fdev-test-key-001"
}{}{
"success": true,
"data": {
"status": "completed",
"rotate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"ref": "object",
"new_key_version": "string"
}
}POST/api/secrets/rotate🔒 auth
Rotates the KMS key version backing a registered SecretRef and stamps rotated_at on the catalog row, returning the rotation result. The reference is passed as `ref` in the BODY. Requires a valid tenant JWT (requireAuth). WHY THE BODY FORM: secretRefCatalog validates every reference against /^secret:\/\/(app|pool|tenant)\/(.+)$/, so a conformant ref ALWAYS contains '://' and at least one further '/' - three or more path segments. A Fastify ':ref' parameter matches exactly ONE segment, so the sibling route POST /api/secrets/:ref/rotate can never match a real ref and 404s on every conformant value; it is retained in the code only as a harmless legacy path. The body is where a slash is not a delimiter, so this is the form that actually works and the one QA must use. Edge cases: an absent or whitespace-only ref is a 400 ValidationError naming the expected shape; rotating an unregistered ref is a 404 NotFound, matched on the service message starting with "Secret reference not registered"; rotation is repeatable but not idempotent - each call mints a new key version and rewrites rotated_at, so callers must not retry blindly; a KMS-side failure is a 500 InternalError, indistinguishable in status from a DB failure.
[ "POST /api/auth/register", "POST /api/secrets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | ref is required in the body, e.g. { "ref": "secret://tenant/my-key" } | body.ref is absent or trims to an empty string |
| 404 | NotFound | Secret reference not registered: <ref> | rotateSecret throws a message starting with "Secret reference not registered" - the ref has no catalog row |
| 500 | InternalError | InternalError | rotateSecret throws any other error - KMS rotation failure or a DB error |
{
"ref": "{{cache:secrets.store.response.data.ref}}"
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"ref": "object",
"new_key_version": "string"
}
}sdk-sequence
POST/api/sequence-templates🔒 auth
Create a reusable message template (subject/body per channel) that sequence steps can reference. tenant_id and name are required; channel defaults to 'email'. UNIQUE per (tenant, name).
[ "POST /api/auth/signup-tenant" ]
channel: email, sms, call, linkedin, task| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and name are required | tenant_id or name missing |
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"name": "{{dynamic:name}}",
"channel": "email",
"subject": "Welcome aboard",
"body": "Hi {{name}}, welcome!",
"category": "custom",
"variables": [
"name"
]
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"channel": "email",
"subject": "Welcome aboard",
"body": "Hi {{name}}, welcome!",
"category": "custom",
"variables": [
"name"
]
}{
"success": true,
"data": {
"sequence_template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"channel": "email",
"subject": "Welcome aboard",
"body": "Hi {{name}}, welcome!",
"category": "custom",
"variables": [
"name"
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"template": {
"template_id": "string"
}
}
}GET/api/sequences🔒 auth
Every sequence defined for a tenant, returned as data.sequences. tenant_id is a REQUIRED QUERY PARAMETER, not a claim: listSequences takes it directly, so omitting it is 400 rather than defaulting to the caller's tenant. Takes no other filter - status and channel query parameters are ignored rather than narrowing the result. Returns the sequence definitions themselves, not enrolments or step state; a caller tracking a contact through a sequence wants the enrolment routes instead. An empty tenant answers 200 with an empty array rather than 404, because 'this tenant has defined no sequences' is an answer.
[ "POST /api/auth/signup-tenant", "POST /api/sequences" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it |
| 400 | ValidationError | tenant_id query param required | The tenant_id query parameter is absent - this route reads the tenant from the query, never from the JWT claim |
| 500 | InternalError | Fastify default error payload from the uncaught service throw | listSequences throws - a non-UUID tenant_id that fails the Postgres uuid cast, or any database error |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"data": {
"sequences": "array"
}
}POST/api/sequences🔒 auth
Create a multi-touch cadence sequence for a tenant. Steps and triggers are added via sub-resource endpoints. tenant_id and name are required; sequence_type defaults to 'lead'. Returns the created sequence (status defaults to 'active').
[ "POST /api/auth/signup-tenant" ]
sequence_type: lead, customer, onboarding, nurture, custom| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and name are required | tenant_id or name missing from body |
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"name": "{{dynamic:name}}",
"description": "Welcome cadence for new leads",
"sequence_type": "lead",
"is_default": false,
"metadata": {}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"description": "Welcome cadence for new leads",
"sequence_type": "lead",
"is_default": false,
"metadata": {}
}{
"success": true,
"data": {
"sequence_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"description": "Welcome cadence for new leads",
"sequence_type": "lead",
"is_default": false,
"metadata": {},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"sequence": {
"sequence_id": "string",
"status": "string"
}
}
}GET/api/sequences/:sequence_id🔒 auth
Fetch a single sequence by id, tenant-scoped via the tenant_id query param. 404 if the sequence does not exist for that tenant.
[ "POST /api/auth/signup-tenant", "POST /api/sequences" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
| 404 | NotFound | NotFound | sequence_id not found for the tenant |
{
"sequence_id": "{{cache:sequences.create.response.data.sequence.sequence_id}}"
}{
"success": true,
"data": {
"sequence_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"sequence": {
"sequence_id": "string"
}
}
}POST/api/sequences/:sequence_id/enroll🔒 auth
Enroll an L4 persona into a sequence (event-based: form_submit/reply/stage_change/manual). Idempotent — if the persona already has an active run in this sequence it returns the existing enrollment (200) and schedules nothing new; otherwise it creates a new enrollment and seeds the first step as a due execution_step (201). The sequence must have at least one step (else 409).
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/sequences", "POST /api/sequences/:sequence_id/steps" ]
event_type: form_submit, reply, stage_change, manual, booking, tag_added| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and subject_persona_id are required | tenant_id or subject_persona_id missing |
| 409 | EnrollFailed | sequence has no steps to enroll into | the sequence has no steps yet |
{
"entity": "sequence.execution_step",
"field": "status",
"flow": [
"pending",
"scheduled",
"sending",
"sent"
],
"transitions": [
{
"from": "pending",
"to": "sent",
"via": "the step-executor tick loop (TK-3614)"
}
]
}{
"sequence_id": "{{cache:sequences.create.response.data.sequence.sequence_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"subject_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"event_type": "form_submit"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"event_type": "form_submit"
}{
"success": true,
"data": {
"enroll_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"event_type": "form_submit",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"enrollment": {
"enrollment_id": "string",
"already_enrolled": "boolean"
}
}
}POST/api/sequences/:sequence_id/steps🔒 auth
Add an ordered step to a sequence. Steps are UNIQUE per (sequence, step_number). template_id (optional) references a sequence-template. schedule_mode/delay_seconds drive when the executor sends the step relative to enrollment. tenant_id and step_number are required.
[ "POST /api/auth/signup-tenant", "POST /api/sequences", "POST /api/sequence-templates" ]
channel: email, sms, call, linkedin, task, waitaction: send, wait, book, task, branchschedule_mode: delay, absolute, immediatesend_mode: individual, bulk| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and step_number are required | tenant_id or step_number missing |
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"sequence_id": "{{cache:sequences.create.response.data.sequence.sequence_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"step_number": 1,
"channel": "email",
"action": "send",
"template_id": "{{cache:sequence-templates.create.response.data.template.template_id}}",
"subject": "Welcome aboard",
"body": "Hi there!",
"schedule_mode": "delay",
"delay_seconds": 60,
"send_mode": "individual"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"step_number": 1,
"channel": "email",
"action": "send",
"template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject": "Welcome aboard",
"body": "Hi there!",
"schedule_mode": "delay",
"delay_seconds": 60,
"send_mode": "individual"
}{
"success": true,
"data": {
"step_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"step_number": 1,
"channel": "email",
"action": "send",
"template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject": "Welcome aboard",
"body": "Hi there!",
"schedule_mode": "delay",
"delay_seconds": 60,
"send_mode": "individual",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"step": {
"step_id": "string",
"step_number": "number"
}
}
}POST/api/sequences/:sequence_id/triggers🔒 auth
Create (or upsert) an event-based enrollment trigger for a sequence: form_submit / reply / stage_change / manual / booking / tag_added. For stage_change, stage_id + trigger_on (enter/exit) apply. Idempotent per (sequence, event_type, stage_id, trigger_on). tenant_id is required.
[ "POST /api/auth/signup-tenant", "POST /api/sequences" ]
event_type: form_submit, reply, stage_change, manual, booking, tag_addedtrigger_on: enter, exit| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id is required | tenant_id missing |
| 401 | Unauthorized | missing or invalid token | no valid Bearer token |
{
"sequence_id": "{{cache:sequences.create.response.data.sequence.sequence_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"event_type": "form_submit",
"trigger_on": "enter",
"condition_json": {},
"enabled": true
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"event_type": "form_submit",
"trigger_on": "enter",
"condition_json": {},
"enabled": true
}{
"success": true,
"data": {
"trigger_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"event_type": "form_submit",
"trigger_on": "enter",
"condition_json": {},
"enabled": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"trigger": {
"trigger_id": "string"
}
}
}POST/api/sequences/enrollments/:enrollment_id/control🔒 auth
Reactively control an enrollment's cadence. action='pause' (pause-on-reply) moves queued steps to 'paused'; 'resume' returns them to 'pending'; 'stop' (stop-on-optout/payment) cancels every still-queued/paused step; 'replace_cta' swaps the template on upcoming steps (requires template_id). reason + event are captured on the affected rows. Returns the number of steps affected. tenant_id and action are required; 400 on an unknown action or a replace_cta without template_id.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/sequences", "POST /api/sequences/:sequence_id/steps", "POST /api/sequences/:sequence_id/enroll" ]
action: pause, resume, stop, replace_cta| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id and action are required | tenant_id or action missing from body |
| 400 | ValidationError | invalid action | action is not one of pause|resume|stop|replace_cta |
| 400 | ValidationError | template_id is required for replace_cta | action is replace_cta but template_id is absent |
{
"enrollment_id": "{{cache:sequences.enroll.response.data.enrollment.enrollment_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"action": "pause",
"reason": "reply",
"event": "inbound.reply"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"action": "pause",
"reason": "reply",
"event": "inbound.reply"
}{
"success": true,
"data": {
"control_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"action": "pause",
"reason": "reply",
"event": "inbound.reply",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"action": "string",
"enrollment_id": "string",
"affected": "number"
}
}POST/api/sequences/guards/check🔒 auth
Evaluate the send guards for a prospective touch and record the decision to the guard audit log. Blocks on: per-lead cooldown (min gap since the subject's last sent touch), max-messages over the rolling window, duplicate content (dedupe_hash already allowed in-window), or an open circuit breaker for the (tenant, channel). A fresh subject with no history is allowed. tenant_id, subject_persona_id and channel are required.
[ "POST /api/auth/signup-tenant", "POST /api/personas" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, subject_persona_id and channel are required | tenant_id, subject_persona_id or channel missing from body |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"subject_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"channel": "email"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email"
}{
"success": true,
"data": {
"check_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"allowed": "boolean",
"reason": "null"
}
}GET/api/sequences/guards/log🔒 auth
List the guard decision audit trail for a tenant, newest first. Each entry records the decision (allow/block), the reason (cooldown/max_messages/duplicate/circuit_open), subject, channel and any dedupe_hash. Tenant-scoped via the required tenant_id query param; optional decision filter (allow|block) and limit. Returns an empty array when no guard checks have run.
[ "POST /api/auth/signup-tenant" ]
decision: allow, block| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id query param required | tenant_id query param missing |
{
"success": true,
"data": [
{
"log_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"entries": []
}
}POST/api/sequences/guards/outcome🔒 auth
Record a send outcome for the per-(tenant, channel) circuit breaker. success=true resets the failure streak and closes a half-open breaker; success=false increments the streak and opens the breaker once breaker_failure_threshold is reached. Returns the breaker state (closed/open/half_open) + failure/success counts. tenant_id, channel and success (boolean) are required.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | tenant_id, channel and success (boolean) are required | tenant_id, channel or success missing/not-a-boolean |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"channel": "email",
"success": true
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"success": true
}{
"success": true,
"data": {
"outcome_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"channel": "email",
"success": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"breaker": {
"state": "string",
"failure_count": "number",
"success_count": "number"
}
}
}POST/api/sequences/tick🔒 auth
Drive one durable step-executor tick on demand: claims up to batch_size (default 50) due execution steps (status pending/scheduled/deferred, next_run_at past) with FOR UPDATE SKIP LOCKED, gates each send against the sequence's send-window / quiet hours (deferring out-of-window touches to the next open slot), applies the frequency-cap + circuit-breaker guards (a blocked touch is 'skipped' and the cadence advances), emits allowed in-window touches via the pluggable step sender ('wait' actions just complete), records the send outcome to the channel breaker, and idempotently enqueues the next step (dedupe_key + ON CONFLICT DO NOTHING). The same logic runs on a timer when SEQUENCE_EXECUTOR_ENABLED. Returns per-tick counts; all zero when nothing is due.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | missing or invalid token | no valid Bearer token is supplied |
{
"batch_size": 50
}{
"batch_size": 50
}{
"success": true,
"data": {
"tick_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"batch_size": 50,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"claimed": "number",
"sent": "number",
"deferred": "number",
"waited": "number",
"failed": "number",
"skipped": "number",
"enqueued": "number"
}
}sdk-service-request
POST/api/service-request/queues🔒 auth
Creates a service-request queue for a tenant with a display name and an optional numeric routing priority, returning the created queue with 201. Edge cases: tenant_id and name are both mandatory and a missing one produces the generic "missing fields" ValidationError; priority is optional and defaulted by the service when omitted; name uniqueness is not enforced at the route, so repeated calls with the same name create duplicate queues unless a database unique constraint rejects it (which surfaces as a 500, not a 409); tenant_id is taken from the body rather than the JWT, so this route does not itself prevent creating a queue under another tenant id — it only requires a valid token.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | missing fields | tenant_id or name is absent or empty in the request body |
| 500 | InternalError | Internal Server Error | createQueue throws — e.g. tenant_id violates a foreign key, a unique constraint on the queue name is hit, or the database is unreachable |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"name": "{{dynamic:name}}",
"priority": 100
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"priority": 100
}{
"success": true,
"data": {
"queue_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"priority": 100,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/service-request/tickets🔒 auth
Creates a service-request ticket against an encounter for a requesting persona, optionally placing it on a queue with a priority/severity and external reference map, and returns the created ticket with 201. Edge cases: tenant_id, encounter_id and requester_persona_id are all mandatory and a missing one yields a single generic ValidationError ("missing fields") without naming the offender; queue_id, priority, severity and external_refs are optional and defaulted by the service; priority/severity are not enum-validated at the route, so an out-of-range value reaches the insert and surfaces as a 500 rather than a 400; a queue_id or encounter_id that does not exist fails the foreign key at insert time and also surfaces as a 500; there is no idempotency key, so re-POSTing the same body creates a second distinct ticket.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/encounters", "POST /api/service-request/queues" ]
priority: low, normal, high, urgentseverity: trivial, minor, major, critical| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | missing fields | Any of tenant_id, encounter_id or requester_persona_id is absent or empty |
| 500 | InternalError | Internal Server Error | createTicket throws — e.g. queue_id/encounter_id/requester_persona_id violates a foreign key, an invalid priority/severity breaks the enum constraint, or the database is unreachable |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}",
"requester_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"queue_id": "{{cache:service-request.queues.response.data.queue.queue_id}}",
"priority": "normal",
"severity": "minor",
"external_refs": {
"zendesk_id": "ZD-1001"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"requester_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"queue_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"priority": "normal",
"severity": "minor",
"external_refs": {
"zendesk_id": "ZD-1001"
}
}{
"success": true,
"data": {
"ticket_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"requester_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"queue_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"priority": "normal",
"severity": "minor",
"external_refs": {
"zendesk_id": "ZD-1001"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/service-request/tickets/:ticket_id🔒 auth
Fetches a single service-request ticket by its path ticket_id and returns the full ticket record. Edge cases: an unknown ticket_id returns 404 NotFound with no body detail; a malformed (non-UUID) ticket_id makes the lookup query fail on type cast and surfaces as a 500 rather than a 400, since the route performs no id-format validation; the handler does not compare the ticket's tenant against the caller's JWT tenant_id, so scoping relies on ticket ids being unguessable — a valid ticket_id from another tenant is still readable by any authenticated caller.
[ "POST /api/auth/signup-tenant", "POST /api/service-request/tickets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 404 | NotFound | NotFound | getTicket returns no row for the supplied ticket_id |
| 500 | InternalError | Internal Server Error | getTicket throws — e.g. ticket_id is not a valid UUID and the query cast fails, or the database is unreachable |
{
"ticket_id": "{{cache:service-request.tickets.response.data.ticket.ticket_id}}"
}{
"success": true,
"data": {
"ticket_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/service-request/tickets/:ticket_id/assign🔒 auth
Assigns an existing ticket to an agent persona by setting assignee_persona_id, returning the updated ticket. Edge cases: assignee_persona_id is required in the body and a missing value returns 400 before any lookup; an unknown ticket_id returns 404; assignment is a plain overwrite, so re-assigning an already-assigned ticket succeeds and silently replaces the previous assignee rather than returning a conflict, which makes the call idempotent for the same persona; the endpoint does not check ticket status, so a resolved or closed ticket can still be re-assigned; an assignee_persona_id that does not exist violates the foreign key at update time and surfaces as a 500.
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /api/service-request/tickets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | missing assignee_persona_id | assignee_persona_id is absent or empty in the request body |
| 404 | NotFound | NotFound | assignTicket finds no ticket for the supplied ticket_id |
| 500 | InternalError | Internal Server Error | assignTicket throws — e.g. assignee_persona_id violates a foreign key, ticket_id is not a valid UUID, or the database is unreachable |
{
"ticket_id": "{{cache:service-request.tickets.response.data.ticket.ticket_id}}"
}{
"assignee_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}{
"assignee_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"assign_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"assignee_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/service-request/tickets/:ticket_id/transition🔒 auth
Advances a ticket to a new lifecycle status (new, in-progress, awaiting-customer, resolved, closed) and returns the updated ticket. Edge cases: `to` is validated against the allowed status list at the route, so an unknown or misspelled status is a 400 before any read; a legal status that is not reachable from the ticket's current status is rejected by transitionTicket with a 409 InvalidTransition carrying the "current → target" message; an unknown ticket_id returns 404; transitioning a ticket to the status it already holds is treated as an invalid transition rather than a no-op, so this call is not idempotent; the guard is state-machine based, meaning e.g. a closed ticket cannot be reopened through this route.
[ "POST /api/auth/signup-tenant", "POST /api/service-request/tickets" ]
to: new, in-progress, awaiting-customer, resolved, closed| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | invalid target status | `to` is missing or is not one of new|in-progress|awaiting-customer|resolved|closed |
| 404 | NotFound | NotFound | transitionTicket finds no ticket for the supplied ticket_id |
| 409 | InvalidTransition | Invalid ticket transition <current> → <to> | The requested status is not reachable from the ticket's current status per the ticket state machine |
| 500 | InternalError | Internal Server Error | The database is unreachable or the update throws outside the InvalidTransition path |
{
"entity": "service_request.ticket",
"field": "status",
"flow": [
"new",
"in-progress",
"awaiting-customer",
"resolved",
"closed"
],
"transitions": [
{
"from": "new",
"to": "in-progress",
"via": "POST /api/service-request/tickets/:ticket_id/transition"
},
{
"from": "new",
"to": "closed",
"via": "POST /api/service-request/tickets/:ticket_id/transition"
},
{
"from": "in-progress",
"to": "awaiting-customer",
"via": "POST /api/service-request/tickets/:ticket_id/transition"
},
{
"from": "in-progress",
"to": "resolved",
"via": "POST /api/service-request/tickets/:ticket_id/transition"
},
{
"from": "awaiting-customer",
"to": "in-progress",
"via": "POST /api/service-request/tickets/:ticket_id/transition"
},
{
"from": "awaiting-customer",
"to": "resolved",
"via": "POST /api/service-request/tickets/:ticket_id/transition"
},
{
"from": "resolved",
"to": "closed",
"via": "POST /api/service-request/tickets/:ticket_id/transition"
}
]
}{
"ticket_id": "{{cache:service-request.tickets.response.data.ticket.ticket_id}}"
}{
"to": "in-progress"
}{
"to": "in-progress"
}{
"success": true,
"data": {
"transition_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"to": "in-progress",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-sla
GET/api/sla/at-risk🔒 auth
Live clocks approaching or past their deadline, ordered by how close that deadline is — a queue that does not put the next breach first is a list rather than a queue. Each row carries minutes_to_due, is_overdue, how many rungs have already fired, the highest severity fired so far and the NEXT rung with the instant it is due. at_risk is derived here and never stored: keeping a column truthful minute by minute would need a background job, and a stale "at risk" flag is worse than none. Returns 200 with at_risk and a count. Requires tenant_id.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks" ]
severity: info, warning, urgent, criticalstate: running, paused, satisfied, breached, cancelledinclude_overdue: true, false| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | a required field is missing from the request |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"at_risk_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"at_risk": "array",
"count": "number"
}
}GET/api/sla/attainment🔒 auth
How often the promise was kept over a window, how long it actually took, and for every miss why and what was done. Percentage attainment plus median and P95 over BUSINESS minutes on each policy's own calendar — the same measure and arithmetic that produced due_at, so a percentile and a deadline can never describe different clocks. Broken down by source, owner, day, hour, reason and policy (pick with ?dimensions=, and an unrecognised dimension is refused rather than dropped, because silently returning fewer breakdowns than were asked for reads as "no data"). Each bucket carries its own misses with cause and recovery, so a row can be read without going back to the source. Only CLOSED clocks count: an open clock has no outcome and scoring it either way would be a guess. A breached clock whose cause nobody recorded appears with reason_code null, grouped as cause_not_recorded and counted in misses_without_cause — named rather than dropped, because a report that quietly omits the misses nobody explained flatters itself. If the clock ceiling is reached the response says so via truncated and clocks_considered instead of capping silently. Returns 200. Required: tenant_id, from, to.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks/:clock_id/satisfy" ]
dimensions: source, owner, day, hour, reason, policyreason_code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recordedstate: running, paused, satisfied, breached, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id, from and to query params are required | a required field is missing from the request |
| 400 | VALIDATION_ERROR | unknown dimension(s) region — allowed: source, owner, day, hour, reason, policy | a dimension outside the allowed set is requested |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"attainment_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"attainment": {
"total": "number",
"attained": "number",
"breached": "number",
"attainment_pct": "number",
"median_business_minutes": "number",
"p95_business_minutes": "number",
"misses_without_cause": "number",
"breakdowns": "object",
"misses": "array",
"truncated": "boolean",
"clocks_considered": "number"
}
}
}GET/api/sla/breach-reasons🔒 auth
The tenant's cause taxonomy in code order, including which codes were auto-registered from use rather than defined deliberately — the list an operator prunes. Returns 200 with reasons and a count. Requires tenant_id.
[ "POST /api/auth/signup-tenant", "POST /api/sla/breach-reasons" ]
code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recordedcategory: capacity, process, external| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | a required field is missing from the request |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"breach_reason_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"reasons": "array",
"count": "number"
}
}POST/api/sla/breach-reasons🔒 auth
Define or relabel a cause code in the tenant's taxonomy. The taxonomy is per tenant and NOT a platform enum: one shared vocabulary for why every vertical missed would be exactly the kind of business rule this package must not hold. Naming a code deliberately clears the is_auto_registered flag it got from first use, which is how an operator distinguishes a vocabulary that was decided from one that accumulated. An upsert, so 200 rather than 201. Required: tenant_id, code.
[ "POST /api/auth/signup-tenant" ]
code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recordedcategory: capacity, process, external| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id and code are required | a required field is missing from the request |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"code": "no_capacity",
"label": "No capacity available",
"category": "capacity"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"code": "no_capacity",
"label": "No capacity available",
"category": "capacity"
}{
"success": true,
"data": {
"breach_reason_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"code": "no_capacity",
"label": "No capacity available",
"category": "capacity",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"reason": {
"code": "string",
"label": "string",
"is_auto_registered": "boolean"
}
}
}POST/api/sla/breach-scan🔒 auth
Move every past-due running clock to breached. This asserts the ARITHMETIC only — the deadline passed — and deliberately does NOT invent a reason code: if the scanner had to produce a cause it would have to make one up, and an invented cause is worse than a missing one because it looks like an answer. The response therefore reports awaiting_cause, the count of breached clocks nobody has explained yet, as visible debt to be cleared through POST /api/sla/clocks/:clock_id/breach. Idempotent — the update only matches state=running, so a second scan in the same second marks nothing twice and emits nothing twice. Returns 200 with the counters. Required: tenant_id.
[ "POST /api/auth/signup-tenant", "GET /api/sla/at-risk" ]
state: running, paused, satisfied, breached, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id is required | a required field is missing from the request |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"limit": 200,
"actor_id": "qa-runner"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"limit": 200,
"actor_id": "qa-runner"
}{
"success": true,
"data": {
"breach_scan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"limit": 200,
"actor_id": "qa-runner",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"clocks_scanned": "number",
"clocks_marked": "number",
"awaiting_cause": "number"
}
}GET/api/sla/breaches🔒 auth
Recorded misses, most recent first, narrowed by policy, owner, reason_code, window, or unrecovered_only — that last filter being the working queue: misses nobody has said what they did about yet. Returns 200 with breaches and a count. Requires tenant_id.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks/:clock_id/breach" ]
reason_code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recordedunrecovered_only: true, false| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | a required field is missing from the request |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"breach_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"breaches": "array",
"count": "number"
}
}GET/api/sla/breaches/:breach_id🔒 auth
One breach record: the cause and its detail, elapsed and overdue business minutes, the owner and source at breach time, whether it was flagged systemic, and the recovery if one has been recorded. Returns 200, or 404 SLA_BREACH_NOT_FOUND outside the tenant.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks/:clock_id/breach" ]
reason_code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recorded| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | a required field is missing from the request |
| 404 | SLA_BREACH_NOT_FOUND | breach record <id> not found for tenant | the breach record does not exist for this tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"breach_id": "{{cache:sla.breach.record.response.data.breach.breach_id}}"
}{
"success": true,
"data": {
"breach_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"breach": {
"breach_id": "string",
"reason_code": "string",
"is_systemic": "boolean"
}
}
}POST/api/sla/breaches/:breach_id/recovery🔒 auth
Record the recovery action and the recovering persona after the fact. The cause stays exactly as recorded — a trigger refuses any change to reason_code, breached_at or the clock reference, because an attainment history that rewrites its own causes cannot be used to argue for anything, whereas recovery genuinely happens later. Returns 200 with the updated record; 404 SLA_BREACH_NOT_FOUND outside the tenant. Required: tenant_id, recovery_action.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks/:clock_id/breach" ]
reason_code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recorded| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id and recovery_action are required | a required field is missing from the request |
| 404 | SLA_BREACH_NOT_FOUND | breach record <id> not found for tenant | the breach record does not exist for this tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"breach_id": "{{cache:sla.breach.record.response.data.breach.breach_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"recovery_action": "backup owner answered at opening and apologised for the delay",
"recovered_by": "persona:backup"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"recovery_action": "backup owner answered at opening and apologised for the delay",
"recovered_by": "persona:backup"
}{
"success": true,
"data": {
"recovery_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"recovery_action": "backup owner answered at opening and apologised for the delay",
"recovered_by": "persona:backup",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"breach": {
"breach_id": "string",
"recovery_action": "string",
"recovered_at": "string"
}
}
}GET/api/sla/calendars🔒 auth
List the tenant's business calendars in slug order, optionally filtered by is_active. Returns 200 with calendars and a count. Requires the tenant_id query param — a calendar list without a tenant would cross a tenant boundary.
[ "POST /api/auth/signup-tenant", "POST /api/sla/calendars" ]
weekend_rule: saturday_sunday, friday_saturday, sunday_only, noneis_active: true, false| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | a required field is missing from the request |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"calendar_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"calendars": "array",
"count": "number"
}
}POST/api/sla/calendars🔒 auth
Create the business calendar a promise is measured against: a NAMED IANA timezone (never a fixed UTC offset — the constraint and the service both refuse "+05:30" or "UTC-5", because an offset cannot know about DST and a due date computed from one is wrong twice a year), per-weekday working windows keyed 1=Monday..7=Sunday, an optional late-coverage extension that keeps a signal arriving one minute before close due the same evening rather than deferring to tomorrow, a weekend rule and a holiday date list. Returns 201 with the stored calendar. Required: tenant_id, slug, name, timezone and a non-empty working_windows — a calendar with no open minute can never produce a due date, so it is refused at creation rather than at the first clock.
[ "POST /api/auth/signup-tenant" ]
weekend_rule: saturday_sunday, friday_saturday, sunday_only, none| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id, slug, name and timezone are required | a required field is missing from the request |
| 400 | VALIDATION_ERROR | working_windows is required — a calendar with no open minute can never make a due date | working_windows is absent or empty |
| 422 | FIXED_OFFSET_TIMEZONE_REJECTED | timezone '+05:30' is a fixed offset, not a zone | the timezone is an offset or an unresolvable zone name |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"slug": "support-hours-{{dynamic:uuid}}",
"name": "Support hours",
"description": "Weekday cover with a half-hour of late coverage",
"timezone": "America/New_York",
"working_windows": {
"1": [
{
"start": "09:00",
"end": "17:00"
}
],
"2": [
{
"start": "09:00",
"end": "17:00"
}
],
"3": [
{
"start": "09:00",
"end": "17:00"
}
],
"4": [
{
"start": "09:00",
"end": "17:00"
}
],
"5": [
{
"start": "09:00",
"end": "17:00"
}
]
},
"late_coverage_extension_minutes": 30,
"weekend_rule": "saturday_sunday",
"holiday_dates": [
"2026-12-25"
],
"metadata": {
"owner": "qa-runner"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"slug": "support-hours-{{dynamic:uuid}}",
"name": "Support hours",
"description": "Weekday cover with a half-hour of late coverage",
"timezone": "America/New_York",
"working_windows": {
"1": [
{
"start": "09:00",
"end": "17:00"
}
],
"2": [
{
"start": "09:00",
"end": "17:00"
}
],
"3": [
{
"start": "09:00",
"end": "17:00"
}
],
"4": [
{
"start": "09:00",
"end": "17:00"
}
],
"5": [
{
"start": "09:00",
"end": "17:00"
}
]
},
"late_coverage_extension_minutes": 30,
"weekend_rule": "saturday_sunday",
"holiday_dates": [
"2026-12-25"
],
"metadata": {
"owner": "qa-runner"
}
}{
"success": true,
"data": {
"calendar_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"slug": "support-hours-{{dynamic:uuid}}",
"name": "Support hours",
"description": "Weekday cover with a half-hour of late coverage",
"timezone": "America/New_York",
"working_windows": {
"1": [
{
"start": "09:00",
"end": "17:00"
}
],
"2": [
{
"start": "09:00",
"end": "17:00"
}
],
"3": [
{
"start": "09:00",
"end": "17:00"
}
],
"4": [
{
"start": "09:00",
"end": "17:00"
}
],
"5": [
{
"start": "09:00",
"end": "17:00"
}
]
},
"late_coverage_extension_minutes": 30,
"weekend_rule": "saturday_sunday",
"holiday_dates": [
"2026-12-25"
],
"metadata": {
"owner": "qa-runner"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"calendar": {
"calendar_id": "string",
"timezone": "string"
}
}
}GET/api/sla/calendars/:calendar_id🔒 auth
Read one business calendar scoped to the tenant, including its working windows, holiday dates, weekend rule and late-coverage extension. Returns 200, or 404 SLA_CALENDAR_NOT_FOUND when the calendar does not belong to this tenant — the same answer as a calendar that does not exist, so the endpoint cannot be used to probe another tenant.
[ "POST /api/auth/signup-tenant", "POST /api/sla/calendars" ]
weekend_rule: saturday_sunday, friday_saturday, sunday_only, none| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | a required field is missing from the request |
| 404 | SLA_CALENDAR_NOT_FOUND | calendar <id> not found for tenant | the calendar does not exist for this tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"calendar_id": "{{cache:sla.calendar.create.response.data.calendar.calendar_id}}"
}{
"success": true,
"data": {
"calendar_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"calendar": {
"calendar_id": "string",
"working_windows": "object"
}
}
}GET/api/sla/clocks🔒 auth
List clocks by deadline, narrowed by subject_ref, policy_id, state or owner_ref. Ordered by due_at ascending so the next thing to miss is first. Returns 200 with clocks and a count. Requires tenant_id.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks" ]
state: running, paused, satisfied, breached, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | a required field is missing from the request |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"clock_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"clocks": "array",
"count": "number"
}
}POST/api/sla/clocks🔒 auth
Start one promise about one subject. source_timestamp is WHEN THE SIGNAL HAPPENED — the message arrived, the form was submitted — not when the platform got around to noticing, and due_at is computed from it in business minutes on the policy calendar, so an overnight arrival is due after the promised amount of OPEN time rather than instantly breaching at opening. source_timestamp, started_at and due_at are then immutable, enforced by a database trigger: merge, reassignment and backup takeover move ownership and never the clock, because restarting it would erase the wait the person on the other end has already had. If a live clock already exists for this policy and subject the existing one is returned with created:false and 200 rather than a second clock, which would double-count the promise and fire the ladder twice. Returns 201 on a genuine start. Required: tenant_id, policy_id, subject_ref.
[ "POST /api/auth/signup-tenant", "POST /api/sla/policies" ]
state: running, paused, satisfied, breached, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id, policy_id and subject_ref are required | a required field is missing from the request |
| 404 | SLA_POLICY_NOT_FOUND | policy <id> not found for tenant | the policy does not exist for this tenant |
| 422 | CALENDAR_NEVER_OPEN | calendar <id> is never open | the policy calendar has no open minute, so no due date can be computed |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"policy_id": "{{cache:sla.policy.create.response.data.policy.policy_id}}",
"subject_ref": "request:{{dynamic:uuid}}",
"source_timestamp": "{{dynamic:pastdatetime-10d}}",
"owner_ref": "persona:{{dynamic:uuid}}",
"metadata": {
"source_ref": "web_form",
"priority": "high"
},
"actor_id": "qa-runner"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_ref": "request:{{dynamic:uuid}}",
"source_timestamp": "2026-01-15T10:30:00Z",
"owner_ref": "persona:{{dynamic:uuid}}",
"metadata": {
"source_ref": "web_form",
"priority": "high"
},
"actor_id": "qa-runner"
}{
"success": true,
"data": {
"clock_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_ref": "request:{{dynamic:uuid}}",
"source_timestamp": "2026-01-15T10:30:00Z",
"owner_ref": "persona:{{dynamic:uuid}}",
"metadata": {
"source_ref": "web_form",
"priority": "high"
},
"actor_id": "qa-runner",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"clock": {
"clock_id": "string",
"state": "string",
"due_at": "string"
},
"created": "boolean"
}
}GET/api/sla/clocks/:clock_id🔒 auth
Read one clock, plus the answer to the question every caller asks next: elapsed_business_minutes — how long this has been waiting in the hours the business is actually open, net of any paused intervals — alongside the policy duration it is measured against and whether it is already overdue. Computing that from the raw columns needs the calendar, so it is returned here rather than left to the caller. Returns 200, or 404 SLA_CLOCK_NOT_FOUND outside the tenant.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks" ]
state: running, paused, satisfied, breached, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | a required field is missing from the request |
| 404 | SLA_CLOCK_NOT_FOUND | clock <id> not found for tenant | the clock does not exist for this tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}{
"success": true,
"data": {
"clock_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"clock": {
"clock_id": "string",
"state": "string",
"source_timestamp": "string"
},
"elapsed_business_minutes": "number",
"duration_minutes": "number",
"is_overdue": "boolean"
}
}POST/api/sla/clocks/:clock_id/breach🔒 auth
Record a missed promise. reason_code is MANDATORY — a blank or absent code returns 422 BREACH_REASON_REQUIRED, because a miss with no stated cause is a number on a dashboard nobody can act on. Unknown codes are auto-registered into the tenant's cause taxonomy and flagged, so a breach is never lost to an unconfigured vocabulary while an operator can still see which codes grew by accident. Elapsed and overdue are measured in BUSINESS minutes on the policy calendar, net of paused intervals. Idempotent per clock: one clock misses its deadline once, and a retried call returns the record that already exists with created:false rather than a second one that would double-count in every report. With is_systemic the breach joins a GROUP (policy + cause + hour by default, or your systemic_group_key) and ONLY the call that creates the group opens an incident — so a ladder that fired four rungs, or twenty clocks failing for one reason, still produce exactly one. Returns 200. Required: tenant_id and reason_code.
[ "POST /api/auth/signup-tenant", "POST /api/sla/breach-scan" ]
reason_code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recordedstate: running, paused, satisfied, breached, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id is required | a required field is missing from the request |
| 422 | BREACH_REASON_REQUIRED | a breach cannot be recorded without a reason_code | reason_code is absent, empty or whitespace only |
| 404 | SLA_CLOCK_NOT_FOUND | clock <id> not found for tenant | the clock does not exist for this tenant |
| 409 | CLOCK_NOT_BREACHED | clock <id> is 'running' and not past due | the clock has not missed anything, so there is nothing to record |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"reason_code": "no_capacity",
"reason_detail": "roster gap overnight, nobody on call for this queue",
"source_ref": "web_form",
"is_systemic": false,
"recorded_by": "persona:manager",
"metadata": {
"reviewed": true
},
"actor_id": "qa-runner"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason_code": "no_capacity",
"reason_detail": "roster gap overnight, nobody on call for this queue",
"source_ref": "web_form",
"is_systemic": false,
"recorded_by": "persona:manager",
"metadata": {
"reviewed": true
},
"actor_id": "qa-runner"
}{
"success": true,
"data": {
"breach_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason_code": "no_capacity",
"reason_detail": "roster gap overnight, nobody on call for this queue",
"source_ref": "web_form",
"is_systemic": false,
"recorded_by": "persona:manager",
"metadata": {
"reviewed": true
},
"actor_id": "qa-runner",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"breach": {
"breach_id": "string",
"reason_code": "string",
"elapsed_business_minutes": "number",
"overdue_business_minutes": "number"
},
"created": "boolean",
"incident_opened": "boolean"
}
}POST/api/sla/clocks/:clock_id/cancel🔒 auth · manual
Cancel a clock with a mandatory reason — the subject withdrew, the request was a duplicate, the promise no longer applies. Any non-terminal clock can be cancelled (running, paused or breached); a satisfied or already-cancelled one returns 409 INVALID_CLOCK_TRANSITION. Returns 200. MANUAL: cancel ends the clock's life, and the create->capture chain in this suite yields exactly one clock_id which the satisfy path needs alive, so an automated case here would either destroy that chain or point at a fabricated id and fail as a 404 while proving nothing. Covered by packages/sdk-sla/tests/clock.integration.test.ts. Required: tenant_id, reason.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks" ]
state: running, paused, satisfied, breached, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id and reason are required | a required field is missing from the request |
| 409 | INVALID_CLOCK_TRANSITION | clock <id> cannot move satisfied -> cancelled | the clock is already satisfied or cancelled |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"reason": "subject withdrew the request",
"actor_id": "qa-runner"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "subject withdrew the request",
"actor_id": "qa-runner"
}{
"success": true,
"data": {
"cancel_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "subject withdrew the request",
"actor_id": "qa-runner",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"clock": {
"clock_id": "string",
"state": "string",
"cancelled_at": "string"
}
}
}GET/api/sla/clocks/:clock_id/firings🔒 auth
The ledger for one clock: which rungs fired, when they were DUE versus when they actually went (so lateness is visible), how many attempts each took, who the audience resolved to at fire time, the action result and the last error on anything that failed. One row per rung per clock, forever — that uniqueness is what makes exactly-once true. Returns 200 with firings and a count.
[ "POST /api/auth/signup-tenant", "POST /api/sla/tick" ]
firing_state: claimed, fired, failed| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | a required field is missing from the request |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}{
"success": true,
"data": {
"firing_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"firings": "array",
"count": "number"
}
}POST/api/sla/clocks/:clock_id/pause🔒 auth
Pause the clock so time spent waiting on somebody else does not burn the responder's promise. The reason MUST appear in the policy's pause_conditions, otherwise 422 PAUSE_REASON_NOT_ALLOWED naming what is allowed — an unconstrained pause button is how a breach becomes invisible, and each listed condition also carries a max_minutes cap. Pausing does not move due_at: the deadline is the promise that was made, and paused time explains a miss rather than excusing it, while elapsed business minutes exclude the parked interval. Only a running clock can pause; anything else is 409. Returns 200. Required: tenant_id, reason.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks/:clock_id/reassign" ]
state: running, paused, satisfied, breached, cancelledpause_reason: awaiting_subject_reply| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id and reason are required | a required field is missing from the request |
| 422 | PAUSE_REASON_NOT_ALLOWED | 'because_i_said_so' is not a pause condition on this policy | the reason is not listed in the policy pause_conditions |
| 409 | INVALID_CLOCK_TRANSITION | clock <id> cannot move paused -> paused | the clock is not running |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"reason": "awaiting_subject_reply",
"actor_id": "qa-runner"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "awaiting_subject_reply",
"actor_id": "qa-runner"
}{
"success": true,
"data": {
"pause_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "awaiting_subject_reply",
"actor_id": "qa-runner",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"clock": {
"clock_id": "string",
"state": "string",
"paused_at": "string"
}
}
}POST/api/sla/clocks/:clock_id/reassign🔒 auth
Hand the response to somebody else — the reassignment and backup-takeover path. It exists as a named operation precisely to make the safe change easy, so nobody reaches for an UPDATE that would also "helpfully" refresh the due date: source_timestamp, started_at and due_at are immutable and a database trigger rejects any attempt to move them. Ownership is fluid, timing is frozen. Returns 200 with the updated clock; 409 INVALID_CLOCK_TRANSITION on a clock that is already closed. Required: tenant_id, owner_ref.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks" ]
state: running, paused, satisfied, breached, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id and owner_ref are required | a required field is missing from the request |
| 404 | SLA_CLOCK_NOT_FOUND | clock <id> not found for tenant | the clock does not exist for this tenant |
| 409 | INVALID_CLOCK_TRANSITION | clock <id> cannot move satisfied -> satisfied | the clock is already satisfied, breached or cancelled |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"owner_ref": "persona:backup-{{dynamic:uuid}}",
"reason": "backup takeover",
"actor_id": "qa-runner"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"owner_ref": "persona:backup-{{dynamic:uuid}}",
"reason": "backup takeover",
"actor_id": "qa-runner"
}{
"success": true,
"data": {
"reassign_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"owner_ref": "persona:backup-{{dynamic:uuid}}",
"reason": "backup takeover",
"actor_id": "qa-runner",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"clock": {
"clock_id": "string",
"owner_ref": "string",
"due_at": "string"
}
}
}POST/api/sla/clocks/:clock_id/resume🔒 auth
Resume a paused clock, appending the closed interval to paused_intervals with its reason so elapsed business minutes stay honest about what was parked and why. Only a paused clock can resume; anything else is 409 INVALID_CLOCK_TRANSITION. Returns 200 with the updated clock. Required: tenant_id.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks/:clock_id/pause" ]
state: running, paused, satisfied, breached, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id is required | a required field is missing from the request |
| 409 | INVALID_CLOCK_TRANSITION | clock <id> cannot move running -> running | the clock is not paused |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"actor_id": "qa-runner"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor_id": "qa-runner"
}{
"success": true,
"data": {
"resume_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor_id": "qa-runner",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"clock": {
"clock_id": "string",
"state": "string",
"paused_intervals": "array"
}
}
}POST/api/sla/clocks/:clock_id/satisfy🔒 auth
Close the promise — but only on the evidence the policy asked for. Every unmet requirement of the satisfaction_contract is collected before refusing, so one 422 SATISFACTION_EVIDENCE_INSUFFICIENT carries a `missing` array naming ALL of them rather than making the caller discover them one rejection at a time. A refused satisfy leaves the clock exactly as it was: no half-closed promise. An evidence kind outside accepted_kinds is refused by name. Without this contract "satisfied" would mean whatever the closing service felt like asserting and the attainment number would stop measuring anything. A BREACHED clock can still be satisfied, and that is the common case rather than an edge one: the deadline passes and somebody answers anyway. Refusing it would leave the clock breached forever with no satisfied_at, so nothing could record when the late response happened and a report could not tell a miss that was eventually answered from one that was abandoned. Nothing is laundered by allowing it — satisfied_at is after due_at so attainment still counts the clock as a miss, the breach record and its cause are immutable rows of their own, and the event carries within_target: false. A satisfied or cancelled clock cannot be satisfied again (409). Returns 200 with the closed clock. Required: tenant_id plus whatever the policy contract demands.
[ "POST /api/auth/signup-tenant", "POST /api/sla/breaches/:breach_id/recovery" ]
state: running, paused, satisfied, breached, cancelledevidence_kind: outbound_reply, resolution| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id is required | a required field is missing from the request |
| 422 | SATISFACTION_EVIDENCE_INSUFFICIENT | clock <id> cannot be satisfied: an evidence reference is required by this policy | the offered evidence does not meet the policy satisfaction contract — the body names every unmet requirement |
| 409 | INVALID_CLOCK_TRANSITION | clock <id> cannot move satisfied -> satisfied | the clock is already satisfied, breached or cancelled |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"evidence_ref": "message:{{dynamic:uuid}}",
"evidence_kind": "outbound_reply",
"evidence_count": 1,
"satisfied_by": "persona:backup",
"actor_id": "qa-runner"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"evidence_ref": "message:{{dynamic:uuid}}",
"evidence_kind": "outbound_reply",
"evidence_count": 1,
"satisfied_by": "persona:backup",
"actor_id": "qa-runner"
}{
"success": true,
"data": {
"satisfy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"evidence_ref": "message:{{dynamic:uuid}}",
"evidence_kind": "outbound_reply",
"evidence_count": 1,
"satisfied_by": "persona:backup",
"actor_id": "qa-runner",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"clock": {
"clock_id": "string",
"state": "string",
"satisfied_at": "string",
"satisfied_by_evidence_ref": "string"
}
}
}POST/api/sla/clocks/merge🔒 auth · manual
When two subjects turn out to be one, fold the absorbed clock into the survivor. The survivor keeps ITS OWN timing — the promise the platform made first is the one it owes — and records merged_from_ref so it still names what it absorbed; the absorbed clock is cancelled with a pointer to the survivor so the trail stays intact. Neither clock's source_timestamp, started_at or due_at moves, and a database trigger enforces that: a merge is one of the moments a system is most tempted to "refresh" a clock, which would erase a wait somebody has already had. Returns 200 with both clocks. MANUAL: needs TWO independently created clocks and the create->capture chain yields one clock_id, so an automated case would have to merge a clock into itself. Covered by packages/sdk-sla/tests/clock.integration.test.ts. Required: tenant_id, surviving_clock_id, merged_clock_id.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks" ]
state: running, paused, satisfied, breached, cancelled| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id, surviving_clock_id and merged_clock_id are required | a required field is missing from the request |
| 404 | SLA_CLOCK_NOT_FOUND | clock <id> not found for tenant | either clock does not exist for this tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"surviving_clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}",
"merged_clock_id": "{{cache:sla.clock.duplicate.response.data.clock.clock_id}}",
"actor_id": "qa-runner"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"surviving_clock_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"merged_clock_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor_id": "qa-runner"
}{
"success": true,
"data": {
"merge_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"surviving_clock_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"merged_clock_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor_id": "qa-runner",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"surviving": {
"clock_id": "string",
"merged_from_ref": "string"
},
"merged": {
"clock_id": "string",
"state": "string"
}
}
}GET/api/sla/policies🔒 auth
List the tenant's policies in slug order, optionally narrowed by subject_kind and is_active. Returns 200 with policies and a count. Requires tenant_id.
[ "POST /api/auth/signup-tenant", "POST /api/sla/policies" ]
is_active: true, false| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | a required field is missing from the request |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"policies": "array",
"count": "number"
}
}POST/api/sla/policies🔒 auth
Create the promise itself: which subject_kind it covers, for how many BUSINESS minutes, on which calendar, which subjects qualify (qualifying_predicate, evaluated as data so a vertical narrows a promise without a platform change), what may pause the clock (pause_conditions, each with a max_minutes cap so a clock cannot be parked indefinitely to dodge a breach) and what actually closes it (satisfaction_contract — without it "satisfied" degrades into whatever the closing service felt like asserting). Returns 201. Required: tenant_id, slug, name, subject_kind, duration_minutes (> 0) and calendar_id.
[ "POST /api/auth/signup-tenant", "POST /api/sla/calendars" ]
predicate_op: eq, ne, in, not_in, gte, lte, existsaccepted_kinds: outbound_reply, resolution| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id, slug, name, subject_kind, duration_minutes and calendar_id are required | a required field is missing from the request |
| 400 | VALIDATION_ERROR | duration_minutes must be greater than zero | duration_minutes is zero or negative |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"slug": "first-response-{{dynamic:uuid}}",
"name": "First response",
"description": "Respond within two business hours",
"subject_kind": "request",
"duration_minutes": 120,
"calendar_id": "{{cache:sla.calendar.create.response.data.calendar.calendar_id}}",
"qualifying_predicate": {
"all": [
{
"field": "priority",
"op": "in",
"value": [
"high",
"urgent"
]
}
]
},
"pause_conditions": [
{
"reason": "awaiting_subject_reply",
"max_minutes": 4320
}
],
"satisfaction_contract": {
"requires_evidence_ref": true,
"accepted_kinds": [
"outbound_reply",
"resolution"
],
"min_evidence_count": 1,
"requires_actor": true
},
"metadata": {
"owner": "qa-runner"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"slug": "first-response-{{dynamic:uuid}}",
"name": "First response",
"description": "Respond within two business hours",
"subject_kind": "request",
"duration_minutes": 120,
"calendar_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"qualifying_predicate": {
"all": [
{
"field": "priority",
"op": "in",
"value": [
"high",
"urgent"
]
}
]
},
"pause_conditions": [
{
"reason": "awaiting_subject_reply",
"max_minutes": 4320
}
],
"satisfaction_contract": {
"requires_evidence_ref": true,
"accepted_kinds": [
"outbound_reply",
"resolution"
],
"min_evidence_count": 1,
"requires_actor": true
},
"metadata": {
"owner": "qa-runner"
}
}{
"success": true,
"data": {
"policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"slug": "first-response-{{dynamic:uuid}}",
"name": "First response",
"description": "Respond within two business hours",
"subject_kind": "request",
"duration_minutes": 120,
"calendar_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"qualifying_predicate": {
"all": [
{
"field": "priority",
"op": "in",
"value": [
"high",
"urgent"
]
}
]
},
"pause_conditions": [
{
"reason": "awaiting_subject_reply",
"max_minutes": 4320
}
],
"satisfaction_contract": {
"requires_evidence_ref": true,
"accepted_kinds": [
"outbound_reply",
"resolution"
],
"min_evidence_count": 1,
"requires_actor": true
},
"metadata": {
"owner": "qa-runner"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"policy": {
"policy_id": "string",
"duration_minutes": "number"
}
}
}GET/api/sla/policies/:policy_id🔒 auth
Read one policy with its qualifying predicate, pause conditions and satisfaction contract — the three pieces of data a caller needs to know why a clock qualified, what may pause it and what will close it. Returns 200, or 404 SLA_POLICY_NOT_FOUND outside the tenant.
[ "POST /api/auth/signup-tenant", "POST /api/sla/policies" ]
predicate_op: eq, ne, in, not_in, gte, lte, exists| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | a required field is missing from the request |
| 404 | SLA_POLICY_NOT_FOUND | policy <id> not found for tenant | the policy does not exist for this tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"policy_id": "{{cache:sla.policy.create.response.data.policy.policy_id}}"
}{
"success": true,
"data": {
"policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"policy": {
"policy_id": "string",
"calendar_id": "string"
}
}
}GET/api/sla/policies/:policy_id/rungs🔒 auth
The policy's ladder in rung order — active rungs by default, all of them with include_inactive=true. Returns 200 with rungs and a count. Each rung carries its offset, audience, severity, action and remediation hint, which is the whole configuration: nothing about escalation lives in code.
[ "POST /api/auth/signup-tenant", "POST /api/sla/policies/:policy_id/rungs" ]
severity: info, warning, urgent, criticalaudience_kind: owner, refs, on_callinclude_inactive: true, false| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | a required field is missing from the request |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"policy_id": "{{cache:sla.policy.create.response.data.policy.policy_id}}"
}{
"success": true,
"data": {
"rung_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"rungs": "array",
"count": "number"
}
}POST/api/sla/policies/:policy_id/rungs🔒 auth
Add a rung to the policy ladder. The ladder is DATA: when it fires, who hears about it, how loudly, what action runs and what the recipient should do. The stored anchor is business minutes from the clock's start — the same arithmetic that produced due_at, so a rung and a deadline can never disagree about the calendar — but minutes_before_due and minutes_after_due are accepted and normalised once at insert, so the row that fires is the row an operator can read. Give exactly ONE of the three offsets; giving two or none returns 422 INVALID_RUNG_OFFSET, as does a minutes_before_due longer than the promise itself. The audience is resolved AT FIRE TIME, not now, because the person on call at 02:00 is not the person who was on call when the policy was written. Returns 201.
[ "POST /api/auth/signup-tenant", "POST /api/sla/policies" ]
severity: info, warning, urgent, criticalaudience_kind: owner, refs, on_call| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id, rung_index and action are required | a required field is missing from the request |
| 422 | INVALID_RUNG_OFFSET | give exactly one of offset_minutes, minutes_before_due or minutes_after_due | no offset or more than one offset is supplied, or minutes_before_due exceeds the policy duration |
| 404 | SLA_POLICY_NOT_FOUND | policy <id> not found for tenant | the policy does not exist for this tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"policy_id": "{{cache:sla.policy.create.response.data.policy.policy_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"rung_index": 0,
"label": "pre-breach warning",
"minutes_before_due": 30,
"action": "notify",
"severity": "warning",
"audience": {
"kind": "owner"
},
"action_config": {
"channel": "email"
},
"remediation_hint": "answer the request or hand it to the backup",
"metadata": {
"owner": "qa-runner"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rung_index": 0,
"label": "pre-breach warning",
"minutes_before_due": 30,
"action": "notify",
"severity": "warning",
"audience": {
"kind": "owner"
},
"action_config": {
"channel": "email"
},
"remediation_hint": "answer the request or hand it to the backup",
"metadata": {
"owner": "qa-runner"
}
}{
"success": true,
"data": {
"rung_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"rung_index": 0,
"label": "pre-breach warning",
"minutes_before_due": 30,
"action": "notify",
"severity": "warning",
"audience": {
"kind": "owner"
},
"action_config": {
"channel": "email"
},
"remediation_hint": "answer the request or hand it to the backup",
"metadata": {
"owner": "qa-runner"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"rung": {
"rung_id": "string",
"offset_minutes": "number"
}
}
}PATCH/api/sla/rungs/:rung_id🔒 auth
Activate or retire one rung without deleting its firing history — the ledger rows that reference it stay queryable, which is why the FK is ON DELETE RESTRICT and there is no DELETE on this resource. A retired rung is simply never claimed by the tick again. Returns 200 with the updated rung; 404 SLA_LADDER_RUNG_NOT_FOUND outside the tenant.
[ "POST /api/auth/signup-tenant", "POST /api/sla/policies/:policy_id/rungs" ]
severity: info, warning, urgent, criticalis_active: true, false| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id and is_active (boolean) are required | a required field is missing from the request |
| 404 | SLA_LADDER_RUNG_NOT_FOUND | ladder rung <id> not found for tenant | the rung does not exist for this tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"rung_id": "{{cache:sla.rung.create.response.data.rung.rung_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"is_active": true
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"is_active": true
}{
"success": true,
"data": {
"rung_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"is_active": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"rung": {
"rung_id": "string",
"is_active": "boolean"
}
}
}POST/api/sla/systemic-incidents/open-pending🔒 auth
Retry the systemic groups whose incident never opened, because no incident opener was wired yet or the last attempt failed. The breach records themselves were never at risk — the incident is opened OUTSIDE the record's write precisely so that a provider being down cannot cost the record — so wiring the integration late or recovering from an outage costs a delay and nothing else. Returns 200 with attempted and opened counts. Required: tenant_id.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks/:clock_id/breach" ]
reason_code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recorded| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id is required | a required field is missing from the request |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"limit": 25
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"limit": 25
}{
"success": true,
"data": {
"open_pending_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"limit": 25,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"attempted": "number",
"opened": "number"
}
}POST/api/sla/tick🔒 auth
Evaluate every running clock against its ladder and fire whatever has come due. Safe to call as often as you like from as many callers as you like: each rung is claimed by inserting a ledger row under a UNIQUE (clock_id, rung_id), so of N concurrent ticks exactly one fires a given rung and the rest report it as skipped_duplicate. No lock is held across the action, because an escalation that pages somebody twice is worse than one that pages them a second late. Also safe to call LATE — a rung that came due an hour ago fires now, and both fire_at and fired_at are recorded so the report shows the ladder ran behind rather than pretending it did not. Failed and stale-claimed firings are retried in the same pass with exponential backoff, never re-firing anything already fired. A rung whose action has no registered handler FAILS visibly instead of being recorded as a silent success. Returns 200 with the counters. Required: tenant_id.
[ "POST /api/auth/signup-tenant", "POST /api/sla/clocks/:clock_id/resume" ]
severity: info, warning, urgent, criticalfiring_state: claimed, fired, failed| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id is required | a required field is missing from the request |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"limit": 100,
"actor_id": "qa-runner"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"limit": 100,
"actor_id": "qa-runner"
}{
"success": true,
"data": {
"tick_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"limit": 100,
"actor_id": "qa-runner",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"clocks_scanned": "number",
"rungs_due": "number",
"rungs_fired": "number",
"rungs_failed": "number",
"rungs_skipped_duplicate": "number",
"rungs_retried": "number",
"errors": "array"
}
}sdk-social
POST/api/identity/social/:provider/callbackpublic
Consumes a verified social IdP assertion (google, apple or microsoft) and federates it into a ProjexCloud identity for the named tenant, returning the resolved person and session material. This path matches the gateway OAuth-callback allowlist regex, so it is deliberately public and takes no bearer token — trust rests entirely on the caller having already verified the provider claims. Edge cases: provider is enum-checked from the path and anything outside google|apple|microsoft is a 400; tenant_id and verified_claims.sub are both mandatory; email, email_verified and name are optional, so an assertion with an unverified or absent email still federates and may create an identity that cannot be matched to an existing person by email; a tenant_id that does not resolve surfaces as 404 via the "not found" message branch; repeat callbacks with the same provider subject resolve to the same person rather than creating duplicates.
[ "POST /api/auth/signup-tenant" ]
provider: google, apple, microsoft| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | provider must be google|apple|microsoft | The :provider path segment is not one of the three supported providers |
| 400 | ValidationError | tenant_id and verified_claims.sub are required | The body omits tenant_id, or verified_claims is missing or has no sub |
| 400 | ValidationError | <message containing "must include" or "required"> | consumeSocialIdToken throws a validation error about missing or malformed claims |
| 404 | NotFound | <entity> not found | consumeSocialIdToken throws an error whose message contains "not found" — e.g. tenant_id does not resolve, or the provider is not configured for that tenant |
| 500 | InternalError | InternalError | consumeSocialIdToken throws for any other reason (identity linking failure, database unreachable) |
{
"provider": "google"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"verified_claims": {
"sub": "{{dynamic:name}}",
"email": "{{dynamic:email}}",
"email_verified": true
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"verified_claims": {
"sub": "Acme QA Sample",
"email": "qa.user@example.com",
"email_verified": true
}
}{
"success": true,
"data": {
"callback_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"verified_claims": {
"sub": "Acme QA Sample",
"email": "qa.user@example.com",
"email_verified": true
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"data": {
"person_id": "string",
"jit_provisioned": true
}
}POST/api/social/handles🔒 auth
Authorizes a social network handle for a tenant and binds it to the persona permitted to act on it, returning 201 with the handle record. Edge cases: tenant_id, network, external_handle_id and authorized_persona_id are all mandatory and a missing one returns the generic "missing fields" ValidationError without naming the field; network is enum-checked against twitter, linkedin, instagram, facebook and tiktok and anything else is a separate "invalid network" 400; the route performs no duplicate check, so re-authorizing the same tenant + network + external_handle_id either creates a second row or trips a database unique constraint that surfaces as a 500 rather than a 409; tenant_id is read from the body rather than the JWT, so the route itself does not prevent naming another tenant; an authorized_persona_id that does not exist violates a foreign key at insert and also surfaces as 500.
[ "POST /api/auth/signup-tenant" ]
network: twitter, linkedin, instagram, facebook, tiktok| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | missing fields | Any of tenant_id, network, external_handle_id or authorized_persona_id is absent or empty |
| 400 | ValidationError | invalid network | network is supplied but is not one of twitter|linkedin|instagram|facebook|tiktok |
| 500 | InternalError | Internal Server Error | authorizeHandle throws — e.g. tenant_id or authorized_persona_id violates a foreign key, a duplicate handle trips a unique constraint, or the database is unreachable |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"network": "twitter",
"external_handle_id": "{{dynamic:name}}",
"authorized_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"network": "twitter",
"external_handle_id": "Acme QA Sample",
"authorized_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"handle_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"network": "twitter",
"external_handle_id": "Acme QA Sample",
"authorized_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"handle": {
"handle_id": "string",
"network": "string"
}
}
}POST/api/social/interactions🔒 auth
Ingests an inbound social interaction (DM, comment, mention or review) against a previously authorized handle, recording the external author id and optional message body, and returns 201 with the stored interaction. Edge cases: handle_id, kind and author_external_id are mandatory and a missing one returns the generic "missing fields" 400; kind is enum-checked against dm, comment, mention and review with a distinct "invalid kind" 400; body is optional so an empty-bodied interaction (for example a bare mention) is accepted; author_persona_id is optional and left null when the external author has not yet been resolved to a known persona; a handle_id that does not exist or was never authorized violates a foreign key at insert and surfaces as a 500 rather than a 404; there is no external-id de-duplication, so replaying the same webhook payload creates duplicate interactions.
[ "POST /api/auth/signup-tenant", "POST /api/social/handles" ]
kind: dm, comment, mention, review| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | missing fields | Any of handle_id, kind or author_external_id is absent or empty |
| 400 | ValidationError | invalid kind | kind is supplied but is not one of dm|comment|mention|review |
| 500 | InternalError | Internal Server Error | ingestInteraction throws — e.g. handle_id violates a foreign key because the handle was never authorized, or the database is unreachable |
{
"handle_id": "{{cache:social.handles.create.response.data.handle.handle_id}}",
"kind": "comment",
"author_external_id": "{{dynamic:name}}",
"author_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
"body": "Loving the new product launch!"
}{
"handle_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "comment",
"author_external_id": "Acme QA Sample",
"author_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"body": "Loving the new product launch!"
}{
"success": true,
"data": {
"interaction_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"handle_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "comment",
"author_external_id": "Acme QA Sample",
"author_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"body": "Loving the new product launch!",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"interaction": {
"interaction_id": "string",
"kind": "string"
}
}
}POST/api/social/interactions/:interaction_id/capture-lead🔒 auth
Converts a social interaction into a captured lead by linking it to a CRM contact, returning the updated interaction. Edge cases: contact_id is required in the body and a missing value is a 400 before any lookup; an unknown interaction_id returns 404 NotFound; the link is a plain overwrite, so re-capturing an interaction that is already linked replaces the previous contact_id rather than returning a conflict — repeating the same call is effectively idempotent, but pointing it at a different contact silently re-parents the lead; a contact_id that does not exist violates a foreign key at update time and surfaces as a 500 rather than a 404; a malformed (non-UUID) interaction_id fails the query cast and also surfaces as 500.
[ "POST /api/auth/signup-tenant", "POST /api/social/interactions" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | missing contact_id | contact_id is absent or empty in the request body |
| 404 | NotFound | NotFound | captureLead finds no interaction for the supplied interaction_id |
| 500 | InternalError | Internal Server Error | captureLead throws — e.g. contact_id violates a foreign key, interaction_id is not a valid UUID, or the database is unreachable |
{
"interaction_id": "{{cache:social.interactions.create.response.data.interaction.interaction_id}}"
}{
"contact_id": "{{dynamic:uuid}}"
}{
"contact_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"capture_lead_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"contact_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"interaction": {
"interaction_id": "string",
"captured_lead_contact_id": "string"
}
}
}sdk-source-record
GET/api/source-assertions🔒 auth
Return EVERY claim matching the filter, superseded rows INCLUDED by default. Hiding them would recreate exactly the last-write-wins behaviour this SDK exists to prevent; a caller that wants one value asks sdk-projection. Ordering is a hint, not a decision: PRIMARY then SURVIVES then ASSERTION then SUPERSEDED, tie-broken by origin trust, then confidence, then recency. effective_at takes the bitemporal slice — only claims whose effective period contains that instant. exclude_superseded=true is the opt-in narrow view. Values stay enveloped in the list; revealing an identifier is a separate, narrower operation. limit is clamped to 1..500 (default 100). Required: tenant_id query param.
[ "POST /api/auth/signup-tenant", "POST /api/source-records", "POST /api/source-assertions" ]
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINEDstatus: SURVIVES, ASSERTION, SUPERSEDED, PRIMARY| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query param is absent |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"source_assertion_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"assertions": "array",
"count": "number"
}
}POST/api/source-assertions🔒 auth
Write one claim: per this origin, over this effective period, the subject's <attribute> was <value>, retrieved at <retrieved_at>. The two time axes are deliberately separate — effective_from/effective_to say when the fact held in the world, retrieved_at says when we learned it — so a late-arriving source can correct history without pretending we knew earlier. Conflicting claims for the same subject+attribute from different origins are the NORMAL case and coexist; nothing here resolves a display value (that is sdk-projection). A value whose attribute is a direct identifier (email, phone, address, national_id, names, date_of_birth...) is envelope-encrypted BEFORE the insert, so the plaintext never reaches the column, the query log or a replica — the response returns value_encrypted:true and a vault_key_ref. Once written, the claim is immutable: correcting it means superseding it. Required: tenant_id, subject_ref, attribute, value, origin_class.
[ "POST /api/auth/signup-tenant", "POST /api/source-records" ]
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINEDstatus: SURVIVES, ASSERTION, SUPERSEDED, PRIMARY| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id, subject_ref, attribute, value and origin_class are required | any required field is missing from the body |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"capture_id": "{{cache:source-record.capture.response.data.source_record.capture_id}}",
"subject_ref": "{{cache:source-record.capture.response.data.source_record.subject_ref}}",
"attribute": "email",
"value": "{{dynamic:email}}",
"origin_class": "LICENSED_THIRD_PARTY",
"confidence": 0.62,
"effective_from": "{{dynamic:pastdatetime-30d}}",
"effective_to": "{{dynamic:futuredatetime+30d}}",
"retrieved_at": "{{dynamic:pastdatetime-1h}}",
"status": "ASSERTION",
"evidence_ref": "evidence:{{dynamic:uuid}}",
"is_pii": true,
"metadata": {
"batch": "{{dynamic:slug}}"
},
"actor_id": "qa-runner",
"purpose": "claim capture",
"causation_id": "{{dynamic:uuid}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"capture_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"attribute": "email",
"value": "qa.user@example.com",
"origin_class": "LICENSED_THIRD_PARTY",
"confidence": 0.62,
"effective_from": "2026-01-15T10:30:00Z",
"effective_to": "2026-01-15T10:30:00Z",
"retrieved_at": "2026-01-15T10:30:00Z",
"status": "ASSERTION",
"evidence_ref": "evidence:{{dynamic:uuid}}",
"is_pii": true,
"metadata": {
"batch": "sample-slug"
},
"actor_id": "qa-runner",
"purpose": "claim capture",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"source_assertion_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"capture_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subject_ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"attribute": "email",
"value": "qa.user@example.com",
"origin_class": "LICENSED_THIRD_PARTY",
"confidence": 0.62,
"effective_from": "2026-01-15T10:30:00Z",
"effective_to": "2026-01-15T10:30:00Z",
"retrieved_at": "2026-01-15T10:30:00Z",
"status": "ASSERTION",
"evidence_ref": "evidence:{{dynamic:uuid}}",
"is_pii": true,
"metadata": {
"batch": "sample-slug"
},
"actor_id": "qa-runner",
"purpose": "claim capture",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"assertion": {
"assertion_id": "string",
"status": "string",
"value_encrypted": "boolean"
}
}
}POST/api/source-assertions/:assertion_id/supersede🔒 auth
Replace a claim by inserting the successor and STAMPING the prior row (status=SUPERSEDED, superseded_by, superseded_at) in one transaction. The prior value, dates, origin and confidence are never touched — the database trigger rejects any other UPDATE and every DELETE — so both claims stay queryable with their original provenance. Supersede is one-way: a second attempt on an already-superseded claim returns 409, and a concurrent supersede rolls the transaction back rather than leaving an orphan successor. Any replacement field omitted is inherited from the prior claim (subject_ref, attribute, capture_id, origin_class). Returns 200: the response carries both rows. Required: tenant_id and the replacement value.
[ "POST /api/auth/signup-tenant", "POST /api/source-records", "POST /api/source-assertions" ]
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINEDstatus: SURVIVES, ASSERTION, SUPERSEDED, PRIMARY| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id and the replacement value are required | either field missing from the body |
| 404 | ASSERTION_NOT_FOUND | assertion <id> not found for tenant | the assertion does not exist for this tenant |
| 409 | ASSERTION_ALREADY_SUPERSEDED | assertion <id> was already superseded by <id> | the claim already names a successor — supersede is one-way |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"assertion_id": "{{cache:source-record.assertion.create.response.data.assertion.assertion_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"value": "{{dynamic:email}}",
"origin_class": "LICENSED_THIRD_PARTY",
"subject_ref": "{{cache:source-record.assertion.create.response.data.assertion.subject_ref}}",
"attribute": "email",
"capture_id": "{{cache:source-record.capture.response.data.source_record.capture_id}}",
"confidence": 0.81,
"effective_from": "{{dynamic:pastdatetime-1d}}",
"effective_to": "{{dynamic:futuredatetime+90d}}",
"retrieved_at": "{{dynamic:pastdatetime-10m}}",
"status": "ASSERTION",
"evidence_ref": "evidence:{{dynamic:uuid}}",
"is_pii": true,
"metadata": {
"correction": true
},
"reason": "the broker issued a correction",
"actor_id": "qa-runner",
"purpose": "claim correction",
"causation_id": "{{dynamic:uuid}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"value": "qa.user@example.com",
"origin_class": "LICENSED_THIRD_PARTY",
"subject_ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"attribute": "email",
"capture_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"confidence": 0.81,
"effective_from": "2026-01-15T10:30:00Z",
"effective_to": "2026-01-15T10:30:00Z",
"retrieved_at": "2026-01-15T10:30:00Z",
"status": "ASSERTION",
"evidence_ref": "evidence:{{dynamic:uuid}}",
"is_pii": true,
"metadata": {
"correction": true
},
"reason": "the broker issued a correction",
"actor_id": "qa-runner",
"purpose": "claim correction",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"supersede_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"value": "qa.user@example.com",
"origin_class": "LICENSED_THIRD_PARTY",
"subject_ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"attribute": "email",
"capture_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"confidence": 0.81,
"effective_from": "2026-01-15T10:30:00Z",
"effective_to": "2026-01-15T10:30:00Z",
"retrieved_at": "2026-01-15T10:30:00Z",
"status": "ASSERTION",
"evidence_ref": "evidence:{{dynamic:uuid}}",
"is_pii": true,
"metadata": {
"correction": true
},
"reason": "the broker issued a correction",
"actor_id": "qa-runner",
"purpose": "claim correction",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"prior": {
"status": "string",
"superseded_by": "string"
},
"replacement": {
"assertion_id": "string"
}
}
}GET/api/source-records🔒 auth
List a tenant's captures, newest first. Every filter is optional and ANDed: trust_state and origin_class narrow by the ENUM columns, source_system and subject_ref by exact match. limit is clamped to 1..500 (default 50) so an unbounded page can never be requested, and offset is floored at 0. Returns an empty array — not a 404 — when nothing matches. Required: tenant_id query param.
[ "POST /api/auth/signup-tenant", "POST /api/source-records" ]
trust_state: P0_CAPTURED, P1_NORMALIZED, P2_CANDIDATE, P3_LINKED, P4_DIRECTorigin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINED| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query param is absent |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"source_record_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"source_records": "array",
"count": "number"
}
}POST/api/source-records🔒 auth
Land one retrieval from one source system as an immutable capture at trust state P0_CAPTURED. raw_evidence is stored exactly as received and is frozen by a database trigger, as is origin_class. Idempotent on the content fingerprint: a repeat of the same payload for the same tenant returns the ORIGINAL capture with created=false instead of forking a second lineage (the fingerprint is derived from the payload with object keys sorted, so a re-serialized body still dedupes). Edge case that matters: an absent or unrecognised origin_class does NOT default to anything plausible — the record lands UNKNOWN_QUARANTINED with quarantine_reason set and quarantined=true, and a quarantined record is refused every later promotion. Required: tenant_id, source_system, raw_evidence.
[ "POST /api/auth/signup-tenant" ]
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINEDevidence_kind: RAW_PAYLOAD, API_RESPONSE, DOCUMENT, SCREENSHOT, LICENSE_TERMS, CONSENT_RECEIPT, SIGNATURE, OTHERtrust_state: P0_CAPTURED, P1_NORMALIZED, P2_CANDIDATE, P3_LINKED, P4_DIRECT| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id, source_system and raw_evidence are required | any of the three required fields is missing from the body |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"source_system": "registry-{{dynamic:slug}}",
"raw_evidence": {
"record_id": "{{dynamic:uuid}}",
"email": "{{dynamic:email}}",
"display_name": "{{dynamic:name}}"
},
"fingerprint": "fp-{{dynamic:uuid}}",
"source_external_id": "ext-{{dynamic:slug}}",
"origin_class": "PUBLIC_RECORD",
"evidence_kind": "API_RESPONSE",
"evidence_ref": "evidence:{{dynamic:uuid}}",
"subject_ref": "subject:{{dynamic:uuid}}",
"retrieved_at": "{{dynamic:pastdatetime-1h}}",
"metadata": {
"ingest_batch": "{{dynamic:slug}}"
},
"actor_id": "qa-runner",
"purpose": "provenance capture",
"causation_id": "{{dynamic:uuid}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"source_system": "registry-{{dynamic:slug}}",
"raw_evidence": {
"record_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "qa.user@example.com",
"display_name": "Acme QA Sample"
},
"fingerprint": "fp-{{dynamic:uuid}}",
"source_external_id": "ext-{{dynamic:slug}}",
"origin_class": "PUBLIC_RECORD",
"evidence_kind": "API_RESPONSE",
"evidence_ref": "evidence:{{dynamic:uuid}}",
"subject_ref": "subject:{{dynamic:uuid}}",
"retrieved_at": "2026-01-15T10:30:00Z",
"metadata": {
"ingest_batch": "sample-slug"
},
"actor_id": "qa-runner",
"purpose": "provenance capture",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"source_record_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"source_system": "registry-{{dynamic:slug}}",
"raw_evidence": {
"record_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "qa.user@example.com",
"display_name": "Acme QA Sample"
},
"fingerprint": "fp-{{dynamic:uuid}}",
"source_external_id": "ext-{{dynamic:slug}}",
"origin_class": "PUBLIC_RECORD",
"evidence_kind": "API_RESPONSE",
"evidence_ref": "evidence:{{dynamic:uuid}}",
"subject_ref": "subject:{{dynamic:uuid}}",
"retrieved_at": "2026-01-15T10:30:00Z",
"metadata": {
"ingest_batch": "sample-slug"
},
"actor_id": "qa-runner",
"purpose": "provenance capture",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"source_record": {
"capture_id": "string",
"trust_state": "string",
"origin_class": "string"
},
"created": "boolean"
}
}GET/api/source-records/:capture_id🔒 auth
Read a single capture and every external identifier crosswalked to it. The crosswalks are returned inline because an external id is what makes the capture re-findable in the system it came from — reading one without the other is almost always a bug. 404 when the capture does not belong to the tenant, so a capture id from another tenant is indistinguishable from one that does not exist. Required: tenant_id query param.
[ "POST /api/auth/signup-tenant", "POST /api/source-records" ]
trust_state: P0_CAPTURED, P1_NORMALIZED, P2_CANDIDATE, P3_LINKED, P4_DIRECTorigin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINED| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query param is absent |
| 404 | SOURCE_RECORD_NOT_FOUND | capture <id> not found for tenant | the capture does not exist, or belongs to another tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"capture_id": "{{cache:source-record.capture.response.data.source_record.capture_id}}"
}{
"success": true,
"data": {
"source_record_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"source_record": {
"capture_id": "string"
},
"crosswalks": "array"
}
}POST/api/source-records/:capture_id/crosswalks🔒 auth
Record the external system + id this capture came from, kept forever. The pair is immutable and the row is undeletable by database trigger: overwriting a crosswalk would silently break the link back to the source system. Idempotent per (tenant, external_system, external_id) — re-linking the same external identity returns the EXISTING crosswalk rather than erroring, which is what makes a retried import safe. Required: tenant_id, external_system, external_id.
[ "POST /api/auth/signup-tenant", "POST /api/source-records" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id, external_system and external_id are required | any of the three required fields is missing |
| 404 | SOURCE_RECORD_NOT_FOUND | capture <id> not found for tenant | the capture does not exist for this tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"capture_id": "{{cache:source-record.capture.response.data.source_record.capture_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"external_system": "registry-{{dynamic:slug}}",
"external_id": "EXT-{{dynamic:uuid}}",
"subject_ref": "{{cache:source-record.capture.response.data.source_record.subject_ref}}",
"metadata": {
"linked_by": "qa-runner"
},
"actor_id": "qa-runner",
"purpose": "crosswalk linkage",
"causation_id": "{{dynamic:uuid}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"external_system": "registry-{{dynamic:slug}}",
"external_id": "EXT-{{dynamic:uuid}}",
"subject_ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"metadata": {
"linked_by": "qa-runner"
},
"actor_id": "qa-runner",
"purpose": "crosswalk linkage",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"crosswalk_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"external_system": "registry-{{dynamic:slug}}",
"external_id": "EXT-{{dynamic:uuid}}",
"subject_ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"metadata": {
"linked_by": "qa-runner"
},
"actor_id": "qa-runner",
"purpose": "crosswalk linkage",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"crosswalk": {
"crosswalk_id": "string",
"external_system": "string",
"external_id": "string"
}
}
}POST/api/source-records/:capture_id/normalize🔒 auth
Extract structured fields from the capture and move it P0_CAPTURED -> P1_NORMALIZED. Extraction is delegated to sdk-parsing through an injected hook; with no parser wired the default is a structural pass-through, so the rung still advances honestly rather than failing closed. The result lands in `normalized` ALONGSIDE raw_evidence, which the database refuses to let anyone overwrite. Idempotent: re-normalizing an already-normalized capture refreshes `normalized` and returns 200 without appending a second transition to the audit chain, because the trust state did not move. Refused with 409 for a quarantined capture — a record with no established provenance does not climb. Returns 200, not 201: it moves an existing resource. Required: tenant_id.
[ "POST /api/auth/signup-tenant", "POST /api/source-records" ]
trust_state: P0_CAPTURED, P1_NORMALIZED, P2_CANDIDATE, P3_LINKED, P4_DIRECT| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id is required | tenant_id missing from the body |
| 404 | SOURCE_RECORD_NOT_FOUND | capture <id> not found for tenant | the capture does not exist for this tenant |
| 409 | RECORD_QUARANTINED | capture <id> is quarantined and cannot be promoted | the capture landed UNKNOWN_QUARANTINED because its provenance was never established |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"capture_id": "{{cache:source-record.capture.response.data.source_record.capture_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"actor_id": "qa-runner",
"purpose": "normalization",
"causation_id": "{{dynamic:uuid}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor_id": "qa-runner",
"purpose": "normalization",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"normalize_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor_id": "qa-runner",
"purpose": "normalization",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"source_record": {
"trust_state": "string"
}
}
}POST/api/source-records/:capture_id/promote🔒 auth
Promote a capture exactly ONE rung, if that rung's evidence requirement is met. Skipping a rung or moving backwards is refused with 409 INVALID_TRUST_TRANSITION listing what IS allowed. Per-rung requirements: P2_CANDIDATE needs a normalized payload; P3_LINKED needs a subject_ref; P4_DIRECT needs an evidence_ref whose evidence_origin_class is FIRST-PARTY (USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM or USER_AUTHORIZED_CONTACT_STORE). A LICENSED_THIRD_PARTY or PUBLIC_RECORD evidence reference does NOT satisfy P4 and is refused with a typed 422 naming the missing first-party evidence — P4 asserts the subject themselves told us, so bought data can never reach it. Every refusal leaves the record where it was and appends NO audit entry claiming promotion. Concurrent double-promotion is a no-op for the loser (the update is guarded on the current state). Returns 200: it moves an existing resource. Required: tenant_id, to_state.
[ "POST /api/auth/signup-tenant", "POST /api/source-records", "POST /api/source-records/:capture_id/normalize" ]
to_state: P0_CAPTURED, P1_NORMALIZED, P2_CANDIDATE, P3_LINKED, P4_DIRECTevidence_origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINEDtrust_state: P0_CAPTURED, P1_NORMALIZED, P2_CANDIDATE, P3_LINKED, P4_DIRECT| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id and to_state are required | either field missing from the body |
| 404 | SOURCE_RECORD_NOT_FOUND | capture <id> not found for tenant | the capture does not exist for this tenant |
| 409 | RECORD_QUARANTINED | capture <id> is quarantined and cannot be promoted | the capture has no established provenance (UNKNOWN_QUARANTINED) |
| 409 | INVALID_TRUST_TRANSITION | <from> -> <to> is not a legal promotion | the requested state skips a rung, repeats one or moves down the ladder |
| 422 | NORMALIZATION_REQUIRED | cannot promote <id> P1_NORMALIZED -> P2_CANDIDATE: missing normalized payload | promoting to P2_CANDIDATE before the capture has been normalized |
| 422 | SUBJECT_REF_REQUIRED | cannot promote <id> P2_CANDIDATE -> P3_LINKED: missing subject_ref | promoting to P3_LINKED with no subject to link to |
| 422 | FIRST_PARTY_EVIDENCE_REQUIRED | cannot promote <id> P3_LINKED -> P4_DIRECT: missing first-party evidence reference | promoting to P4_DIRECT with no evidence_ref, or with evidence whose origin is not first-party |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"capture_id": "{{cache:source-record.capture.response.data.source_record.capture_id}}"
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"to_state": "P2_CANDIDATE",
"subject_ref": "{{cache:source-record.capture.response.data.source_record.subject_ref}}",
"evidence_ref": "evidence:{{dynamic:uuid}}",
"evidence_origin_class": "FIRST_PARTY_DIRECT",
"actor_id": "qa-runner",
"purpose": "trust promotion",
"decision_ref": "decision:{{dynamic:uuid}}",
"causation_id": "{{dynamic:uuid}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"to_state": "P2_CANDIDATE",
"subject_ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"evidence_ref": "evidence:{{dynamic:uuid}}",
"evidence_origin_class": "FIRST_PARTY_DIRECT",
"actor_id": "qa-runner",
"purpose": "trust promotion",
"decision_ref": "decision:{{dynamic:uuid}}",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"promote_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"to_state": "P2_CANDIDATE",
"subject_ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"evidence_ref": "evidence:{{dynamic:uuid}}",
"evidence_origin_class": "FIRST_PARTY_DIRECT",
"actor_id": "qa-runner",
"purpose": "trust promotion",
"decision_ref": "decision:{{dynamic:uuid}}",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"source_record": {
"trust_state": "string"
}
}
}GET/api/source-rights/attestations🔒 auth
List a tenant's signed attestations, newest first, optionally narrowed to one capture or one origin class. Returns an empty array rather than a 404 when nothing matches. limit is clamped to 1..500 (default 50). Required: tenant_id query param.
[ "POST /api/auth/signup-tenant", "POST /api/source-records", "POST /api/source-rights/attestations" ]
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINED| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query param is absent |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"attestation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"attestations": "array",
"count": "number"
}
}POST/api/source-rights/attestations🔒 auth
Record and sign the answer to "were we allowed to hold this, and what may we do with it?". The signature covers the TERMS, not just the row: source fingerprint, mapping version and the normalized permitted-use set, so a later claim of broader permissions cannot pass as the original grant. permitted_uses is trimmed, lower-cased, deduped and sorted before signing, so the same grant always signs identically. HARD RULE: an origin_class of LICENSED_THIRD_PARTY or PARTNER_PROVIDED is REFUSED with 422 unless an evidence blob reference is present — bought or partner-supplied data must carry its paperwork, and the refusal happens before anything is written. evidence_payload is captured through sdk-evidence when that bridge is wired; with no bridge it does NOT satisfy the rule, because a fabricated blob id is exactly the failure this prevents. The row is immutable once signed. Either capture_id or source_fingerprint must be present to bind the signature to. Required: tenant_id, attestor_principal, origin_class, permitted_uses[].
[ "POST /api/auth/signup-tenant", "POST /api/source-records" ]
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINEDevidence_kind: RAW_PAYLOAD, API_RESPONSE, DOCUMENT, SCREENSHOT, LICENSE_TERMS, CONSENT_RECEIPT, SIGNATURE, OTHER| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id, attestor_principal, origin_class and permitted_uses[] are required | any required field is missing, or permitted_uses is not an array |
| 422 | ATTESTATION_EVIDENCE_REQUIRED | origin_class LICENSED_THIRD_PARTY requires an evidence blob reference before it can be attested | origin_class is LICENSED_THIRD_PARTY or PARTNER_PROVIDED and no evidence blob reference resolved |
| 422 | SOURCE_FINGERPRINT_REQUIRED | an attestation needs either a capture_id to read the fingerprint from, or an explicit source_fingerprint | neither capture_id nor source_fingerprint identifies what the signature covers |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"attestor_principal": "principal:compliance-officer",
"origin_class": "LICENSED_THIRD_PARTY",
"permitted_uses": [
"outreach",
"analytics"
],
"capture_id": "{{cache:source-record.capture.response.data.source_record.capture_id}}",
"source_fingerprint": "{{cache:source-record.capture.response.data.source_record.fingerprint}}",
"jurisdiction": "EU",
"license_ref": "LIC-{{dynamic:slug}}",
"collection_period_start": "{{dynamic:pastdatetime-30d}}",
"collection_period_end": "{{dynamic:futuredatetime+365d}}",
"evidence_blob_ref": "evidence:{{dynamic:uuid}}",
"evidence_kind": "LICENSE_TERMS",
"mapping_version": "map-{{dynamic:slug}}",
"metadata": {
"reviewed_by": "qa-runner"
},
"purpose": "rights attestation",
"causation_id": "{{dynamic:uuid}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"attestor_principal": "principal:compliance-officer",
"origin_class": "LICENSED_THIRD_PARTY",
"permitted_uses": [
"outreach",
"analytics"
],
"capture_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"source_fingerprint": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"jurisdiction": "EU",
"license_ref": "LIC-{{dynamic:slug}}",
"collection_period_start": "2026-01-15T10:30:00Z",
"collection_period_end": "2026-01-15T10:30:00Z",
"evidence_blob_ref": "evidence:{{dynamic:uuid}}",
"evidence_kind": "LICENSE_TERMS",
"mapping_version": "map-{{dynamic:slug}}",
"metadata": {
"reviewed_by": "qa-runner"
},
"purpose": "rights attestation",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"attestation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"attestor_principal": "principal:compliance-officer",
"origin_class": "LICENSED_THIRD_PARTY",
"permitted_uses": [
"outreach",
"analytics"
],
"capture_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"source_fingerprint": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"jurisdiction": "EU",
"license_ref": "LIC-{{dynamic:slug}}",
"collection_period_start": "2026-01-15T10:30:00Z",
"collection_period_end": "2026-01-15T10:30:00Z",
"evidence_blob_ref": "evidence:{{dynamic:uuid}}",
"evidence_kind": "LICENSE_TERMS",
"mapping_version": "map-{{dynamic:slug}}",
"metadata": {
"reviewed_by": "qa-runner"
},
"purpose": "rights attestation",
"causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"attestation": {
"attestation_id": "string",
"signature": "string",
"origin_class": "string"
}
}
}GET/api/source-rights/attestations/:attestation_id🔒 auth
Read one signed attestation with its full terms: attestor principal, permitted uses, jurisdiction, licence reference, collection period, evidence blob reference and the signature over them. 404 when the attestation belongs to another tenant, so an id from elsewhere is indistinguishable from one that does not exist. Required: tenant_id query param.
[ "POST /api/auth/signup-tenant", "POST /api/source-records", "POST /api/source-rights/attestations" ]
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINEDevidence_kind: RAW_PAYLOAD, API_RESPONSE, DOCUMENT, SCREENSHOT, LICENSE_TERMS, CONSENT_RECEIPT, SIGNATURE, OTHER| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id query param required | the tenant_id query param is absent |
| 404 | ATTESTATION_NOT_FOUND | attestation <id> not found for tenant | the attestation does not exist, or belongs to another tenant |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"attestation_id": "{{cache:source-record.attestation.create.response.data.attestation.attestation_id}}"
}{
"success": true,
"data": {
"attestation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"attestation": {
"attestation_id": "string",
"permitted_uses": "array"
}
}
}GET/api/source-rights/permitted-use🔒 auth
The refusal mechanism, over HTTP: a consumer asks whether the source rights cover a purpose BEFORE acting, which is what keeps licensed data out of an incompatible use. FAILS CLOSED at every step — no attestation (NO_ATTESTATION), a purpose outside the attested set (PURPOSE_NOT_ATTESTED), or a lapsed collection period (COLLECTION_PERIOD_LAPSED) all return permitted:false with the reason named and the uses that ARE granted, so the caller sees its next best option. Absence of evidence is never read as permission. Scope the question by subject_ref (checks every attestation covering captures linked to that subject; ANY grant suffices, since each covers its own source), or by capture_id / source_fingerprint for one source. `at` defaults to now. ALWAYS 200 — "not permitted" is a successful answer to the question, not a failed request. Required: tenant_id, purpose.
[ "POST /api/auth/signup-tenant", "POST /api/source-records", "POST /api/source-rights/attestations" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | VALIDATION_ERROR | tenant_id and purpose query params are required | either query param is absent |
| 401 | Unauthorized | missing or invalid token | no valid tenant Bearer token on the request |
{
"success": true,
"data": [
{
"permitted_use_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"permitted": "boolean",
"permitted_uses": "array"
}
}sdk-storm
GET/api/storm/overlaypublic
P7 FR-STM-1..4 / AC-4 — public storm overlay query. Returns the storm events and their intensity-cell counts overlapping the requested bounding box, optionally narrowed to events since an ISO timestamp. QA edge cases: all four bbox corners are mandatory and are parsed with parseFloat, so missing, empty or non-numeric values ('abc', '') all become NaN and hit the same 400 'min_lat, min_lng, max_lat, max_lng are required floats'; note parseFloat is lenient, so '24abc' parses to 24 and is accepted. A second 400 guards inverted boxes (min_lat > max_lat or min_lng > max_lng); a degenerate zero-area box where min equals max is allowed. Out-of-earth-range coordinates are NOT rejected — the handler enforces ordering only, so lat 999 simply returns an empty events array. `since` is optional and passed through untouched; a bbox with no storms returns 200 with events:[] rather than 404. The query is read-only and idempotent, has no pagination or result cap, and is not tenant-scoped. '/api/storm/overlay' is on the authGate.ts PUBLIC_EXACT allowlist, so it is deliberately unauthenticated — no bearer token is needed and no 401 is ever returned (matching requiresAuth:false).
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 400 | ValidationError | min_lat, min_lng, max_lat, max_lng are required floats | Any of the four bbox query params is absent, empty, or not parseable by parseFloat (result is not finite) |
| 400 | ValidationError | min must be less than max for both lat and lng | All four parse as finite numbers but the box is inverted — min_lat > max_lat or min_lng > max_lng |
| 500 | StormQueryFailed | <error message thrown by queryStormByBbox> | queryStormByBbox throws — e.g. an unparseable `since` value reaching the query, the storm schema/PostGIS being unavailable, or the DB pool failing |
{
"success": true,
"data": [
{
"overlay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"events": "array",
"cell_count": "number"
}{
"success": true,
"data": [
{
"overlay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 400.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"events": "array",
"cell_count": "number"
}{
"success": true,
"data": [
{
"overlay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 400.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"events": "array",
"cell_count": "number"
}sdk-taxonomy
GET/api/taxonomy/extraction-schemas🔒 auth
Looks up the active extraction schema for a document_kind, resolving the tenant-specific override first and falling back to the platform-default schema when tenant_id is omitted or has no override. Edge cases: document_kind is a required query param and its absence is a 400; tenant_id is optional and defaults to null, which selects the global schema — passing a tenant with no override transparently returns the global one rather than 404; a document_kind with no active schema at either level returns 404 with a success:false envelope (note this SDK uses {success, data} rather than the {data} envelope used elsewhere); only the active version is returned, so a schema that exists but was deactivated reads as 404; all datastore failures collapse into a generic 500 "Lookup failed" with no detail.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | Missing query param: document_kind | The document_kind query param is absent |
| 404 | NotFound | No active extraction schema for document_kind | No active extraction schema exists for the document_kind at either the tenant or the global level |
| 500 | InternalError | Lookup failed | lookupExtractionSchema throws — database unreachable or the query fails |
{
"success": true,
"data": [
{
"extraction_schema_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true,
"data": [
{
"extraction_schema_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 400.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}GET/api/taxonomy/healthpublic
Liveness probe for sdk-taxonomy, returning { sdk: "sdk-taxonomy", status: "ok" }. This route registers no requireAuth preHandler and its path ends in /health, which the gateway default-deny auth gate treats as public, so it answers 200 with no bearer token — QA should assert it is reachable unauthenticated. Edge cases: the response is a static literal that does not touch Postgres or any taxonomy table, so it stays 200 even when the taxonomy datastore is down — it must not be used as a readiness or dependency-health signal; it accepts no parameters, ignores query strings, and has no error branch of its own.
{
"success": true,
"data": [
{
"health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"sdk": "sdk-taxonomy",
"status": "ok"
}GET/api/taxonomy/prompt-templates🔒 auth
Looks up the active prompt template for a purpose_tag, optionally narrowed by template name, resolving a tenant-specific override before the platform default. Edge cases: purpose_tag is a required query param and its absence is a 400 while name is optional — supplying a name that does not exist narrows the lookup to nothing and returns 404 rather than falling back to the unnamed template for that purpose_tag; tenant_id is optional and defaults to null, selecting the global template, so a tenant with no override silently receives the global one; only active templates are matched, so a deactivated template reads as 404; the response uses the {success, data} envelope; any datastore error collapses into a generic 500 "Lookup failed".
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | Missing query param: purpose_tag | The purpose_tag query param is absent |
| 404 | NotFound | No active prompt template for purpose_tag | No active prompt template matches the purpose_tag (and name, when supplied) at either the tenant or the global level |
| 500 | InternalError | Lookup failed | lookupPromptTemplate throws — database unreachable or the query fails |
{
"success": true,
"data": [
{
"prompt_template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true,
"data": [
{
"prompt_template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 400.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}POST/api/taxonomy/versions/:taxonomy_version_id/activate🔒 auth
Activates a taxonomy version by id, making it the live version and recording the JWT subject as the actor (falling back to "system" when the token carries no sub). Edge cases: an unknown taxonomy_version_id is detected only by string-matching "not found" in the thrown error message and returned as 404 — any other service error, including attempting to activate a version that is already active or one belonging to a superseded taxonomy, collapses into a generic 500 "Activation failed" with no distinguishing detail; activation supersedes the previously active version rather than erroring, so ordering matters; a malformed (non-UUID) id fails the query cast and also surfaces as 500; the route's own missing-path-param 400 is effectively unreachable because Fastify will not match the route without the segment.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 400 | ValidationError | Missing path param: taxonomy_version_id | The :taxonomy_version_id path segment resolves to an empty value (not normally reachable — Fastify would not match the route) |
| 404 | NotFound | <error message containing "not found"> | activateTaxonomyVersion throws an error whose message contains "not found" — the taxonomy version id does not exist |
| 500 | InternalError | Activation failed | activateTaxonomyVersion throws for any other reason (invalid state transition, constraint violation, database unreachable) |
{
"entity": "taxonomy.version",
"field": "status",
"flow": [
"draft",
"active",
"deprecated",
"retired"
],
"transitions": [
{
"from": "draft",
"to": "active",
"via": "POST /api/taxonomy/versions/:taxonomy_version_id/activate"
},
{
"from": "active",
"to": "deprecated",
"via": "POST /api/taxonomy/versions/:taxonomy_version_id/activate"
}
]
}{
"taxonomy_version_id": "{{var:taxonomy_version_id}}"
}{
"taxonomy_version_id": "{{var:taxonomy_version_id}}"
}{
"taxonomy_version_id": "{{var:taxonomy_version_id}}"
}{
"success": true,
"data": {
"activate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"taxonomy_version_id": "{{var:taxonomy_version_id}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-tenant
POST/api/geo-nodes🔒 auth
Creates a node in the tenant.geo_node residency tree (region > country > state > city > locality), used to place tenants for data-residency separately from pool_index. Requires name and a kind from the CHECK-constrained enum; residency_class defaults to 'open', code and parent_geo_node_id are optional. QA edge cases: the validator only rejects a missing/empty name, an out-of-enum kind, and an out-of-enum residency_class — everything else falls through to Postgres. There is NO unique constraint on (kind, code) or name, so re-POSTing the same payload creates a second distinct geo_node_id (non-idempotent, duplicates are silently allowed). A parent_geo_node_id that does not exist violates the self-referencing FK and is remapped to 400 ValidationError (not 404). A parent_geo_node_id that is not a valid UUID string yields 500 InternalError, since the uncaught() helper only special-cases 'violates foreign key' / 'duplicate key' / 'not found' text. Oversized name/code have no length cap (TEXT columns). The route is NOT tenant-scoped: geo_node rows are platform-global, so any valid tenant JWT can create nodes visible to every tenant; only the presence of a valid JWT is checked, never a role or ADMIN_OPS_TOKEN.
[ "POST /api/auth/register" ]
kind: region, country, state, city, localityresidency_class: open, regulated, sovereign| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization header or a non-Bearer scheme. Enforced by the api-gateway default-deny authGate (/api/geo-nodes is not on the public allowlist) and again by the route's requireAuth preHandler. |
| 401 | Unauthorized | Invalid or expired token | verifyJwt() rejects the token — bad signature, wrong JWT_SECRET, or expired exp. |
| 400 | ValidationError | body must be an object | The request body is null, absent, or a non-object JSON scalar/array. |
| 400 | ValidationError | name is required | name is missing, not a string, or an empty string (validateCreateGeoNode collects this in details[]). |
| 400 | ValidationError | kind must be one of region, country, state, city, locality | kind is missing or outside the enum. Returned alongside any other collected errors in the same details[] array. |
| 400 | ValidationError | residency_class must be one of open, regulated, sovereign | residency_class is supplied as a string outside the enum. A missing residency_class is fine — it defaults to 'open'. |
| 400 | ValidationError | insert or update on table "geo_node" violates foreign key constraint | parent_geo_node_id is a well-formed UUID that has no matching tenant.geo_node row. uncaught() maps 'violates foreign key' to 400, not 404. |
| 409 | Conflict | duplicate key value violates unique constraint | Any unique-index violation on tenant.geo_node. Today only the geo_node_id primary key is unique and it is server-generated by gen_random_uuid(), so this branch of uncaught() is effectively unreachable for this endpoint — listed because the mapping exists in the shared handler. |
| 500 | InternalError | InternalError | Any DB error whose message does not contain 'violates foreign key', 'duplicate key', or 'not found' — most commonly parent_geo_node_id being a non-UUID string ('invalid input syntax for type uuid'), or a connection-pool failure. Also returned by the route-level try/catch in sdk-tenant registerRoutes. |
{
"kind": "region",
"code": "us-east-1",
"name": "{{dynamic:name}}",
"residency_class": "open"
}{
"kind": "region",
"code": "us-east-1",
"name": "Acme QA Sample",
"residency_class": "open"
}{
"success": true,
"data": {
"geo_node_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"kind": "region",
"code": "us-east-1",
"name": "Acme QA Sample",
"residency_class": "open",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"geo_node": {
"geo_node_id": "string",
"kind": "string"
}
}
}POST/api/resellers🔒 auth
Creates a first-class reseller (white-label brand + commission config) in tenant.reseller and emits reseller.created.v1 to the admin audit pool. Requires org_id and brand_name; invoice_aggregation defaults to 'per-tenant' and cname_host, support_contact and commission_rules are optional (the last two default to {} when omitted or non-object). QA edge cases: org_id must reference an existing tenant.org row — a valid-but-unknown UUID trips the FK and is reported as 400 ValidationError, while a non-UUID string produces a 500 because uncaught() only text-matches 'violates foreign key' / 'duplicate key' / 'not found'. There is NO unique constraint on brand_name or cname_host, so repeat POSTs create additional reseller_id rows (non-idempotent; duplicate brands and colliding CNAME hosts are accepted). support_contact and commission_rules are stored as opaque JSONB with no schema check — bad emails, negative or >100 commission percentages all persist. invoice_aggregation is double-guarded: the validator rejects out-of-enum values with 400, and the column CHECK would otherwise reject them. The endpoint is not tenant-scoped or admin-gated: any caller with a valid tenant JWT can create a reseller against any org_id.
[ "POST /api/auth/signup-tenant" ]
invoice_aggregation: per-tenant, consolidated| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization header or a non-Bearer scheme. Applied by the gateway default-deny authGate (/api/resellers is not public) and by the route's requireAuth preHandler. |
| 401 | Unauthorized | Invalid or expired token | verifyJwt() throws on the presented token — tampered signature or expired exp. |
| 400 | ValidationError | body must be an object | Request body is null, missing, or not a JSON object. |
| 400 | ValidationError | org_id is required | org_id is absent, not a string, or empty. |
| 400 | ValidationError | brand_name is required | brand_name is absent, not a string, or empty. Returned in the same details[] array as any other collected validation errors. |
| 400 | ValidationError | invoice_aggregation must be one of per-tenant, consolidated | invoice_aggregation is supplied as a string outside the enum. Omitting it is valid — the service defaults to 'per-tenant'. |
| 400 | ValidationError | insert or update on table "reseller" violates foreign key constraint | org_id is a well-formed UUID with no matching tenant.org row (FK is ON DELETE RESTRICT). uncaught() maps FK violations to 400, not 404. |
| 409 | Conflict | duplicate key value violates unique constraint | Any unique-index violation on tenant.reseller. Only reseller_id (server-generated UUID PK) is unique today, so this branch is effectively unreachable here — listed because the shared uncaught() handler maps it. |
| 500 | InternalError | InternalError | Any DB error not matching the FK/duplicate/not-found text — chiefly org_id being a non-UUID string ('invalid input syntax for type uuid'), or pool exhaustion. Also the route-level catch in sdk-tenant registerRoutes. Note the reseller.created.v1 emitEvent is awaited inside createReseller, so an audit-pool failure fails the whole request with 500 even though the row was inserted. |
{
"org_id": "{{cache:auth.signup-tenant.response.data.org_id}}",
"brand_name": "{{dynamic:name}}",
"cname_host": "reseller.example.com",
"support_contact": {
"email": "support@reseller.example.com",
"phone": "+1-555-0100"
},
"commission_rules": {
"default_pct": 15
},
"invoice_aggregation": "consolidated"
}{
"org_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"brand_name": "Acme QA Sample",
"cname_host": "reseller.example.com",
"support_contact": {
"email": "support@reseller.example.com",
"phone": "+1-555-0100"
},
"commission_rules": {
"default_pct": 15
},
"invoice_aggregation": "consolidated"
}{
"success": true,
"data": {
"reseller_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"org_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"brand_name": "Acme QA Sample",
"cname_host": "reseller.example.com",
"support_contact": {
"email": "support@reseller.example.com",
"phone": "+1-555-0100"
},
"commission_rules": {
"default_pct": 15
},
"invoice_aggregation": "consolidated",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"reseller": {
"reseller_id": "string",
"org_id": "string",
"brand_name": "string",
"cname_host": "string",
"support_contact": {},
"commission_rules": {},
"invoice_aggregation": "string",
"portfolio_kill_switch": false,
"created_at": "string"
}
}
}GET/api/role-templates🔒 auth
Lists role templates for one app. tenant.role_template is keyed (tenant_id, app_id, name) with a NULLABLE tenant_id, and that nullability carries the whole model: a NULL row is the PLATFORM DEFAULT shipped with the app (unique on app_id+name) and a tenant_id row is that tenant OVERRIDING the same role name (unique on tenant_id+app_id+name). A tenant can therefore redefine what Manager means without forking the app, and a tenant that never touches it keeps inheriting. The response must let a client tell the two apart WITHOUT comparing tenant_id itself — the roles screen renders inherited rows read-only and offers override as a deliberate action, and if it had to infer origin client-side the two would eventually be confused and every tenant would end up owning a private copy of every default role, which is exactly the fork the nullable column exists to avoid. parent_role_template_id is returned so the inheritance chain can be shown and a permission traced to the ancestor that grants it.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | authentication required | role templates describe who may do what inside a tenant, so the list is never public |
| 400 | ValidationError | app_id is required | templates are per-app; an unscoped list would mix roles from apps the caller may not administer |
{
"success": true,
"data": [
{
"role_template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {}
}POST/api/tenants🔒 auth
Provisions a top-level tenant. app_id, display_name and region are required; isolation_tier (S | P | G) plus the pool/reseller/geo/brand fields are optional. Edge cases: isolation_tier is whitelist-checked, so an unknown tier is a 400, while all other optional fields are passed through unvalidated; a parent_tenant_id, reseller_id or geo_node_id referencing no row is caught as a foreign-key violation and mapped to 400 ValidationError (NOT 404); a duplicate natural key such as brand_domain maps to 409 Conflict; module_subscriptions must be an array or it is silently dropped; a whitespace-only display_name trims to empty and is rejected.
[ "POST /api/auth/signup-tenant" ]
isolation_tier: S, P, G| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | app_id is required / display_name is required / region is required / isolation_tier must be one of S, P, G | any validateCreateTenant check fails; all failures are returned together in details[] |
| 400 | ValidationError | <pg error: violates foreign key constraint ...> | reseller_id, geo_node_id or parent_tenant_id references a row that does not exist |
| 409 | Conflict | <pg error: duplicate key value violates unique constraint ...> | a unique constraint (e.g. brand_domain) is violated |
| 404 | NotFound | <error text containing 'not found'> | the service throws a not-found error while resolving a referenced entity |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"app_id": "{{cache:auth.signup-tenant.response.data.app_id}}",
"display_name": "{{dynamic:name}}",
"region": "us-east-1",
"isolation_tier": "S",
"brand_domain": "acme.example.com",
"admin_pool_index": "admin",
"app_pool_index": {},
"module_subscriptions": []
}{
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"display_name": "Acme QA Sample",
"region": "us-east-1",
"isolation_tier": "S",
"brand_domain": "acme.example.com",
"admin_pool_index": "admin",
"app_pool_index": {},
"module_subscriptions": []
}{
"success": true,
"data": {
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"display_name": "Acme QA Sample",
"region": "us-east-1",
"isolation_tier": "S",
"brand_domain": "acme.example.com",
"admin_pool_index": "admin",
"app_pool_index": {},
"module_subscriptions": [],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/tenants/:tenant_id🔒 auth
Fetches a tenant by id. Edge cases: an unknown :tenant_id returns 404 with the id echoed in details[]; the lookup is by id alone and is not scoped to the caller, so any authenticated caller holding a tenant_id can read that tenant; a :tenant_id that is not a valid UUID reaches the query and is handled by the shared uncaught() mapper — it matches none of the foreign-key / duplicate / not-found text patterns and therefore returns 500 InternalError rather than 400.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 404 | NotFound | No tenant with id <tenant_id> | no tenant row matches :tenant_id |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"success": true,
"data": {
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/tenants/:tenant_id/bus🔒 auth
Creates a business unit under :tenant_id. name and kind are required; parent_bu_id is optional and nests the BU inside another BU. Edge cases: kind is free text here — only presence is checked, so an unsupported kind is rejected by the DB (surfacing as 500) rather than by a 400; an unknown :tenant_id or a parent_bu_id that does not exist is a foreign-key violation mapped to 400 ValidationError, NOT 404; a duplicate BU name within the tenant maps to 409 Conflict where a unique constraint exists; the handler does not guard against a parent_bu_id belonging to a different tenant, nor against creating a cycle in the BU tree.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | name is required / kind is required | validateCreateBu fails; all failures are returned together in details[] |
| 400 | ValidationError | <pg error: violates foreign key constraint ...> | :tenant_id or parent_bu_id references a row that does not exist |
| 409 | Conflict | <pg error: duplicate key value violates unique constraint ...> | a BU unique constraint (e.g. name within the tenant) is violated |
| 404 | NotFound | <error text containing 'not found'> | the service throws a not-found error while resolving the tenant or parent BU |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"name": "{{dynamic:name}}",
"kind": "region"
}{
"name": "Acme QA Sample",
"kind": "region"
}{
"success": true,
"data": {
"bus_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"kind": "region",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/tenants/:tenant_id/contactpublic
Resolves a tenant's primary/billing contact from its FOUNDING member (earliest active identity.tenant_membership row): display_name from the L2 profile band, email and phone from the person's aliases. Purpose-bound, consent-gated and audited (TK-3572). Edge cases: ?purpose= defaults to 'support'; the internal purposes support/billing/operations are TPO-allowed, but ANY other purpose requires an active, unrevoked, unexpired consent.receipt for that person and FAILS CLOSED with 403 consent_absent when none exists; a tenant with no active members returns 404 even though the tenant itself exists; every access — granted or denied — writes a consent.contact_read.{granted,denied}.v1 audit event, and audit failures are swallowed so they never block the decision; a non-UUID tenant_id fails the ::uuid cast and returns 500.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Gateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent |
| 401 | Unauthorized | Invalid or expired token | authGate ran requireAuth and the JWT failed verification or had expired |
| 403 | consent_absent | reading tenant contact for purpose '<purpose>' requires an active consent receipt | ?purpose= is outside {support, billing, operations} and the contact person has no active (unrevoked, unexpired) consent.receipt row |
| 404 | NotFound | No active member found for tenant | the tenant has no identity.tenant_membership row with status=active |
| 500 | InternalError | <postgres error text> | :tenant_id is not a valid UUID (::uuid cast fails), or the contact/consent query errors |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"success": true,
"data": {
"contact_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "contact not found"
}{
"success": true,
"data": {
"person_id": "string",
"display_name": "string",
"email": "string",
"phone": "string"
}
}POST/api/tenants/:tenant_id/fiscal-calendar🔒 auth
Defines the tenant's fiscal calendar and generates its periods, returning the generated period rows with 201. year_start_month (integer 1..12) and base_currency (exactly 3 characters, ISO-4217) are required; period_kind (year | quarter | month | week) is optional and whitelist-checked. Edge cases: year_start_month is range-checked so 0 or 13 is a 400, but a non-integer such as 6.5 passes the Number.isFinite check and reaches the service; base_currency is checked for LENGTH ONLY, so 'XXX' or '123' is accepted as a currency; an unknown :tenant_id is a foreign-key violation mapped to 400 ValidationError, not 404; re-posting a calendar for a tenant that already has one trips the period unique constraint and maps to 409 Conflict, so this route is not safely repeatable.
[ "POST /api/auth/signup-tenant" ]
period_kind: year, quarter, month, week| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | year_start_month must be an integer 1..12 / base_currency must be an ISO-4217 3-letter code / period_kind must be one of year, quarter, month, week | any validateSetFiscalCalendar check fails; all failures are returned together in details[] |
| 400 | ValidationError | <pg error: violates foreign key constraint ...> | :tenant_id references a tenant that does not exist |
| 409 | Conflict | <pg error: duplicate key value violates unique constraint ...> | fiscal periods already exist for that tenant/year and are re-generated |
| 404 | NotFound | <error text containing 'not found'> | the service throws a not-found error while resolving the tenant |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"year_start_month": 4,
"base_currency": "USD",
"period_kind": "quarter"
}{
"year_start_month": 4,
"base_currency": "USD",
"period_kind": "quarter"
}{
"success": true,
"data": {
"fiscal_calendar_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"year_start_month": 4,
"base_currency": "USD",
"period_kind": "quarter",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/tenants/:tenant_id/reseller-attach🔒 auth
Attaches an existing reseller to :tenant_id and optionally overrides the commission_rules for that pairing. Returns the updated tenant with 200 (not 201). Edge cases: reseller_id is the only required field; commission_rules must be an object or it is silently DROPPED (a string or array is discarded, not rejected); an unknown reseller_id or :tenant_id is a foreign-key violation mapped to 400 ValidationError rather than 404 — unless the service raises a 'not found' message, which maps to 404; re-attaching the SAME reseller either overwrites the attachment or trips a unique constraint mapped to 409 Conflict, so retries are not safely idempotent.
[ "POST /api/auth/signup-tenant", "POST /api/resellers" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | reseller_id is required | body.reseller_id is absent or empty after trimming |
| 400 | ValidationError | <pg error: violates foreign key constraint ...> | :tenant_id or reseller_id references a row that does not exist |
| 409 | Conflict | <pg error: duplicate key value violates unique constraint ...> | re-attaching a reseller that is already attached trips a unique constraint |
| 404 | NotFound | <error text containing 'not found'> | the service throws a not-found error while resolving the tenant or reseller |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"reseller_id": "{{cache:resellers.create.response.data.reseller.reseller_id}}",
"commission_rules": {
"default_pct": 12
}
}{
"reseller_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"commission_rules": {
"default_pct": 12
}
}{
"success": true,
"data": {
"reseller_attach_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reseller_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"commission_rules": {
"default_pct": 12
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/tenants/:tenant_id/sub-tenants🔒 auth
Creates a sub-tenant beneath :tenant_id. app_id, display_name and region are required; placement (share | tier-p | tier-g) decides whether the child shares the parent's pool or gets a dedicated tier. Edge cases: placement is whitelist-checked only when supplied — omitting it is legal and defers to the service default; the PARENT :tenant_id is not validated by the handler, so an unknown or non-UUID parent surfaces as a foreign-key violation mapped to 400 ValidationError, not 404; duplicate natural keys map to 409 Conflict; nesting depth and per-parent sub-tenant counts are not capped at this layer.
[ "POST /api/auth/signup-tenant" ]
placement: share, tier-p, tier-gisolation_tier: S, P, G| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | app_id is required / display_name is required / region is required / placement must be one of share, tier-p, tier-g | any validateCreateSubTenant check fails; all failures are returned together in details[] |
| 400 | ValidationError | <pg error: violates foreign key constraint ...> | :tenant_id (the parent), reseller_id or geo_node_id references a row that does not exist |
| 409 | Conflict | <pg error: duplicate key value violates unique constraint ...> | a unique constraint (e.g. brand_domain) is violated |
| 404 | NotFound | <error text containing 'not found'> | the service throws a not-found error while resolving the parent tenant or a referenced entity |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"app_id": "{{cache:auth.signup-tenant.response.data.app_id}}",
"display_name": "{{dynamic:name}}",
"region": "us-east-1",
"placement": "share",
"isolation_tier": "S",
"brand_domain": "sub.example.com"
}{
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"display_name": "Acme QA Sample",
"region": "us-east-1",
"placement": "share",
"isolation_tier": "S",
"brand_domain": "sub.example.com"
}{
"success": true,
"data": {
"sub_tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"display_name": "Acme QA Sample",
"region": "us-east-1",
"placement": "share",
"isolation_tier": "S",
"brand_domain": "sub.example.com",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-tenant-lifecycle
POST/api/tenant-lifecycle/:tenant_id/offboard🔒 auth
Starts tenant offboarding and stamps a data-retention deadline, defaulting to 30 days from now (per FR-TLC-6) when deadline_at is omitted, and returns the resulting lifecycle state. Edge cases: ownership is checked first — a JWT whose tenant_id and parent_tenant_id both differ from the path tenant_id gets 403; deadline_at must parse as a date, and any unparseable string (including a non-ISO format) yields 400 "deadline_at must be ISO-8601"; a deadline in the past is accepted by the route because only NaN is rejected, so back-dated deadlines are not guarded here; offboarding a tenant that is already offboarding or in a state with no legal edge to offboarded returns 409, making repeat calls non-idempotent.
[ "POST /api/auth/signup-tenant" ]
current_state: active, suspended, offboarding, offboarded, sandbox| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | Tenant ownership check failed | The JWT's tenant_id and parent_tenant_id both differ from the :tenant_id in the path |
| 400 | ValidationError | deadline_at must be ISO-8601 | deadline_at is present but does not parse to a valid date (new Date(...) yields NaN) |
| 409 | InvalidTransition | Invalid tenant lifecycle transition <from> → offboarding | The tenant's current lifecycle state has no legal edge to the offboarding state |
| 500 | InternalError | Internal Server Error | The database is unreachable or the state write throws outside the InvalidTransition path |
{
"entity": "tenant_lifecycle.state",
"field": "current_state",
"flow": [
"active",
"offboarding",
"offboarded"
],
"transitions": [
{
"from": "active",
"to": "offboarding",
"via": "POST /api/tenant-lifecycle/:tenant_id/offboard"
},
{
"from": "suspended",
"to": "offboarding",
"via": "POST /api/tenant-lifecycle/:tenant_id/offboard"
},
{
"from": "offboarding",
"to": "offboarded",
"via": "scheduler:runOffboardDeadlineTick"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"deadline_at": "{{dynamic:futuredatetime}}"
}{
"deadline_at": "2026-01-15T10:30:00Z"
}{
"success": true,
"data": {
"offboard_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"deadline_at": "2026-01-15T10:30:00Z",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/tenant-lifecycle/:tenant_id/reinstate🔒 auth
Reinstates a previously suspended tenant back to its active lifecycle state, attributing the change to the JWT subject. Takes no body. Edge cases: ownership is enforced the same way as suspend — the JWT tenant_id or parent_tenant_id must match the path tenant_id, otherwise 403; reinstating a tenant that is already active is an invalid transition (409), so the call is not idempotent; a tenant that has been offboarded past its deadline cannot be reinstated through this route and also returns 409 with the "from → active" message; an unknown tenant_id is caught by the ownership check and returns 403 rather than 404.
[ "POST /api/auth/signup-tenant", "POST /api/tenant-lifecycle/:tenant_id/suspend" ]
current_state: active, suspended, offboarding, offboarded, sandbox| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | Tenant ownership check failed | The JWT's tenant_id and parent_tenant_id both differ from the :tenant_id in the path |
| 409 | InvalidTransition | Invalid tenant lifecycle transition <from> → active | The tenant is not in a state from which reinstatement is allowed (e.g. already active, or offboarded) |
| 500 | InternalError | Internal Server Error | The database is unreachable or the state write throws outside the InvalidTransition path |
{
"entity": "tenant_lifecycle.state",
"field": "current_state",
"flow": [
"active",
"suspended",
"active"
],
"transitions": [
{
"from": "active",
"to": "suspended",
"via": "POST /api/tenant-lifecycle/:tenant_id/suspend"
},
{
"from": "suspended",
"to": "active",
"via": "POST /api/tenant-lifecycle/:tenant_id/reinstate"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{}{
"success": true,
"data": {
"reinstate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/tenant-lifecycle/:tenant_id/state🔒 auth
Reads the current lifecycle state record for a tenant (active / suspended / offboarding and its associated metadata such as reason, actor and deadline). Edge cases: ownership is enforced before the read — the JWT tenant_id or parent_tenant_id must equal the path tenant_id, so probing another tenant's state returns 403, not 404; a tenant the caller does own but which has no lifecycle row yet (never suspended, reinstated or offboarded) returns 404 NotFound rather than a synthesized "active" default, so consumers must treat 404 as "no lifecycle event recorded"; a malformed tenant_id fails the ownership comparison and returns 403 rather than a validation error.
[ "POST /api/auth/signup-tenant", "POST /api/tenant-lifecycle/:tenant_id/suspend" ]
current_state: active, suspended, offboarding, offboarded, sandbox| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | Tenant ownership check failed | The JWT's tenant_id and parent_tenant_id both differ from the :tenant_id in the path |
| 404 | NotFound | NotFound | getState returns no lifecycle row for the supplied tenant_id |
| 500 | InternalError | Internal Server Error | The database is unreachable or the state read throws |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"success": true,
"data": {
"state_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/tenant-lifecycle/:tenant_id/suspend🔒 auth
Suspends a tenant with a mandatory human-readable reason and returns the resulting lifecycle state, recording the JWT subject as the acting actor (falling back to "api-gateway" when the token has no sub). Edge cases: the caller must own the tenant — the JWT tenant_id must equal the path tenant_id, or its parent_tenant_id must (the reseller-attached path per FR-TLC-7) — otherwise 403 before any state read; reason is required and an empty string is rejected; suspending a tenant that is already suspended, or one in a terminal offboarded state, is rejected by the state machine with 409 InvalidTransition, so this call is not idempotent; an unknown tenant_id fails the ownership check first and returns 403 rather than 404.
[ "POST /api/auth/signup-tenant" ]
current_state: active, suspended, offboarding, offboarded, sandbox| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | Tenant ownership check failed | The JWT's tenant_id and parent_tenant_id both differ from the :tenant_id in the path |
| 400 | ValidationError | reason is required | The body omits reason or supplies an empty value |
| 409 | InvalidTransition | Invalid tenant lifecycle transition <from> → suspended | The tenant's current lifecycle state cannot move to suspended (already suspended, or offboarded) |
| 500 | InternalError | Internal Server Error | The database is unreachable or the state write throws outside the InvalidTransition path |
{
"entity": "tenant_lifecycle.state",
"field": "current_state",
"flow": [
"active",
"suspended",
"offboarding",
"offboarded"
],
"transitions": [
{
"from": "active",
"to": "suspended",
"via": "POST /api/tenant-lifecycle/:tenant_id/suspend"
},
{
"from": "suspended",
"to": "active",
"via": "POST /api/tenant-lifecycle/:tenant_id/reinstate"
},
{
"from": "active",
"to": "offboarding",
"via": "POST /api/tenant-lifecycle/:tenant_id/offboard"
},
{
"from": "suspended",
"to": "offboarding",
"via": "POST /api/tenant-lifecycle/:tenant_id/offboard"
},
{
"from": "offboarding",
"to": "offboarded",
"via": "scheduler:runOffboardDeadlineTick"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}{
"reason": "non-payment"
}{
"reason": "non-payment"
}{
"success": true,
"data": {
"suspend_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "non-payment",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/tenant-lifecycle/sandbox🔒 auth
Creates a sandbox tenant beneath the caller's own tenant, optionally with an expiry timestamp and a data sanitization policy, returning the sandbox record with 201. The parent tenant is taken from the JWT tenant_id — it is never read from the body, so a sandbox cannot be attached to a tenant the caller does not hold a token for. Edge cases: a token with no tenant_id claim is rejected 403 before any work; expires_at is optional but when present must parse as a date, otherwise 400; an expires_at in the past is not rejected by the route; sanitization_policy is free-form here and validated downstream; there is no idempotency key or name-uniqueness check, so repeated calls create additional sandbox tenants.
[ "POST /api/auth/signup-tenant" ]
current_state: active, suspended, offboarding, offboarded, sandbox| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer <jwt> header on a requireAuth route |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or exp in the past) |
| 403 | Forbidden | Caller must have tenant_id | The verified JWT carries no tenant_id claim, so no parent tenant can be derived |
| 400 | ValidationError | expires_at must be ISO-8601 | expires_at is present but does not parse to a valid date |
| 500 | InternalError | Internal Server Error | createSandboxTenant throws — e.g. the parent tenant row is missing, the sanitization policy is rejected downstream, or the database is unreachable |
{
"entity": "tenant_lifecycle.state",
"field": "current_state",
"flow": [
"active",
"sandbox",
"offboarded"
],
"transitions": [
{
"from": "active",
"to": "sandbox",
"via": "POST /api/tenant-lifecycle/sandbox"
},
{
"from": "sandbox",
"to": "offboarded",
"via": "scheduler:runOffboardDeadlineTick"
}
]
}{
"expires_at": "{{dynamic:futuredatetime}}",
"sanitization_policy": "default-mask-pii"
}{
"expires_at": "2026-01-15T10:30:00Z",
"sanitization_policy": "default-mask-pii"
}{
"success": true,
"data": {
"sandbox_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"expires_at": "2026-01-15T10:30:00Z",
"sanitization_policy": "default-mask-pii",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-trace
GET/api/trace/:trace_id🔒 auth
Trace timeline (G12). Returns trace.trace header + trace.span rows in started_at order. Render budget <5s for 50 spans.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | MissingPathParam | Missing path param: trace_id | trace_id resolves falsy (defensive - an empty path segment normally route-misses to 404 first) |
| 404 | NotFound | <service message containing "not found"> | getTraceTimeline throws for an unknown trace_id |
| 500 | LookupFailed | Lookup failed | getTraceTimeline throws any other error - a non-UUID trace_id failing the uuid cast, or any DB error |
| 500 | InternalError | InternalError | the handler throws outside its own try/catch and the route wrapper catch fires |
{
"trace_id": "{{var:trace_id}}"
}{
"success": true,
"data": {
"trace_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}POST/api/trace/exports🔒 auth
Signed PDF/JSON trace export. Persists trace.export with artifact_s3_key + HMAC signature; emits trace.export.requested.v1 + trace.export.ready.v1 (operational retention).
[ "POST /api/auth/signup-tenant", "POST /api/personas", "POST /policy/:id/evaluate" ]
format: pdf, json| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | Required: tenant_id, requestor_persona_id, trace_id, format (pdf|json) | any of tenant_id/requestor_persona_id/trace_id/format missing |
| 400 | ValidationError | format must be pdf or json | format present but not 'pdf' or 'json' |
| 404 | NotFound | trace_id <id> not found | exportTrace/getTraceTimeline throws an Error whose message includes 'not found' |
| 500 | ExportFailed | Export failed | any other error thrown by exportTrace (insert failure, etc.) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"requestor_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"trace_id": "{{cache:policy.evaluate.response.data.trace_id}}",
"format": "pdf"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"requestor_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trace_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"format": "pdf"
}{
"success": true,
"data": {
"export_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"requestor_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trace_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"format": "pdf",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}GET/api/trace/healthpublic
Static liveness probe for sdk-trace; returns {sdk:"sdk-trace", status:"ok"} with no DB or downstream call. Public: the gateway authGate allowlists any path ending in /health, so it answers 200 with no Authorization header, with a malformed or expired bearer token, and regardless of tenant. Edge cases: the body is a constant so there is no data path that can 404 or 500; query strings and extra headers are ignored; a non-GET verb on this path is a Fastify 404 route-miss rather than a handler error.
{
"success": true,
"data": [
{
"health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"sdk": "sdk-trace",
"status": "ok"
}POST/api/trace/regression-assert🔒 auth
FR-TRC-8 regression test API. Body: {trace_id, expected_layers: string[]}. Returns {pass, matched_layers, missing_layers, extra_layers}. CI uses this to assert traces match the expected 8-layer composition.
[ "POST /api/auth/register" ]
expected_layers: gateway, identity, consent, pool-router, vault, policy, rebac, meter, sdk-body, tool, agent, lineage| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | Required: trace_id, expected_layers (string[]) | trace_id is absent or empty, or expected_layers is absent or not an array (an empty array [] passes the check) |
| 500 | RegressionAssertFailed | Regression assert failed | regressionAssert throws - an unknown trace_id, a non-UUID trace_id failing the uuid cast, or any DB error; there is no 404 branch, so a missing trace surfaces as a 500 |
| 500 | InternalError | InternalError | the handler throws outside its own try/catch and the route wrapper catch fires |
{
"trace_id": "{{var:trace_id}}",
"expected_layers": [
"gateway",
"identity",
"policy",
"meter"
]
}{
"trace_id": "{{var:trace_id}}",
"expected_layers": [
"gateway",
"identity",
"policy",
"meter"
]
}{
"success": true,
"data": {
"regression_assert_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trace_id": "{{var:trace_id}}",
"expected_layers": [
"gateway",
"identity",
"policy",
"meter"
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true
}sdk-vault
GET/admin/byok/bindingspublic
Lists EVERY BYOK/CMEK binding across all tenants, newest first, optionally filtered by grant_status, limit capped at 500 (default 200). This is the platform console's view and the counterpart to GET /admin/byok/bindings/tenant/:tenant_id, which answers only 'does THIS tenant have a binding'. The operator question is the other one — which tenants have brought their own key, and is any of them degraded or mid-revoke — and grant_status is the field to read first: 'revoking' means a customer's revoke is in flight and 'degraded' means their CMK stopped answering, and both are incidents rather than resting states. Deliberately unscoped and therefore deliberately admin-gated with ADMIN_OPS_TOKEN: it is a roster of every customer's key arrangement, so there is no tenant-facing route into it. Returns binding metadata including customer_kms_key_arn, which is an ARN identifying the customer's key, never key material. Edge cases: an environment with no bindings returns an empty array with 200, not 404; an unknown grant_status value returns an empty array rather than an error, since the column is constrained and a typo should not look like a server fault; a revoked binding remains listed with revoked_at set, because the roster is also the record of who has revoked.
_source: p, a, c, k, a, g, e, s, /, s, d, k, -, v, a, u, l, t, /, s, r, c, /, d, b, /, m, i, g, r, a, t, i, o, n, s, /, 0, 0, 2, _, b, y, o, k, ., s, q, l, :, 2, 5grant_status: active, revoking, revoked, degraded| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | admin token required | admin token required | The x-admin-ops-token header is absent, empty, or does not match ADMIN_OPS_TOKEN |
| 500 | InternalError | InternalError | The vault.byok_binding query fails — database unavailable or the pool is exhausted |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.GET/admin/vault/keyspublic
Lists vault keys at ANY tier, including the tenant-less ones — root, app and pool carry tenant_id NULL because they wrap every tenant, so they belong to the operator rather than to any single customer and are invisible to the tenant-scoped GET /api/vault/keys. Optional tier and tenant_id narrowing, limit capped at 500 (default 200). Returns metadata only: key_id, tier, scope_id, parent_key_id, kms_ref, state, algorithm, issued_at, rotated_at, shredded_at, tenant_id, region. vault.key holds no key material, so nothing secret is returned; kms_ref is a handle. This route and the tenant route read the SAME table through DIFFERENT service functions (listKeysForOperator vs listKeysForTenant) rather than one function taking a widen-the-scope flag — a flag is something a caller can pass wrongly, whereas a separate function means no tenant request has a code path into the operator query. Gated by ADMIN_OPS_TOKEN. Edge cases: a tier with no keys returns an empty array with 200, not 404; an unrecognised tier value returns an empty array because the column is CHECK-constrained and a typo should not read as a server fault; a shredded key still appears, with state 'shredded', shredded_at set and kms_ref NULL, because the row is the record of the erasure.
_source: p, a, c, k, a, g, e, s, /, s, d, k, -, v, a, u, l, t, /, s, r, c, /, d, b, /, m, i, g, r, a, t, i, o, n, s, /, 0, 0, 1, _, i, n, i, t, _, k, e, y, _, h, i, e, r, a, r, c, h, y, ., s, q, l, :, 1, 3tier: root, app, pool, tenant, person, device, encounter| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | admin token required | admin token required | The x-admin-ops-token header is absent, empty, or does not match ADMIN_OPS_TOKEN |
| 500 | InternalError | InternalError | The vault.key query fails — database unavailable, or tenant_id is supplied in a form the ::uuid cast rejects |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.GET/admin/vault/kms-statuspublic
Reports, per KMS provider (aws-kms, gcp-kms, hsm-pkcs11), what is ACTUALLY serving calls in this process: credentialsPresent, mode (real | synthetic | unregistered) and wiredBy — the environment variables that would wire it. Always returns all three entries, so a provider is never silently missing from the report. This exists because CONFIGURATION AND RUNTIME DISAGREE, and the disagreement is the thing worth seeing: a vault.kms config row naming a provider states intent, while whether its credentials reached the process is a separate fact. Production ran for weeks configured for BYOK and served by a simulation, because the registry substituted a synthetic provider whenever real credentials were absent — no flag, no log line, and every screen reporting success. mode is derived, not stored: 'real' when the provider's own available() probe passes; otherwise 'unregistered' in a protected environment (anything explicitly named other than development/dev/local/test), where the substitution is now refused so BYOK calls fail loudly; otherwise 'synthetic', which is permitted only on a developer machine and is accompanied by a boot warning when the environment declares nothing at all. Pure and side-effect free — it probes env and the provider registry, and registers nothing. Edge cases: an environment with no credentials at all still returns 200 with three entries rather than an error, because 'nothing is wired' is a legitimate and important answer; the response never contains a credential, only the NAMES of the variables that would supply one.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | admin token required | admin token required | The x-admin-ops-token header is absent, empty, or does not match ADMIN_OPS_TOKEN |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.POST/api/vault/decrypt🔒 auth
Reverses POST /api/vault/encrypt: unwraps the DEK via the secret reference and decrypts the bundle, returning 200 with { plaintext_b64 }. All five of ref, ciphertext_b64, wrapped_dek_b64, iv_b64 and tag_b64 are mandatory non-empty strings - every missing field is reported in the same 400 details array. Edge cases: an unregistered or malformed ref is a 400; a tampered ciphertext, wrapped DEK, IV or auth tag fails AES-GCM authentication inside the service and is not specially mapped, so it surfaces as a generic 500 InternalError rather than a 400; decrypting a bundle whose key has since been shredded also fails as a 500; the operation is read-only and safely repeatable; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/secrets", "POST /api/vault/encrypt" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | <field> is required | Any of ref, ciphertext_b64, wrapped_dek_b64, iv_b64, tag_b64 is absent, not a string, or an empty string |
| 400 | ValidationError | Secret reference not registered ... / Invalid secret reference ... | envelopeDecrypt rejects ref - the secret reference is malformed or has never been registered |
| 500 | InternalError | InternalError | Any other envelopeDecrypt failure - notably GCM authentication failure from a tampered/mismatched bundle, and decryption under a shredded key |
{
"ref": "{{cache:secrets.store.response.data.ref}}",
"ciphertext_b64": "{{cache:vault.encrypt.response.data.ciphertext_b64}}",
"wrapped_dek_b64": "{{cache:vault.encrypt.response.data.wrapped_dek_b64}}",
"iv_b64": "{{cache:vault.encrypt.response.data.iv_b64}}",
"tag_b64": "{{cache:vault.encrypt.response.data.tag_b64}}"
}{
"ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ciphertext_b64": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"wrapped_dek_b64": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"iv_b64": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tag_b64": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}{
"success": true,
"data": {
"decrypt_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ciphertext_b64": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"wrapped_dek_b64": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"iv_b64": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tag_b64": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"plaintext_b64": "string"
}
}POST/api/vault/encrypt🔒 auth
Envelope-encrypts a base64 plaintext under the KMS key behind the supplied secret reference, returning 200 with the base64 bundle (ciphertext_b64, wrapped_dek_b64, iv_b64, tag_b64) needed by POST /api/vault/decrypt. ref is trimmed and must be non-empty; plaintext_b64 must be a non-empty string. Edge cases: an empty plaintext_b64 is rejected as missing, so encrypting empty content is not supported; a ref that is syntactically wrong or has not been registered in the secret registry is surfaced as a 400 (not a 404); the plaintext is base64-decoded leniently, so non-base64 input is not rejected outright; each call mints a fresh DEK/IV, so encrypting the same plaintext twice yields different bundles; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/secrets" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | ref is required | ref is absent or whitespace-only |
| 400 | ValidationError | plaintext_b64 is required | plaintext_b64 is absent or an empty string |
| 400 | ValidationError | Secret reference not registered ... / Invalid secret reference ... | envelopeEncrypt rejects ref - the secret reference is malformed or has never been registered |
| 500 | InternalError | InternalError | Any other envelopeEncrypt failure (KMS unavailable, wrap failure) |
{
"ref": "{{cache:secrets.store.response.data.ref}}",
"plaintext_b64": "aGVsbG8td29ybGQ="
}{
"ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"plaintext_b64": "aGVsbG8td29ybGQ="
}{
"success": true,
"data": {
"encrypt_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"plaintext_b64": "aGVsbG8td29ybGQ=",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"ciphertext_b64": "string",
"wrapped_dek_b64": "string",
"iv_b64": "string",
"tag_b64": "string",
"ref": "string"
}
}GET/api/vault/healthpublic
Liveness probe for sdk-vault; returns 200 with { sdk: "sdk-vault", status: "ok" }. Constant-response handler - no body parsing, no database access and no branches, so it has no failure path. The path ends in /health, so the api-gateway default-deny authGate classifies it as public: no Authorization header is required and supplying a malformed one does not produce a 401. There are therefore no error cases.
{
"success": true,
"data": [
{
"health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"sdk": "sdk-vault",
"status": "ok"
}GET/api/vault/keys🔒 auth
Lists the vault keys VISIBLE TO THE CALLING TENANT, newest first, with optional tier and scope_id narrowing and a limit capped at 500 (default 200). Returns metadata only — key_id, tier, scope_id, parent_key_id, kms_ref, state, algorithm, issued_at, rotated_at, shredded_at, tenant_id, region. There is no key material in vault.key at all, so nothing secret is returned; kms_ref is a handle, not a key. The tenant filter is applied IN SQL (listKeysForTenant), not by the handler, because every read into vault.key is a potential cross-tenant key inventory: tier + scope_id + kms_ref describe the shape of another customer's key hierarchy and parent_key_id walks upward through it. Tiers ABOVE tenant — root, app, pool — carry tenant_id NULL because they wrap every tenant, so they are excluded by construction and are NOT visible here even to the tenant that created them; an operator view goes through an ADMIN_OPS_TOKEN route instead. The definition captures NOTHING: the only key producer for this project creates a ROOT-tier key (tenant_id NULL), which this tenant-scoped list deliberately excludes, so data.0 would never populate and any consumer binding it would block forever. Edge cases: a token carrying no tenant_id is 403 rather than an empty list, because an empty list would read as 'you have no keys' when the truth is 'this token cannot express a key scope'; a fresh tenant legitimately returns an empty array with 200; tier and scope_id can only narrow within the tenant and can never widen beyond it; limit is clamped rather than rejected, so an absurd value degrades instead of failing.
[ "POST /api/auth/signup-tenant", "POST /api/vault/keys" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization header, or not in Bearer form — rejected by the gateway default-deny authGate before requireAuth |
| 401 | Unauthorized | Invalid or expired token | The bearer token fails JWT verification or has expired |
| 403 | no tenant scope | This token carries no tenant, so no key scope can be derived | The JWT verifies but carries no tenant_id claim — a platform-level token cannot list tenant keys and must use the admin route |
| 500 | InternalError | InternalError | The vault.key query fails — database unavailable or the pool is exhausted |
{success,data} envelope derived from the request contract; assert shape + HTTP 200.POST/api/vault/keys🔒 auth
Issues a new vault key at the requested tier of the key hierarchy and returns 201 with the key record. tier, kms_ref and region are mandatory; every non-root tier additionally requires parent_key_id, which is re-checked by a database trigger. algorithm is optional and silently defaults to AES-256-GCM - any value other than ChaCha20-Poly1305 is coerced rather than rejected. Edge cases: a tier outside the canonical list is rejected with the valid values echoed back; a non-root tier without parent_key_id is rejected before the database is touched; a parent_key_id whose tier is not the legal parent of the requested tier, or which does not exist, is surfaced as a 400 from the service layer rather than a 404; kms_ref and region are trimmed, so whitespace-only values count as missing; requires a valid JWT.
[ "POST /api/auth/signup-tenant" ]
tier: root, app, pool, tenant, person, device, encounteralgorithm: AES-256-GCM, ChaCha20-Poly1305| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | body must be an object | Request body is absent or not a JSON object |
| 400 | ValidationError | tier is required / tier must be one of <KEY_TIERS> | tier is missing or is not one of the canonical key tiers |
| 400 | ValidationError | kms_ref is required | kms_ref is absent or whitespace-only |
| 400 | ValidationError | region is required | region is absent or whitespace-only |
| 400 | ValidationError | parent_key_id is required for non-root tiers | tier is not "root" and parent_key_id is not a string |
| 400 | ValidationError | Invalid parent tier ... / Parent key ... | issueKey rejects the parent - the parent key does not exist, or its tier is not the legal parent of the requested tier |
| 500 | InternalError | InternalError | Any other issueKey failure (KMS unavailable, database error, hierarchy trigger failure) |
{
"tier": "root",
"kms_ref": "kms-root-001",
"algorithm": "AES-256-GCM",
"region": "us-east-1"
}{
"tier": "root",
"kms_ref": "kms-root-001",
"algorithm": "AES-256-GCM",
"region": "us-east-1"
}{
"success": true,
"data": {
"key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tier": "root",
"kms_ref": "kms-root-001",
"algorithm": "AES-256-GCM",
"region": "us-east-1",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/api/vault/keys/:key_id🔒 auth
Reads ONE vault key, and only if it belongs to the calling tenant. Returns metadata only — vault.key holds no key material. The tenant predicate is part of the SQL (getKeyForTenant), so the ownership check cannot be skipped by a caller that forgets it. A key belonging to another tenant answers 404, never 403: a 403 would confirm the id EXISTS somewhere, which is precisely what a caller enumerating ids wants, so absence and denial are made indistinguishable. WHY THE PRIMARY CASE IS A 404 AND NOT A 200. The only producer for this endpoint, POST /api/vault/keys, creates a ROOT-tier key, and root/app/pool rows carry tenant_id NULL because they wrap every tenant — so a tenant token deliberately cannot see them, and chaining that id and expecting 200 is unsatisfiable by construction. Reaching a 200 needs a TENANT-tier key, which requires two POSTs to this same endpoint in order (root, then tenant with parent_key_id), and one definition per METHOD+ENDPOINT cannot express that — the constraint recorded in TK-4137. So the case asserts the isolation instead, which is the property actually worth pinning: a root key is invisible to a tenant, and is indistinguishable from a key that does not exist. Flip the first case to 200 once a tenant-tier key id is reachable from cache. Edge cases: a shredded key still reads back for its owner, showing state 'shredded' with shredded_at set and kms_ref NULL, because the row is the audit trail of the erasure; a non-UUID key_id raises 22P02 from the ::uuid cast and surfaces as 500 rather than 400.
[ "POST /api/auth/signup-tenant", "POST /api/vault/keys" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization header, or not in Bearer form — rejected by the gateway default-deny authGate before requireAuth |
| 403 | no tenant scope | This token carries no tenant, so no key scope can be derived | The JWT verifies but carries no tenant_id claim |
| 404 | Key not found | Key not found | No key with that id within the calling tenant — covers both a genuinely unknown id and a key owned by another tenant, deliberately indistinguishable |
| 500 | InternalError | InternalError | key_id is not a valid UUID so the ::uuid cast raises 22P02, or the database is unavailable |
{
"key_id": "{{cache:vault.keys.create.response.data.key_id}}"
}{success,data} envelope derived from the request contract; assert shape + HTTP 404.POST/api/vault/keys/:key_id/rotate🔒 auth
Rotates the key identified by :key_id, minting a new key version and returning 200 with the updated key record. The request body is optional; reason, when present, is recorded on the rotation audit trail. Edge cases: an unknown key_id and a key that exists but is not in a rotatable state (for example already shredded or retired) are collapsed into the same 404 - the handler matches on the "not found" / "not in a rotatable" substrings, so a state-precondition failure is indistinguishable from a missing key by status code alone; rotation is NOT idempotent (each successful call produces a new version); a non-UUID key_id fails the Postgres UUID cast and is reported as a generic 500; requires a valid JWT.
[ "POST /api/auth/signup-tenant", "POST /api/vault/keys" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 404 | NotFound | key not found / key is not in a rotatable state | rotateKey throws with a message containing "not found" or "not in a rotatable" - the key does not exist or its current status forbids rotation |
| 500 | InternalError | InternalError | Any other rotateKey failure, including a non-UUID key_id (Postgres 22P02) and KMS/database errors |
{
"entity": "vault.key",
"field": "state",
"flow": [
"issued",
"active",
"rotated",
"shredded"
],
"transitions": [
{
"from": "active",
"to": "rotated",
"via": "POST /api/vault/keys/:key_id/rotate"
}
]
}{
"key_id": "{{cache:vault.keys.create.response.data.key_id}}"
}{
"reason": "scheduled"
}{
"reason": "scheduled"
}{
"success": true,
"data": {
"status": "completed",
"reason": "scheduled",
"rotate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/api/vault/keys/:key_id/shred🔒 auth
Cryptographically shreds the key identified by :key_id - the key material is destroyed and everything encrypted under it becomes permanently unrecoverable. A non-empty reason in the body is mandatory and is recorded on the audit trail. Returns 200 with the shredded key record. Edge cases: a missing or empty reason is rejected with 400 before any state change; an unknown key_id and an already-shredded key both return 404 (the handler maps "not found" and "already shredded" onto the same status), so the operation is not idempotent from the caller's point of view - the first call succeeds and every replay 404s; a non-UUID key_id fails the Postgres UUID cast and is reported as a generic 500; requires a valid JWT.
[ "POST /api/auth/register", "POST /api/vault/keys" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate) |
| 400 | ValidationError | reason is required for shred | Body is absent or reason is missing/empty |
| 404 | NotFound | key not found / key already shredded | shredKey throws with a message containing "not found" or "already shredded" |
| 500 | InternalError | InternalError | Any other shredKey failure, including a non-UUID key_id (Postgres 22P02) and KMS/database errors |
{
"entity": "vault.key",
"field": "state",
"flow": [
"issued",
"active",
"rotated",
"shredded"
],
"transitions": [
{
"from": "active",
"to": "shredded",
"via": "POST /api/vault/keys/:key_id/shred"
},
{
"from": "rotated",
"to": "shredded",
"via": "POST /api/vault/keys/:key_id/shred"
}
]
}{
"key_id": "{{cache:vault.keys.create.response.data.key_id}}"
}{
"reason": "compliance-shred"
}{
"reason": "compliance-shred"
}{
"success": true,
"data": {
"shred_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "compliance-shred",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}sdk-webhook
GET/api/webhooks/deliveries🔒 auth
Lists a tenant's dead-lettered webhook deliveries — the handler delegates to listDlq, so this is the DLQ view rather than all delivery attempts. Edge cases: ?tenant_id= is REQUIRED and is caller-asserted from the query string, not the JWT, so omitting it is a 400; ?limit= is passed through Number() with no clamping, so a non-numeric limit becomes NaN and an enormous limit is honoured, while omitting it falls back to the service default of 100; a tenant with an empty DLQ returns 200 with an empty array, never 404; there is no cursor, so paging beyond the limit is impossible.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | tenant_id required | ?tenant_id= query param is absent |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"success": true,
"data": [
{
"delivery_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"deliveries": "array"
}
}POST/api/webhooks/deliveries/:delivery_id/replay🔒 auth
Replays a dead-lettered delivery by resetting it to pending with attempts=0, next_attempt_at=now() and dlq_until cleared. Takes no body. Edge cases: BOTH an unknown delivery_id AND a delivery whose status is not 'dlq' return the SAME 404 DeliveryNotInDlq — so a delivery that already succeeded or is still pending is reported as 'not in DLQ', and a SECOND replay of the same delivery also 404s because the first replay moved it out of dlq status; a delivery whose dlq_until has already passed returns 409 DlqWindowExpired, the expiry case to test; the response is the updated delivery row, not the downstream endpoint's response.
[ "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 404 | DeliveryNotInDlq | Delivery <delivery_id> is not in DLQ | no delivery row matches :delivery_id, OR the row exists but its status is not dlq (already delivered, still pending, or already replayed) |
| 409 | DlqWindowExpired | Delivery <delivery_id> replay window has expired | the delivery is in dlq but its dlq_until timestamp is in the past |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"entity": "webhook.delivery",
"field": "status",
"flow": [
"pending",
"delivering",
"succeeded",
"failed",
"dlq"
],
"transitions": [
{
"from": "dlq",
"to": "pending",
"via": "POST /api/webhooks/deliveries/:delivery_id/replay"
}
]
}{
"delivery_id": "{{var:dlq_delivery_id}}"
}{}{
"success": true,
"data": {
"status": "completed",
"replay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"delivery": {
"delivery_id": "string",
"status": "string"
}
}
}GET/api/webhooks/dlq🔒 auth
Gateway-composed dead-letter queue view for a tenant, delegating to sdk-webhook's listDlq with a FIXED limit of 100 — there is no ?limit= override and no paging cursor on this route, so a tenant with a deeper DLQ is silently truncated at 100 rows. Edge cases: ?tenant_id= is REQUIRED (400 if absent) and is caller-asserted from the query string rather than derived from the JWT; an empty DLQ returns 200 with an empty data array, never 404; a tenant_id that is not a valid UUID fails inside the SDK query and returns 500 with the raw Postgres message.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Gateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent |
| 401 | Unauthorized | Invalid or expired token | authGate ran requireAuth and the JWT failed verification or had expired |
| 400 | ValidationError | tenant_id required | ?tenant_id= query param is absent or empty |
| 500 | InternalError | <postgres error text> | tenant_id is not a valid UUID, or listDlq fails |
{
"success": true,
"data": [
{
"dlq_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true,
"data": "array"
}GET/api/webhooks/endpoints🔒 auth
Gateway-composed list of a tenant's registered webhook endpoints, delegating to sdk-webhook's listEndpointsForTenant. Note the POST counterpart on this same path is mounted by sdk-webhook's own router, not here. Edge cases: ?tenant_id= is REQUIRED (400 if absent) and is caller-asserted from the query string rather than derived from the JWT; a tenant with no endpoints returns 200 with an empty data array, never 404; there is no paging or limit parameter on this route; a tenant_id that is not a valid UUID fails inside the SDK query and returns 500 with the raw Postgres message.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Gateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent |
| 401 | Unauthorized | Invalid or expired token | authGate ran requireAuth and the JWT failed verification or had expired |
| 400 | ValidationError | tenant_id required | ?tenant_id= query param is absent or empty |
| 500 | InternalError | <postgres error text> | tenant_id is not a valid UUID, or listEndpointsForTenant fails |
{
"success": true,
"data": [
{
"endpoint_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"success": true,
"data": "array"
}POST/api/webhooks/endpoints🔒 auth
Registers a tenant webhook endpoint: url, signing_key_ref and signing_algo (hmac-sha256 by default, or hmac-sha512). Edge cases: tenant_id must be a well-formed UUID and the url MUST start with https:// — plain http is rejected 400; signing_key_ref is a reference to a stored secret and its existence is NOT verified at registration time; signing_algo defaults when omitted but any other value is a 400; the registry additionally runs an SSRF-style url validator, and a rejection there throws WebhookUrlRejectedError which the controller's fail() mapper does NOT special-case — so a blocked or private URL comes back as 500 InternalError, not 400; mtls_client_cert_ref is optional and unvalidated.
[ "POST /api/auth/signup-tenant" ]
signing_algo: hmac-sha256, hmac-sha512| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | tenant_id must be a UUID / url is required / url must use https:// / signing_key_ref is required / signing_algo must be hmac-sha256 or hmac-sha512 | any validateRegisterEndpoint check fails; all failures are returned together in details[] |
| 500 | InternalError | InternalError | registerEndpoint threw WebhookUrlRejectedError (url rejected by the external/SSRF validator) — fail() does not map it, so it degrades to 500 — or the insert failed |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"url": "https://webhook.example.com/projex",
"signing_key_ref": "{{var:signing_key_ref}}",
"signing_algo": "hmac-sha256",
"mtls_client_cert_ref": "{{var:mtls_client_cert_ref}}"
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"url": "https://webhook.example.com/projex",
"signing_key_ref": "{{var:signing_key_ref}}",
"signing_algo": "hmac-sha256",
"mtls_client_cert_ref": "{{var:mtls_client_cert_ref}}"
}{
"success": true,
"data": {
"endpoint_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"url": "https://webhook.example.com/projex",
"signing_key_ref": "{{var:signing_key_ref}}",
"signing_algo": "hmac-sha256",
"mtls_client_cert_ref": "{{var:mtls_client_cert_ref}}",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"endpoint": {
"endpoint_id": "string",
"tenant_id": "string",
"status": "string"
}
}
}POST/api/webhooks/endpoints/:endpoint_id/subscribe🔒 auth
Subscribes a registered endpoint to one event_type, with an optional filter_predicate. The endpoint_id is taken from the PATH and overwrites any endpoint_id present in the body. Edge cases: :endpoint_id must be a UUID and event_type must be non-empty; the event_type must already exist in the event-type registry — an unknown one returns 400 UnregisteredEventType, a distinct code from the plain ValidationError; an endpoint_id that is a valid UUID but matches no endpoint returns 404 EndpointNotFound; filter_predicate is accepted only if it is an object and is otherwise silently dropped rather than rejected; there is no duplicate-subscription guard on the route, so re-subscribing the same (endpoint, event_type) depends on the DB constraint.
[ "POST /api/auth/signup-tenant", "POST /api/webhooks/endpoints" ]
event_type: billing.invoice.finalized.v1, billing.invoice.paid.v1, billing.dunning.advanced.v1, billing.reprice.dry-run.completed.v1| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | endpoint_id must be a UUID / event_type is required | validateSubscribe fails; all failures are returned together in details[] |
| 400 | UnregisteredEventType | Event type <event_type> is not registered | the event_type has no entry in the event-type registry |
| 404 | EndpointNotFound | Endpoint <endpoint_id> not found | :endpoint_id is a valid UUID but no webhook endpoint row matches |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"endpoint_id": "{{cache:webhooks.register-endpoint.response.data.endpoint.endpoint_id}}"
}{
"event_type": "billing.invoice.finalized.v1",
"filter_predicate": {}
}{
"event_type": "billing.invoice.finalized.v1",
"filter_predicate": {}
}{
"success": true,
"data": {
"subscribe_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"event_type": "billing.invoice.finalized.v1",
"filter_predicate": {},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"subscription": {
"subscription_id": "string",
"endpoint_id": "string",
"event_type": "string",
"active": "boolean"
}
}
}POST/api/webhooks/publish🔒 auth
Manually publishes an event into the webhook outbox for fan-out to every matching subscription. Returns 202 Accepted — delivery is asynchronous, so 202 means the event was ENQUEUED, not that any endpoint received it. Edge cases: tenant_id must be a UUID and event_type, event_id and a payload OBJECT are all required (a payload that is a string or array fails the typeof check); event_id is caller-supplied and acts as the outbox idempotency key, so reusing one deduplicates a repeat publish while a fresh id on retry fans the event out twice; publishing an event_type with zero subscriptions is still a successful 202 with an empty fan-out.
[ "POST /api/auth/signup-tenant" ]
event_type: billing.invoice.finalized.v1, billing.invoice.paid.v1, billing.dunning.advanced.v1, billing.reprice.dry-run.completed.v1| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler) |
| 401 | Unauthorized | Invalid or expired token | JWT signature invalid, malformed, or exp has elapsed |
| 400 | ValidationError | tenant_id must be a UUID / event_type is required / event_id is required / payload object is required | any validatePublish check fails; all failures are returned together in details[] |
| 500 | InternalError | InternalError | Unexpected service/DB failure (connection loss, or a constraint the handler does not map) |
{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"event_type": "billing.invoice.finalized.v1",
"event_id": "{{var:event_id}}",
"payload": {
"invoice_id": "inv_0001",
"amount_due_cents": 12500,
"currency": "USD"
}
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"event_type": "billing.invoice.finalized.v1",
"event_id": "{{var:event_id}}",
"payload": {
"invoice_id": "inv_0001",
"amount_due_cents": 12500,
"currency": "USD"
}
}{
"success": true,
"data": {
"status": "accepted",
"job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 202.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"deliveries_enqueued": "number",
"delivery_ids": "array"
}
}sdk-workflow
GET/api/workflows/:run_id🔒 auth
Queries the full state of one workflow run by run_id - run header plus its steps and any compensations. Requires a valid tenant JWT (requireAuth). Edge cases: the lookup is by run_id alone and the caller JWT tenant is never compared to the run envelope, so any authenticated caller can read any run including its step payloads - tenant scoping must be tested explicitly; a well-formed but unknown run_id returns 404 NotFound with the id echoed in details[]; runs in every state (running, paused, completed, failed, compensated) return 200, so the caller must inspect the status field rather than expecting a non-200 for a failed run; a non-UUID run_id fails the uuid cast and is routed through fail(), whose "not found" matcher can turn a driver error into a 409 InvalidState, otherwise it is a 500.
[ "POST /api/auth/register", "POST /api/workflows/definitions", "POST /api/workflows/start" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 404 | NotFound | Run <run_id> not found | getRun returns no record for the run_id |
| 409 | InvalidState | <error message containing "not found" or "not in running"> | getRun throws an error whose message matches the fail() state matcher |
| 500 | InternalError | InternalError | getRun throws any other error - a non-UUID run_id failing the uuid cast, or any DB error |
{
"run_id": "{{cache:workflows.start.response.data.run_id}}"
}{
"success": true,
"data": {
"workflow_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"run": "object",
"steps": "array",
"compensations": "array"
}
}POST/api/workflows/:run_id/signal🔒 auth
Delivers a named signal (with an optional payload) to an in-progress workflow run identified by the run_id path param, returning {signaled:true}. Requires a valid tenant JWT (requireAuth). Edge cases: signal_name is mandatory and payload defaults to {} when omitted; there is a hard state precondition - the run must be in running or paused state, so signalling a completed, failed or compensated run is a 409 InvalidState, and an unknown run_id also surfaces as a 409 rather than a 404 because fail() maps both "not found" and "not in running" onto the same status; the endpoint is not idempotent - re-sending the same signal_name delivers it again, so a retry after a timeout can double-advance the run; if the signal advances the run into a step whose handler is not registered in this process the call fails with 400 StepHandlerNotFound; the caller JWT tenant is not compared to the run envelope, so cross-tenant signalling is not blocked here.
[ "POST /api/auth/register", "POST /api/workflows/definitions", "POST /api/workflows/start" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | signal_name is required | validateSignal fails - signal_name absent or empty |
| 400 | StepHandlerNotFound | No step handler registered for '<name>' | the signal advances the run to a step whose handler is not registered in this process (StepHandlerNotFoundError) |
| 400 | WorkflowMissingHandlers | Workflow definition missing step handlers: <names> | the run definition resolves to steps with no registered handlers (WorkflowDefinitionMissingHandlersError) |
| 404 | WorkflowDefinitionNotFound | Workflow definition <name> not found | signal raises WorkflowDefinitionNotFoundError because the run definition is no longer registered |
| 409 | InvalidState | <message containing "not found" or "not in running"> | the run_id does not exist, or the run is not in running/paused state (completed, failed or compensated) - both collapse to this status |
| 500 | InternalError | InternalError | signal throws any other error - engine failure or a DB error |
{
"run_id": "{{cache:workflows.start.response.data.run_id}}"
}{
"signal_name": "approve",
"payload": {
"approver_id": "{{cache:auth.register.response.data.userId}}"
}
}{
"signal_name": "approve",
"payload": {
"approver_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{
"success": true,
"data": {
"signal_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"signal_name": "approve",
"payload": {
"approver_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"signaled": "boolean"
}
}POST/api/workflows/definitions🔒 auth
Registers or upserts a workflow definition (name, optional version/namespace, and step_specs) and returns 201 with the stored definition. Requires a valid tenant JWT (requireAuth). Edge cases: name is mandatory and step_specs must be a non-empty array whose every entry is shaped {name: string, compensate?: string} - an empty array and a malformed entry are two distinct 400 messages; registration also fails with 400 WorkflowMissingHandlers when a step named in step_specs has no runtime handler registered in this process, which means the same payload can succeed or fail depending on which handlers the deployment loaded; a step whose compensate handler is unregistered surfaces as 400 StepHandlerNotFound; because it is an upsert, re-posting the same name/version is accepted and overwrites rather than returning 409, so it is idempotent on name+version and changing step_specs silently redefines the workflow for subsequent runs.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | name is required / step_specs must be a non-empty array / each step_specs entry needs {name: string, compensate?: string} | validateRegisterDefinition fails; details[] carries every failed rule |
| 400 | WorkflowMissingHandlers | Workflow definition missing step handlers: <names> | registerWorkflow raises WorkflowDefinitionMissingHandlersError - one or more steps named in step_specs have no handler registered in this process |
| 400 | StepHandlerNotFound | No step handler registered for '<name>' | registerWorkflow raises StepHandlerNotFoundError while resolving a step or compensation handler |
| 409 | InvalidState | <error message containing "not found" or "not in running"> | a service error whose message matches the fail() state matcher |
| 500 | InternalError | InternalError | registerWorkflow throws any other error - a DB failure or unmapped service error |
{
"name": "qa-workflow-demo",
"version": "1.0.0",
"namespace": "admin",
"step_specs": [
{
"name": "dunning.send-reminder",
"compensate": "dunning.rollback-reminder"
},
{
"name": "dunning.write-off"
}
]
}{
"name": "qa-workflow-demo",
"version": "1.0.0",
"namespace": "admin",
"step_specs": [
{
"name": "dunning.send-reminder",
"compensate": "dunning.rollback-reminder"
},
{
"name": "dunning.write-off"
}
]
}{
"success": true,
"data": {
"definition_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "qa-workflow-demo",
"version": "1.0.0",
"namespace": "admin",
"step_specs": [
{
"name": "dunning.send-reminder",
"compensate": "dunning.rollback-reminder"
},
{
"name": "dunning.write-off"
}
],
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"definition": {
"workflow_def_id": "string",
"name": "string",
"version": "string",
"namespace": "string",
"step_specs": "array",
"status": "string"
}
}
}POST/api/workflows/start🔒 auth
Starts a run of a registered workflow definition, resolving the active definition by name (+ optional version/namespace, default namespace "admin"), drives the runtime engine, and returns 201 with run_id/status/steps/output. The envelope is inherited from the JWT (tenant_id/sub) when not supplied. Edge cases: missing name (only required field), a name/version/namespace with no matching active definition (404), and a step whose handler is not registered in-process (400).
[ "POST /api/auth/register", "POST /api/personas", "POST /policy/:id/evaluate", "POST /scim/v2/Users", "POST /api/workflows/definitions" ]
envelope.actor.kind: human, service, agent, support_impersonator| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not Bearer form (requireAuth) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification (requireAuth) |
| 400 | ValidationError | details[]: name is required / body must be an object | validateStartRun fails |
| 404 | WorkflowDefinitionNotFound | No active workflow definition for name=... version=... namespace=... | startRun finds no active definition |
| 400 | StepHandlerNotFound | step handler not registered | executeRun hits a step with no in-process handler |
| 500 | InternalError | InternalError | any other unmapped error |
{
"entity": "workflow.run",
"field": "status",
"flow": [
"running",
"completed",
"compensated"
],
"transitions": [
{
"from": "running",
"to": "completed",
"via": "POST /api/workflows/start"
},
{
"from": "running",
"to": "compensated",
"via": "POST /api/workflows/start"
}
]
}{
"name": "qa-workflow-demo",
"version": "1.0.0",
"namespace": "admin",
"envelope": {
"persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
"trace_id": "{{cache:policy.evaluate.response.data.trace_id}}",
"actor": {
"kind": "service",
"id": "{{cache:scim.create.response.data.person_id}}"
}
},
"input": {
"amount": 1500,
"currency": "USD"
}
}{
"name": "qa-workflow-demo",
"version": "1.0.0",
"namespace": "admin",
"envelope": {
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trace_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor": {
"kind": "service",
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
},
"input": {
"amount": 1500,
"currency": "USD"
}
}{
"success": true,
"data": {
"start_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "qa-workflow-demo",
"version": "1.0.0",
"namespace": "admin",
"envelope": {
"persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trace_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"actor": {
"kind": "service",
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
},
"input": {
"amount": 1500,
"currency": "USD"
},
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 201.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"run_id": "string",
"status": "string",
"steps": "array",
"output": "object"
}
}semantic-service
POST/api/build/plan🔒 auth
Planner v2: retrieve-then-compose. Embeds the intent locally (bge-small), retrieves top-K candidate SDKs, injects the foundation/AIM tier + dependency closure, then composes a plan from candidates only. Returns { plan: { summary, recommended_sdks[], custom_work[], clarifying_questions[], vertical_pack, complexity }, meta: { catalog_size, provider, audit_status, retrieval } }.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | unauthorized | unauthorized | The Authorization header is absent. NOTE: this is a Next.js App Router route (apps/tenant-workspace/app/api/build/plan/route.ts), NOT a gateway-mounted Fastify route, so the api-gateway authGate does not apply. It only checks that the header EXISTS — the token is never verified, so an expired/garbage/non-Bearer value still passes. |
| 400 | IntentRequired | intent is required | body.intent is missing, null, or trims to the empty string (whitespace-only counts as empty). |
| 400 | IntentTooLong | intent exceeds 2000 characters | The trimmed intent is longer than 2000 characters — oversized-input guard that keeps the compose prompt bounded. |
| 422 | NoCandidateSdks | no candidate SDKs matched the intent; try describing the app differently | After retrieve (top-K=20) + foundation injection + dependency expansion, resolveCandidates() returns an empty list — nothing in the catalog scored against the intent. |
| 500 | CatalogLoadFailed | failed to load SDK catalog: <underlying error> | loadCatalogWithMeta() throws while reading sdk-capability.json manifests off disk (unreadable dir, malformed manifest JSON). |
| 500 | CatalogEmpty | no SDK manifests found at <packagesDir> (tried PROJEXCLOUD_PACKAGES_DIR env, cwd/packages, cwd/../../packages). cwd=<cwd> | The catalog loads but is empty — the packages directory could not be resolved from PROJEXCLOUD_PACKAGES_DIR, cwd/packages, or cwd/../../packages. Typical in a container where the portal image does not ship packages/. |
| 502 | PlanComposeFailed | <LLM provider error message> | generateBuildPlan() throws for any reason other than a missing provider/key — upstream LLM HTTP error, timeout, or an unparseable plan response. |
| 503 | LlmProviderUnavailable | No LLM provider configured / <PROVIDER>_API_KEY is not set | generateBuildPlan() throws with a message containing 'No LLM provider' or 'API_KEY is not set' — the planner LLM is unconfigured. Deliberately distinguished from 502 so callers can tell config gaps from upstream failures. |
| 500 | InternalError | Internal Server Error | await req.json() throws on a malformed or non-JSON body — this is not caught by the route, so the Next.js runtime returns a bare 500. |
{
"intent": "A financial accounting system with invoices, AR/AP and monthly close"
}{
"intent": "A financial accounting system with invoices, AR/AP and monthly close"
}{
"success": true,
"data": {
"plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"intent": "A financial accounting system with invoices, AR/AP and monthly close",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"plan": {
"recommended_sdks": []
}
}{
"intent": ""
}{
"intent": ""
}{
"success": true,
"data": {
"plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"intent": "",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 400.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}{
"intent": "a dispatch app"
}{
"intent": "a dispatch app"
}{
"success": true,
"data": {
"plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"intent": "a dispatch app",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 401.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/bridge🔒 auth
Lists every registered cross-domain bridge (semantic.cross_domain_bridge) as {success:true, data:[...]} with bridge_id, from_object_type_id, to_object_type_id, access_mode and requires_cross_tenant_consent. QA edge cases: the handler takes no query parameters — it is a global, unfiltered, unpaginated read, so there is no tenant scoping, no limit/offset and no ordering guarantee; a fresh database legitimately returns 200 with an empty data array rather than a 404. It is a pure read (idempotent, safe to re-run) and there are no validation branches, so the only client-side failure is authentication: /bridge is not on the gateway public allowlist, so the default-deny auth gate requires a valid tenant JWT. Because the route has no try/catch, a datastore failure surfaces as Fastify's default 500 envelope ({statusCode, error:'Internal Server Error', message}) rather than the {success:false} shape used by the write endpoints.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — /bridge is not on the authGate.ts public allowlist, so requireAuth rejects the request before the route handler |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or expired) |
| 500 | Internal Server Error | Internal Server Error | listBridges() throws (semantic schema missing, DB pool unavailable) — uncaught, so Fastify's default error handler responds |
{
"success": true,
"data": [
{
"bridge_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/bridge🔒 auth
Registers a cross-domain bridge between two semantic object types, returning 200 {success:true, data:{bridge_id, from_object_type_id, to_object_type_id, access_mode, requires_cross_tenant_consent}} and emitting a semantic.bridge.created.v1 audit entry. QA edge cases: the INSERT is ON CONFLICT (from_object_type_id, to_object_type_id) DO UPDATE, so re-posting the same pair is idempotent on identity — it returns the SAME bridge_id (never 409) but overwrites access_mode and requires_cross_tenant_consent, which is the way to test duplicate handling. Only from_object_type_id and to_object_type_id are checked for presence; access_mode defaults to 'read-only' and requires_cross_tenant_consent defaults to true when omitted. Everything past that check is wrapped in one catch that maps ANY thrown error to 400, so missing-FK cases (an object_type_id that does not exist), malformed UUIDs, an access_mode outside {read-only, read-write} rejected by the column constraint, and a self-referencing bridge all surface as 400 with the raw Postgres message in `error` — never 404 and never 500. Empty-string ids are falsy and hit the required-fields branch instead. The bridge is global (no tenant_id column), so it is not tenant-scoped; the path is not on the gateway public allowlist, so a valid tenant JWT is required.
[ "POST /api/auth/register", "POST /ontology/register" ]
access_mode: read-only, read-write| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — /bridge is not on the authGate.ts public allowlist, so requireAuth rejects before the handler |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or expired) |
| 400 | ValidationError | from_object_type_id and to_object_type_id required | Body is missing/empty, or either id is absent or an empty string |
| 400 | BridgeCreateFailed | <Postgres error message from the INSERT, e.g. invalid input syntax for type uuid / violates foreign key constraint / violates check constraint> | createBridge throws — malformed UUID, object_type_id that does not exist, access_mode outside the allowed enum, or any other DB failure; the handler's catch maps every error to 400 |
{
"from_object_type_id": "{{cache:ontology.register.response.data.object_types.0.object_type_id}}",
"to_object_type_id": "{{cache:ontology.register.response.data.object_types.1.object_type_id}}",
"access_mode": "read-only",
"requires_cross_tenant_consent": true
}{
"from_object_type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"to_object_type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"access_mode": "read-only",
"requires_cross_tenant_consent": true
}{
"success": true,
"data": {
"bridge_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"from_object_type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"to_object_type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"access_mode": "read-only",
"requires_cross_tenant_consent": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/intent/plan🔒 auth
Compiles a SemanticIntent into an executable Plan (G9 AC-8): resolves intent.subject.type to a semantic.object_type in the given ontology, selects matching semantic.capability_graph_edge rows by goal-keyword / pre-condition / post-condition fit, topologically orders them, persists an intent_plan row with status 'proposed', and emits a semantic.intent.planned.v1 audit entry. Returns 200 {success:true, data:{plan_id, intent_id, subject_id, steps[], generated_at, status}}. QA edge cases: five fields are mandatory and checked as one branch — tenant_id, ontology_id, goal, subject and trace_id; any missing one (or a missing `intent` wrapper) gives the same 400 'intent missing required fields'. Note subject.id is NOT validated, so an intent with subject:{type:'Patient'} and no id still plans and yields steps with an undefined subject argument. Everything after the presence check is caught and returned as 400, so semantic misses that a tester would expect as 404 are 400s instead: an unknown subject type in the ontology, and — the most common empty-result case — a goal whose tokens match no capability edge ('no capability_graph_edge matches goal ...'). A goal of one noise word therefore fails rather than returning an empty plan. Intent persistence is idempotent on (tenant_id, goal): re-posting the same tenant+goal reuses the existing intent_id but always mints a NEW plan_id, so the endpoint as a whole is not idempotent. agent_run_id is optional and defaults to null. There is no pagination — every matched step is inlined into steps[]. The path is not on the gateway public allowlist, so a valid tenant JWT is required; tenant_id comes from the body and is not cross-checked against the token.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /ontology/register" ]
status: proposed, approved, executing, completed, abandoned| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — authGate.ts default-deny gate rejects /intent/plan before the handler |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or expired) |
| 400 | ValidationError | intent missing required fields | Body missing, `intent` absent, or any of intent.tenant_id / ontology_id / goal / subject / trace_id is missing or falsy |
| 400 | PlanFailed | [sdk-semantic] unknown subject type '<type>' in ontology <ontology_id> | No semantic.object_type row matches intent.subject.type for the given ontology_id (includes a bogus or non-existent ontology_id) |
| 400 | PlanFailed | [sdk-semantic] no capability_graph_edge matches goal '<goal>' for subject type '<type>' | Candidate edges exist for the subject type but none satisfies the goal keywords / pre-conditions / post-conditions — an unplannable goal |
| 400 | PlanFailed | [sdk-semantic] cannot persist intent — subject type '<type>' not registered | ensureIntentRow cannot resolve the subject object_type_id when creating the backing semantic.intent row |
| 400 | PlanFailed | <Postgres or audit error message from the INSERT into semantic.intent_plan / appendAuditEntry> | Any other failure while materializing or auditing the plan (malformed UUID ontology_id, FK violation, DB unavailable) — the handler's catch maps every thrown error to 400 |
{
"intent": {
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"ontology_id": "{{cache:ontology.register.response.data.ontology.ontology_id}}",
"goal": "summarize clinical note",
"subject": {
"type": "Patient",
"id": "{{var:subject_id}}"
},
"parameters": {},
"trace_id": "{{var:trace_id}}"
},
"agent_run_id": null
}{
"intent": {
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ontology_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"goal": "summarize clinical note",
"subject": {
"type": "Patient",
"id": "{{var:subject_id}}"
},
"parameters": {},
"trace_id": "{{var:trace_id}}"
},
"agent_run_id": null
}{
"success": true,
"data": {
"plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"intent": {
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ontology_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"goal": "summarize clinical note",
"subject": {
"type": "Patient",
"id": "{{var:subject_id}}"
},
"parameters": {},
"trace_id": "{{var:trace_id}}"
},
"agent_run_id": null,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/ontology🔒 auth
Lists every registered ontology known to the semantic service, returning the full collection in one response. Edge cases: the handler takes no query parameters — there is no filtering by name, tenant or status and no pagination or limit, so active and deprecated versions all come back together and the payload grows unbounded with the number of registered ontologies; an empty registry returns 200 with an empty data array rather than 404; the route has no validation branch and no not-found branch, so the only reachable failure is an unhandled datastore error surfacing through the Fastify error handler as 500.
[ "POST /api/auth/register", "POST /ontology/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Mounted in the api-gateway, /ontology/* is not on the default-deny allowlist, so the root authGate hook rejects a request with no valid tenant JWT. The standalone semantic-service binary registers these routes without auth |
| 500 | InternalError | Internal Server Error | listOntologies throws (database unreachable); the route has no try/catch, so the failure surfaces through the default Fastify error handler |
{
"success": true,
"data": [
{
"ontology_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/ontology/:id/deprecate🔒 auth
Deprecates a specific ontology version by id with an optional reason, returning the updated ontology record. Edge cases: reason is optional and defaults to the literal "unspecified" when the body omits it, so a deprecation is never blocked for lack of justification; every error from deprecateOntology maps to 404 with the raw message, so an unknown id, an id already deprecated, and a datastore failure all present as 404 and must be told apart by message text; because a repeat call on an already-deprecated version raises rather than no-ops, the operation is not idempotent; deprecating the active version leaves the ontology name with no active version, after which GET /ontology/:name/active returns 404.
[ "POST /api/auth/register", "POST /ontology/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Mounted in the api-gateway, /ontology/* is not on the default-deny allowlist, so the root authGate hook rejects a request with no valid tenant JWT. The standalone semantic-service binary registers these routes without auth |
| 404 | NotFound | <error message from deprecateOntology> | deprecateOntology throws — the ontology id is unknown or the version is already deprecated. Datastore errors are also caught by this branch |
{
"entity": "semantic.ontology",
"field": "status",
"flow": [
"draft",
"active",
"deprecated",
"retired"
],
"transitions": [
{
"from": "active",
"to": "deprecated",
"via": "POST /ontology/:id/deprecate"
}
]
}{
"id": "{{cache:ontology.register.response.data.ontology.ontology_id}}"
}{
"reason": "superseded by healthcare-core 2.0.0"
}{
"reason": "superseded by healthcare-core 2.0.0"
}{
"success": true,
"data": {
"deprecate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"reason": "superseded by healthcare-core 2.0.0",
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/ontology/:name/active🔒 auth
Fetches the currently active ontology version for a given ontology name from the path. Edge cases: any error thrown by getActiveOntology is mapped to 404 with the raw message, so a name that was never registered and a name whose every version has been deprecated (leaving no active version) both return 404 and are only distinguishable by message text; a datastore failure is also swallowed into that 404 branch rather than surfacing as 500, so a 404 here does not strictly prove absence; the lookup is by name only with no version or tenant qualifier, so it always reflects the most recent activation.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Mounted in the api-gateway, /ontology/* is not on the default-deny allowlist, so the root authGate hook rejects a request with no valid tenant JWT. The standalone semantic-service binary registers these routes without auth |
| 404 | NotFound | <error message from getActiveOntology> | getActiveOntology throws — the ontology name is unknown, or it has no currently active version because all versions were deprecated. Datastore errors are also caught by this branch |
{
"name": "{{var:active_ontology_name}}"
}{
"success": true,
"data": {
"active_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/ontology/register🔒 auth
Registers a DomainOntologyBundle under a bundle_ref and, unless activate is explicitly false, makes it the active version for its ontology name — activate defaults to true, so an ordinary register call silently supersedes whatever version was previously active. Edge cases: both bundle and bundle_ref are required and a missing one is a 400; every downstream failure from registerOntology is also collapsed to 400 with the raw error message rather than a typed status, so a structurally invalid bundle, an unresolvable reference inside it, and a duplicate bundle_ref all present as 400 and must be distinguished by message text; re-registering an identical bundle_ref is therefore not a safe idempotent retry; the route performs no tenant scoping of its own.
[ "POST /api/auth/register" ]
cardinality: 1:1, 1:N, N:N| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Mounted in the api-gateway, /ontology/* is not on the default-deny allowlist, so the root authGate hook rejects a request with no valid tenant JWT. The standalone semantic-service binary registers these routes without auth |
| 400 | ValidationError | bundle and bundle_ref required | The body is missing bundle or bundle_ref |
| 400 | RegistrationError | <error message from registerOntology> | registerOntology throws — invalid bundle structure, duplicate bundle_ref, or an unresolvable reference inside the bundle. The raw message is returned |
{
"bundle": {
"name": "healthcare-core",
"version": "1.0.0",
"parent_ontology": null,
"object_types": [
{
"name": "Patient",
"attribute_schema": {
"mrn": "string"
},
"backed_by": "persona.persona_ext:patient_chart"
},
{
"name": "Encounter",
"attribute_schema": {
"code": "string"
},
"backed_by": "clinical.encounter"
}
],
"relation_types": [
{
"name": "treats",
"cardinality": "1:N",
"rebac_kind_mapping": "member",
"from_object_type_name": "Patient",
"to_object_type_name": "Encounter"
}
],
"capability_graph": [
{
"tool_sku": "clinical.note.summarize",
"pre_conditions": {},
"post_conditions": {},
"object_type_name": "Patient",
"requires_relation_name": "treats"
}
]
},
"bundle_ref": "@projexlight/contracts@3.1.0",
"activate": true
}{
"bundle": {
"name": "healthcare-core",
"version": "1.0.0",
"parent_ontology": null,
"object_types": [
{
"name": "Patient",
"attribute_schema": {
"mrn": "string"
},
"backed_by": "persona.persona_ext:patient_chart"
},
{
"name": "Encounter",
"attribute_schema": {
"code": "string"
},
"backed_by": "clinical.encounter"
}
],
"relation_types": [
{
"name": "treats",
"cardinality": "1:N",
"rebac_kind_mapping": "member",
"from_object_type_name": "Patient",
"to_object_type_name": "Encounter"
}
],
"capability_graph": [
{
"tool_sku": "clinical.note.summarize",
"pre_conditions": {},
"post_conditions": {},
"object_type_name": "Patient",
"requires_relation_name": "treats"
}
]
},
"bundle_ref": "@projexlight/contracts@3.1.0",
"activate": true
}{
"success": true,
"data": {
"register_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"bundle": {
"name": "healthcare-core",
"version": "1.0.0",
"parent_ontology": null,
"object_types": [
{
"name": "Patient",
"attribute_schema": {
"mrn": "string"
},
"backed_by": "persona.persona_ext:patient_chart"
},
{
"name": "Encounter",
"attribute_schema": {
"code": "string"
},
"backed_by": "clinical.encounter"
}
],
"relation_types": [
{
"name": "treats",
"cardinality": "1:N",
"rebac_kind_mapping": "member",
"from_object_type_name": "Patient",
"to_object_type_name": "Encounter"
}
],
"capability_graph": [
{
"tool_sku": "clinical.note.summarize",
"pre_conditions": {},
"post_conditions": {},
"object_type_name": "Patient",
"requires_relation_name": "treats"
}
]
},
"bundle_ref": "@projexlight/contracts@3.1.0",
"activate": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/plan/:id🔒 auth
Reads a single intent plan by plan_id from semantic.intent_plan, returning 200 {success:true, data:{plan_id, intent_id, subject_id, steps[], generated_by_agent_run_id, generated_at, status}}. QA edge cases: :id must be a well-formed UUID — getPlan runs a bare `WHERE plan_id = $1` with no try/catch, so a non-UUID path segment ('abc', '123') raises a Postgres 'invalid input syntax for type uuid' error that escapes as Fastify's default 500, NOT a 404; only a syntactically valid but unknown UUID returns the clean 404 {success:false, error:'plan <id> not found'}. The read is idempotent, unpaginated (the full steps array is inlined however long the plan is) and returns the CURRENT status, so fetching after POST /plan/:id/status is the way to confirm a lifecycle transition. There is no tenant filter in the query — any authenticated caller who knows a plan_id can read it — so tenant-isolation tests must not expect a 403/404 for another tenant's plan. /plan/:id is not on the gateway public allowlist, so the default-deny auth gate requires a valid tenant JWT before the handler runs.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /ontology/register", "POST /intent/plan" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — authGate.ts default-deny gate rejects /plan/:id before the handler |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or expired) |
| 404 | NotFound | plan <id> not found | getPlan returns null — the plan_id is a valid UUID but no semantic.intent_plan row matches |
| 500 | Internal Server Error | Internal Server Error | getPlan throws — most commonly a non-UUID :id producing 'invalid input syntax for type uuid', or the semantic schema / DB pool being unavailable; the route has no catch so Fastify's default error handler responds |
{
"id": "{{cache:intent.plan.response.data.plan_id}}"
}{
"success": true,
"data": {
"plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active",
"created_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/plan/:id/status🔒 auth
Advances an intent plan through its lifecycle (proposed → approved → executing → completed|abandoned, plus proposed → abandoned) by UPDATEing semantic.intent_plan.status, returning 200 {success:true, data:{plan_id, status}}; reaching 'completed' or 'abandoned' also emits a semantic.plan.executed.v1 audit entry. QA edge cases: the handler does NOT enforce the transition graph — the UPDATE is unconditional, so jumping proposed → completed, or moving backwards from executing → proposed, both succeed with 200. Re-posting the same status is idempotent (row updates to the same value, still 200). Only presence of `status` is validated (400 when missing, empty string, or an empty body); everything after that is wrapped in a single catch that maps EVERY error to 404, so a status outside the DB enum ('shipped'), a non-UUID :id ('abc' → invalid input syntax for type uuid), and a valid-but-unknown plan_id all return 404 {success:false, error:...} — never 400 or 500 for those cases. The distinguishing signal is the `error` string, not the status code. The UPDATE has no tenant predicate, so any authenticated caller holding a plan_id can transition it; the path is not on the gateway public allowlist, so a valid tenant JWT is required.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /ontology/register", "POST /intent/plan" ]
status: proposed, approved, executing, completed, abandoned| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | No Authorization: Bearer header — authGate.ts default-deny gate rejects /plan/:id/status before the handler |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails verifyJwt (bad signature, malformed, or expired) |
| 400 | ValidationError | status required | Body missing entirely, or body.status absent / empty string |
| 404 | NotFound | [sdk-semantic] plan '<id>' not found | The UPDATE matches no row — plan_id is a valid UUID but does not exist |
| 404 | PlanStatusUpdateFailed | <Postgres error message, e.g. invalid input syntax for type uuid / invalid input value for enum plan_status> | updatePlanStatus throws for any other reason — non-UUID :id, a status value outside the DB enum, or a DB failure; the handler's catch maps every error to 404 |
{
"entity": "plan",
"field": "status",
"flow": [
"proposed",
"approved",
"executing",
"completed",
"abandoned"
],
"transitions": [
{
"from": "proposed",
"to": "approved",
"via": "POST /plan/:id/status"
},
{
"from": "approved",
"to": "executing",
"via": "POST /plan/:id/status"
},
{
"from": "executing",
"to": "completed",
"via": "POST /plan/:id/status"
},
{
"from": "proposed",
"to": "abandoned",
"via": "POST /plan/:id/status"
}
]
}{
"id": "{{cache:intent.plan.response.data.plan_id}}"
}{
"status": "approved"
}{
"status": "approved"
}{
"success": true,
"data": {
"statu_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "approved",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}GET/policy🔒 auth
Lists semantic-service policies, optionally filtered by the tenant_id and ontology_id query params. Mounted into the api-gateway from services/semantic-service, so the gateway default-deny authGate applies and a valid tenant JWT is required even though the handler declares no requireAuth. Edge cases: tenant_id is optional and defaults to null when omitted, which lists the global/unscoped policies rather than everything the caller can see - and because the filter comes from the query string and not the JWT, passing another tenant id is not rejected, so tenant scoping must be tested explicitly; no matches returns 200 with an empty data array, never a 404; there is no limit/offset paging, so the whole result set comes back in one payload; the handler has no try/catch and no validation at all, so a non-UUID tenant_id or ontology_id fails the uuid cast and is surfaced by the Fastify default error handler as a 500.
[ "POST /api/auth/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 500 | InternalServerError | Internal Server Error | listPolicies throws and the route has no try/catch, so the Fastify default error handler responds - chiefly a non-UUID tenant_id or ontology_id failing the uuid cast, or any DB error |
{
"success": true,
"data": [
{
"policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}POST/policy/:id/evaluate🔒 auth
Evaluates the semantic-service policy named by the :id path param against an evaluation context (subject_type, action, resource_type, trace_id). Mounted into the api-gateway from services/semantic-service, so the gateway default-deny authGate applies and a valid tenant JWT is required even though the handler declares no requireAuth. Edge cases: all four of subject_type, action, resource_type and trace_id are mandatory and any omission produces a single 400; a deny decision is still a 200 with the decision in the body, so only transport failures produce a non-200; note the catch is unconditional and maps EVERY service throw to 404, so an unknown policy id, a non-UUID id failing the uuid cast, an IQL evaluation error and a DB outage all return the same 404 with the raw message - a 500 is never returned from this route, which makes an infrastructure failure indistinguishable from a missing policy.
[ "POST /api/auth/register", "POST /policy/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 400 | ValidationError | subject_type, action, resource_type, trace_id required | any of subject_type, action, resource_type or trace_id is absent or empty |
| 404 | EvaluateFailed | <service error message> | evaluate throws for any reason - unknown or inactive policy id, a non-UUID id failing the uuid cast, an IQL evaluation error, or a DB error; the catch maps every throw to 404 |
{
"id": "{{cache:policy.register.response.data.policy_id}}"
}{
"subject_type": "Doctor",
"subject_id": "{{var:subject_id}}",
"action": "write",
"resource_type": "Prescription",
"resource_id": "{{var:resource_id}}",
"active_edges": [
{
"kind": "care-team",
"from_object_type": "Doctor",
"to_object_type": "Patient",
"to_object_id": "{{var:resource_id}}",
"active": true
}
],
"trace_id": "{{var:trace_id}}"
}{
"subject_type": "Doctor",
"subject_id": "{{var:subject_id}}",
"action": "write",
"resource_type": "Prescription",
"resource_id": "{{var:resource_id}}",
"active_edges": [
{
"kind": "care-team",
"from_object_type": "Doctor",
"to_object_type": "Patient",
"to_object_id": "{{var:resource_id}}",
"active": true
}
],
"trace_id": "{{var:trace_id}}"
}{
"success": true,
"data": {
"status": "completed",
"subject_type": "Doctor",
"subject_id": "{{var:subject_id}}",
"action": "write",
"resource_type": "Prescription",
"resource_id": "{{var:resource_id}}",
"active_edges": [
{
"kind": "care-team",
"from_object_type": "Doctor",
"to_object_type": "Patient",
"to_object_id": "{{var:resource_id}}",
"active": true
}
],
"trace_id": "{{var:trace_id}}",
"evaluate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"policy_id": "string",
"decision": "string",
"reason": "string",
"latency_ms": 0,
"trace_id": "string"
}
}POST/policy/register🔒 auth
Registers a semantic-service policy against an ontology from IQL source, optionally activating it immediately via the activate flag (default false, so a newly registered policy is inert until activated). Mounted into the api-gateway from services/semantic-service, so the gateway default-deny authGate applies and a valid tenant JWT is required even though the handler has no requireAuth of its own. Edge cases: ontology_id, name and iql_source are presence-checked; tenant_id is optional and defaults to null, which registers a global cross-tenant policy - omitting it is silently accepted rather than rejected, so tenant scoping must be asserted deliberately; an unknown ontology_id, an IQL parse failure and a duplicate name all collapse into the same 400 with the raw service message, so this route never returns 404 or 409; there is no idempotency key, so re-registering the same name either duplicates or conflicts depending on the schema and either way surfaces as a 400.
[ "POST /api/auth/register", "POST /api/auth/signup-tenant", "POST /ontology/register" ]
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 401 | Unauthorized | Missing bearer token | Authorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce) |
| 401 | Unauthorized | Invalid or expired token | Bearer token fails JWT verification in the gateway authGate |
| 400 | ValidationError | ontology_id, name, iql_source required | any of ontology_id, name or iql_source is absent or empty |
| 400 | RegisterFailed | <service error message> | registerPolicy throws - unknown ontology_id, IQL parse failure, duplicate name, non-UUID identifiers, or any DB error; the catch flattens every failure to 400 |
{
"entity": "semantic.policy",
"field": "status",
"flow": [
"draft",
"active",
"deprecated"
],
"transitions": [
{
"from": "draft",
"to": "active",
"via": "POST /policy/register"
}
]
}{
"tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
"ontology_id": "{{cache:ontology.register.response.data.ontology.ontology_id}}",
"name": "{{dynamic:name}}",
"description": "Doctor with active care-team may write a Prescription",
"iql_source": "ALLOW Doctor WITH care-team(Patient) TO write Prescription",
"activate": true
}{
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ontology_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"description": "Doctor with active care-team may write a Prescription",
"iql_source": "ALLOW Doctor WITH care-team(Patient) TO write Prescription",
"activate": true
}{
"success": true,
"data": {
"register_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ontology_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme QA Sample",
"description": "Doctor with active care-team may write a Prescription",
"iql_source": "ALLOW Doctor WITH care-team(Patient) TO write Prescription",
"activate": true,
"status": "active",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "unauthorized: missing or invalid token"
}{
"data": {
"policy_id": "string",
"tenant_id": "string",
"ontology_id": "string",
"name": "string",
"status": "string"
}
}telemetry
GET/metricspublic
Prometheus scrape endpoint for the api-gateway. Each scrape best-effort refreshes the MDM/EMPI gauges and then renders the whole metric registry as text/plain; version=0.0.4, including the http_request_duration_seconds histogram fed by the gateway's onResponse hook (labelled by method and status_class). Edge cases: the EMPI gauge refresh is wrapped in a swallow-all try/catch, so an EMPI or database outage still yields 200 with the remaining metrics and merely stale MDM gauges - it never fails the scrape; '/metrics' is on the authGate public allowlist, so it responds identically with no Authorization header or an invalid one and never returns 401; it takes no query parameters and no body, so there is no validation branch and no client-error response; the body is plain-text exposition rather than JSON and grows with label cardinality instead of paginating.
| HTTP | Code | Message | When it happens |
|---|---|---|---|
| 500 | InternalServerError | Internal Server Error | metricsRegistry.render() itself throws - it is the one call in the handler left outside the swallow-all try/catch, and the gateway registers no setErrorHandler, so the failure surfaces as Fastify's default 500 payload. Not reachable through request input; only via a corrupted metric registry. |
{
"success": true,
"data": [
{
"metric_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "active"
}
],
"total": 1
}{success,data} envelope derived from the request contract; assert shape + HTTP 200.{
"success": false,
"error": "validation failed: a required field is missing or invalid"
}