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

85SDKs / services
716documented APIs
726test cases
▶ QA Test Plan — dependency-ordered test waves & what to test first ↗

api-gateway

57 API(s)
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Request field options
home_region: us-east-1, us-west-2, eu-west-1, ap-south-1
replication_overrides: sync, async, single-region
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\active-active-profiles-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrortenant_id, home_region, paired_regions[], contract_addendum_ref requiredtenant_id, home_region or contract_addendum_ref is missing/empty, or paired_regions is not an array (an empty array passes this guard)
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Activate an active-active (tier-G+) profile for a tenant → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /admin/active-active/profiles"
]
Implemented in
services/api-gateway/src/app.ts
Request field options
to_region: us-east-1, us-west-2, eu-west-1, ap-south-1
from_region: us-east-1, us-west-2, eu-west-1, ap-south-1
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\active-active-profiles-profile-id-drills-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorto_region requiredto_region is missing, null, or an empty string in the body (from_region is optional and never required)
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Run a failover drill against a tenant's active-active profile → expects HTTP 201
Path params
{
  "profile_id": "{{cache:active-active-profiles.create.response.data.profile_id}}"
}
Payload (template)
{
  "to_region": "us-west-2",
  "from_region": "us-east-1"
}
Example request
{
  "to_region": "us-west-2",
  "from_region": "us-east-1"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "to_region": "us-west-2",
    "from_region": "us-east-1",
    "drill_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /admin/active-active/profiles"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\active-active-profiles-tenant-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
404NotFoundno profile for tenantgetActiveActiveProfile(tenant_id) returns null/undefined — that tenant has no active-active profile
500InternalServerErrorInternal Server ErrorgetActiveActiveProfile() or listReplicationStreams() throws — most commonly tenant_id failing a uuid cast, or Postgres unavailable; unhandled by the route, so Fastify default error serialization applies
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch a tenant's active-active profile + replication streams → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "profile_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "profile not found"
}
e.g. HTTP 404
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\approvals-breaches-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
500InternalServerError<database error message>the breach query throws — jsonb_array_elements failing on a malformed route steps column, or Postgres unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List SLA-breached pending approval requests (operator view) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "breach_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/approvals/routes",
  "POST /api/approvals/requests"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
decision: approved, rejected
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\approvals-requests-request-id-operator-override-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrordecision + reason + operator_id requiredany of decision, reason or operator_id is missing, null, or an empty string
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Operator overrides a pending approval request → expects HTTP 200
Path params
{
  "request_id": "{{cache:approvals.requests.create.response.data.request.request_id}}"
}
Payload (template)
{
  "decision": "approved",
  "reason": "Operator override — SLA breach escalation resolved out-of-band",
  "operator_id": "{{var:operator_id}}"
}
Example request
{
  "decision": "approved",
  "reason": "Operator override — SLA breach escalation resolved out-of-band",
  "operator_id": "{{var:operator_id}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\approvals-routes-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List all approval routes (operator cross-tenant view) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\apps-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
400ValidationErrorapp_id, display_name are requiredapp_id or display_name is missing, null, or an empty string
500InternalServerError<database error message>appEnsure() throws — e.g. Postgres unavailable or a constraint violation on the app/org insert
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Provision an app (and its owning org) for tenant creation → expects HTTP 201
Path params
Payload (template)
{
  "app_id": "{{dynamic:name}}",
  "display_name": "QA Admin App",
  "org_name": "QA Admin Org"
}
Example request
{
  "app_id": "Acme QA Sample",
  "display_name": "QA Admin App",
  "org_name": "QA Admin Org"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\audit-entries-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Browse a tenant's audit entries → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "entry_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/audit/append"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\audit-entries-entry-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
404NotFoundentry not foundno row in audit.entry matches the entry_id path param
500InternalServerError<database error message>the SELECT throws — entry_id not castable to the column type, or Postgres unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch a single audit entry by id → expects HTTP 200
Path params
{
  "entry_id": "{{cache:audit.append.response.data.entry_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "entry_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "entry not found"
}
e.g. HTTP 404
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\audit-verify-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrortenant_id query param requiredthe tenant_id query-string parameter is missing or empty — note it is read from the query string, not the request body
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Verify a tenant's audit hash-chain → expects HTTP 200
Path params
Payload (template)
{}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "verify_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Request field options
provider: aws-kms, gcp-kms, hsm-pkcs11
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\byok-bindings-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrortenant_id, provider, customer_kms_key_arn, tenant_key_id, operator_id all requiredany of tenant_id, provider, customer_kms_key_arn, tenant_key_id or operator_id is missing, null, or an empty string
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Bind a customer CMK (BYOK) for a tenant → expects HTTP 200
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /admin/byok/bindings"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\byok-bindings-binding-id-revoke-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorreason + operator_id requiredreason or operator_id is missing, null, or an empty string in the body
404NotFoundbinding not foundrevokeCmk() returns null/undefined — no binding exists with that binding_id (or it is no longer revocable)
500InternalServerError<revoke error message>revokeCmk() throws — KMS unreachable, the audit/DB write failing, or Postgres unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Revoke a tenant's BYOK CMK binding (renders data undecryptable) → expects HTTP 200
Path params
{
  "binding_id": "{{cache:byok-bindings.create.response.data.binding_id}}"
}
Payload (template)
{
  "reason": "QA automated revoke drill",
  "operator_id": "{{var:operator_id}}"
}
Example request
{
  "reason": "QA automated revoke drill",
  "operator_id": "{{var:operator_id}}"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "reason": "QA automated revoke drill",
    "operator_id": "{{var:operator_id}}",
    "revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /admin/byok/bindings"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\byok-bindings-binding-id-rotate-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorprevious_tenant_key_id, new_tenant_key_id, operator_id requiredany of previous_tenant_key_id, new_tenant_key_id or operator_id is missing, null, or an empty string
409UndecryptableError<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
500InternalServerError<rotation error message>rotateCmk() throws anything other than UndecryptableError — unknown binding_id (there is no 404 branch), KMS unreachable, or the DB write failing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Rotate a tenant's BYOK CMK binding → expects HTTP 200
Path params
{
  "binding_id": "{{cache:byok-bindings.create.response.data.binding_id}}"
}
Payload (template)
{
  "previous_tenant_key_id": "{{var:tenant_key_id}}",
  "new_tenant_key_id": "{{var:new_tenant_key_id}}",
  "operator_id": "{{var:operator_id}}"
}
Example request
{
  "previous_tenant_key_id": "{{var:tenant_key_id}}",
  "new_tenant_key_id": "{{var:new_tenant_key_id}}",
  "operator_id": "{{var:operator_id}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /admin/byok/bindings"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\byok-bindings-tenant-tenant-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
404NotFoundno binding for tenantgetByokBindingForTenant(tenant_id) returns null/undefined — the tenant exists or not, but has no BYOK binding row
500InternalServerErrorInternal Server Errorthe 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch the BYOK binding for a tenant → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "tenant not found"
}
e.g. HTTP 404
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\federation-chaos-drill-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
400ValidationErrorfederation_id, from_region, to_region are requiredany of federation_id, from_region or to_region is missing, null, or an empty string
500InternalServerError<drill error message>federationOrchestrator.runChaosDrill() throws — unknown federation_id or region, the target pool being unreachable, or the failover_event insert failing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Run a chaos-drill failover for a federation → expects HTTP 200
Path params
Payload (template)
{
  "federation_id": "{{var:federation_id}}",
  "from_region": "us-east-1",
  "to_region": "us-west-2"
}
Example request
{
  "federation_id": "{{var:federation_id}}",
  "from_region": "us-east-1",
  "to_region": "us-west-2"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
driver: nessie, glue, none
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\federation-iceberg-backend-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
400ValidationErrordriver is requiredthe driver field is missing, null, or an empty string in the body
400ValidationError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Hot-reload the iceberg backend driver to a no-op backend → expects HTTP 200
Path params
Payload (template)
{
  "driver": "none",
  "base_url": "https://nessie.example.com/api/v1",
  "bearer_token": "{{static:test-bearer-token}}"
}
Example request
{
  "driver": "none",
  "base_url": "https://nessie.example.com/api/v1",
  "bearer_token": "test-bearer-token"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/federation/iceberg-catalogs"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\federation-iceberg-bindings-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
400ValidationErrorbinding_id, catalog_id, table_ref are requiredany of binding_id, catalog_id or table_ref is missing, null, or an empty string
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Bind an Iceberg table to a ClickHouse source → expects HTTP 201
Path params
Payload (template)
{
  "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"
  ]
}
Example request
{
  "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"
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\federation-iceberg-catalogs-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
500InternalServerErrorInternal Server Errorthe 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List all Iceberg catalogs → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "iceberg_catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
backend: glue, nessie, hive
status: active, degraded, retired
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\federation-iceberg-catalogs-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
400ValidationErrorcatalog_id, region, backend, root_url are requiredany of catalog_id, region, backend or root_url is missing, null, or an empty string
400ValidationErrorbackend must be one of: glue, nessie, hivebackend is present but not one of glue, nessie, hive
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register an Iceberg catalog for a region → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\federation-orchestrator-stats-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the failover orchestrator probe counters → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "orchestrator_stat_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\invoices-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
500InternalServerError<database error message>the SELECT against billing.invoice throws — tenant_id not castable to uuid, from/to not castable to date, or Postgres unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List invoices filtered by tenant + period window → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "invoice_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/billing/invoices/generate"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\invoices-invoice-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
404NotFoundinvoice not foundinvoice_id fails the UUID regex (checked before any DB access), or it is a valid UUID with no matching row in billing.invoice
500InternalServerError<database error message>the invoice or line_item SELECT throws — Postgres unavailable or the billing schema missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch a single invoice with its line items → expects HTTP 200
Path params
{
  "invoice_id": "{{cache:billing-invoices.generate.response.data.invoice.invoice_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "invoice_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "invoice not found"
}
e.g. HTTP 404
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
event_type: parsing.field.extracted.v1, recommendation.suggestion.generated.v1, semantic.intent.planned.v1, ai-gateway.complete.v1
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\lineage-backfill-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Dry-run backfill of lineage edges for one event type → expects HTTP 200
Path params
Payload (template)
{
  "pool_index": "{{var:pool_index}}",
  "event_type": "ai-gateway.complete.v1",
  "batch_size": 500,
  "dry_run": true,
  "from": "{{dynamic:pastdatetime}}",
  "to": "{{dynamic:futuredatetime}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\meter-hardcap-override-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
400ValidationErrortenant_id, sku, until, operator_id, reason are all requiredany of tenant_id, sku, until, operator_id or reason is missing, null, or an empty string
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Apply an operator override lifting a denied hard cap → expects HTTP 200
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\meter-pricing-catalogs-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
500InternalServerErrorInternal Server ErrorlistPricingCatalogs() throws (Postgres unavailable or missing pricing tables) — unhandled by the route, so Fastify default error serialization applies
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List all pricing catalogs → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "pricing_catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\meter-pricing-catalogs-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorcatalog_id, version, operator_id requiredcatalog_id, version or operator_id is missing, null, an empty string, or (for version) the number 0 — the guard is a falsy check
500InternalServerError<database error message>createCatalogVersion() throws — most commonly a unique-constraint violation because that catalog_id/version already exists, or Postgres unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a new draft pricing-catalog version → expects HTTP 200
Path params
Payload (template)
{
  "catalog_id": "qa-pricing-catalog-{{dynamic:slug}}",
  "version": 1,
  "operator_id": "{{var:operator_id}}"
}
Example request
{
  "catalog_id": "qa-pricing-catalog-{{dynamic:slug}}",
  "version": 1,
  "operator_id": "{{var:operator_id}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/meter/pricing-catalogs"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\meter-pricing-catalogs-catalog-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
404NotFoundcatalog not foundgetPricingCatalog(catalog_id) returns a result whose catalog is null/undefined — no row with that catalog_id exists
500InternalServerErrorInternal Server ErrorgetPricingCatalog() throws (Postgres unavailable, missing pricing tables) — unhandled by the route, so Fastify default error serialization applies
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch a pricing catalog with its rates → expects HTTP 200
Path params
{
  "catalog_id": "{{cache:meter-pricing-catalogs.create.response.data.catalog_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "pricing_catalog_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "pricing_catalog not found"
}
e.g. HTTP 404
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/meter/pricing-catalogs"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
mode: flat_per_call, tiered_per_call, passthrough_plus_margin, per_unit, bundled_subscription, free_internal
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\meter-pricing-catalogs-catalog-id-rates-sku-put.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorunit, mode, operator_id requiredunit, mode or operator_id is missing, null, or an empty string in the body
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Upsert a rate row for a SKU on a catalog → expects HTTP 200
Path params
{
  "catalog_id": "{{cache:meter-pricing-catalogs.create.response.data.catalog_id}}",
  "sku": "ai.gateway.tokens.input"
}
Payload (template)
{
  "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}}"
}
Example request
{
  "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}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/meter/pricing-catalogs"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: draft, active, retired
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\meter-pricing-catalogs-catalog-id-status-patch.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorstatus + operator_id requiredstatus or operator_id is missing, null, or an empty string in the body
500InternalServerError<database error message>setCatalogStatus() throws — status outside draft|active|retired hitting the DB check constraint, or Postgres unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Promote a draft catalog to active → expects HTTP 200
Path params
{
  "catalog_id": "{{cache:meter-pricing-catalogs.create.response.data.catalog_id}}"
}
Payload (template)
{
  "status": "active",
  "operator_id": "{{var:operator_id}}"
}
Example request
{
  "status": "active",
  "operator_id": "{{var:operator_id}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
k8s_distribution: vanilla, openshift, rancher, tanzu
air_gap_mode: strict, diode-in, diode-bidi
billing_mode: internal-report-only, flat-fee, per-incident
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\onprem-installs-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorcustomer_id, cluster_name, k8s_distribution, installed_version requiredany of customer_id, cluster_name, k8s_distribution or installed_version is missing, null, or an empty string
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register an on-prem install (air-gapped cluster) → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/onprem/installs"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\onprem-installs-install-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
404NotFoundinstall not foundgetOnpremInstall(install_id) returns null/undefined — no install row matches that install_id
500InternalServerErrorInternal Server Errorthe lookup throws — most commonly install_id failing a uuid cast, or Postgres unavailable; unhandled by the route, so Fastify default error serialization applies
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch an on-prem install by id → expects HTTP 200
Path params
{
  "install_id": "{{cache:onprem.register-install.response.data.install_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "install_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "install not found"
}
e.g. HTTP 404
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/onprem/installs"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\onprem-installs-install-id-billing-reports-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorperiod_start, period_end, artifact_local_path requiredany of period_start, period_end or artifact_local_path is missing, null, or an empty string
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Generate an internal-only billing report for a period → expects HTTP 201
Path params
{
  "install_id": "{{cache:onprem.register-install.response.data.install_id}}"
}
Payload (template)
{
  "period_start": "2026-04-01",
  "period_end": "2026-06-30",
  "artifact_local_path": "/var/onprem/reports/2026-q2.pdf"
}
Example request
{
  "period_start": "2026-04-01",
  "period_end": "2026-06-30",
  "artifact_local_path": "/var/onprem/reports/2026-q2.pdf"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/onprem/installs"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\onprem-installs-install-id-bundles-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorbundle_version + signature_verified requiredbundle_version is missing/empty, or signature_verified is absent or not a JSON boolean (strings "true"/"false" and numbers are rejected)
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Record a quarterly signed-bundle apply on an on-prem install → expects HTTP 201
Path params
{
  "install_id": "{{cache:onprem.register-install.response.data.install_id}}"
}
Payload (template)
{
  "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"
    }
  ]
}
Example request
{
  "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"
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/onprem/installs"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
backend: ollama, vllm, text-generation-inference
quantization: fp16, int8, int4, awq
status: ready, loading, disabled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\onprem-installs-install-id-local-llms-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrormodel_id, backend, endpoint_url, quantization requiredany of model_id, backend, endpoint_url or quantization is missing, null, or an empty string
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register a local LLM model on an on-prem install → expects HTTP 201
Path params
{
  "install_id": "{{cache:onprem.register-install.response.data.install_id}}"
}
Payload (template)
{
  "model_id": "llama-3.1-8b-instruct",
  "backend": "vllm",
  "endpoint_url": "http://vllm.onprem.svc.cluster.local:8000/v1",
  "quantization": "int8",
  "status": "loading"
}
Example request
{
  "model_id": "llama-3.1-8b-instruct",
  "backend": "vllm",
  "endpoint_url": "http://vllm.onprem.svc.cluster.local:8000/v1",
  "quantization": "int8",
  "status": "loading"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\pools-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
500InternalServerError<database error message>the SELECT against routing.pool throws — Postgres unavailable, pool exhausted, or the routing schema missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List all routing pools → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "pool_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
pool_family: admin, app, evidence, warehouse, vector
status: ACTIVE, MIGRATING, DRAINING, MAINTENANCE, RETIRED, QUARANTINE
isolation_class: shared, dedicated
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/admin/pools-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorpool_index, pool_family, region, primary_endpoint are requiredany of the four required fields is missing
400ValidationErrorinvalid pool_familypool_family is not in admin|app|evidence|warehouse|vector
400ValidationErrorapp_id is required when pool_family='app'pool_family='app' but no app_id
401Unauthorizedadmin token requiredmissing or invalid x-admin-ops-token (requireAdmin)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create an ACTIVE admin-family pool → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\pools-pool-index-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
404NotFoundpool not foundno row in routing.pool matches the pool_index path param
500InternalServerError<database error message>any of the pool, tenant-count or lifecycle-history queries throws — Postgres unavailable or the routing schema missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch a pool with tenant count + lifecycle history → expects HTTP 200
Path params
{
  "pool_index": "{{var:pool_index}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "pool_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "pool not found"
}
e.g. HTTP 404
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
to_status: ACTIVE, MIGRATING, DRAINING, MAINTENANCE, RETIRED, QUARANTINE
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\pools-pool-index-status-patch.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorto_status + reason + operator_id requiredany of to_status, reason or operator_id is missing, null, or an empty string
404NotFoundpool not foundno row in routing.pool matches pool_index — checked after validation and before the transition is recorded
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Transition a pool into MAINTENANCE → expects HTTP 200
Path params
{
  "pool_index": "{{var:pool_index}}"
}
Payload (template)
{
  "to_status": "MAINTENANCE",
  "reason": "Scheduled maintenance window for QA",
  "operator_id": "{{var:operator_id}}"
}
Example request
{
  "to_status": "MAINTENANCE",
  "reason": "Scheduled maintenance window for QA",
  "operator_id": "{{var:operator_id}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\security-ops-tokens-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
500InternalServerError<listing error message>listOpsTokens() throws — Postgres unavailable or the admin.ops_token table missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List active ops token metadata (never secrets) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "ops_token_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\security-ops-tokens-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
400ValidationErrorlabel is requiredthe label field is missing, null, empty, or whitespace-only (it is trimmed before the check)
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Mint a short-lived QA ops token → expects HTTP 201
Path params
Payload (template)
{
  "label": "{{dynamic:name}}",
  "ttl_seconds": 3600,
  "reason": "automated qa mint",
  "created_by": "api-test-runner"
}
Example request
{
  "label": "Acme QA Sample",
  "ttl_seconds": 3600,
  "reason": "automated qa mint",
  "created_by": "api-test-runner"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/security/ops-tokens"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\security-ops-tokens-id-delete.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
404NotFoundtoken not found or already revokedrevokeOpsToken(id) returns falsy — no admin.ops_token row with that id, or it was already revoked
500InternalServerError<revoke error message>revokeOpsToken() throws, or the post-revoke cache invalidation/audit-event emission fails — e.g. Postgres unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "ops_token",
  "field": "status",
  "flow": [
    "active",
    "revoked"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "revoked",
      "via": "DELETE /admin/security/ops-tokens/:id"
    }
  ]
}
Revoke a minted ops token by id → expects HTTP 200
Path params
{
  "id": "{{cache:ops-tokens.create.response.data.id}}"
}
Payload (template)
Expected output ✓
{
  "success": true
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\security-rotate-signing-key-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Emergency-rotate the capability-token signing key → expects HTTP 200
Path params
Payload (template)
{
  "reason": "QA emergency rotation drill",
  "actor_id": "{{var:operator_id}}"
}
Example request
{
  "reason": "QA emergency rotation drill",
  "actor_id": "{{var:operator_id}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/sovereign/regions",
  "POST /admin/sovereign/regions/:region_id/bundles"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\sovereign-bundles-release-id-applied-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
404NotFoundrelease not foundmarkSovereignBundleApplied(release_id) returns null/undefined — no release row matches that release_id
500InternalServerError<update error message>markSovereignBundleApplied() throws — release_id failing a uuid cast, or Postgres unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Mark a previously-shipped sovereign bundle release as applied → expects HTTP 200
Path params
{
  "release_id": "{{cache:sovereign.ship-bundle.response.data.release_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\sovereign-regions-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
500InternalServerErrorInternal Server ErrorlistSovereignRegions() throws (Postgres unavailable or missing sovereign schema) — unhandled by the route, so Fastify default error serialization applies
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List all sovereign region configs (operator view) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "region_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
regime: fedramp-high, il5, pipl, eu-sovereign, uae-trd
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\sovereign-regions-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorregion_id, regime, operator_partner, kms_provider, operator_id requiredany of region_id, regime, operator_partner, kms_provider or operator_id is missing, null, or an empty string
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register a sovereign region config (natural-key region_id) → expects HTTP 200
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/sovereign/regions"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
regime: fedramp-high, il5, pipl, eu-sovereign, uae-trd
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\sovereign-regions-region-id-attestations-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorregime, auditor_id, issued_at, expires_at, artifact_ref requiredany of regime, auditor_id, issued_at, expires_at or artifact_ref is missing, null, or an empty string
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Record a regime attestation for a sovereign region → expects HTTP 201
Path params
{
  "region_id": "{{cache:sovereign.register.response.data.region_id}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/sovereign/regions"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\sovereign-regions-region-id-bundles-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorversion, bundle_artifact_ref, signature_hex requiredany of version, bundle_artifact_ref or signature_hex is missing, null, or an empty string
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Ship a signed bundle release to a sovereign region → expects HTTP 201
Path params
{
  "region_id": "{{cache:sovereign.register.response.data.region_id}}"
}
Payload (template)
{
  "version": "2026.3.0-{{dynamic:slug}}",
  "bundle_artifact_ref": "oci://registry.projexlight.com/sovereign/eu-sov-fra:2026.3.0",
  "signature_hex": "deadbeefcafef00dba5eba11c0ffee00"
}
Example request
{
  "version": "2026.3.0-{{dynamic:slug}}",
  "bundle_artifact_ref": "oci://registry.projexlight.com/sovereign/eu-sov-fra:2026.3.0",
  "signature_hex": "deadbeefcafef00dba5eba11c0ffee00"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/sovereign/regions"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
kind: egress-attempt, cross-region-route, policy-violation
severity: info, warn, critical
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\sovereign-regions-region-id-leaks-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
400ValidationErrorkind + severity requiredkind or severity is missing, null, or an empty string in the body
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Ingest a leak-monitor alert for a sovereign region → expects HTTP 201
Path params
{
  "region_id": "{{cache:sovereign.register.response.data.region_id}}"
}
Payload (template)
{
  "kind": "egress-attempt",
  "severity": "critical",
  "incident_ref": "INC-2026-0714-eu-sov-fra"
}
Example request
{
  "kind": "egress-attempt",
  "severity": "critical",
  "incident_ref": "INC-2026-0714-eu-sov-fra"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\storm-ingest-now-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
500InternalServerError<ingest error message>ingestStormOnce() throws — every provider in the fallback chain failed, the upstream call timed out, or the storm.* upsert failed
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Trigger a one-shot storm ingest over a trailing window → expects HTTP 200
Path params
Payload (template)
{
  "lookback_hours": 1
}
Example request
{
  "lookback_hours": 1
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\tenants-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
500InternalServerError<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 }
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List tenants (admin-ops gated) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /admin/apps"
]
Implemented in
services/api-gateway/src/app.ts
Request field options
isolation_tier: S, P, G
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\tenants-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
400ValidationErrorapp_id, display_name, region are requiredany 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 {})
400ValidationError<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)
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a tenant under a provisioned app → expects HTTP 201
Path params
Payload (template)
{
  "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"
  ]
}
Example request
{
  "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"
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /admin/pools"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: ACTIVE, MIGRATING, QUARANTINED
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/admin/tenants-tenant-id-pool-map-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id must be a UUIDthe :tenant_id path param is not a valid UUID
400ValidationErroradmin_pool_index and region are requiredeither required body field is missing
400ForeignKeyViolationviolates foreign key constraintadmin_pool_index (or evidence_pool_index) does not reference an existing routing.pool
401Unauthorizedadmin token requiredmissing or invalid x-admin-ops-token (requireAdmin)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Map a tenant to an admin pool → expects HTTP 201
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
{
  "admin_pool_index": "{{cache:admin.pools.create.response.data.pool_index}}",
  "app_pool_index": {},
  "region": "us-east-1",
  "status": "ACTIVE"
}
Example request
{
  "admin_pool_index": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "app_pool_index": {},
  "region": "us-east-1",
  "status": "ACTIVE"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\webhooks-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
500InternalServerError<database error message>the SELECT against webhook.endpoint throws — the tenant_id query param not castable to uuid, or Postgres unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List webhook endpoints across all tenants (operator view) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "webhook_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\webhooks-dlq-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
500InternalServerError<database error message>the DLQ join across webhook.delivery/subscription/endpoint throws — Postgres unavailable or the webhook schema missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List dead-lettered webhook deliveries across tenants (operator view) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "dlq_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\admin\webhooks-dlq-delivery-id-replay-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() 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)
404DeliveryNotInDlq<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)
409DlqWindowExpired<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
500InternalServerError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "webhook.delivery",
  "field": "status",
  "flow": [
    "pending",
    "dlq",
    "pending"
  ],
  "transitions": [
    {
      "from": "dlq",
      "to": "pending",
      "via": "POST /admin/webhooks/dlq/:delivery_id/replay"
    }
  ]
}
Replay a dead-lettered webhook delivery (operator) → expects HTTP 200
Path params
{
  "delivery_id": "{{var:dlq_delivery_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "replay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\admin\asset-rollup-backfill-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
409ConflictClickHouse not enabledconfig.clickhouse.enabled is false — the deployment has no ClickHouse, checked immediately after auth and before the body is read
500InternalServerError<rollup error message>runSensorRollup() throws — unparseable from/to producing an Invalid Date, ClickHouse unreachable, or the delete/insert of the rollup tables failing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Backfill the trailing 24h of sensor rollups → expects HTTP 200
Path params
Payload (template)
{
  "lookback_hours": 24,
  "from": "{{dynamic:pastdatetime}}",
  "to": "{{dynamic:futuredatetime}}"
}
Example request
{
  "lookback_hours": 24,
  "from": "2026-01-15T10:30:00Z",
  "to": "2026-01-15T10:30:00Z"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
api-gatewayW0 · test wave
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\health\index-get.json
Error responses
HTTPCodeMessageWhen it happens
404Not FoundRoute <METHOD>:/health not foundA 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Health probe returns ok → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400

connector-twilio-voice

8 API(s)
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.

SDK / service
connector-twilio-voiceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/voice/tracking-numbers",
  "POST /api/voice/calls"
]
Implemented in
packages/connector-twilio-voice/src/server/routes.ts
Request field options
status: queued, initiated, ringing, in-progress, completed, busy, no-answer, canceled, failed
direction: inbound, outbound
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\voice\calls-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
401UnauthorizedUnauthorizedAuthorization bearer token missing or invalid
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List outbound calls for the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
connector-twilio-voiceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/voice/tracking-numbers"
]
Implemented in
packages/connector-twilio-voice/src/server/routes.ts
Request field options
status: queued, initiated, ringing, in-progress, completed, busy, no-answer, canceled, failed
direction: inbound, outbound
recording_withheld_reason: consent_denied, consent_unknown, not_requested
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\voice\calls-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, install_id and to_number are requiredtenant_id, install_id or to_number missing from body
400NoCallerIdAvailableno from_number and no active tracking number to call fromno from_number given and the tenant has no active tracking number
401UnauthorizedUnauthorizedAuthorization bearer token missing or invalid
422ProviderErrorcall placement failed: <upstream message>the injected Twilio provider rejects the call (bad credentials, non-voice from number, geo-permission block)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
TransitionTriggered by
queued -> initiated -> ringing -> in-progressTwilio call progress, delivered to POST /api/voice/webhooks/twilio/status
in-progress -> completedcall ended normally (status webhook stamps ended_at + duration)
queued -> busy|no-answer|canceled|failedcall never connected (terminal, delivered by the status webhook)
Place a recorded call from the provisioned tracking number → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
connector-twilio-voiceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/voice/tracking-numbers",
  "POST /api/voice/calls"
]
Implemented in
packages/connector-twilio-voice/src/server/routes.ts
Request field options
status: queued, initiated, ringing, in-progress, completed, busy, no-answer, canceled, failed
answered_by: human, machine_start, machine_end_beep, machine_end_silence, machine_end_other, fax, unknown
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\voice\calls-voice_call_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
401UnauthorizedUnauthorizedAuthorization bearer token missing or invalid
404NotFoundNotFoundvoice_call_id unknown or belongs to another tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch the placed call → expects HTTP 200
Path params
{
  "voice_call_id": "{{cache:voice.calls.create.response.data.call.voice_call_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
connector-twilio-voiceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/voice/tracking-numbers"
]
Implemented in
packages/connector-twilio-voice/src/server/routes.ts
Request field options
status: active, released, deleted-upstream
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\voice\tracking-numbers-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
401UnauthorizedUnauthorizedAuthorization bearer token missing or invalid
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List active tracking numbers for the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "tracking_number_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
connector-twilio-voiceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/connector-twilio-voice/src/server/routes.ts
Request field options
status: active, released, deleted-upstream
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\voice\tracking-numbers-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and install_id are requiredtenant_id or install_id missing from body
401UnauthorizedUnauthorizedAuthorization bearer token missing or invalid
409NumberAlreadyProvisioned<number> is already provisioned and active for this tenantthe tenant already holds an active claim on that phone number under a different upstream SID
422ProviderErrornumber provisioning failed: <upstream message>the injected Twilio provider rejects the purchase (bad credentials, region not permitted)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Provision a tracking number for the signup tenant → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
connector-twilio-voiceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/voice/tracking-numbers",
  "POST /api/voice/calls"
]
Implemented in
packages/connector-twilio-voice/src/server/routes.ts
Request field options
status: active, released, deleted-upstream
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\voice\tracking-numbers-tracking_number_id-release-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing from body
401UnauthorizedUnauthorizedAuthorization bearer token missing or invalid
404NotFoundNotFoundtracking_number_id unknown or belongs to another tenant
422ProviderErrornumber release failed: <upstream message>the injected Twilio provider rejects the release
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
TransitionTriggered by
active -> releasedPOST /api/voice/tracking-numbers/:tracking_number_id/release (stamps released_at; row retained for attribution)
Release the provisioned tracking number → expects HTTP 200
Path params
{
  "tracking_number_id": "{{cache:voice.tracking-numbers.create.response.data.tracking_number.tracking_number_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
connector-twilio-voiceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/voice/tracking-numbers",
  "POST /api/voice/calls"
]
Implemented in
packages/connector-twilio-voice/src/server/webhookRoutes.ts
Request field options
recording_withheld_reason: consent_denied, consent_unknown, not_requested
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\voice\webhooks-twilio-recording-post.json
Error responses
HTTPCodeMessageWhen it happens
202Acceptedunknown CallSid <sid>the CallSid is not in the mirror, or the payload has no CallSid — acknowledged so Twilio stops retrying
401InvalidSignatureInvalidSignatureTWILIO_AUTH_TOKEN is configured and X-Twilio-Signature is missing, wrong, or computed over different params
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Recording ready for the placed call → expects HTTP 200
Path params
Payload (template)
{
  "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"
}
Example request
{
  "CallSid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "RecordingSid": "RE{{dynamic:slug}}",
  "RecordingUrl": "https://api.twilio.com/2010-04-01/Recordings/RE{{dynamic:slug}}",
  "RecordingDuration": "40"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
connector-twilio-voiceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/voice/tracking-numbers",
  "POST /api/voice/calls"
]
Implemented in
packages/connector-twilio-voice/src/server/webhookRoutes.ts
Request field options
CallStatus: queued, initiated, ringing, in-progress, completed, busy, no-answer, canceled, failed
AnsweredBy: human, machine_start, machine_end_beep, machine_end_silence, machine_end_other, fax, unknown
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\voice\webhooks-twilio-status-post.json
Error responses
HTTPCodeMessageWhen it happens
202Acceptedunknown CallSid <sid>the CallSid is not in the mirror, or the payload has no CallSid — acknowledged so Twilio stops retrying
401InvalidSignatureInvalidSignatureTWILIO_AUTH_TOKEN is configured and X-Twilio-Signature is missing, wrong, or computed over different params
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
TransitionTriggered by
queued -> ringing -> in-progresssuccessive status callbacks as the call progresses
in-progress -> completedfinal status callback; stamps ended_at and duration_seconds
any -> is_voicemail=truea callback whose AnsweredBy is machine_start/machine_end_* (AMD classification, sticky)
Voicemail reached: completed call answered by a machine → expects HTTP 200
Path params
Payload (template)
{
  "CallSid": "{{cache:voice.calls.create.response.data.call.external_id}}",
  "CallStatus": "completed",
  "AnsweredBy": "machine_start",
  "CallDuration": "42",
  "ErrorCode": ""
}
Example request
{
  "CallSid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "CallStatus": "completed",
  "AnsweredBy": "machine_start",
  "CallDuration": "42",
  "ErrorCode": ""
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "data": {
    "matched": "boolean",
    "voice_call_id": "string",
    "status": "string",
    "is_voicemail": "boolean",
    "voicemail_detected": "boolean"
  }
}

contracts

2 API(s)
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).

SDK / service
Testability
manual — Contracts-only update. No HTTP route exists. Verification is the existing CI lint (assertRegisteredEventType) plus type-check; both run via `pnpm -w build`.
Source spec
tests\api_definitions\contracts\p6a-event-registry-post.json
Manual: verify event registry contains all P6A keys + types compile → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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).

SDK / service
Testability
manual — Contracts-only update. No HTTP route exists. Verification is the existing CI lint (assertRegisteredEventType) plus type-check; both run via `pnpm -w build`.
Source spec
tests\api_definitions\contracts\p6b-event-registry-post.json
Manual: verify event registry contains all P6B keys + types compile → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "success": true
}

hdk-camera

2 API(s)
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.

SDK / service
hdk-cameraW7 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/hdk-camera/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\hdk\camera-capabilities-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List native camera capabilities → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "capability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
hdk-cameraW7 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/hdk-camera/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\hdk\camera-recording-presets-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List camera recording presets → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "recording_preset_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

hdk-diagnostic

2 API(s)
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.

SDK / service
hdk-diagnosticW1 · test wave
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\hdk-diagnostic\drain-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in "Bearer <token>" form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler)
400FST_ERR_CTP_EMPTY_JSON_BODYBody cannot be empty when content-type is set to application/jsonContent-Type: application/json declared but the request body is empty or unparseable (Fastify JSON body parser, before the handler runs)
500InternalServerErrorInternal Server ErrordrainQueue 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Drain queue → expects HTTP 200
Path params
Payload (template)
{
  "limit": 50
}
Example request
{
  "limit": 50
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
hdk-diagnosticW1 · test wave
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\hdk-diagnostic\events-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in "Bearer <token>" form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler)
400ValidationErrormissing fieldsdevice_uuid or category missing/empty in the body
400FST_ERR_CTP_EMPTY_JSON_BODYBody cannot be empty when content-type is set to application/jsonContent-Type: application/json declared but the request body is empty or unparseable (Fastify JSON body parser, before the handler runs)
500InternalServerErrorInternal Server ErrorcaptureEvent 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Capture event → expects HTTP 202
Path params
Payload (template)
{
  "device_uuid": "{{static:test-device-uuid-001}}",
  "category": "battery",
  "payload": {
    "level": 0.42,
    "charging": false
  },
  "occurred_at": "{{dynamic:pastdatetime}}"
}
Example request
{
  "device_uuid": "test-device-uuid-001",
  "category": "battery",
  "payload": {
    "level": 0.42,
    "charging": false
  },
  "occurred_at": "2026-01-15T10:30:00Z"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "accepted",
    "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 202.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

hdk-idp

3 API(s)
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /scim/v2/Users"
]
Source spec
tests\api_definitions\hdk-idp\claims-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrormissing fieldsdevice_uuid or person_id missing/falsy in body
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register claim → expects HTTP 201
Path params
Payload (template)
{
  "device_uuid": "device-hdk-idp-e2e-001",
  "person_id": "{{cache:scim.create.response.data.person_id}}",
  "biometric_template_envelope": "dGVzdC1iaW9tZXRyaWMtdGVtcGxhdGU=",
  "pin_envelope": "dGVzdC1waW4tZW52ZWxvcGU="
}
Example request
{
  "device_uuid": "device-hdk-idp-e2e-001",
  "person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "biometric_template_envelope": "dGVzdC1iaW9tZXRyaWMtdGVtcGxhdGU=",
  "pin_envelope": "dGVzdC1waW4tZW52ZWxvcGU="
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/hdk-idp/claims"
]
Source spec
tests\api_definitions\hdk-idp\devices-device_uuid-claims-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in "Bearer <token>" form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List claims → expects HTTP 200
Path params
{
  "device_uuid": "{{cache:claims.register.response.data.claim.device_uuid}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "claim_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/hdk-idp/claims"
]
Request field options
method: biometric, pin, passkey
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\hdk-idp\offline-auth-log-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in "Bearer <token>" form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler)
400ValidationErrormissing fieldsdevice_uuid, person_id, method or occurred_at missing/empty in the body
400ValidationErrorinvalid methodmethod is present but not one of 'biometric', 'pin', 'passkey'
400FST_ERR_CTP_EMPTY_JSON_BODYBody cannot be empty when content-type is set to application/jsonContent-Type: application/json declared but the request body is empty or unparseable (Fastify JSON body parser, before the handler runs)
500InternalServerErrorInternal Server ErrorlogOfflineAuth 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Sync biometric offline-auth → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "method": "biometric",
  "occurred_at": "2026-01-15T10:30:00Z"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

hdk-image-editor

1 API(s)
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.

SDK / service
hdk-image-editorW7 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/hdk-image-editor/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\hdk\image-editor-capabilities-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List native image-editor capabilities → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "capability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

hdk-map

2 API(s)
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.

SDK / service
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/hdk-map/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\hdk\map-capabilities-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List native map capabilities → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "capability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/hdk-map/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\hdk\map-tile-providers-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List map tile providers → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "tile_provider_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

hdk-measure

3 API(s)
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.

SDK / service
hdk-measureW7 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/hdk/measure"
]
Implemented in
packages/hdk-measure/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\hdk\measure-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorcapture_id query param requiredthe capture_id query param is absent or an empty string
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List measurements for a capture → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "measure_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
hdk-measureW7 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/hdk-measure/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
kind: area, distance, volume
accuracy_class: high, medium, low
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\hdk\measure-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorcapture_id, kind, value, unit, device_uuid are requiredany 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)
400ValidationError[hdk-measure] invalid kind '<kind>'kind is present but not one of 'area', 'distance', 'volume'
400ValidationError[hdk-measure] value must be a finite numbervalue is NaN, Infinity, or a non-numeric type
400ValidationError[hdk-measure] value must be non-negativevalue is a finite number below zero
400ValidationError[hdk-measure] captured_at is not a valid ISO 8601 timestampcaptured_at is supplied but does not parse as an ISO 8601 date
400ValidationError[hdk-measure] insert failedthe 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
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Record an AR measurement → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
hdk-measureW7 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/hdk/measure"
]
Implemented in
packages/hdk-measure/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\hdk\measure-id-get.json
Error responses
HTTPCodeMessageWhen it happens
404NotFoundnot foundno hdk_measure.measurement row matches the :id path param (unknown, deleted, or malformed id)
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch one measurement by id → expects HTTP 200
Path params
{
  "id": "{{cache:measure.create.response.data.measurement_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "measure_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

hdk-permissions

2 API(s)
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.

SDK / service
hdk-permissionsW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/hdk-permissions/snapshots"
]
Source spec
tests\api_definitions\hdk-permissions\devices-device_uuid-latest-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in "Bearer <token>" form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler)
404NotFoundNotFoundno surface_snapshot row exists for the given device_uuid (latestSnapshot returns null)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get latest snapshot → expects HTTP 200
Path params
{
  "device_uuid": "{{cache:hdk-permissions.snapshot.response.data.snapshot.device_uuid}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "latest_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
hdk-permissionsW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Source spec
tests\api_definitions\hdk-permissions\snapshots-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in "Bearer <token>" form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler)
400ValidationErrormissing fieldsdevice_uuid, tenant_id or permission_set missing/empty in the body (persona_id is optional)
400FST_ERR_CTP_EMPTY_JSON_BODYBody cannot be empty when content-type is set to application/jsonContent-Type: application/json declared but the request body is empty or unparseable (Fastify JSON body parser, before the handler runs)
500InternalServerErrorInternal Server ErrorsnapshotSurface 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Capture surface → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

hdk-scanner

1 API(s)
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.

SDK / service
hdk-scannerW7 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/hdk-scanner/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\hdk\scanner-capabilities-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List native scanner capabilities → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "capability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

hdk-sync

8 API(s)
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "PUT /api/hdk-sync/event-type-policies"
]
Source spec
tests\api_definitions\hdk-sync\conflicts-resolve-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrormissing fieldsevent_type, input_a or input_b is absent/falsy
409UnregisteredEventTypeevent_type <type> has no registered conflict_policyresolveConflict threw — chiefly because no policy is registered for the event_type; the catch maps every resolver throw to this 409
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Resolve CRDT note conflict → expects HTTP 200
Path params
Payload (template)
{
  "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"
      }
    ]
  }
}
Example request
{
  "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"
      }
    ]
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\hdk-sync\event-type-policies-list-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List policies → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "event_type_policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register"
]
Request field options
conflict_policy: crdt, lww, merge, event-sourcing, human-review
retention_class: transient, operational, regulated
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\hdk-sync\event-type-policies-put.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrormissing fieldsevent_type or conflict_policy is absent/empty
400ValidationErrorinvalid conflict_policyconflict_policy is not one of crdt, lww, merge, event-sourcing, human-review
400ValidationErrorinvalid retention_classretention_class was supplied but is not one of transient, operational, regulated
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register RGA-text policy for clinical notes → expects HTTP 200
Path params
Payload (template)
{
  "event_type": "clinical.note.edit.v1",
  "conflict_policy": "human-review",
  "strategy_detail": "human-review:dual-control",
  "retention_class": "regulated"
}
Example request
{
  "event_type": "clinical.note.edit.v1",
  "conflict_policy": "human-review",
  "strategy_detail": "human-review:dual-control",
  "retention_class": "regulated"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "PUT /api/hdk-sync/event-type-policies"
]
Source spec
tests\api_definitions\hdk-sync\event-type-policies-event_type-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
404NotFoundNotFoundno policy row exists for the supplied :event_type
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get clinical note policy → expects HTTP 200
Path params
{
  "event_type": "{{cache:hdk-sync.event-type-policies.create.response.data.policy.event_type}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "event_type_policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "PUT /api/hdk-sync/event-type-policies",
  "POST /api/hdk-sync/conflicts/resolve"
]
Request field options
status: open, in-review, resolved, rejected
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\hdk-sync\human-review-task_id-resolve-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrorinvalid statusstatus is absent or is not one of open, in-review, resolved, rejected
404NotFoundNotFoundno human-review task matches :task_id
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Mark task resolved → expects HTTP 200
Path params
{
  "task_id": "{{cache:hdk-sync.conflicts-resolve.response.data.human_review_task.task_id}}"
}
Payload (template)
{
  "status": "resolved",
  "resolved_value": {
    "decision": "merge"
  }
}
Example request
{
  "status": "resolved",
  "resolved_value": {
    "decision": "merge"
  }
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "resolved",
    "resolved_value": {
      "decision": "merge"
    },
    "resolve_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\hdk-sync\human-review-open-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List open review tasks → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "open_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/hdk-sync/replay/start"
]
Source spec
tests\api_definitions\hdk-sync\replay-batch_id-complete-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
404NotFoundNotFoundno replay batch matches :batch_id (unknown id, or the update matched no row)
500Internal Server ErrorInternal 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Complete replay → expects HTTP 200
Path params
{
  "batch_id": "{{cache:hdk-sync.replay-start.response.data.batch.batch_id}}"
}
Payload (template)
{
  "conflict_count": 0
}
Example request
{
  "conflict_count": 0
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "PUT /api/hdk-sync/event-type-policies"
]
Source spec
tests\api_definitions\hdk-sync\replay-start-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrormissing fieldsdevice_uuid or tenant_id is absent/empty, or envelopes is not an array
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Start replay with one envelope → expects HTTP 201
Path params
Payload (template)
{
  "device_uuid": "test-device-uuid-001",
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "envelopes": []
}
Example request
{
  "device_uuid": "test-device-uuid-001",
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "envelopes": []
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

hdk-video-editor

1 API(s)
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.

SDK / service
hdk-video-editorW7 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/hdk-video-editor/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\hdk\video-editor-capabilities-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List native video-editor capabilities → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "capability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

hdk-watermark

3 API(s)
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.

SDK / service
hdk-watermarkW7 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/hdk/watermark"
]
Implemented in
packages/hdk-watermark/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\hdk\watermark-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorvariant_id query param requiredthe variant_id query param is absent or an empty string
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List watermark applications for a variant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "watermark_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
hdk-watermarkW7 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/hdk-watermark/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
scheme: visible, invisible, cryptographic
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\hdk\watermark-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorvariant_id, scheme, payload_envelope are requiredany of variant_id, scheme or payload_envelope is missing from the body (route-level guard)
400ValidationError[hdk-watermark] invalid scheme '<scheme>'scheme is present but not one of 'visible', 'invisible', 'cryptographic'
400ValidationError[hdk-watermark] payload_envelope must be Buffer or stringpayload_envelope is supplied as a non-string, non-Buffer type (number, object, array)
400ValidationError[hdk-watermark] payload_envelope is emptypayload_envelope decodes to zero bytes (e.g. an empty base64 string or whitespace only)
400ValidationError[hdk-watermark] payload_envelope size <n> exceeds limit <MAX_PAYLOAD_BYTES>the decoded envelope exceeds HDK_WATERMARK_MAX_PAYLOAD_BYTES (default 16384 bytes)
400ValidationError[hdk-watermark] insert failedthe 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
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Record a watermark application → expects HTTP 201
Path params
Payload (template)
{
  "variant_id": "{{var:variant_id}}",
  "scheme": "visible",
  "payload_envelope": "eyJ3IjoxfQ==",
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Example request
{
  "variant_id": "{{var:variant_id}}",
  "scheme": "visible",
  "payload_envelope": "eyJ3IjoxfQ==",
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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).

SDK / service
hdk-watermarkW7 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/hdk/watermark"
]
Implemented in
packages/hdk-watermark/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\hdk\watermark-id-get.json
Error responses
HTTPCodeMessageWhen it happens
404NotFoundnot foundno hdk_watermark.application row matches the :id path param (unknown, deleted, or malformed id)
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch one watermark application by id → expects HTTP 200
Path params
{
  "id": "{{cache:watermark.create.response.data.application_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "watermark_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

pool-federation-runtime

3 API(s)
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.

SDK / service
pool-federation-runtimeW0 · test wave
Implemented in
services/pool-federation-runtime/src/app.ts
Request field options
from_region: us-east-1, us-west-2, eu-west-1, ap-south-1
to_region: us-east-1, us-west-2, eu-west-1, ap-south-1
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\chaos-drill-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
503ServiceUnavailableorchestrator not available in this deploymentthe 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
400ValidationErrorfederation_id, from_region, to_region requiredany 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)
500InternalServerError<drill error message>orchestrator.runChaosDrill() throws — unknown federation or region, or the failover_event write failing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Run an operator chaos-drill failover between regions → expects HTTP 201
Path params
Payload (template)
{
  "federation_id": "{{var:federation_id}}",
  "from_region": "us-east-1",
  "to_region": "us-west-2"
}
Example request
{
  "federation_id": "{{var:federation_id}}",
  "from_region": "us-east-1",
  "to_region": "us-west-2"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
pool-federation-runtimeW0 · test wave
Implemented in
services/pool-federation-runtime/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
trigger: chaos-drill, production-failover, operator-initiated
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\failovers\index-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenWhen 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
401UnauthorizedInvalid or expired tokenGateway-mounted only: the bearer token fails verifyJwt (bad signature, malformed, or expired)
400invalid_triggertrigger must be one of chaos-drill,production-failover,operator-initiatedbody.trigger is missing, not a string, or outside the sanctioned FAILOVER_TRIGGERS set — the only field-level validation in the handler
409duplicate_eventfailover event_id '<event_id>' already recordedThe INSERT into federation.failover_event violates the event_id primary key (Postgres 23505) — replaying the same event_id
500InternalError<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
500Internal Server ErrorInternal Server ErrorNo 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Record an operator-initiated failover → expects HTTP 201
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
pool-federation-runtimeW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
services/pool-federation-runtime/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
query_class: resolver, dsar, analytics, lineage
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\routes\federation-id-query-class-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenGateway-mounted deployment: no Authorization: Bearer header — /routes/* is not on the authGate.ts public allowlist. Not produced by the standalone :8083 binary
401UnauthorizedInvalid or expired tokenGateway-mounted deployment: the bearer token fails verifyJwt (bad signature, malformed, or expired)
400invalid_query_classquery_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)
404route_not_foundroute_not_foundresolveRoute returns null — no federation.route row exists for the (federation_id, query_class) pair, including an unknown or malformed federation_id
500Internal Server ErrorInternal Server ErrorresolveRoute 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Resolve a sanctioned cross-pool route → expects HTTP 200
Path params
{
  "federation_id": "{{var:federation_id}}",
  "query_class": "resolver"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

registry-mcp

1 API(s)
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.

SDK / service
registry-mcpW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
services/registry-mcp/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
isError: True, False
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/registry-mcp/call-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorname is requiredThe request body omits name, or it is empty
401Unauthorizedmissing Authorization header or x-projex-api-keyNeither an Authorization bearer token nor an x-projex-api-key header is present
429RateLimitedrate limitedThe resolved tenant has exceeded its allowance for this window
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Invoke a read tool and receive an MCP content envelope → expects HTTP 200
Path params
Payload (template)
{
  "name": "list_sdks",
  "arguments": {}
}
Example request
{
  "name": "list_sdks",
  "arguments": {}
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "content": "array",
  "isError": "boolean"
}

sdk-agent-runtime

12 API(s)
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.

SDK / service
sdk-agent-runtimeW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/sdk-agent-runtime/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
tier: sync, orchestration, batch
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\agent-runtime\agents-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
500ListFailedList failedlistAgentDefinitions throws - NaN limit/offset from a non-numeric query value, a non-UUID tenant_id failing the uuid cast, or any DB error
500InternalErrorInternalErrorthe handler throws outside its own try/catch and the route wrapper catch fires
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List agent definitions → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-agent-runtimeW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Request field options
tier: sync, orchestration, batch
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\agent-runtime-agents\agents-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo 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.
401UnauthorizedInvalid or expired tokenBearer token present but verifyJwt() throws — bad signature, malformed JWT, or exp in the past.
400ValidationErrorRequired: name, acting_persona_id, tier, vector_namespace, created_bycreateAgentDefinitionHandler 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.
500CreateFailedCreate failedThe 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).
500InternalErrorInternalErrorThe 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.
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create agent definition → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "success": true
}
GET/api/agent-runtime/agents/:id🔒 auth

Fetch an agent_definition by id.

SDK / service
sdk-agent-runtimeW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/agent-runtime/agents"
]
Source spec
tests\api_definitions\agent-runtime-agents\id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization header or a non-Bearer scheme. Applied by the gateway default-deny authGate and again by the route's requireAuth preHandler.
401UnauthorizedInvalid or expired tokenverifyJwt() rejects the bearer token — tampered signature, wrong secret, or expired exp claim.
400ValidationErrorMissing path param: idreq.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.
404NotFoundagent_definition not foundThe 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.
500LookupFailedLookup failedThe 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.
500InternalErrorInternalErrorRoute-level try/catch in registerRoutes — an error thrown outside the handler's own catch, with no reply already sent.
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get agent definition → expects HTTP 200
Path params
{
  "id": "{{cache:agent-runtime-agents.create.response.data.agent_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "agent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-agent-runtimeW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/sdk-agent-runtime/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\agent-runtime\health-get.json
Agent runtime health check → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-agent-runtimeW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/sdk-agent-runtime/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: running, completed, failed, terminated_ttl_expired, terminated_kill_switch, terminated_quota
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\agent-runtime\runs-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
500ListFailedList failedlistAgentRuns throws - NaN limit/offset, a non-UUID tenant_id or agent_id failing the uuid cast, or any DB error
500InternalErrorInternalErrorthe handler throws outside its own try/catch and the route wrapper catch fires
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List agent runs → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-agent-runtimeW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/personas",
  "POST /api/agent-runtime/agents"
]
Implemented in
packages/sdk-agent-runtime/src/server/handlers/agentRunController.ts
Request field options
actor_kind: human, service, agent
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\agent-runtime-runs\runs-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrorRequired: agent_id, persona_id, trace_id, actor_id, actor_kindThe body is missing any one of agent_id, persona_id, trace_id, actor_id or actor_kind
404NotFound<entity> not foundstartAgentRun throws an error whose message contains "not found" — e.g. the referenced agent_id has no agent_definition row
500InternalErrorStart run failedstartAgentRun throws for any other reason (constraint violation, model snapshot resolution failure, database unreachable)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Start agent run → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "success": true
}
GET/api/agent-runtime/runs/:id🔒 auth

Fetch a single agent_run including agent_chain + execution_log_ref.

SDK / service
sdk-agent-runtimeW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/agent-runtime/runs"
]
Source spec
tests\api_definitions\agent-runtime-runs\id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrorMissing path param: idThe :id path segment resolves to an empty value
404NotFoundagent_run not foundgetAgentRun returns no row for the supplied id
500InternalErrorLookup failedgetAgentRun throws — e.g. id is not a valid UUID and the query cast fails, or the database is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get agent run → expects HTTP 200
Path params
{
  "id": "{{cache:agent-runtime-runs.create.response.data.run_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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'.

SDK / service
sdk-agent-runtimeW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/personas",
  "POST /api/agent-runtime/agents",
  "POST /api/agent-runtime/runs"
]
Implemented in
packages/sdk-agent-runtime/src/server/handlers/replayController.ts
Source spec
tests\api_definitions\agent-runtime-runs\id-replay-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrorMissing path param: run_idThe :run_id path segment resolves to an empty value
404NotFound<run> not found | no execution log entriesreplayRun 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
500InternalErrorReplay failedreplayRun throws for any other reason (model snapshot unavailable, deterministic re-execution error, database unreachable)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Replay a freshly-started run (no execution log yet) returns 404 → expects HTTP 404
Path params
{
  "run_id": "{{cache:agent-runtime-runs.create.response.data.run_id}}"
}
Payload (template)
{
  "current_model_snapshot_id": null,
  "dryRun": true
}
Example request
{
  "current_model_snapshot_id": null,
  "dryRun": true
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "current_model_snapshot_id": null,
    "dryRun": true,
    "replay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 404.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-agent-runtimeW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/personas",
  "POST /api/agent-runtime/agents",
  "POST /api/agent-runtime/runs"
]
Implemented in
packages/sdk-agent-runtime/src/server/handlers/rollbackController.ts
Source spec
tests\api_definitions\agent-runtime-runs\id-rollback-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrorMissing path param: run_idThe :run_id path segment resolves to an empty value
400ValidationErrorto_seq must be an integer >= -1The to_seq query param is present but is not parseable as an integer, or parses to a value below -1
500InternalErrorRollback failedrollbackRun 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Roll back a freshly-started run (empty action journal) returns 200 → expects HTTP 200
Path params
{
  "run_id": "{{cache:agent-runtime-runs.create.response.data.run_id}}"
}
Payload (template)
{
  "reason": "Operator-initiated rollback for testing",
  "actor_id": "{{var:operator_id}}"
}
Example request
{
  "reason": "Operator-initiated rollback for testing",
  "actor_id": "{{var:operator_id}}"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "reason": "Operator-initiated rollback for testing",
    "actor_id": "{{var:operator_id}}",
    "rollback_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-agent-runtimeW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/agent-runtime/agents",
  "POST /api/agent-runtime/runs"
]
Source spec
tests\api_definitions\agent-runtime-tokens\mint-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400MissingRequiredFieldMissing required field: run_id, agent_id, acting_persona_id, tool_sku, args, tenant_scope are requiredany 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)
403scope_violationscope_violationmintToken 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
500MintFailedMint failedmintToken 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
500InternalErrorInternalErrorthe handler throws outside its own try/catch and the route wrapper catch fires
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Mint capability token → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-agent-runtimeW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/agent-runtime/agents",
  "POST /api/agent-runtime/runs",
  "POST /api/agent-runtime/tokens"
]
Request field options
actor_kind: human, service, agent
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\agent-runtime-tokens\id-revoke-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400MissingPathParamMissing path param: token_idtoken_id resolves falsy (defensive - an empty path segment normally route-misses to 404 first)
400MissingRequiredFieldMissing required field: reasonbody.reason is absent, an empty string, or not a string
500RevokeFailedRevoke failedrevokeToken throws - a non-UUID token_id failing the uuid cast, audit-emit failure, or any DB error
500InternalErrorInternalErrorthe handler throws outside its own try/catch and the route wrapper catch fires
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "agents.capability_token",
  "field": "status",
  "flow": [
    "active",
    "revoked"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "revoked",
      "via": "POST /api/agent-runtime/tokens/:token_id/revoke"
    }
  ]
}
Revoke a capability token → expects HTTP 200
Path params
{
  "token_id": "{{cache:agent-runtime-tokens.mint.response.data.token_id}}"
}
Payload (template)
{
  "reason": "Operator-initiated revoke for testing",
  "actor_id": "{{var:operator_id}}",
  "actor_kind": "human"
}
Example request
{
  "reason": "Operator-initiated revoke for testing",
  "actor_id": "{{var:operator_id}}",
  "actor_kind": "human"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
sdk-agent-runtimeW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/agent-runtime/agents",
  "POST /api/agent-runtime/runs",
  "POST /api/agent-runtime/tokens"
]
Source spec
tests\api_definitions\agent-runtime-tokens\id-validate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400MissingPathParamMissing path param: token_idtoken_id resolves falsy (defensive - an empty path segment normally route-misses to 404 first)
400MissingRequiredFieldMissing required field: argsbody.args is undefined (absent body, or a body with no args key); args:null passes the check
500ValidateFailedValidate failedvalidateToken throws - a non-UUID token_id failing the uuid cast, or any DB error
500InternalErrorInternalErrorthe handler throws outside its own try/catch and the route wrapper catch fires
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Validate freshly-minted token → expects HTTP 200
Path params
{
  "token_id": "{{cache:agent-runtime-tokens.mint.response.data.token_id}}"
}
Payload (template)
{
  "args": {
    "first_name": "Ada"
  }
}
Example request
{
  "args": {
    "first_name": "Ada"
  }
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "args": {
      "first_name": "Ada"
    },
    "validate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "success": true
}

sdk-ai-gateway

10 API(s)
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.

SDK / service
sdk-ai-gatewayW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/agent-runtime/agents",
  "POST /api/agent-runtime/runs"
]
Implemented in
packages/sdk-ai-gateway/src/server/handlers/completionController.ts
Request field options
request.provider_hint: anthropic, openai, gemini, bedrock, local-llama, local-mistral
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\ai-gateway\complete-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrorMissing required: request, contextbody.request or body.context absent
400ValidationErrorrequest.model and request.prompt are requiredrequest.model or request.prompt missing
400ValidationErrorcontext.agent_id, context.run_id, and context.trace_id are requiredany of context.agent_id/run_id/trace_id missing
503ProviderUnavailableprovider not available / no route matches and no provider_hint suppliedcomplete() throws an Error whose message includes 'not available' or 'no route matches'
500CompletionFailedCompletion failedany other error thrown by complete() (kill-switch, provider adapter failure, DB insert failure)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Completion records consent + budget + redaction + trace → expects HTTP 200
Path params
Payload (template)
{
  "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": []
  }
}
Example request
{
  "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": []
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-ai-gatewayW6 · test wave
Implemented in
packages/sdk-ai-gateway/src/server/routes.ts
Source spec
tests\api_definitions\ai-gateway\health-get.json
Health probe returns ok → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-ai-gatewayW6 · test wave
Testability
manual — Response is a text/event-stream (SSE) produced by a live third-party model adapter (real Anthropic/OpenAI stream). The JSON-only API runner cannot consume SSE frames nor supply a real provider adapter, so this is exercised by a specialised streaming integration client. Chain is wired for that harness.
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/agent-runtime/agents",
  "POST /api/agent-runtime/runs"
]
Implemented in
packages/sdk-ai-gateway/src/server/handlers/completionController.ts
Request field options
request.provider_hint: anthropic, openai, gemini, bedrock, local-llama, local-mistral
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\ai-gateway\stream-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorMissing required: request, contextthe body is absent, or either the request or the context object is missing
400ValidationErrorrequest.model and request.prompt are requiredrequest is present but request.model or request.prompt is missing or empty
400ValidationErrorcontext.agent_id, context.run_id, and context.trace_id are requiredcontext is present but any of agent_id, run_id or trace_id is missing or empty
200StreamErrorevent: 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
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
SSE stream produces chunks and final completion row → expects HTTP 200
Path params
Payload (template)
{
  "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": []
  }
}
Example request
{
  "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": []
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-ai-gatewayW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/ai-gateway/tenant-credentials"
]
Implemented in
packages/sdk-ai-gateway/src/server/handlers/tenantCredentialController.ts
Source spec
tests\api_definitions\ai-gateway\tenant-credentials-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param is requiredthe tenant_id query param is absent or empty
500ListFailedlist failedlistTenantCredentials throws — malformed tenant UUID or a database error; the underlying message is logged but never returned
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List bindings for a tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "tenant_credential_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-ai-gatewayW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-ai-gateway/src/server/handlers/tenantCredentialController.ts
Request field options
provider_id: anthropic, openai, bedrock, gemini
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\ai-gateway\tenant-credentials-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, provider_id, raw_key are requiredthe body is absent or any of tenant_id, provider_id or raw_key is missing or empty
400ValidationErrorunsupported provider_id: <provider_id>provider_id is not one of 'anthropic', 'openai', 'bedrock', 'gemini'
400ValidationErrorraw_key must be a non-trivial stringraw_key is not a string or is shorter than 8 characters
500BindFailed<underlying error message> | bind failedbindTenantCredential 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
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Bind an OpenAI key for the tenant → expects HTTP 201
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-ai-gatewayW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/ai-gateway/tenant-credentials"
]
Implemented in
packages/sdk-ai-gateway/src/server/handlers/tenantCredentialController.ts
Source spec
tests\api_definitions\ai-gateway\tenant-credentials-binding_id-delete.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorbinding_id path param is requiredthe :binding_id path segment is empty
400ValidationErrorreason must be at least 6 charactersbody.reason is absent, or has fewer than 6 characters after trimming (whitespace-only reasons are rejected)
404NotFound<error message containing "not found">no active binding matches the binding_id — unknown id, or the binding was already revoked by an earlier call
500RevokeFailed<underlying error message> | revoke failedrevokeTenantCredential throws for any reason other than not-found — malformed binding_id UUID, audit/KMS failure, or a database error
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Revoke an active binding with a reason → expects HTTP 200
Path params
{
  "binding_id": "{{cache:ai-gateway-tenant-credentials.create.response.data.binding.binding_id}}"
}
Payload (template)
{
  "reason": "{{static:rotating to a new provider account}}"
}
Example request
{
  "reason": "rotating to a new provider account"
}
Expected output ✓
{
  "success": true
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-ai-gatewayW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/ai-gateway/tenant-credentials"
]
Implemented in
packages/sdk-ai-gateway/src/server/handlers/tenantCredentialController.ts
Source spec
tests\api_definitions\ai-gateway\tenant-credentials-binding_id-patch.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorbinding_id path param is requiredthe :binding_id path segment is empty
400ValidationErrorraw_key must be a non-trivial stringbody.raw_key is absent or shorter than 8 characters
404NotFound<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)
500RotateFailed<underlying error message> | rotate failedrotateTenantCredential throws for any reason other than not-found — envelope/KMS failure, malformed binding_id UUID, or a database error
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Rotate the raw key on an existing binding → expects HTTP 200
Path params
{
  "binding_id": "{{cache:ai-gateway-tenant-credentials.create.response.data.binding.binding_id}}"
}
Payload (template)
{
  "raw_key": "{{static:sk-test-NEWDUMMYKEYAFTERROTATE}}"
}
Example request
{
  "raw_key": "sk-test-NEWDUMMYKEYAFTERROTATE"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-ai-gatewayW6 · test wave
Testability
manual — Contract-only change in packages/contracts/src/events.ts. The three event types ai_gateway.tenant_credential.{bound,rotated,revoked}.v1 are emitted by the bind/rotate/revoke endpoints (TK-3448/3449/3450). Producer-side correctness is exercised by those endpoints' integration tests; this file is only an anchor for the contracts task.
Source spec
tests\api_definitions\ai-gateway\tenant-credential-events-contract.json
Three event types registered with regulated retention → expects HTTP 200
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "success": true,
  "data": {
    "ai_gateway.tenant_credential_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-ai-gatewayW6 · test wave
Testability
manual — Effect is observable in the audit ledger payload (credential_source field) and the persisted ai_gateway.completion.billed_cost (0 for BYOK). Covered by integration test referenced in PRD AC-4.
Source spec
tests\api_definitions\ai-gateway\meter-byok-sku-switch.json
Tenant credential completion stamps credential_source=tenant and bills 0 markup → expects HTTP 200
Path params
Payload (template)
{
  "scenario": "tenant_credential",
  "expected_audit_payload_field": "credential_source=tenant",
  "expected_billed_cost": 0,
  "expected_token_sku_emitted": false,
  "expected_governance_sku_emitted": true
}
Example request
{
  "scenario": "tenant_credential",
  "expected_audit_payload_field": "credential_source=tenant",
  "expected_billed_cost": 0,
  "expected_token_sku_emitted": false,
  "expected_governance_sku_emitted": true
}
Expected output ✓
{
  "success": true,
  "data": {
    "completionService.emitCompletionEvent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "success": true
}
Platform credential completion stamps credential_source=platform and bills with margin → expects HTTP 200
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "success": true,
  "data": {
    "completionService.emitCompletionEvent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-ai-gatewayW6 · test wave
Testability
manual — Internal resolver function exercised indirectly by /api/ai-gateway/complete and /stream. Unit-tested in packages/sdk-ai-gateway/tests/resolver-fallthrough.spec.ts (added with this task).
Source spec
tests\api_definitions\ai-gateway\resolver-tenant-fallthrough.json
Error responses
HTTPCodeMessageWhen it happens
503ProviderNotAvailable[ai-gateway] provider <provider_id> not availableloadProviderRow 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Tenant active binding → tenant credential → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{static:00000000-0000-0000-0000-000000000001}}",
  "provider_id": "openai",
  "model": "gpt-4o",
  "tenant_binding": "active",
  "expected_credential_source": "tenant"
}
Example request
{
  "tenant_id": "00000000-0000-0000-0000-000000000001",
  "provider_id": "openai",
  "model": "gpt-4o",
  "tenant_binding": "active",
  "expected_credential_source": "tenant"
}
Expected output ✓
{
  "success": true,
  "data": {
    "completionService.loadProviderRow_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "success": true
}
No tenant binding → platform credential → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{static:00000000-0000-0000-0000-000000000002}}",
  "provider_id": "openai",
  "model": "gpt-4o",
  "tenant_binding": "absent",
  "expected_credential_source": "platform"
}
Example request
{
  "tenant_id": "00000000-0000-0000-0000-000000000002",
  "provider_id": "openai",
  "model": "gpt-4o",
  "tenant_binding": "absent",
  "expected_credential_source": "platform"
}
Expected output ✓
{
  "success": true,
  "data": {
    "completionService.loadProviderRow_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "success": true
}
Model not in allowlist → platform credential → expects HTTP 200
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "success": true,
  "data": {
    "completionService.loadProviderRow_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "success": true
}

sdk-analytics

6 API(s)
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.

SDK / service
sdk-analyticsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets",
  "POST /api/analytics/datasets",
  "POST /api/analytics/datasets/:spec_id/build"
]
Source spec
tests\api_definitions\analytics\builds-build-id-export-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400BadRequesttenant context requiredthe verified JWT carries no tenant_id claim
404NotFounddataset build not foundbuild_id does not exist or belongs to a different tenant
500InternalError<error message from exportDatasetBuild>the service/DB call throws (bad asset/sensor reference, ClickHouse or Postgres failure)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Export a built dataset to the warehouse → expects HTTP 200
Path params
{
  "build_id": "{{cache:analytics.build.response.data.build_id}}"
}
Payload (template)
{
  "target": "iceberg://warehouse/datasets"
}
Example request
{
  "target": "iceberg://warehouse/datasets"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-analyticsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Source spec
tests\api_definitions\analytics\datasets-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400BadRequesttenant context requiredthe verified JWT carries no tenant_id claim
500InternalError<error message from listDatasetSpecs>the service/DB call throws (bad asset/sensor reference, ClickHouse or Postgres failure)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List dataset specs for the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "dataset_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-analyticsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets"
]
Request field options
grain: minute, hour, day
aggregations: avg, min, max, last, count
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\analytics\datasets-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400BadRequesttenant context requiredthe verified JWT carries no tenant_id claim
400BadRequestname and asset_id are requiredbody omits name or asset_id (or sends them empty)
500InternalError<error message from createDatasetSpec>the service/DB call throws (bad asset/sensor reference, ClickHouse or Postgres failure)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register a per-minute feature dataset spec → expects HTTP 201
Path params
Payload (template)
{
  "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": []
  }
}
Example request
{
  "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": []
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-analyticsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets",
  "POST /api/analytics/datasets"
]
Source spec
tests\api_definitions\analytics\datasets-spec-id-build-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400BadRequesttenant context requiredthe verified JWT carries no tenant_id claim
400BadRequestfrom and to are requiredbody omits either the from or the to window bound
404NotFounddataset spec not foundspec_id does not exist or belongs to a different tenant
500InternalError<error message from buildDatasetFromSpec>the service/DB call throws (bad asset/sensor reference, ClickHouse or Postgres failure)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Build feature windows for a 1h range → expects HTTP 200
Path params
{
  "spec_id": "{{cache:analytics.dataset.response.data.spec_id}}"
}
Payload (template)
{
  "from": "{{dynamic:pastdatetime}}",
  "to": "{{dynamic:datetime}}"
}
Example request
{
  "from": "2026-01-15T10:30:00Z",
  "to": "2026-01-15T10:30:00Z"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-analyticsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets",
  "POST /api/analytics/datasets"
]
Source spec
tests\api_definitions\analytics\datasets-spec-id-builds-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400BadRequesttenant context requiredthe verified JWT carries no tenant_id claim
500InternalError<error message from listDatasetBuilds>the service/DB call throws (bad asset/sensor reference, ClickHouse or Postgres failure)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List builds with lineage refs → expects HTTP 200
Path params
{
  "spec_id": "{{cache:analytics.dataset.response.data.spec_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "build_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-analyticsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets",
  "POST /api/analytics/datasets"
]
Request field options
kind: intervals, provider
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\analytics\datasets-spec-id-label-source-put.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400BadRequesttenant context requiredthe verified JWT carries no tenant_id claim
400BadRequestkind must be 'intervals' or 'provider'body.kind is missing or is any value other than 'intervals' / 'provider'
404NotFounddataset spec not foundspec_id does not exist or belongs to a different tenant
500InternalError<error message from updateDatasetLabelSource>the service/DB call throws (bad asset/sensor reference, ClickHouse or Postgres failure)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Set interval-based labels → expects HTTP 200
Path params
{
  "spec_id": "{{cache:analytics.dataset.response.data.spec_id}}"
}
Payload (template)
{
  "kind": "intervals",
  "default_label": 0,
  "intervals": [
    {
      "from": "{{dynamic:pastdatetime}}",
      "to": "{{dynamic:datetime}}",
      "label": 1
    }
  ],
  "provider_args": {}
}
Example request
{
  "kind": "intervals",
  "default_label": 0,
  "intervals": [
    {
      "from": "2026-01-15T10:30:00Z",
      "to": "2026-01-15T10:30:00Z",
      "label": 1
    }
  ],
  "provider_args": {}
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-api-keys

15 API(s)
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.

SDK / service
sdk-api-keysW2 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/api-keys"
]
Source spec
tests\api_definitions\api-keys\list-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrortenant_id query param is requiredThe tenant_id query param is missing or trims to an empty string
500InternalErrorInternalErrorlistKeys throws — e.g. tenant_id is not a valid UUID and the query cast fails, or the database is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List keys for the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "api_key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-api-keysW2 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Source spec
tests\api_definitions\api-keys\create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationErrortenant_id is requiredtenant_id is missing or blank after trimming
400ValidationErrorscopes must be a non-empty string arrayscopes is absent, not an array, empty, or contains a non-string element
400ValidationErrorrate_limit_rpm must be a positive numberrate_limit_rpm is supplied but is not finite or is <= 0
400ValidationErrorexpires_at must be ISO-8601expires_at is supplied but Date.parse cannot parse it
500InternalErrorInternalErrorissueKey throws — e.g. tenant_id violates a foreign key, key hashing fails, or the database is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Issue a key with crm + engagement scopes → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-api-keysW2 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/api-keys"
]
Source spec
tests\api_definitions\api-keys\id-revoke-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
404NotFoundNo active key with id <key_id>No active key matches key_id — it does not exist, or it was already revoked
500InternalErrorInternalErrorrevokeKey throws — e.g. key_id is not a valid UUID and the query cast fails, or the database is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Revoke the freshly-issued key → expects HTTP 200
Path params
{
  "key_id": "{{cache:api-keys.create.response.data.key.key_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-api-keysW2 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/api-keys"
]
Source spec
tests\api_definitions\api-keys\id-rotate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
404NotFoundNo rotatable key with id <key_id>No rotatable key matches key_id — it does not exist, or it is already revoked
500InternalErrorInternalErrorrotateKey throws — e.g. key_id is not a valid UUID, the replacement insert violates a constraint, or the database is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "api-key",
  "field": "status",
  "flow": [
    "active",
    "rotating",
    "revoked",
    "expired"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "rotating",
      "via": "POST /api/api-keys/:key_id/rotate"
    }
  ]
}
Rotate the freshly-issued key → expects HTTP 201
Path params
{
  "key_id": "{{cache:api-keys.create.response.data.key.key_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "rotate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-api-keysW2 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/applications"
]
Implemented in
packages/sdk-api-keys/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
environment: live, test
status: active, disabled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/applications/list-get.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List after creating one → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "application_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-api-keysW2 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-api-keys/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
environment: live, test
status: active, disabled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/applications/create-post.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a live application → expects HTTP 201
Path params
Payload (template)
{
  "name": "Journey backend",
  "environment": "live",
  "description": "Server-to-server calls from our backend"
}
Example request
{
  "name": "Journey backend",
  "environment": "live",
  "description": "Server-to-server calls from our backend"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-api-keysW2 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/applications"
]
Implemented in
packages/sdk-api-keys/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: active, rotating, revoked, expired
environment: live, test
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/applications/id-get.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the application just created → expects HTTP 200
Path params
{
  "application_id": "{{cache:applications.create.response.data.application.application_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "application_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-api-keys
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/applications"
]
Implemented in
packages/sdk-api-keys/src/server/handlers/apiKeyController.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/applications/id-patch.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header - the gateway default-deny authGate
400ValidationErrorThis credential carries no tenant contexttenantOf() cannot resolve a tenant - the JWT carries no tenant_id claim, e.g. a bare /api/auth/register token
404NotFoundNo such applicationNo application matches (application_id, tenant_id) - including one owned by a DIFFERENT tenant, which answers 404 rather than 403
500InternalErrorInternalErrorupdateApplication throws - a non-UUID application_id that fails the Postgres uuid cast, or any database error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Rename the application just created → expects HTTP 200
Path params
{
  "application_id": "{{cache:applications.create.response.data.application.application_id}}"
}
Payload (template)
{
  "name": "renamed-integration-{{dynamic:slug}}",
  "description": "Updated by the api_definition regression suite"
}
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-api-keysW2 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/applications"
]
Implemented in
packages/sdk-api-keys/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: active, disabled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/applications/id-disable-post.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Disable the application and revoke what it owns → expects HTTP 200
Path params
{
  "application_id": "{{cache:applications.create.response.data.application.application_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-api-keysW2 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/applications"
]
Implemented in
packages/sdk-api-keys/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
scopes: sla.clock.read, sla.clock.write, sla.policy.read, crm.contact.read, notification.send.write
environment: live, test
status: active, rotating, revoked, expired
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/applications/id-keys-post.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Issue a scoped, rate-limited, expiring key → expects HTTP 201
Path params
{
  "application_id": "{{cache:applications.create.response.data.application.application_id}}"
}
Payload (template)
{
  "name": "nightly sync",
  "scopes": [
    "sla.clock.read",
    "sla.clock.write"
  ],
  "rate_limit_rpm": 600,
  "expires_at": "{{dynamic:futuredatetime+30d}}"
}
Example request
{
  "name": "nightly sync",
  "scopes": [
    "sla.clock.read",
    "sla.clock.write"
  ],
  "rate_limit_rpm": 600,
  "expires_at": "2026-01-15T10:30:00Z"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-api-keysW2 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/applications",
  "POST /api/applications/{application_id}/keys"
]
Implemented in
packages/sdk-api-keys/src/services/credentialService.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
grant_type: client_credentials
token_type: Bearer
error: invalid_request, invalid_client, invalid_scope, unsupported_grant_type
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/auth/token-post.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Exchange a key for a service token → expects HTTP 200
Path params
Payload (template)
{
  "grant_type": "client_credentials",
  "client_id": "{{cache:applications.create.response.data.application.slug}}",
  "client_secret": "{{cache:applications.id-keys.response.data.plaintext}}"
}
Example request
{
  "grant_type": "client_credentials",
  "client_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "client_secret": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-api-keysW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/keys"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\keys\index-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
400ValidationErrortenant_id requiredthe tenant_id query param is absent or empty
500ListFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List API keys for the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-api-keysW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\keys\index-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
400ValidationErrortenant_id + scope(s) requiredtenant_id is absent, or the effective scope list is empty (neither scopes[] nor scope supplied, or scopes:[])
500IssueFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "api-key",
  "field": "status",
  "flow": [
    "active",
    "rotating",
    "revoked",
    "expired"
  ],
  "transitions": [
    {
      "from": "none",
      "to": "active",
      "via": "POST /api/keys"
    }
  ]
}
Issue a new API key for the tenant → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "name": "production-integration-key",
  "scope": "crm.contact.read"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "name": "production-integration-key",
  "scope": "crm.contact.read"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-api-keysW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/keys"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\keys\key-id-revoke-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
400ValidationErrorreason requiredbody.reason is absent, empty, or whitespace-only after trim
404NotFoundkey not found or already revokedrevokeKey reports no row updated - the key_id does not exist, or it was already revoked by an earlier call
500RevokeFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "api-key",
  "field": "status",
  "flow": [
    "active",
    "rotating",
    "revoked",
    "expired"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "revoked",
      "via": "POST /api/keys/:key_id/revoke"
    }
  ]
}
Revoke the freshly-issued key → expects HTTP 200
Path params
{
  "key_id": "{{cache:keys.create.response.data.key_id}}"
}
Payload (template)
{
  "reason": "compromised-credential-rotation"
}
Example request
{
  "reason": "compromised-credential-rotation"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "reason": "compromised-credential-rotation",
    "revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-api-keys
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/keys"
]
Implemented in
packages/sdk-api-keys/src/server/handlers/apiKeyController.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/keys/key-id-rotate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header - the gateway default-deny authGate
400ValidationErrorThis credential carries no tenant contexttenantOf() cannot resolve a tenant - the JWT carries no tenant_id claim
404NotFoundNo rotatable key with that idNo active key matches (key_id, tenant_id) - the key never existed, is already revoked or expired, or belongs to a DIFFERENT tenant
500InternalErrorInternalErrorrotateKey throws - a non-UUID key_id that fails the Postgres uuid cast, key-material generation failure, or any database error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Rotate the key just issued → expects HTTP 201
Path params
{
  "key_id": "{{cache:keys.create.response.data.key_id}}"
}
Payload (template)
{}
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
Runner assertion
{
  "data": {
    "key": {
      "key_id": "string",
      "prefix": "string"
    },
    "plaintext": "string"
  }
}

sdk-approval

11 API(s)
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.

SDK / service
sdk-approvalW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/approvals/routes",
  "POST /api/approvals/requests"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\approvals\requests-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenGateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent
401UnauthorizedInvalid or expired tokenauthGate ran requireAuth and the JWT failed verification or had expired
400ValidationErrortenant_id required?tenant_id= query param is absent or empty
500InternalError<postgres error text>tenant_id or assignee_persona_id is not a valid UUID (::uuid cast fails), or the request query errors
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List pending requests for tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-approvalW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/approvals/routes"
]
Implemented in
packages/sdk-approval/src/server/routes.ts
Source spec
tests\api_definitions\approvals\requests-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrortenant_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 UUIDany validateSubmitRequest check fails; all failures are returned together in details[]
404RouteNotFoundRoute <route_id> not foundroute_id is a valid UUID but no approval.route row matches
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "approval.request",
  "field": "status",
  "flow": [
    "pending",
    "approved",
    "rejected",
    "escalated",
    "timed-out",
    "cancelled"
  ],
  "transitions": [
    {
      "from": null,
      "to": "pending",
      "via": "POST /api/approvals/requests"
    }
  ]
}
Submit refund approval request → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-approvalW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/approvals/routes",
  "POST /api/approvals/requests"
]
Implemented in
packages/sdk-approval/src/server/routes.ts
Source spec
tests\api_definitions\approvals\requests-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
404NotFoundRequest <request_id> not foundno approval.request row matches :request_id
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get request state + steps → expects HTTP 200
Path params
{
  "request_id": "{{cache:approvals.requests.create.response.data.request.request_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-approvalW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/approvals/routes",
  "POST /api/approvals/requests"
]
Implemented in
services/api-gateway/src/app.ts
Request field options
decision: approved, rejected
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\approvals\requests-request-id-decide-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenGateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent
401UnauthorizedInvalid or expired tokenauthGate ran requireAuth and the JWT failed verification or had expired
400ValidationErrordecision + comment + decider_persona_id requiredany of decision, comment or decider_persona_id is absent/empty
500InternalError<postgres error text>:request_id is not a valid UUID, or decision is not an accepted approval.request status value
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Approve a pending request directly → expects HTTP 200
Path params
{
  "request_id": "{{cache:approvals.requests.create.response.data.request.request_id}}"
}
Payload (template)
{
  "decision": "approved",
  "comment": "Approved after finance review",
  "decider_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}"
}
Example request
{
  "decision": "approved",
  "comment": "Approved after finance review",
  "decider_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-approvalW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/approvals/routes"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\approvals\routes-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenGateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent
401UnauthorizedInvalid or expired tokenauthGate ran requireAuth and the JWT failed verification or had expired
400ValidationErrortenant_id required?tenant_id= query param is absent or empty
500InternalError<postgres error text>tenant_id is not a valid UUID (::uuid cast fails), or the route query errors
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List routes for tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-approvalW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-approval/src/server/routes.ts
Request field options
kind: single, m-of-n, role
status: draft, active, deprecated
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\approvals\routes-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrortenant_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[]
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create 2-step approval route (manager single -> finance m-of-n) → expects HTTP 201
Path params
Payload (template)
{
  "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": {}
}
Example request
{
  "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": {}
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-approvalW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/approvals/routes",
  "POST /api/approvals/requests"
]
Implemented in
packages/sdk-approval/src/server/routes.ts
Request field options
decision: approve, reject
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\approvals\steps-id-decide-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrorstep_id path param must be a UUID / decision must be 'approve' or 'reject' / acting_persona_id must be a UUIDany validateDecide check fails; all failures are returned together in details[]
403NotYourStepStep is assigned to <approver_persona_id>, not <acting_persona_id>acting_persona_id is not the persona the step is assigned to
404StepNotFoundStep <step_id> not foundstep_id is a valid UUID but no approval.step row matches
409StepAlreadyDecidedStep <step_id> already decidedthe step already has a non-null decision (replay / double-submit)
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Approve manager step → expects HTTP 200
Path params
{
  "step_id": "{{cache:approvals.requests.create.response.data.pending_steps.0.step_id}}"
}
Payload (template)
{
  "decision": "approve",
  "acting_persona_id": "{{cache:auth.signup-tenant.response.data.userId}}",
  "reason": "LGTM after review"
}
Example request
{
  "decision": "approve",
  "acting_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "reason": "LGTM after review"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-approvalW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/approvals/routes"
]
Implemented in
packages/sdk-approval/src/server/routes.ts
Source spec
tests\api_definitions\break-glass\request-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrorroute_id, tenant_id, justification are requiredroute_id or justification is missing, or no tenant_id can be resolved from the body or the JWT
400BreakGlassjustification is requiredrequestBreakGlass re-validates the justification and finds it empty
400BreakGlassfailed to create break-glass grantGrant creation returns no row — e.g. route_id does not resolve to a usable approval route
500InternalErrorInternalErrorrequestBreakGlass throws a non-BreakGlassError (approval-route expansion failure, database unreachable)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "approval.break_glass_grant",
  "field": "status",
  "flow": [
    "pending",
    "active",
    "expired",
    "revoked"
  ],
  "transitions": [
    {
      "from": null,
      "to": "pending",
      "via": "POST /api/break-glass"
    }
  ]
}
Request emergency break-glass access to a patient record → expects HTTP 201
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-approvalW5 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-approval/src/server/routes.ts
Source spec
tests\api_definitions\break-glass\id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
404NotFoundNotFoundgetBreakGlass returns no grant for the supplied grant_id
500InternalErrorInternalErrorgetBreakGlass throws — e.g. grant_id is not a valid UUID and the query cast fails, or the database is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read grant status and latest certificate → expects HTTP 200
Path params
{
  "grant_id": "{{cache:break-glass.request.response.data.grant.grant_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "break_glass_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-approvalW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/approvals/routes",
  "POST /api/break-glass"
]
Implemented in
packages/sdk-approval/src/server/routes.ts
Request field options
decision: approve, reject
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\break-glass\decide-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrorstep_id and decision (approve|reject) are requiredstep_id is missing, or decision is absent or not exactly "approve" or "reject"
400BreakGlassgrant <grant_id> not foundNo break-glass grant exists for the path grant_id
400BreakGlassgrant <grant_id> is <status>, not pendingThe grant has already left pending status (approved, rejected, expired or consumed)
500InternalErrorInternalErrordecideBreakGlass throws a non-BreakGlassError (step write failure, database unreachable)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Approve the gating request, activating the grant → expects HTTP 200
Path params
{
  "grant_id": "{{cache:break-glass.request.response.data.grant.grant_id}}"
}
Payload (template)
{
  "step_id": "{{cache:break-glass.request.response.data.pending_step_ids.0}}",
  "decision": "approve",
  "reason": "Emergency verified - access approved"
}
Example request
{
  "step_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "decision": "approve",
  "reason": "Emergency verified - access approved"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-approvalW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/approvals/routes",
  "POST /api/break-glass",
  "POST /api/break-glass/:grant_id/decide"
]
Implemented in
packages/sdk-approval/src/server/routes.ts
Source spec
tests\api_definitions\break-glass\use-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErroraction is requiredThe body omits action or supplies an empty value
403BreakGlassgrant <grant_id> not foundNo break-glass grant exists for the path grant_id
403BreakGlassgrant <grant_id> has expiredThe grant was approved but its TTL window has elapsed
403BreakGlassgrant <grant_id> is <status>, not activeThe grant is still pending approval, was rejected, or has already been consumed
403BreakGlassaction '<action>' is outside the grant scopeThe requested action is not permitted by the scope object recorded on the grant
500InternalErrorInternalErroruseBreakGlass throws a non-BreakGlassError (certificate write failure, database unreachable)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Exercise the active grant within scope, emitting a certificate → expects HTTP 200
Path params
{
  "grant_id": "{{cache:break-glass.request.response.data.grant.grant_id}}"
}
Payload (template)
{
  "action": "read",
  "target_id": "{{var:target_id}}"
}
Example request
{
  "action": "read",
  "target_id": "{{var:target_id}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "certificate": {
      "grant_id": "string",
      "cert_hash": "string"
    }
  }
}

sdk-asset

5 API(s)
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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Source spec
tests\api_definitions\assets\index-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400tenant_id requiredtenant_id requiredNeither body.tenant_id nor the JWT tenant_id claim is present
500<underlying error message>Asset registration failedassetRegister throws - duplicate device_uuid, malformed components payload, or database error. The raw message is echoed back
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register a humanoid with a component/sensor tree → expects HTTP 201
Path params
Payload (template)
{
  "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
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}
Example request
{
  "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
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets"
]
Source spec
tests\api_definitions\assets\asset-id-commands-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400tenant context requiredtenant context requiredThe JWT carries no tenant_id claim
500<underlying error message>Command listing failedlistCommandsByAsset throws - includes a non-UUID asset_id (Postgres 22P02) and database errors
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List commands for an asset → expects HTTP 200
Path params
{
  "asset_id": "{{cache:assets.create.response.data.asset_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "command_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets"
]
Source spec
tests\api_definitions\assets\asset-id-credentials-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400tenant context requiredtenant context requiredThe JWT carries no tenant_id claim
500<underlying error message>Credential issuance failedissueRobotCredential throws - invalid/unknown asset_id, unparseable expires_at, or database error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Mint a credential scoped to the robot → expects HTTP 201
Path params
{
  "asset_id": "{{cache:assets.create.response.data.asset_id}}"
}
Payload (template)
{
  "rate_limit_rpm": 600,
  "expires_at": "{{dynamic:futuredatetime}}"
}
Example request
{
  "rate_limit_rpm": 600,
  "expires_at": "2026-01-15T10:30:00Z"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets"
]
Request field options
bucket: second, minute, hour, day
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\assets\asset-id-readings-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
500<underlying error message>Reading query failedassetQueryReadings throws - malformed from/to timestamps, a non-UUID asset_id, or a ClickHouse/Postgres error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Query minute-bucketed readings for an asset → expects HTTP 200
Path params
{
  "asset_id": "{{cache:assets.create.response.data.asset_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "reading_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets"
]
Source spec
tests\api_definitions\assets\asset-id-twin-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
404asset not foundasset not foundNo asset exists for the supplied asset_id
500<underlying error message>Twin lookup failedassetGetTwin throws - includes a non-UUID asset_id (Postgres 22P02) and database errors
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the full nested twin → expects HTTP 200
Path params
{
  "asset_id": "{{cache:assets.create.response.data.asset_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "twin_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-assignment

16 API(s)
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).

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Request field options
strategy: default, round_robin, fair_share
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\assignment\assign-by-task-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortask_id and tenant_id are requiredtask_id or tenant_id missing from body
400ValidationErrorlocation {lat,lng} is requiredlocation missing or lat/lng not numbers
400ValidationErrorinvalid strategystrategy is not one of default|round_robin|fair_share
409NoEligiblePersonano eligible persona for taskno candidate passes the skill/availability/capacity gates or all raced to capacity
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Round-robin assign among three eligible, skilled personas → expects HTTP 201
Path params
Payload (template)
{
  "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"
  ]
}
Example request
{
  "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"
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assignment/route"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
outcome: ASSIGNED, FALLBACK, REVIEW, UNROUTABLE
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/assignment/decisions-get.json
Error responses
HTTPCodeMessageWhen it happens
404ROUTING_DECISION_NOT_FOUNDno decision <id>the id names nothing belonging to this tenant
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read one recorded decision by id → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "decision_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
strategy: default, round_robin, fair_share
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/assignment/rotation-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the rotation cursors → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "rotation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assignment/routes"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
dry_run: true, false
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/assignment/route-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORsubject_ref is requiredsubject_ref is missing, or candidate_persona_ids is empty
400VALIDATION_ERRORcandidate_persona_ids must be a non-empty arrayno candidates are supplied
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Route a subject through the pipeline → expects HTTP 200
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assignment/routes"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/assignment/routes-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List rule versions → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
activate: true, false
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/assignment/routes-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORrules (object) or activate_version (number) is requiredthe payload carries neither a rule body nor a version to activate
404ROUTING_RULE_SET_NOT_FOUNDno active routing rule set 'x' for this tenantactivate_version names a version that was never published
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Publish a rule set and make it active → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assignment/routes",
  "POST /api/assignment/route"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/assignment/simulate-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORcandidate_version (number) is requiredcandidate_version is missing or not a number
400VALIDATION_ERRORcandidate_persona_ids must be a non-empty arrayno candidates are supplied
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Simulate a candidate rule version → expects HTTP 200
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-assignment
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/coverage/schedules",
  "POST /api/assignment/routes",
  "POST /api/assignment/route",
  "POST /api/assignment/simulate"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
limit: 1, 50, 500
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/assignment/simulations-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
400VALIDATION_ERRORcandidate_version must be an integercandidate_version is supplied but is not parseable as an integer
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
The tenant's runs come back newest first → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-assignment
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/coverage/schedules",
  "POST /api/assignment/routes",
  "POST /api/assignment/route",
  "POST /api/assignment/simulate"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
outcome: ASSIGNED, FALLBACK, REVIEW, UNROUTABLE
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/assignment/simulations-simulation_id-get.json
Error responses
HTTPCodeMessageWhen it happens
404SIMULATION_NOT_FOUNDno simulation run with that id for this tenantthe simulation_id is well-formed but names no run this tenant owns
400VALIDATION_ERRORsimulation_id must be a UUIDthe path segment is not a well-formed UUID
404SIMULATION_NOT_FOUNDno simulation run with that id for this tenantthe run exists but belongs to another tenant - reported as absent rather than forbidden, because confirming it exists elsewhere leaks that a simulation happened
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
A cited simulation resolves to the report it produced → expects HTTP 200
Path params
{
  "simulation_id": "{{cache:assignment.simulate.simulation_id}}"
}
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/assignment/workload-persona-id-put.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorpersona_id must be a UUIDthe :persona_id path param is not a valid UUID (guards the ::uuid cast in setWorkload)
401UnauthorizedMissing bearer tokenno Authorization header or an invalid/expired tenant JWT (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Upsert a persona workload profile → expects HTTP 200
Path params
{
  "persona_id": "{{dynamic:uuid}}"
}
Payload (template)
{
  "capacity_per_day": 8,
  "skills": [
    "plumbing",
    "hvac"
  ],
  "available_from": "2026-01-01T00:00:00Z",
  "available_to": "{{dynamic:futuredatetime+30d}}"
}
Example request
{
  "capacity_per_day": 8,
  "skills": [
    "plumbing",
    "hvac"
  ],
  "available_from": "2026-01-01T00:00:00Z",
  "available_to": "2026-01-15T10:30:00Z"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/assignment/assignments-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORsource_timestamp (ISO-8601) is requiredthe payload omits source_timestamp or it is not parseable
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Offer a subject with a backup and a manager → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assignments"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/assignment/assignments-record_id-get.json
Error responses
HTTPCodeMessageWhen it happens
404ASSIGNMENT_NOT_FOUNDno assignment <id>the id names nothing belonging to this tenant
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read an assignment with its history → expects HTTP 200
Path params
{
  "record_id": "{{cache:assignment.assignments.create.record_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "assignment_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assignments"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/assignment/assignments-record_id-accept-post.json
Error responses
HTTPCodeMessageWhen it happens
409INVALID_ASSIGNMENT_TRANSITIONcannot accept an assignment that is COMPLETEDthe assignment has already been closed
404ASSIGNMENT_NOT_FOUNDno assignment <id>the id names nothing belonging to this tenant
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Accept an offered assignment → expects HTTP 200
Path params
{
  "record_id": "{{cache:assignment.assignments.create.record_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
  "actor": "{{static:qa}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "actor": "qa"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assignments"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/assignment/assignments-record_id-decline-post.json
Error responses
HTTPCodeMessageWhen it happens
400REASON_REQUIREDa decline must carry a reasonreason is missing, empty or whitespace
409NO_BACKUP_DESIGNATEDassignment <id> has no backup to fall tothe assignment was offered without a backup
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Decline with a reason → expects HTTP 200
Path params
{
  "record_id": "{{cache:assignment.assignments.create.record_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "reason": "outside my service area",
  "actor": "{{static:qa}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "reason": "outside my service area",
  "actor": "qa"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assignments",
  "POST /api/personas"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/assignment/assignments-record_id-reassign-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORto_persona_id is requiredthe payload omits to_persona_id
400REASON_REQUIREDa reassignment must carry a reasonreason is missing, empty or whitespace
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Reassign with a reason → expects HTTP 200
Path params
{
  "record_id": "{{cache:assignment.assignments.create.record_id}}"
}
Payload (template)
{
  "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}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "to_persona_id": "{{var:coverage_backup_persona_id}}",
  "reason": "territory rebalance",
  "actor": "qa-manager"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-assignmentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assignments"
]
Implemented in
packages/sdk-assignment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/assignment/assignments-sweep-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Sweep expired offers → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "limit": 100
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "limit": 100
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "scanned": "number",
    "fell_back": "array",
    "stranded": "array"
  }
}

sdk-audit

6 API(s)
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).

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/tenants/:tenant_id/bus",
  "POST /scim/v2/Users"
]
Implemented in
packages/sdk-audit/src/server/handlers/auditController.ts
Request field options
actor_kind: human, service, agent
retention_class: transient, operational, regulated
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\audit\append-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrordetails[]: 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 objectvalidateAppendInput fails
400UnregisteredEventTypeUnregistered event_type: <type>. Add it to EVENT_TYPE_REGISTRY first.appendAuditEntry throws for an event_type not in EVENT_TYPE_REGISTRY
500InternalErrorInternalErrorany other error thrown by appendAuditEntry (DB failure, etc.)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Append entry to canonical audit chain → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-audit/src/server/handlers/exportController.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
format: pdf, jsonl
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\audit\export-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — rejected by the gateway default-deny auth gate (authGate.ts → requireAuth) before exportHandler executes
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or expired)
400ValidationErrortenant_id is requiredbody.tenant_id is absent, empty, or not a string
400ValidationErrorformat must be pdf or jsonlbody.format is supplied but is neither 'pdf' nor 'jsonl'
400ValidationErrorrange_start (ISO 8601) requiredbody.range_start is missing or parses to an invalid Date (NaN)
400ValidationErrorrange_end (ISO 8601) requiredbody.range_end is missing or parses to an invalid Date (NaN)
400ValidationErrorrange_start must be <= range_endBoth dates parse but the range is inverted
500InternalErrorInternalErrorcreateExportRequest returns no row ('Failed to create export request') or materializeExport throws for inline=true (e.g. 'Export request <id> not found', DB/storage failure)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Request an inline JSONL export of the tenant audit chain → expects HTTP 201
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "format": "jsonl",
  "range_start": "{{dynamic:pastdatetime}}",
  "range_end": "{{dynamic:futuredatetime}}",
  "inline": true
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-audit/src/server/handlers/verifyController.ts
Source spec
tests\api_definitions\audit\verify-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — the gateway default-deny auth gate (authGate.ts → requireAuth) blocks /api/audit/verify before verifyHandler runs
401UnauthorizedInvalid or expired tokenBearer token present but verifyJwt rejects it (bad signature, malformed, expired)
400ValidationErrorpool_index is requiredbody.pool_index missing, not a string, or blank/whitespace-only after trim (includes an empty {} body)
409ChainBreakdata.ok=false with break_at_seq and break_reason setverifyChain detects a broken link (prev_hash does not match the previous entry_hash) or a tampered entry (recomputed entry_hash mismatch)
500InternalErrorInternalErrorverifyChain throws — e.g. the audit.entry / audit.chain_head query fails or the DB pool is unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Verify chain for an empty pool → expects HTTP 200
Path params
Payload (template)
{
  "pool_index": "app-healthcare-007",
  "from_seq": 1,
  "to_seq": 100
}
Example request
{
  "pool_index": "app-healthcare-007",
  "from_seq": 1,
  "to_seq": 100
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "pool_index": "app-healthcare-007",
    "from_seq": 1,
    "to_seq": 100,
    "verify_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
Source spec
tests\api_definitions\events\types-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate, not by the route itself
500InternalErrorInternalErrorserializing the registry throws — logged and returned only if the reply has not already been sent
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the platform baseline plus this tenant's own event types → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-audit
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/routes/events.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
retention_class: transient, operational, regulated
conflict_policy: crdt, lww, merge, event-sourcing, human-review
schema_state: active, deprecated, retired
compaction_policy: none, lww, count
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/events/types-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate
400ValidationErrordetails[]: a tenant-scoped token is required to register an event typethe verified claims carry no tenant_id, so the registration has no scope to land in
400ValidationErrordetails[]: 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
400ValidationErrordetails[]: event_type '<name>' is a platform baseline type and cannot be redefined by a tenantthe requested name already exists in the compile-time EVENT_TYPE_REGISTRY
400ValidationErrordetails[]: 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 requiredregisterTenantEventType validation fails; every problem is reported together so a caller fixing one at a time does not deploy twice
500InternalErrorInternalErrorthe insert or read-back throws for any other reason (DB unreachable, etc.) — logged and returned only if the reply has not already been sent
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register a tenant event type → expects HTTP 201
Path params
Payload (template)
{
  "event_type": "capture.lead.created.v1",
  "retention_class": "regulated",
  "conflict_policy": "event-sourcing",
  "schema_state": "active",
  "compaction_policy": "none",
  "schema_version": 1
}
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/register"
]
Implemented in
services/api-gateway/src/routes/events.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
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
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\events\types-type-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate, not by the route itself
404UnregisteredEventTypeevent_type '<type>' is not registeredthe :type path param is not a key of EVENT_TYPE_REGISTRY (exact, case-sensitive match)
500InternalErrorInternalErrorthe lookup/serialization throws — logged and returned only if the reply has not already been sent
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Look up one event type, platform baseline first then tenant → expects HTTP 200
Path params
{
  "type": "event.session.opened.v1"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-billing

4 API(s)
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.

SDK / service
sdk-billingW6 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-billing/src/server/routes.ts
Request field options
actor_kind: human, agent, service
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\billing\invoices-generate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenJWT missing tenant_id claimToken verifies but carries no tenant_id claim; billing surfaces refuse to run unscoped
400ValidationErrortenant_id must be a UUIDThe tenant_id claim injected from the JWT is not a well-formed UUID
400ValidationErrorcatalog_id is requiredcatalog_id is missing or blank in the body
400ValidationErrorperiod_start must be YYYY-MM-DDperiod_start is missing or not in YYYY-MM-DD form
400ValidationErrorperiod_end must be YYYY-MM-DDperiod_end is missing or not in YYYY-MM-DD form
400ValidationErrorperiod_start must be <= period_endBoth dates parse but the range is inverted
404CatalogNotFoundRate catalog not foundgenerateInvoice throws CatalogNotFoundError because catalog_id does not resolve to a catalog
500InternalErrorInternalErrorInvoice generation throws for any other reason (usage aggregation failure, ClickHouse/Postgres unreachable)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "invoice",
  "field": "status",
  "flow": [
    "draft",
    "finalized",
    "paid",
    "void",
    "failed"
  ],
  "transitions": [
    {
      "from": "draft",
      "to": "finalized",
      "via": "POST /api/billing/invoices/generate"
    }
  ]
}
Generate Jan 2026 invoice → expects HTTP 201
Path params
Payload (template)
{
  "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
    }
  ]
}
Example request
{
  "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
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-billingW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-billing/src/server/routes.ts
Source spec
tests\api_definitions\billing\live-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenJWT missing tenant_id claimToken verifies but carries no tenant_id claim; billing surfaces refuse to run unscoped
400ValidationErrortenant_id must be a UUIDThe tenant_id claim injected from the JWT is not a well-formed UUID
404CatalogNotFoundRate catalog not foundreadLiveMeter throws CatalogNotFoundError while resolving the tenant's active rate catalog
500InternalErrorInternalErrorThe meter store (Redis/ClickHouse) is unreachable or the read throws
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read live meter for tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "live_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-billingW6 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-billing/src/server/routes.ts
Request field options
actor_kind: human, agent, service
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\billing\reprice-dry-run-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenJWT missing tenant_id claimToken verifies but carries no tenant_id claim; billing surfaces refuse to run unscoped
400ValidationErrortenant_id must be a UUIDThe tenant_id claim injected from the JWT is not a well-formed UUID
400ValidationErrorperiod_start must be YYYY-MM-DDperiod_start is missing or not in YYYY-MM-DD form
400ValidationErrorperiod_end must be YYYY-MM-DDperiod_end is missing or not in YYYY-MM-DD form
400ValidationErrorbaseline_catalog_id is requiredbaseline_catalog_id is missing or blank
400ValidationErrortarget_catalog_id is requiredtarget_catalog_id is missing or blank
404CatalogNotFoundRate catalog not foundrunRepriceDryRun throws CatalogNotFoundError because the baseline or target catalog id does not resolve
500InternalErrorInternalErrorThe dry run throws for any other reason (usage replay failure, datastore unreachable)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Compare current catalog against itself (baseline == target dry-run) → expects HTTP 201
Path params
Payload (template)
{
  "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
    }
  ]
}
Example request
{
  "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
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-billingW6 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-billing/src/server/routes.ts
Request field options
group_by: app_id, bu_id, persona_kind, encounter_id, sku, actor_kind
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\billing\showback-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenJWT missing tenant_id claimToken verifies but carries no tenant_id claim; billing surfaces refuse to run unscoped
400ValidationErrortenant_id must be a UUIDThe tenant_id claim injected from the JWT is not a well-formed UUID
400ValidationErrorperiod_start must be YYYY-MM-DDperiod_start is missing or not in YYYY-MM-DD form
400ValidationErrorperiod_end must be YYYY-MM-DDperiod_end is missing or not in YYYY-MM-DD form
404CatalogNotFoundRate catalog not foundcomputeShowback throws CatalogNotFoundError while resolving the tenant's rate catalog
500InternalErrorInternalErrorThe aggregation throws or the usage store is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Split by app_id + bu_id for Jan 2026 → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "showback_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "rows": "array",
    "total_amount": "number"
  }
}

sdk-campaign

6 API(s)
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.

SDK / service
sdk-campaignW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "PUT /api/flags"
]
Implemented in
packages/sdk-campaign/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\campaigns\index-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrormissing fieldsbody omits tenant_id or name (details: ["missing fields"])
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "campaign.campaign",
  "field": "status",
  "flow": [
    "draft",
    "scheduled",
    "running",
    "paused",
    "completed"
  ],
  "transitions": [
    {
      "from": "(none)",
      "to": "draft",
      "via": "POST /api/campaigns"
    }
  ]
}
Create a marketing campaign for the signed-up tenant → expects HTTP 201
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "name": "{{dynamic:name}}",
  "variant_flag_id": "{{cache:flags.create.response.data.flag.flag_id}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "name": "Acme QA Sample",
  "variant_flag_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-campaignW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/campaigns"
]
Implemented in
packages/sdk-campaign/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\campaigns\campaign-id-journeys-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Define a two-step journey (delay then notification) → expects HTTP 201
Path params
{
  "campaign_id": "{{cache:campaigns.create.response.data.campaign.campaign_id}}"
}
Payload (template)
{
  "steps": [
    {
      "kind": "delay",
      "duration_hours": 24
    },
    {
      "kind": "notification",
      "template_code": "welcome_v1"
    }
  ]
}
Example request
{
  "steps": [
    {
      "kind": "delay",
      "duration_hours": 24
    },
    {
      "kind": "notification",
      "template_code": "welcome_v1"
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-campaignW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/campaigns"
]
Implemented in
packages/sdk-campaign/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\campaigns\campaign-id-segments-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Upsert a segment whose DSL targets the tenant population → expects HTTP 201
Path params
{
  "campaign_id": "{{cache:campaigns.create.response.data.campaign.campaign_id}}"
}
Payload (template)
{
  "dsl": {
    "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
    "match": "all",
    "rules": [
      {
        "attr": "status",
        "op": "eq",
        "value": "active"
      }
    ]
  }
}
Example request
{
  "dsl": {
    "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "match": "all",
    "rules": [
      {
        "attr": "status",
        "op": "eq",
        "value": "active"
      }
    ]
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-campaignW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/campaigns",
  "POST /api/campaigns/:campaign_id/journeys",
  "POST /api/personas"
]
Implemented in
packages/sdk-campaign/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\campaigns\journeys-journey-id-runs-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrormissing subject_persona_idbody omits subject_persona_id (details: ["missing subject_persona_id"])
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Start a journey run for a subject persona → expects HTTP 201
Path params
{
  "journey_id": "{{cache:campaigns.journey.create.response.data.journey.journey_id}}"
}
Payload (template)
{
  "subject_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}
Example request
{
  "subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-campaignW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/campaigns",
  "POST /api/campaigns/:campaign_id/journeys",
  "POST /api/personas",
  "POST /api/campaigns/journeys/:journey_id/runs"
]
Implemented in
packages/sdk-campaign/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\campaigns\runs-run-id-advance-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
404NotFoundNotFoundno campaign.journey_run row exists for the given run_id
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Advance a journey run by one step → expects HTTP 200
Path params
{
  "run_id": "{{cache:campaigns.run.create.response.data.run.run_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-campaignW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/campaigns",
  "POST /api/campaigns/:campaign_id/segments"
]
Implemented in
packages/sdk-campaign/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\campaigns\segments-segment-id-compute-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
404NotFoundNotFoundno campaign.segment row exists for the given segment_id
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Recompute a segment's population estimate → expects HTTP 200
Path params
{
  "segment_id": "{{cache:campaigns.segment.create.response.data.segment.segment_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-command

6 API(s)
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.

SDK / service
sdk-command
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/assets",
  "POST /api/commands"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/admin/commands-dispatch-now-post.json
Error responses
HTTPCodeMessageWhen it happens
401admin token requiredadmin token requiredThe x-admin-ops-token header is absent, empty, or does not match ADMIN_OPS_TOKEN
500<underlying error message>Dispatch pass faileddispatchApprovedCommands throws — a database error while selecting or updating command.command
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Operator runs one dispatch pass → expects HTTP 200
Path params
Payload (template)
{}
Expected output ✓
Illustrative — standard {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.

SDK / service
sdk-commandW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets"
]
Implemented in
services/api-gateway/src/app.ts
Request field options
risk_class: low, medium, high, critical
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\commands\index-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400tenant_id requiredtenant_id requiredNeither body.tenant_id nor the JWT tenant_id claim is present
400issuer identity requiredissuer identity requiredThe JWT carries no sub claim to record as issued_by
400target_asset_id and type are requiredtarget_asset_id and type are requiredbody.target_asset_id or body.type is absent or empty
403<CommandAuthorizationError message>Command authorization deniedissueCommand 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 failedAny other issueCommand failure - unknown/non-UUID target_asset_id, unknown target_component_id, or database error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Issue a low-risk move command → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-commandW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets",
  "POST /api/commands"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\commands\command-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400tenant context requiredtenant context requiredThe JWT carries no tenant_id claim
404command not foundcommand not foundNo 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 failedgetCommand throws - includes a non-UUID command_id (Postgres 22P02) and database errors
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read command status → expects HTTP 200
Path params
{
  "command_id": "{{cache:commands.create.response.data.command_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "command_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-commandW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/assets",
  "POST /api/assets/:asset_id/credentials",
  "POST /api/commands"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\commands\command-id-ack-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ok (boolean) is requiredok (boolean) is requiredBody is absent or ok is not a JSON boolean (the string "true" is rejected)
401missing credentialmissing credentialNo Bearer token on the request, so no robot credential was presented to ackCommandWithCredential
401invalid, expired, or revoked keyinvalid, expired, or revoked keyThe presented robot credential fails verifyKey - unknown, past its expires_at, or revoked
403credential not scoped to this commandcredential not scoped to this commandThe credential lacks the command-ack scope or the scope for this command's target_asset_id
404command not foundcommand not foundNo command with that id exists within the tenant that owns the presented credential
409command not in dispatched statecommand not in dispatched stateThe command is not in the dispatched state - already acked, still pending approval, or cancelled. Replayed acks land here
500<underlying error message>Ack processing failedackCommandWithCredential throws - non-UUID command_id (Postgres 22P02) or database error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Robot acks a dispatched command → expects HTTP 200
Path params
{
  "command_id": "{{cache:commands.create.response.data.command_id}}"
}
Payload (template)
{
  "ok": true,
  "code": "DONE",
  "message": "executed",
  "data": {}
}
Example request
{
  "ok": true,
  "code": "DONE",
  "message": "executed",
  "data": {}
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-commandW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets",
  "POST /api/commands"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\commands\command-id-decision-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400tenant context requiredtenant context requiredThe JWT carries no tenant_id claim
400approver identity requiredapprover identity requiredThe JWT carries no sub claim to record as decided_by
400approved (boolean) is requiredapproved (boolean) is requiredBody is absent or approved is not a JSON boolean
409command not found or not pendingcommand not found or not pendingapplyCommandApprovalDecision 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 failedapplyCommandApprovalDecision throws - non-UUID command_id (Postgres 22P02), audit-write failure, or database error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Approve a pending risky command → expects HTTP 200
Path params
{
  "command_id": "{{cache:commands.create.response.data.command_id}}"
}
Payload (template)
{
  "approved": true,
  "reason": "operator confirmed safe"
}
Example request
{
  "approved": true,
  "reason": "operator confirmed safe"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-commandW6 · test wave
Testability
manual — WebSocket upgrade endpoint (HTTP 101) — the robot/edge agent subscribes to receive dispatched commands for its asset. Not exercisable by the request/response regression runner; verify with a WS client.
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\commands\stream-asset-id-get.json
Edge agent subscribes to its asset's command stream → expects HTTP 101
Path params
{
  "asset_id": "{{cache:assets.create.response.data.asset_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "stream_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 101.
On error
{
  "success": false,
  "error": "stream not found"
}
e.g. HTTP 404

sdk-config

6 API(s)
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).

SDK / service
sdk-configW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/config"
]
Implemented in
packages/sdk-config/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
scope: platform, tenant, app, app_user
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/config/index-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorscope must be platform|tenant|app|app_userscope missing or invalid
401UnauthorizedMissing bearer tokenno/invalid tenant JWT
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List tenant-scoped config → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-configW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-config/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
scope: platform, tenant, app, app_user
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/config/index-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorscope (platform|tenant|app|app_user) and key are requiredscope missing/invalid or key missing
400ValidationErrorprovide value OR secret_ref, not bothboth value and secret_ref supplied
403Forbiddencannot write another tenant's configtenant scope_id != caller tenant, or platform scope without operator role
401UnauthorizedMissing bearer tokenno/invalid tenant JWT (default-deny authGate)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Set a tenant-scoped config value → expects HTTP 201
Path params
Payload (template)
{
  "scope": "tenant",
  "scope_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "key": "qa.config.smoke",
  "value": {
    "provider": "anthropic",
    "model": "claude-opus-4-8"
  }
}
Example request
{
  "scope": "tenant",
  "scope_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "key": "qa.config.smoke",
  "value": {
    "provider": "anthropic",
    "model": "claude-opus-4-8"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
sdk-configW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/config"
]
Implemented in
packages/sdk-config/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/config/resolve-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorkey is requiredthe key query param is missing
401UnauthorizedMissing bearer tokenno/invalid tenant JWT
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Resolve a key set at tenant scope → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "resolve_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-configW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-config/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
scope: platform, tenant, app, app_user
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/config/revoke-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorscope and key are requiredscope missing/invalid or key missing
404NotFoundconfig value not foundno row for that scope/scope_id/key
401UnauthorizedMissing bearer tokenno/invalid tenant JWT
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Revoke a tenant config value → expects HTTP 200
Path params
Payload (template)
{
  "scope": "tenant",
  "scope_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "key": "qa.config.revoke.smoke"
}
Example request
{
  "scope": "tenant",
  "scope_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "key": "qa.config.revoke.smoke"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-configW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-config/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
scope: platform, tenant, app, app_user
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/config/rotate-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorscope, key and secret_ref are requiredany required field missing
404NotFoundconfig value not foundno row for that scope/scope_id/key
401UnauthorizedMissing bearer tokenno/invalid tenant JWT
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Rotate a tenant secret config value → expects HTTP 200
Path params
Payload (template)
{
  "scope": "tenant",
  "scope_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "key": "qa.config.rotate.smoke",
  "secret_ref": "vault:rotated-{{dynamic:slug}}"
}
Example request
{
  "scope": "tenant",
  "scope_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "key": "qa.config.rotate.smoke",
  "secret_ref": "vault:rotated-{{dynamic:slug}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-configW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/config"
]
Implemented in
packages/sdk-config/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
scope: platform, tenant, app, app_user
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/config/value-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorscope and key are requiredscope missing/invalid or key missing
404NotFoundconfig value not foundno row for that scope/scope_id/key
401UnauthorizedMissing bearer tokenno/invalid tenant JWT
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get one tenant config row → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "value_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "config_id": "string",
    "scope": "string",
    "key": "string",
    "status": "string"
  }
}

sdk-connectors

22 API(s)
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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\connectors\index-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id requiredthe tenant_id query param is absent or empty
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List connector installs for a tenant (tenant-scoped query) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "connector_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Request field options
connector_kind: slack, salesforce, jira, github, hubspot, linear, microsoft365, snowflake, zendesk, zoom, gworkspace
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\connectors\dlq-replay-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorprovide deadletter_id or tenant_idneither deadletter_id nor tenant_id is present in the body
404NotFounddeadletter_id not found or already resolveda deadletter_id is given but no replayable (dlq/retrying/discarded) row matches
409ReplayFailedreplay failedan unexpected database error occurs during replay
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Bulk-replay a tenant's dead-lettered syncs → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "connector_kind": "github"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "connector_kind": "github"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "connector_kind": "github",
    "replay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Source spec
tests\api_definitions\connectors\dlq-retry-tick-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tokenno valid Bearer token is supplied
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Run a retry tick (nothing due -> zero counts) → expects HTTP 200
Path params
Payload (template)
{
  "batch_size": 20
}
Example request
{
  "batch_size": 20
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-connectorsW6 · test wave
Implemented in
packages/sdk-connectors/src/server/routes.ts
Request field options
kind: slack, salesforce, jira, github, hubspot, linear, microsoft365, snowflake, zendesk, zoom, gworkspace
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\connectors\inbound-kind-post.json
Error responses
HTTPCodeMessageWhen it happens
404UnknownConnectorKindno connector kind '<kind>'the :kind path segment is not a recognised connector kind
401InvalidSignaturemissing or invalid x-connector-signaturea non-challenge event is posted without a valid HMAC signature
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Subscription-verification handshake echoes the challenge → expects HTTP 200
Path params
{
  "kind": "slack"
}
Payload (template)
{
  "type": "url_verification",
  "challenge": "verify-token-abc123"
}
Example request
{
  "type": "url_verification",
  "challenge": "verify-token-abc123"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Request field options
connector_kind: slack, salesforce, jira, github, hubspot, linear, microsoft365, snowflake, zendesk, zoom, gworkspace
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\connectors\installs-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrormissing fieldsany of tenant_id, connector_kind or installed_by is missing or empty in the body
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "connectors.install",
  "field": "status",
  "flow": [
    "pending",
    "active",
    "paused",
    "uninstalled"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "uninstalled",
      "via": "POST /api/connectors/installs/:install_id/uninstall"
    }
  ]
}
Install slack connector → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/connectors/installs"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Source spec
tests\api_definitions\connectors\installs-install-id-get.json
Error responses
HTTPCodeMessageWhen it happens
404NotFoundNotFoundno connectors.install row matches the :install_id path param (unknown, or malformed id)
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read a connector install by id → expects HTTP 200
Path params
{
  "install_id": "{{cache:connectors.create.response.data.install.install_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "install_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/connectors/installs"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Source spec
tests\api_definitions\connectors\installs-id-health-get.json
Error responses
HTTPCodeMessageWhen it happens
404NotFoundNotFoundinstall_id does not exist
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Health snapshot for a freshly installed connector → expects HTTP 200
Path params
{
  "install_id": "{{cache:connectors.create.response.data.install.install_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/connectors/installs"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Source spec
tests\api_definitions\connectors\installs-install-id-sync-post.json
Error responses
HTTPCodeMessageWhen it happens
409SyncFailedinstall <install_id> not foundthe :install_id path param matches no connectors.install row — surfaced as 409, not 404
409SyncFailedno adapter registered for <connector_kind>the install's connector_kind has no adapter registered in this gateway build
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Sync a connector install (slack stub returns 0 records when unconfigured) → expects HTTP 200
Path params
{
  "install_id": "{{cache:connectors.create.response.data.install.install_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "sync_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/connectors/installs"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Source spec
tests\api_definitions\connectors\installs-install-id-tools-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List tool manifest for an install → expects HTTP 200
Path params
{
  "install_id": "{{cache:connectors.create.response.data.install.install_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "tool_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/connectors/installs"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Request field options
tool_name: slack.message.post, slack.channel.list, slack.user.lookup, slack.thread.reply
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\connectors\installs-install-id-tools-call-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrormissing tool_namebody.tool_name is absent or empty
409ToolCallFailedinstall <install_id> not foundthe :install_id path param matches no connectors.install row — surfaced as 409, not 404
409ToolCallFailedinstall <install_id> is <status>the install exists but its status is not 'active' (e.g. uninstalled, error)
409ToolCallFailedtool <tool_name> not in manifest for install <install_id>the requested tool_name is not present in the install's synced tool manifest
409ToolCallFailedno adapter registered for <connector_kind>the install's connector_kind has no adapter registered in this gateway build
409ToolCallFailed<adapter or vendor error message>the adapter itself throws — vendor API rejection, invalid args, expired credential_ref, network failure
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Invoke a connector tool by name (slack stub returns NotConfigured payload with 200) → expects HTTP 200
Path params
{
  "install_id": "{{cache:connectors.create.response.data.install.install_id}}"
}
Payload (template)
{
  "tool_name": "slack.message.post",
  "args": {
    "channel": "C0DEMO",
    "text": "Hello from ProjexCloud"
  }
}
Example request
{
  "tool_name": "slack.message.post",
  "args": {
    "channel": "C0DEMO",
    "text": "Hello from ProjexCloud"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/connectors/installs"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Source spec
tests\api_definitions\connectors\installs-install-id-uninstall-post.json
Error responses
HTTPCodeMessageWhen it happens
404NotFoundNotFounduninstallConnector returns null — no matching connectors.install row for the :install_id path param
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "connectors.install",
  "field": "status",
  "flow": [
    "pending",
    "active",
    "paused",
    "uninstalled"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "uninstalled",
      "via": "POST /api/connectors/installs/:install_id/uninstall"
    }
  ]
}
Uninstall a connector install → expects HTTP 200
Path params
{
  "install_id": "{{cache:connectors.create.response.data.install.install_id}}"
}
Payload (template)
{
  "actor_id": "{{cache:auth.signup-tenant.response.data.userId}}"
}
Example request
{
  "actor_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Source spec
tests\api_definitions\connectors\kinds-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List registered connector adapter kinds → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "kind_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/connectors/lead-form-events-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token — reading archived lead payloads is tenant data access, unlike the HMAC-gated ingest endpoint
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List all archived deliveries for the tenant → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "lead_form_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-connectorsW6 · test wave
Testability
manual — Provider-signed webhook: every request must carry a valid HMAC over the EXACT request bytes, computed with a per-tenant secret the regression runner does not hold, and the runner re-serialises JSON bodies (which changes key order and whitespace and therefore invalidates any signature). Exercised end-to-end against real Postgres by packages/sdk-connectors/tests/leadForms.integration.test.ts — 21 cases covering all four adapters, signature acceptance/rejection, concurrent replay and archive-on-rejection.
Implemented in
packages/sdk-connectors/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/connectors/lead-form-inbound-post.json
Error responses
HTTPCodeMessageWhen it happens
401INVALID_SIGNATUREinvalid or missing x-hub-signature-256 signaturethe 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
404UNKNOWN_PLATFORMplatform must be one of: META, LINKEDIN, TIKTOK, GOOGLEthe :platform segment names a provider with no registered adapter
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Meta lead form with valid signature is accepted and archived → expects HTTP 202
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "platform": "meta"
}
Payload (template)
{
  "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"
                ]
              }
            ]
          }
        }
      ]
    }
  ]
}
Example request
{
  "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"
                ]
              }
            ]
          }
        }
      ]
    }
  ]
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "accepted",
    "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 202.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/connectors/lead-form-reprocess-post.json
Error responses
HTTPCodeMessageWhen it happens
404LEAD_FORM_EVENT_NOT_FOUNDNotFoundevent_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
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token — re-processing mutates tenant data
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Re-process a previously rejected delivery → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "event_id": "{{cache:connectors.leadform.response.data.event_id}}"
}
Payload (template)
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-connectorsW6 · test wave
Testability
manual — Signed webhook: every request must carry an HMAC over the EXACT request bytes computed with a per-tenant secret the regression runner does not hold, and the runner re-serialises JSON bodies (changing key order and whitespace), which invalidates any signature. Exercised end-to-end against real Postgres by packages/sdk-connectors/tests/websiteAdapter.integration.test.ts — 17 cases covering transcript, handoff, verbatim permissions, replay and archive-on-rejection.
Implemented in
packages/sdk-connectors/src/adapters/leadFormAdapters.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/connectors/website-chat-inbound-post.json
Error responses
HTTPCodeMessageWhen it happens
401INVALID_SIGNATUREinvalid or missing x-projex-signature signaturethe signature header is absent, computed over different bytes, or computed with a different secret — nothing is archived, exactly as for the social adapters
202UNKNOWN_EVENT_KINDunknown event_kind '<kind>' — expected one of demo_request, pricing_enquiry, contact, chatevent_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
202PERMISSION_NOT_GRANTEDpermission present but not grantedevery submitted permission is false — archived with the reason so the raw consent block is still available for review
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Chat lead with transcript and human handoff → expects HTTP 202
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "accepted",
    "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 202.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
sdk-connectorsW6 · test wave
Implemented in
packages/connector-slack/src/server/routes.ts
Request field options
type: url_verification, event_callback
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\connectors\slack-events-post.json
Error responses
HTTPCodeMessageWhen it happens
401InvalidSignatureInvalidSignaturethe x-slack-signature / x-slack-request-timestamp pair does not validate against SLACK_SIGNING_SECRET over the raw body, or the headers are missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
URL verification handshake (no signature required) → expects HTTP 200
Path params
Payload (template)
{
  "type": "url_verification",
  "challenge": "test-challenge-string"
}
Example request
{
  "type": "url_verification",
  "challenge": "test-challenge-string"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/connector-slack/src/server/routes.ts
Source spec
tests\api_definitions\connectors\slack-install-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorcode requiredbody.code is absent, empty, or not a string
503NotConfiguredSLACK_CLIENT_ID/SECRET env not seteither SLACK_CLIENT_ID or SLACK_CLIENT_SECRET is unset in the gateway environment
400SlackError<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
500InternalErrorInternalErrorthe oauthExchange call throws — network failure reaching slack.com or an unparseable response
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
OAuth exchange returns 503 when SLACK_CLIENT_ID/SECRET not configured → expects HTTP 503
Path params
Payload (template)
{
  "code": "test-oauth-code",
  "redirect_uri": "https://example.com/slack/callback"
}
Example request
{
  "code": "test-oauth-code",
  "redirect_uri": "https://example.com/slack/callback"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 503.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/connector-slack/src/server/routes.ts
Source spec
tests\api_definitions\connectors\slack-post-message-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorchannel + text requiredbody.channel or body.text is absent, empty, or not a string
400SlackError<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
500InternalErrorInternalErrorthe chatPostMessage call throws — network failure reaching slack.com or an unparseable response
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
postMessage returns 400 SlackError when SLACK_BOT_TOKEN not configured → expects HTTP 400
Path params
Payload (template)
{
  "channel": "C0DEMO",
  "text": "Hello from ProjexCloud"
}
Example request
{
  "channel": "C0DEMO",
  "text": "Hello from ProjexCloud"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 400.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Request field options
status: dlq, retrying, resolved, discarded
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\connectors\tenants-tenant-id-dlq-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tokenno valid Bearer token is supplied
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List a tenant's DLQ (empty is a valid result) → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "dlq_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Source spec
tests\api_definitions\connectors\tenants-tenant-id-dlq-reconcile-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tokenno valid Bearer token is supplied
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Reconcile a tenant's DLQ (clean queue -> zero counts) → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-connectorsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-connectors/src/server/routes.ts
Source spec
tests\api_definitions\connectors\tenants-tenant-id-installs-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List connector installs for a tenant → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "install_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
10 API(s)
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.

SDK / service
sdk-consentW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\consent\purposes-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
400ValidationErrortenant_id requiredthe tenant_id query param is absent or empty
500QueryFailed<postgres error message>the SELECT against consent.purpose throws - missing schema/table or any DB error; the raw driver message is echoed in error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List consent purposes for the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "purpos_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-consentW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas",
  "POST /api/consents/purposes"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\consent\receipts-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
400ValidationErrortenant_id requiredthe tenant_id query param is absent or empty
500QueryFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List consent receipts filtered by tenant, subject and purpose → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "receipt_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-consentW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/consents/purposes",
  "POST /api/consents"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\consent\receipts-receipt-id-revoke-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
500QueryFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "consent.receipt",
  "field": "status",
  "flow": [
    "active",
    "revoked"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "revoked",
      "via": "POST /api/consent/receipts/:receipt_id/revoke"
    }
  ]
}
Revoke a previously granted consent receipt → expects HTTP 200
Path params
{
  "receipt_id": "{{cache:consents.create.response.data.receipt.receipt_id}}"
}
Payload (template)
{
  "reason": "User requested withdrawal of consent."
}
Example request
{
  "reason": "User requested withdrawal of consent."
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "reason": "User requested withdrawal of consent.",
    "revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-consentW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/consents/purposes"
]
Implemented in
packages/sdk-consent/src/server/routes.ts
Source spec
tests\api_definitions\consents\grant-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationError<field> is requiredAny of person_id, purpose_id, processor, app_id, jurisdiction, granted_by_actor is absent or whitespace-only
400ValidationErrorexpires_at must be ISO-8601expires_at is supplied but Date.parse cannot parse it
400ValidationErrorpurpose_id does not existThe receipt insert raises a foreign-key violation - purpose_id has not been registered via POST /api/consents/purposes
409ConflictActive receipt already exists for this tupleA duplicate-key violation - an active receipt already covers this (person, purpose, processor, jurisdiction) tuple
451CrossBorderViolation<CrossBorderError message>grantConsent throws CrossBorderError - the source_tenant_id/target_tenant_id transfer is not permitted for this jurisdiction (FR-CNS-5)
500InternalErrorInternalErrorAny other grantConsent failure (database error, event-emit failure)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Grant consent for the registered purpose → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-consentW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/consents/purposes",
  "POST /api/consents"
]
Implemented in
packages/sdk-consent/src/server/routes.ts
Source spec
tests\api_definitions\consents\id-revoke-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationErrorrevoked_by is requiredrevoked_by is absent or whitespace-only
400ValidationErrorreason is requiredreason is absent or whitespace-only
404NotFound<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)
500InternalErrorInternalErrorAny other revokeConsent failure, including a non-UUID receipt_id (Postgres 22P02) and database errors
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "consent.receipt",
  "field": "revoked_at",
  "flow": [
    "active",
    "revoked"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "revoked",
      "via": "POST /api/consents/:receipt_id/revoke"
    }
  ]
}
Revoke the previously granted receipt → expects HTTP 200
Path params
{
  "receipt_id": "{{cache:consents.create.response.data.receipt.receipt_id}}"
}
Payload (template)
{
  "revoked_by": "{{cache:auth.register.response.data.userId}}",
  "reason": "User requested withdrawal of marketing consent."
}
Example request
{
  "revoked_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "reason": "User requested withdrawal of marketing consent."
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-consentW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/consents/purposes",
  "POST /api/consents"
]
Implemented in
packages/sdk-consent/src/server/routes.ts
Source spec
tests\api_definitions\consents\check-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationError<field> is requiredAny of person_id, purpose_id, processor, jurisdiction is absent or whitespace-only
500InternalErrorInternalErrorcheckConsent throws (database unavailable or query error)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Check the granted receipt is active → expects HTTP 200
Path params
Payload (template)
{
  "person_id": "{{cache:auth.register.response.data.userId}}",
  "purpose_id": "{{cache:consents.purposes.create.response.data.purpose.purpose_id}}",
  "processor": "tenant",
  "jurisdiction": "US-CA"
}
Example request
{
  "person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "purpose_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "processor": "tenant",
  "jurisdiction": "US-CA"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
sdk-consent
Depends on
[
  "POST /api/auth/register",
  "POST /api/consents/purposes",
  "POST /api/consents"
]
Implemented in
packages/sdk-consent/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/consents/check-bulk-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
400ValidationErrorbody must be an object with an items[] arrayRequest body is absent, not a JSON object, or is itself an array
400ValidationErroritems must be an arrayThe items key is present but is not an array
400ValidationErroritems must not be emptyitems is an empty array - rejected rather than answered '0 of 0 succeeded', which would let a campaign report a clean check having evaluated nobody
400ValidationErroritems exceeds the per-request maximum of 1000; page the batchMore than 1000 items are supplied
500InternalErrorInternalErrorcheckConsentBulk throws (database unavailable or query error)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
One granted tuple and one malformed item - the bad item must not fail the batch → expects HTTP 200
Path params
Payload (template)
{
  "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"
    }
  ]
}
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-consentW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/consents/purposes",
  "POST /api/consents"
]
Implemented in
packages/sdk-consent/src/server/routes.ts
Source spec
tests\api_definitions\consents\export-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
500InternalErrorInternalErrorexportReceipts throws - includes a non-UUID person_id (Postgres 22P02) and database errors
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Export receipts for the freshly-registered person → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "export_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
sdk-consent
Depends on
[
  "POST /api/auth/register",
  "POST /api/consents/purposes"
]
Implemented in
packages/sdk-consent/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/consents/purposes-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
403ForbiddenService token is missing required scope: consent.purpose.readAn API key or machine token that does not hold consent.purpose.read (or a covering wildcard) is presented
400ValidationErrorlimit and offset must be numberslimit or offset is present but not parseable as a number
500InternalErrorInternalErrorlistPurposes throws (database unavailable or query error)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the registry and see the purpose just registered → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-consentW2 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/sdk-consent/src/server/routes.ts
Request field options
legal_basis: consent, contract, legitimate-interest, vital, public-task, legal-obligation
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\consents\purposes-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationErrorpurpose_id / app_id / description is requiredAny of purpose_id, app_id, description is absent or whitespace-only
400ValidationErrorlegal_basis must be one of consent, contract, legitimate-interest, vital, public-task, legal-obligationlegal_basis is missing or outside the closed set
400ValidationErrordefault_jurisdictions must be an array of stringsdefault_jurisdictions is supplied but is not an array, or contains a non-string element
409Conflictpurpose_id already registeredThe insert raises a duplicate-key violation - this purpose_id already exists in the registry
500InternalErrorInternalErrorAny other registerPurpose failure (database error, event-emit failure)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register a marketing-email purpose → expects HTTP 201
Path params
Payload (template)
{
  "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"
  ]
}
Example request
{
  "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"
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "purpose": {
      "purpose_id": "string",
      "app_id": "string",
      "legal_basis": "string"
    }
  }
}

sdk-content

7 API(s)
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.

SDK / service
sdk-contentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-content/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: draft, published, archived
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\content\items-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrormissing fieldstenant_id, type_code or slug is absent/empty
500Internal Server ErrorInternal Server Errortenant_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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a draft article content item → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type_code": "article",
  "slug": "Acme QA Sample",
  "owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-contentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/content/items"
]
Implemented in
packages/sdk-content/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: draft, published, archived
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\content\items-item-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
404NotFoundNotFoundno content item exists for :item_id
500Internal Server ErrorInternal Server Error:item_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch a content item by id → expects HTTP 200
Path params
{
  "item_id": "{{cache:content.item.create.response.data.item.item_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "item_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-contentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/content/items"
]
Implemented in
packages/sdk-content/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: draft, published, archived
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\content\items-item-id-archive-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
404NotFoundNotFoundno content item exists for :item_id
500Internal Server ErrorInternal Server Error:item_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Archive a draft content item → expects HTTP 200
Path params
{
  "item_id": "{{cache:content.item.create.response.data.item.item_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-contentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/content/items"
]
Implemented in
packages/sdk-content/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\content\items-item-id-versions-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
500Internal Server ErrorInternal Server Error:item_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List versions for a content item → expects HTTP 200
Path params
{
  "item_id": "{{cache:content.item.create.response.data.item.item_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "version_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-contentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/content/items"
]
Implemented in
packages/sdk-content/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\content\items-item-id-versions-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
500Internal Server ErrorInternal 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a content version with body, media and tags → expects HTTP 201
Path params
{
  "item_id": "{{cache:content.item.create.response.data.item.item_id}}"
}
Payload (template)
{
  "payload": {
    "title": "{{dynamic:name}}",
    "body": "Draft body copy for the first version."
  },
  "media_refs": [
    "media://hero-image"
  ],
  "taxonomy_tags": [
    "news",
    "featured"
  ]
}
Example request
{
  "payload": {
    "title": "Acme QA Sample",
    "body": "Draft body copy for the first version."
  },
  "media_refs": [
    "media://hero-image"
  ],
  "taxonomy_tags": [
    "news",
    "featured"
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-contentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/content/items",
  "POST /api/content/items/:item_id/versions"
]
Implemented in
packages/sdk-content/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: draft, published, archived
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\content\items-item-id-versions-version-id-publish-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrormissing published_bybody.published_by is absent/empty
404NotFoundNotFoundno version matches the (:item_id, :version_id) pair — unknown item, unknown version, or the version belongs to another item
500Internal Server ErrorInternal 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Publish a version on behalf of the signed-up user → expects HTTP 200
Path params
{
  "item_id": "{{cache:content.item.create.response.data.item.item_id}}",
  "version_id": "{{cache:content.version.create.response.data.version.version_id}}"
}
Payload (template)
{
  "published_by": "{{cache:auth.signup-tenant.response.data.userId}}"
}
Example request
{
  "published_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "published_by": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "publish_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-contentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-content/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\content\taxonomies-put.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrormissing fieldstenant_id or name is absent/empty
500Internal Server ErrorInternal Server Errortenant_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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Upsert a tenant taxonomy with a small structure tree → expects HTTP 200
Path params
Payload (template)
{
  "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"
      }
    ]
  }
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "name": "Acme QA Sample",
  "structure": {
    "root": "topics",
    "children": [
      {
        "code": "news",
        "label": "News"
      },
      {
        "code": "guides",
        "label": "Guides"
      }
    ]
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-conversation

5 API(s)
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.

SDK / service
sdk-conversationW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-conversation/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/conversation/compose-guardrail-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORchannels must be a non-empty arraychannels is absent, not an array, or empty
400VALIDATION_ERRORchannel_facts is required — this SDK holds no consent or policy logic and cannot decide without resolver outputchannel_facts is absent or not an object; guessing here would embed policy this package must not own
400VALIDATION_ERRORunknown channel(s): TELEPATHYchannels contains a value outside the eight enumerated channels
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Opt-out outranks quiet hours in the reason list → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "channels": [
    "SMS"
  ],
  "channel_facts": {
    "SMS": {
      "opted_out": true,
      "quiet_hours": true
    }
  }
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "channels": [
    "SMS"
  ],
  "channel_facts": {
    "SMS": {
      "opted_out": true,
      "quiet_hours": true
    }
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-conversationW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/conversations/threads",
  "POST /api/conversations/messages"
]
Implemented in
packages/sdk-conversation/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/conversation/inbox-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query parameter is absent
400VALIDATION_ERRORchannel must be one of: EMAIL, SMS, VOICE, VOICEMAIL, SOCIAL_DM, WEB_CHAT, IN_PERSON, INTERNAL_NOTEchannel is not one of the eight enumerated values — returning an empty 200 would be indistinguishable from 'no matching threads'
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request — GET is gated exactly as POST
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Unfiltered inbox returns the tenant's open threads → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "inbox_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-conversationW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/conversations/threads"
]
Implemented in
packages/sdk-conversation/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/conversation/messages-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERROR[sdk-conversation] delivery_state 'NOT_APPLICABLE' is reserved for internal notesdelivery_state NOT_APPLICABLE is sent on any channel other than INTERNAL_NOTE — that state is what marks a row as never-to-be-dispatched
400VALIDATION_ERRORchannel must be one of: EMAIL, SMS, VOICE, VOICEMAIL, SOCIAL_DM, WEB_CHAT, IN_PERSON, INTERNAL_NOTEchannel is not one of the eight enumerated values
400VALIDATION_ERRORbody_ref is requiredbody_ref is absent, empty or whitespace-only
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Record an outbound SMS on the thread → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-conversationW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-conversation/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/conversation/threads-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORpurpose is requiredpurpose is absent, empty or whitespace-only
400VALIDATION_ERRORsubject_ref is requiredsubject_ref is absent, empty or whitespace-only
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Open a thread with purpose and an eligibility snapshot → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-conversationW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/conversations/threads",
  "POST /api/conversations/messages"
]
Implemented in
packages/sdk-conversation/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/conversation/threads-id-get.json
Error responses
HTTPCodeMessageWhen it happens
404THREAD_NOT_FOUNDNotFoundthe id names no thread
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query parameter is absent, so the read cannot be tenant-scoped
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch the thread and its messages → expects HTTP 200
Path params
{
  "id": "{{cache:conversations.threads.response.data.thread.thread_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "thread_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-coverage

16 API(s)
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.

SDK / service
sdk-coverage
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/coverage/backup-designations"
]
Implemented in
packages/sdk-coverage/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/coverage/backup-designations-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it
403Forbiddentenant_id does not match the authenticated tenantThe query names a tenant_id different from the credential's tenant claim
400ValidationErrortenant_id is requiredThe credential carries no tenant_id claim and none is supplied in the query
500InternalErrorInternalErrorlistBackups throws a non-domain error - a database failure
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List every backup designation in the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-coverageW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-coverage/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/coverage/backup-designations-post.json
Error responses
HTTPCodeMessageWhen it happens
422VALIDATION_ERRORa persona cannot be their own backupprimary_persona_id and backup_persona_id are the same
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
A backup with a five-minute acceptance window → expects HTTP 201
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-coverage
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/coverage/capacity-policies"
]
Implemented in
packages/sdk-coverage/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/coverage/capacity-policies-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it
403Forbiddentenant_id does not match the authenticated tenantThe query names a tenant_id different from the credential's tenant claim
400ValidationErrortenant_id is requiredThe credential carries no tenant_id claim and none is supplied in the query
500InternalErrorInternalErrorlistCapacityPolicies throws a non-domain error - a database failure
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List every capacity policy in the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-coverageW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-coverage/src/services/capacityService.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/coverage/capacity-policies-post.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Per-band limits with a reserved-headroom threshold → expects HTTP 201
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-coverageW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/coverage/schedules",
  "PUT /api/coverage/presence"
]
Implemented in
packages/sdk-coverage/src/services/eligibilityService.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
include_ineligible: true, false
ignore_presence: true, false
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/coverage/eligible-get.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Ask who can act now, with reasons for those who cannot → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "eligible_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-coverageW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/coverage/on-call"
]
Implemented in
packages/sdk-coverage/src/services/gapService.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
tier: 1, 2, 3
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/coverage/gaps-get.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Look ahead over the next week for tier 1 → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "gap_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-coverage
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/coverage/holiday-calendars"
]
Implemented in
packages/sdk-coverage/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/coverage/holiday-calendars-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it
403Forbiddentenant_id does not match the authenticated tenantThe query names a tenant_id different from the credential's tenant claim
400ValidationErrortenant_id is requiredThe credential carries no tenant_id claim and none is supplied in the query
500InternalErrorInternalErrorlistHolidayCalendars throws a non-domain error - a database failure
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List every holiday calendar in the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-coverageW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-coverage/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/coverage/holiday-calendars-post.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
A regional list with named owner → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-coverage
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/coverage/on-call"
]
Implemented in
packages/sdk-coverage/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/coverage/on-call-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it
403Forbiddentenant_id does not match the authenticated tenantThe query names a tenant_id different from the credential's tenant claim
400ValidationErrortenant_id is requiredThe credential carries no tenant_id claim and none is supplied in the query
500InternalErrorInternalErrorlistRoster throws a non-domain error - a database failure
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List every roster entry in the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-coverageW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-coverage/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
tier: 1, 2, 3
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/coverage/on-call-post.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Tier 1 for the coming week → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-coverageW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/coverage/on-call"
]
Implemented in
packages/sdk-coverage/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
tier: 1, 2, 3
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/coverage/on-call-current-get.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Resolve the current audience for a rotation → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "current_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-coverageW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-coverage/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: AVAILABLE, MEETING, OFFLINE, PTO, ON_CALL
source: MANUAL, CALENDAR, SYSTEM
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/coverage/presence-put.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
A manual claim of being available → expects HTTP 200
Path params
Payload (template)
{
  "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
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "AVAILABLE",
  "source": "MANUAL",
  "manual_hold_minutes": 30
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-coverageW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/coverage/schedules"
]
Implemented in
packages/sdk-coverage/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
weekday: 0, 1, 2, 3, 4, 5, 6
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/coverage/schedules-get.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List every schedule in the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "schedule_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-coverageW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-coverage/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
weekday: 0, 1, 2, 3, 4, 5, 6
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/coverage/schedules-post.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
A weekday schedule in the persona own timezone → expects HTTP 201
Path params
Payload (template)
{
  "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"
    }
  ]
}
Example request
{
  "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"
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-coverage
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/coverage/time-off"
]
Implemented in
packages/sdk-coverage/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
kind: PTO, MEETING, OUTAGE, HOLIDAY
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/coverage/time-off-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it
403Forbiddentenant_id does not match the authenticated tenantThe query names a tenant_id different from the credential's tenant claim - tenantOf() refuses rather than preferring either side
400ValidationErrortenant_id is requiredThe credential carries no tenant_id claim and none is supplied in the query
500InternalErrorInternalErrorlistTimeOff throws a non-domain error - a database failure; domain errors are 422 but this read path raises none
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List every time-off record in the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-coverageW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-coverage/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
kind: PTO, MEETING, OUTAGE, HOLIDAY
source: MANUAL, CALENDAR, SYSTEM
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/coverage/time-off-post.json
Error responses
HTTPCodeMessageWhen it happens
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
A day of PTO → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "time_off": {
      "time_off_id": "string",
      "kind": "string"
    }
  }
}

sdk-crm

28 API(s)
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.

SDK / service
Depends on
[
  "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"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
kind: call, email, meeting, note, task, voicemail
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\crm\activities-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrormissing fieldsbody omits encounter_id, kind, or actor_persona_id
400ValidationErrorinvalid activity kindbody.kind is not a member of ACTIVITY_KINDS
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Log an activity inside an encounter (encounter_id + kind + actor_persona_id required) → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "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"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Request field options
call_direction: inbound, outbound
call_disposition: answered, no_answer, busy, failed, voicemail, left_message
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\crm\activities-call-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorencounter_id and actor_persona_id are requiredencounter_id or actor_persona_id missing from body
400ValidationErrorcall_disposition must be one of answered|no_answer|busy|failed|voicemail|left_messagecall_disposition is outside the enum
400ValidationErrorcall_direction must be one of inbound|outboundcall_direction is outside the enum
400ValidationErrorcall_duration_seconds must be a non-negative numbercall_duration_seconds is negative or not a number
401UnauthorizedUnauthorizedAuthorization bearer token missing or invalid
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Log an answered outbound call with recording consent → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "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"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Request field options
kind: call, voicemail
call_disposition: answered, no_answer, busy, failed, voicemail, left_message
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\crm\activities-calls-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorencounter_id query param requiredencounter_id query param missing
401UnauthorizedUnauthorizedAuthorization bearer token missing or invalid
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the call timeline for the encounter → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "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"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Request field options
call_direction: inbound, outbound
call_disposition: voicemail, left_message
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\crm\activities-voicemail-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorencounter_id and actor_persona_id are requiredencounter_id or actor_persona_id missing from body
400ValidationErrorvoicemail call_disposition must be voicemail or left_messagecall_disposition is any other value, including otherwise-valid call dispositions like answered
400ValidationErrorcall_direction must be one of inbound|outboundcall_direction is outside the enum
400ValidationErrorcall_duration_seconds must be a non-negative numbercall_duration_seconds is negative or not a number
401UnauthorizedUnauthorizedAuthorization bearer token missing or invalid
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Log an outbound voicemail with transcript → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
outcome_class: won, lost, disqualified, paused
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/crm/close-reasons-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrortenant_id is requiredbody omits tenant_id
400VALIDATION_ERRORcode and label are requiredcode or label is missing or blank after trimming
400VALIDATION_ERRORreactivation_after_days makes no sense when reactivation is not allowedreactivation_allowed is false and reactivation_after_days is set
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Define a competitive-loss reason that demands a competitor and a learning note → expects HTTP 200
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
lifecycle_stage: lead, prospect, customer, churned, former
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\crm\contacts-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrormissing fieldsbody omits tenant_id or persona_id (details: ["missing fields"])
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create persona-keyed contact (tenant_id + persona_id required) → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas",
  "POST /api/crm/contacts"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\crm\contacts-contact-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
404NotFoundNotFoundno crm contact row exists for the given contact_id
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch an existing contact by id → expects HTTP 200
Path params
{
  "contact_id": "{{cache:crm.contacts.response.data.contact.contact_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "contact_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas",
  "POST /api/crm/contacts"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
lifecycle_stage: lead, prospect, customer, churned, former
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\crm\contacts-contact-id-patch.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
404NotFoundNotFoundno crm contact row exists for the given contact_id
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Update contact lifecycle_stage on an existing contact → expects HTTP 200
Path params
{
  "contact_id": "{{cache:crm.contacts.response.data.contact.contact_id}}"
}
Payload (template)
{
  "lifecycle_stage": "customer",
  "owner_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
  "custom_fields": {
    "tier": "gold"
  },
  "external_refs": {
    "hubspot_id": "HS-002"
  }
}
Example request
{
  "lifecycle_stage": "customer",
  "owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "custom_fields": {
    "tier": "gold"
  },
  "external_refs": {
    "hubspot_id": "HS-002"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
GET/api/crm/deals🔒 auth

List a tenant's deals, optionally filtered by stage, newest first. Paginated via limit/offset query params.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Source spec
tests\api_definitions\crm\deals-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List deals for a tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "deal_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "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"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\crm\deals-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrormissing fieldsbody omits tenant_id, encounter_id, or name (details: ["missing fields"])
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create deal on an encounter (tenant_id + encounter_id + name required) → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "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"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Source spec
tests\api_definitions\crm\deals-deal-id-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
404NotFoundNotFounddeal_id not found for the tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get the created deal → expects HTTP 200
Path params
{
  "deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "deal_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "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"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Request field options
priority: low, medium, high, critical
fit: poor, moderate, strong, ideal
forecast: omitted, pipeline, best_case, commit, closed
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\crm\deals-deal-id-patch.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing from body
404NotFoundNotFounddeal_id not found for the tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Enrich the deal (priority/fit/forecast) → expects HTTP 200
Path params
{
  "deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}
Payload (template)
{
  "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"
  ]
}
Example request
{
  "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"
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/crm/deals",
  "POST /api/crm/deals/:deal_id/next-action"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Source spec
tests\api_definitions\crm\deals-deal_id-next-action-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
404NotFoundno open NEXT action for this dealdeal has no open next action
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get the open next action → expects HTTP 200
Path params
{
  "deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "next_action_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/crm/deals"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Request field options
action_type: call, email, meeting, task, linkedin, sms, proposal, other
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\crm\deals-deal_id-next-action-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and due_at are requiredrequired field missing
404NotFounddeal not foundno deal for tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Set a follow-up call as the next action → expects HTTP 201
Path params
{
  "deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "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"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Source spec
tests\api_definitions\crm\deals-deal_id-next-action-complete-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing
404NotFoundno open NEXT action to completedeal has no open next action
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Complete the next action → expects HTTP 200
Path params
{
  "deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "outcome": "Spoke with buyer; budget confirmed"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "outcome": "Spoke with buyer; budget confirmed"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/crm/deals",
  "POST /api/crm/deals/:deal_id/next-action"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Source spec
tests\api_definitions\crm\deals-deal_id-save-gate-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
404NotFounddeal not foundno deal for tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Save-gate allows (open next action present) → expects HTTP 200
Path params
{
  "deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "save_gate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/crm/deals"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Request field options
to_stage: qualifying, proposal, negotiation, closed-won, closed-lost
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\crm\deals-deal_id-stage-guard-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
400ValidationErrorto_stage query param must be a valid stageto_stage missing/invalid
404NotFounddeal not foundno deal
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Guard allows qualifying -> proposal → expects HTTP 200
Path params
{
  "deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "stage_guard_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "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"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
stage: qualifying, proposal, negotiation, closed-won, closed-lost
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\crm\deals-deal-id-transition-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrorinvalid stagebody.stage is missing or not one of qualifying|proposal|negotiation|closed-won|closed-lost
404NotFoundNotFoundno deal exists for deal_id (guardedTransition returns null or throws DealNotFoundError)
409InvalidTransition<StageTransitionError message>the stage guard rejects the move: invalid stage sequence, unmet stage criteria, or the deal is already in a terminal stage
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Transition an existing deal to a valid stage → expects HTTP 200
Path params
{
  "deal_id": "{{cache:crm.deals.response.data.deal.deal_id}}"
}
Payload (template)
{
  "stage": "proposal"
}
Example request
{
  "stage": "proposal"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
GET/api/crm/funnel-stages🔒 auth

List a tenant's configurable pipeline stages in board order (sort_order).

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Source spec
tests\api_definitions\crm\funnel-stages-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List funnel stages → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "funnel_stage_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Source spec
tests\api_definitions\crm\funnel-stages-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and name are requiredtenant_id or name missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a Discovery stage → expects HTTP 201
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/crm/subjects/:subject_ref/next-action"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/crm/next-actions-id-reschedule-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrortenant_id and new_due_at are requiredbody omits tenant_id or new_due_at
400RESCHEDULE_REASON_REQUIREDmoving a due date requires a reasonreason is missing or blank, new_due_at is unparseable, or the new date equals the current one
404NEXT_ACTION_NOT_FOUNDno open next actionthe id does not exist for this tenant, or its action is completed or cancelled
409PUSH_THRESHOLD_REACHEDthis action has already been pushed at or past the threshold - a further push needs a manager's authorisationpush_count >= the tenant's push_threshold and approved_by is absent or blank
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Push the date with a reason on the record → expects HTTP 200
Path params
{
  "id": "{{cache:crm.subject-next-action.response.data.next_action.next_action_id}}"
}
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/crm/subjects/:subject_ref/next-action"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/crm/next-actions-overdue-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrortenant_id query param requiredtenant_id query param omitted
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the overdue queue for the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "overdue_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/crm/pipeline-aging-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrortenant_id query param requiredtenant_id query param omitted
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the aging report for the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "aging_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Source spec
tests\api_definitions\crm\pipeline-board-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get the pipeline board → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "board_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Source spec
tests\api_definitions\crm\pipeline-stale-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List stale deals → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "stale_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/crm/subjects/:subject_ref/next-action"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/crm/subjects-subject_ref-next-action-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrortenant_id query param requiredtenant_id query param omitted
404NotFoundno open next action for this subjectthe subject has no action in status 'open' for this tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the action just committed → expects HTTP 200
Path params
{
  "subject_ref": "{{var:crm_subject_ref}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "next_action_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
action_type: call, email, meeting, task, linkedin, sms, proposal, other
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/crm/subjects-subject_ref-next-action-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrortenant_id is requiredbody omits tenant_id
400NEXT_ACTION_INCOMPLETEthis subject cannot be saved until its next action is completeaction_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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Commit a call with all five elements of the commitment → expects HTTP 201
Path params
{
  "subject_ref": "{{var:crm_subject_ref}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/crm/subjects/:subject_ref/next-action"
]
Implemented in
packages/sdk-crm/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/crm/subjects-subject_ref-save-gate-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrortenant_id query param requiredtenant_id query param omitted
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Allowed once a complete action is committed → expects HTTP 200
Path params
{
  "subject_ref": "{{var:crm_subject_ref}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "save_gate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "gate": {
      "allowed": "boolean",
      "subject_ref": "string",
      "missing": "array"
    }
  }
}

sdk-data-credits

14 API(s)
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.

SDK / service
sdk-data-credits
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/data-credits/admin-tenant-credits-grant-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredrequireAdmin() found no valid x-admin-ops-token header — missing, or matching neither env ADMIN_OPS_TOKEN nor an active admin.ops_token hash
400ValidationErrortenant_id must be a UUIDthe :tenant_id path segment is not a UUID
400VALIDATION_ERRORexactly one of credits or top_up_to is requiredthe body names both credits and top_up_to, or neither — GrantRefused from grantCredits()
400VALIDATION_ERRORcredits must be a positive numberthe 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fund the run's tenant to a known floor → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
{
  "top_up_to": 100
}
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-data-creditsW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-data-credits/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/data-credits/capabilities-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the outcomes this tenant can buy → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "capability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-data-creditsW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-data-credits/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/data-credits/capabilities-estimate-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORcapability_key is requiredthe query omits capability_key
404CAPABILITY_NOT_FOUNDno capability 'x' is available to this tenantthe capability_key names nothing active in the platform or tenant catalog
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Quote a capability for this tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "estimate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-data-creditsW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/capability-requests"
]
Implemented in
packages/sdk-data-credits/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: PENDING_APPROVAL, APPROVED, REJECTED, EXECUTING, COMPLETED, FAILED
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/data-credits/capability-requests-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List recent requests → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "capability_request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-data-creditsW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-data-credits/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/data-credits/capability-requests-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORcapability_key is requiredthe payload omits capability_key or subject_fingerprint
404CAPABILITY_NOT_FOUNDno such capability for this tenantcapability_key names nothing in the catalog
402INSUFFICIENT_CREDITSthis request needs N credits and M are availablethe account balance minus existing holds cannot cover the quoted price
403DAILY_CAP_EXCEEDEDthis role has spent X of Y credits in the last 24 hoursthe requester's role is under a DAILY_CAP policy whose rolling window is full
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Ask for an outcome about a subject → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-data-creditsW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/capability-requests"
]
Implemented in
packages/sdk-data-credits/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/data-credits/capability-requests-request_id-get.json
Error responses
HTTPCodeMessageWhen it happens
404CAPABILITY_REQUEST_NOT_FOUNDno capability request <id>the id names nothing belonging to this tenant
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read one request → expects HTTP 200
Path params
{
  "request_id": "{{cache:capability-requests.create.request_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "capability_request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-data-creditsW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/capability-requests"
]
Implemented in
packages/sdk-data-credits/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
approved: true, false
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/data-credits/capability-requests-request_id-approve-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORa refusal must carry a reasonapproved=false is sent without a reason
409NOT_AWAITING_APPROVALrequest <id> is <status>, not waiting for an approval decisionthe request has already been decided or has already executed
404CAPABILITY_REQUEST_NOT_FOUNDno capability request <id>the id names nothing belonging to this tenant
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Approve a waiting request → expects HTTP 200
Path params
{
  "request_id": "{{cache:capability-requests.create.request_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "approved": true,
  "approval_ref": "apr-{{dynamic:slug}}",
  "decided_by": "{{static:qa-manager}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "approved": true,
  "approval_ref": "apr-{{dynamic:slug}}",
  "decided_by": "qa-manager"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-data-creditsW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/capability-requests"
]
Implemented in
packages/sdk-data-credits/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/data-credits/capability-requests-request_id-execute-post.json
Error responses
HTTPCodeMessageWhen it happens
409APPROVAL_REQUIREDrequest <id> needs an approval decision before it can executethe requester's role is REQUEST_ONLY, or the request is at or above the bulk threshold, and nobody has approved it yet
409SETTLEMENT_CONFLICTrequest <id> already settled as X for N creditsa second execution would settle the same reservation differently
404CAPABILITY_REQUEST_NOT_FOUNDno capability request <id>the id names nothing belonging to this tenant
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Execute an approved request → expects HTTP 200
Path params
{
  "request_id": "{{cache:capability-requests.create.request_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "subject": "+15551234567"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "subject": "+15551234567"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-data-creditsW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-data-credits/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/data-credits/credits-balance-get.json
Error responses
HTTPCodeMessageWhen it happens
404CREDIT_ACCOUNT_NOT_FOUNDthis tenant has no credit accountno credit account has been provisioned for the tenant
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the credit balance → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "balance_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-data-creditsW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-data-credits/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/data-credits/credits-budgets-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the budget policies → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "budget_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-data-creditsW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-data-credits/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
mode: REQUEST_ONLY, DAILY_CAP, FULL
is_active: true, false
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/data-credits/credits-budgets-put.json
Error responses
HTTPCodeMessageWhen it happens
422VALIDATION_ERRORa DAILY_CAP policy must carry a daily_capmode is DAILY_CAP and daily_cap is absent
400VALIDATION_ERRORmode must be one of REQUEST_ONLY, DAILY_CAP, FULLthe payload names a mode that does not exist, or omits role_ref
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Cap a role at a rolling daily limit with a bulk gate → expects HTTP 200
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-data-creditsW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/capability-requests"
]
Implemented in
packages/sdk-data-credits/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
entry_type: GRANT, RESERVATION, CHARGE, REFUND, RELEASE, ADJUSTMENT
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/data-credits/credits-ledger-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Export the ledger for one request → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "ledger_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-data-creditsW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-data-credits/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/data-credits/credits-reservations-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORcapability_key is requiredthe payload omits capability_key or subject_fingerprint
402INSUFFICIENT_CREDITSthis request needs N credits and M are availablebalance minus existing holds cannot cover the quote
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Hold credits for a self-run lookup → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-data-creditsW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/credits/reservations"
]
Implemented in
packages/sdk-data-credits/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
outcome: MATCHED, NO_MATCH, TECHNICAL_FAILURE, CACHE_HIT
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/data-credits/credits-reservations-reservation_id-settle-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORoutcome must be one of MATCHED, NO_MATCH, TECHNICAL_FAILURE, CACHE_HITthe payload names an outcome that is not a settlement case
404RESERVATION_NOT_FOUNDno reservation <id>the id names nothing belonging to this tenant
409SETTLEMENT_CONFLICTrequest <id> already settled as X for N creditsa retry asserts a different outcome or a different charge
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Settle a hold as a no-match, charging nothing → expects HTTP 200
Path params
{
  "reservation_id": "{{cache:credits-reservations.create.reservation_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "outcome": "NO_MATCH",
  "result": null
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "outcome": "NO_MATCH",
  "result": null
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "outcome": "string",
    "credits_charged": "number",
    "credits_reserved": "number"
  }
}

sdk-data-rights

9 API(s)
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.

SDK / service
sdk-data-rightsW6 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-data-rights/src/server/routes.ts
Request field options
status: succeeded, failed
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\data-rights\executions-execution_id-result-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrormissing statusbody omits status (details: ["missing status"])
404NotFoundNotFoundrecordExecutionResult finds no execution row for execution_id
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Record success → expects HTTP 200
Path params
{
  "execution_id": "{{cache:data-rights-executions.plan.response.data.executions.0.execution_id}}"
}
Payload (template)
{
  "status": "succeeded",
  "audit_entry_id": "{{var:audit_entry_id}}",
  "error_detail": null
}
Example request
{
  "status": "succeeded",
  "audit_entry_id": "{{var:audit_entry_id}}",
  "error_detail": null
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-data-rightsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-data-rights/src/server/routes.ts
Source spec
tests\api_definitions\data-rights\reconciliation-run-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Run reconciliation (empty discrepancies -> green) → expects HTTP 200
Path params
Payload (template)
{
  "discrepancies": []
}
Example request
{
  "discrepancies": []
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-data-rightsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /scim/v2/Users"
]
Implemented in
packages/sdk-data-rights/src/server/routes.ts
Request field options
kind: access, erasure, rectification, restriction, objection, portability
jurisdiction: GDPR, DPDP, CCPA, LGPD
approval_policy: auto, manager-approval, cross-tenant-approval
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\data-rights\requests-create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrormissing fieldsperson_id or kind missing from body
400ValidationErrorinvalid kindkind not in the six DSAR kinds
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Submit erasure request → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "kind": "erasure",
  "jurisdiction": "GDPR",
  "approval_policy": "manager-approval"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-data-rightsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/data-rights/requests"
]
Implemented in
packages/sdk-data-rights/src/server/routes.ts
Source spec
tests\api_definitions\data-rights\requests-request_id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
404NotFoundNotFoundno data_rights DSAR request row exists for the given request_id
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get DSAR request → expects HTTP 200
Path params
{
  "request_id": "{{cache:data-rights-requests.create.response.data.request.request_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-data-rightsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/data-rights/requests",
  "POST /api/data-rights/reconciliation/run"
]
Implemented in
packages/sdk-data-rights/src/server/routes.ts
Source spec
tests\api_definitions\data-rights\requests-request_id-certificate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
409ReconciliationRedReconciliation red — DSAR completion blockedisReconciliationGreen() is false, i.e. the most recent reconciliation run recorded outstanding discrepancies
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Issue cert with shred proofs → expects HTTP 201
Path params
{
  "request_id": "{{cache:data-rights-requests.create.response.data.request.request_id}}"
}
Payload (template)
{
  "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}}"
}
Example request
{
  "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}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-data-rightsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/data-rights/residency/touch",
  "POST /api/data-rights/requests"
]
Implemented in
packages/sdk-data-rights/src/server/routes.ts
Source spec
tests\api_definitions\data-rights\requests-request_id-plan-executions-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Plan executions → expects HTTP 201
Path params
{
  "request_id": "{{cache:data-rights-requests.create.response.data.request.request_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-data-rightsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/data-rights/requests"
]
Implemented in
packages/sdk-data-rights/src/server/routes.ts
Request field options
to: submitted, identity-verified, approval-pending, grace-period, executing, certificate-issued, audited, rejected
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\data-rights\requests-request_id-transition-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrorinvalid target statebody.to is missing or not one of the eight DSAR_STATES values
404NotFoundNotFoundtransitionRequest finds no request row for request_id
409InvalidTransitionInvalid transition <current status> → <to>the DSAR state machine does not permit moving from the request's current status to the requested one
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Transition submitted -> identity-verified → expects HTTP 200
Path params
{
  "request_id": "{{cache:data-rights-requests.create.response.data.request.request_id}}"
}
Payload (template)
{
  "to": "identity-verified",
  "approval_ref": "{{var:approval_ref}}",
  "grace_until": "{{dynamic:futuredatetime}}"
}
Example request
{
  "to": "identity-verified",
  "approval_ref": "{{var:approval_ref}}",
  "grace_until": "2026-01-15T10:30:00Z"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-data-rightsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/data-rights/residency/touch",
  "POST /scim/v2/Users"
]
Implemented in
packages/sdk-data-rights/src/server/routes.ts
Source spec
tests\api_definitions\data-rights\residency-person_id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List residency → expects HTTP 200
Path params
{
  "person_id": "{{cache:scim.create.response.data.person_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "residency_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-data-rightsW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /scim/v2/Users"
]
Implemented in
packages/sdk-data-rights/src/server/routes.ts
Source spec
tests\api_definitions\data-rights\residency-touch-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrormissing fieldsperson_id, pool_index or tenant_id missing from body
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Record residency for a person+pool → expects HTTP 200
Path params
Payload (template)
{
  "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"
  ]
}
Example request
{
  "person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "pool_index": "admin-us-east-1",
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "data_classes": [
    "profile",
    "persona"
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-deliverability

18 API(s)
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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/deliverability/webhooks/:provider"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Request field options
classification: hard_bounce, soft_bounce, complaint, delivered, other
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\deliverability\bounce-events-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List bounce events → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "bounce_event_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Request field options
channel: email, sms, all
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\deliverability\check-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and channel are requiredtenant_id or channel missing
400ValidationErroraddress or addresses[] is requiredneither address nor addresses provided
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Check a fresh address (not suppressed) → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "channel": "email",
  "address": "{{dynamic:email}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "channel": "email",
  "address": "qa.user@example.com"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverability
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
channel: email, sms, all
error_code: VALIDATION_ERROR
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/deliverability/check-bulk-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
400ValidationErrorbody must be an object with an items[] arrayRequest body is absent, not a JSON object, or is itself an array
400ValidationErroritems must not be emptyitems is an empty array
400ValidationErroritems exceeds the per-request maximum of 1000; page the batchMore than 1000 items are supplied
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Mixed email and sms recipients in one call, plus one invalid channel → expects HTTP 200
Path params
Payload (template)
{
  "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"
    }
  ]
}
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/deliverability/mailboxes"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Source spec
tests\api_definitions\deliverability\mailboxes-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List mailboxes → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "mailbox_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Source spec
tests\api_definitions\deliverability\mailboxes-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, imap_host and username are requiredrequired field missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register an inbox → expects HTTP 201
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/deliverability/mailboxes"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Source spec
tests\api_definitions\deliverability\mailboxes-mailbox_id-replies-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and message_id are requiredrequired field missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Ingest a human reply → expects HTTP 201
Path params
{
  "mailbox_id": "{{cache:deliverability.mailbox.create.response.data.mailbox.mailbox_id}}"
}
Payload (template)
{
  "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": {}
}
Example request
{
  "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": {}
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/deliverability/mailboxes"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Source spec
tests\api_definitions\deliverability\mailboxes-mailbox_id-sync-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing
404NotFoundmailbox not foundno mailbox for tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Sync the mailbox → expects HTTP 200
Path params
{
  "mailbox_id": "{{cache:deliverability.mailbox.create.response.data.mailbox.mailbox_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "sync_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Request field options
channel: email, sms, all
purpose: unsubscribe, resubscribe, preferences
scope: tenant, global
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\deliverability\optout-tokens-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, channel and address are requiredrequired field missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Issue an unsubscribe token → expects HTTP 201
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "channel": "email",
  "address": "{{dynamic:email}}",
  "purpose": "unsubscribe",
  "scope": "tenant",
  "ttl_seconds": 604800
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "channel": "email",
  "address": "qa.user@example.com",
  "purpose": "unsubscribe",
  "scope": "tenant",
  "ttl_seconds": 604800
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/deliverability/optout-tokens"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Source spec
tests\api_definitions\deliverability\optout-redeem-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortoken is requiredtoken missing
410Gonetoken is unknown, already used, or expiredtoken invalid/used/expired
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Redeem the opt-out token → expects HTTP 200
Path params
Payload (template)
{
  "token": "{{cache:deliverability.optout-token.create.response.data.token}}",
  "feedback": "Too many emails"
}
Example request
{
  "token": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "feedback": "Too many emails"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/deliverability/mailboxes/:mailbox_id/replies"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Request field options
classification: human, auto_reply, ooo, bounce, other
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\deliverability\reply-events-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List reply events → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "reply_event_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/deliverability/reputation/record"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Request field options
channel: email, sms
status: good, watch, paused
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\deliverability\reputation-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get email reputation → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "reputation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Request field options
channel: email, sms
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\deliverability\reputation-record-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing
400ValidationErrorchannel must be email or smsinvalid channel
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Record a healthy batch (2% bounce -> good) → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "channel": "email",
  "sent": 100,
  "delivered": 96,
  "bounced": 2,
  "complained": 0
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "channel": "email",
  "sent": 100,
  "delivered": 96,
  "bounced": 2,
  "complained": 0
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/deliverability/reputation/record"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Request field options
channel: email, sms
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\deliverability\reputation-resume-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing
404NotFoundno reputation row for that tenant/channelnothing to resume
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Resume the email channel → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "channel": "email"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "channel": "email"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/deliverability/suppressions"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Request field options
channel: email, sms, all
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\deliverability\suppressions-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List suppressions → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "suppression_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Request field options
channel: email, sms, all
reason: manual, optout, hard_bounce, soft_bounce, complaint, dnc, list_unsubscribe
scope: tenant, global
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\deliverability\suppressions-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, channel and address are requiredrequired field missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Suppress an email (manual) → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Request field options
channel: email, sms, all
scope: tenant, global
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\deliverability\suppressions-remove-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, channel and address are requiredrequired field missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Remove a suppression → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "channel": "email",
  "address": "{{dynamic:email}}",
  "scope": "tenant"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "channel": "email",
  "address": "qa.user@example.com",
  "scope": "tenant"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Request field options
provider: ses, sendgrid, mailgun, postmark, twilio
algo: sha1, sha256
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\deliverability\webhook-secrets-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, provider and signing_secret are requiredrequired field missing
400ValidationErrorprovider must be ses, sendgrid, mailgun, postmark or twilioinvalid provider
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register a SendGrid signing secret → expects HTTP 201
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "provider": "sendgrid",
  "signing_secret": "{{dynamic:uuid}}",
  "algo": "sha256"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "provider": "sendgrid",
  "signing_secret": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "algo": "sha256"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-deliverabilityW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-deliverability/src/server/routes.ts
Source spec
tests\api_definitions\deliverability\webhooks-provider-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
400ValidationErrorunknown providerprovider not in the supported set
401InvalidSignaturewebhook HMAC signature verification faileda signing secret is configured for (tenant, provider) and the signature does not match
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Deliver a hard-bounce notification (no secret configured -> accepted, auto-suppress) → expects HTTP 200
Path params
{
  "provider": "{{static:postmark}}"
}
Payload (template)
{
  "event_type": "hard_bounce",
  "address": "{{dynamic:email}}",
  "message_id": "msg-{{dynamic:uuid}}"
}
Example request
{
  "event_type": "hard_bounce",
  "address": "qa.user@example.com",
  "message_id": "msg-{{dynamic:uuid}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "data": {
    "processed": "number",
    "suppressed": "number"
  }
}

sdk-device

6 API(s)
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.

SDK / service
sdk-deviceW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Request field options
platform: ios, android, web, desktop
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\devices\create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrormissing fieldsdevice_uuid or platform is absent or an empty string
400ValidationErrorinvalid platformplatform is not one of ios, android, web, desktop
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register iOS device → expects HTTP 201
Path params
Payload (template)
{
  "device_uuid": "{{dynamic:uuid}}",
  "platform": "ios",
  "os_version": "17.2",
  "app_version": "1.0.0",
  "device_key_ref": "{{dynamic:uuid}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-deviceW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/devices"
]
Request field options
platform: ios, android, web, desktop
status: active, revoked, stolen
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\devices\device_uuid-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
404NotFoundNotFoundNo device.device row exists for the supplied device_uuid
500Internal Server Errorinvalid input syntax for type uuiddevice_uuid is not a valid UUID - no format guard, so Postgres 22P02 escapes as an unhandled 500
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get device → expects HTTP 200
Path params
{
  "device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "device_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-deviceW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/devices"
]
Request field options
method: secure-enclave, key-attestation, safetynet, play-integrity
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\devices\device_uuid-attest-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrormissing fieldsmethod or signature_envelope is absent or an empty string
400ValidationErrorinvalid methodmethod is not one of secure-enclave, key-attestation, safetynet, play-integrity
500Internal Server Errorinsert or update on table "attestation" violates foreign key constraintdevice_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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Attest via secure-enclave → expects HTTP 201
Path params
{
  "device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}"
}
Payload (template)
{
  "method": "secure-enclave",
  "signature_envelope": "YmFzZTY0ZW52ZWxvcGU=",
  "expires_at": "{{dynamic:futuredatetime}}",
  "verified": true
}
Example request
{
  "method": "secure-enclave",
  "signature_envelope": "YmFzZTY0ZW52ZWxvcGU=",
  "expires_at": "2026-01-15T10:30:00Z",
  "verified": true
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-deviceW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/devices",
  "POST /scim/v2/Users"
]
Request field options
status: active, suspended, revoked
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\devices\device_uuid-link-person-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrormissing person_idperson_id missing from body
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Link person to device → expects HTTP 200
Path params
{
  "device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}"
}
Payload (template)
{
  "person_id": "{{cache:scim.create.response.data.person_id}}"
}
Example request
{
  "person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-deviceW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/devices"
]
Request field options
status: active, suspended, revoked
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\devices\device_uuid-persons-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
500Internal Server Errorinvalid input syntax for type uuiddevice_uuid is not a valid UUID - no format guard, so Postgres 22P02 escapes as an unhandled 500
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List persons for device → expects HTTP 200
Path params
{
  "device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-deviceW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/devices"
]
Request field options
reason: revoked, stolen
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\devices\device_uuid-revoke-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
404NotFoundNotFoundThe UPDATE matched no row - no device.device row exists for the supplied device_uuid
500Internal Server Errorinvalid input value for enum / invalid input syntax for type uuidreason is outside the revoked|stolen enum, or device_uuid is not a valid UUID - neither is guarded, so the Postgres error escapes unhandled
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Revoke device → expects HTTP 200
Path params
{
  "device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}"
}
Payload (template)
{
  "reason": "revoked"
}
Example request
{
  "reason": "revoked"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "reason": "revoked",
    "revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-diagnostic-telemetry

6 API(s)
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.

SDK / service
sdk-diagnostic-telemetryW1 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/devices"
]
Implemented in
packages/sdk-diagnostic-telemetry/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\diagnostic\crash-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate, not by the route itself
400BadRequestdevice_uuid query param requiredthe device_uuid query param is absent or empty
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List crashes for a registered device → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "crash_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-diagnostic-telemetryW1 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/devices",
  "POST /scim/v2/Users"
]
Implemented in
packages/sdk-diagnostic-telemetry/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\diagnostic\crash-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate, not by the route itself
400ValidationErrordevice_uuid, app_version, os_version, stack_envelope, occurred_at are requiredthe body omits any one of the five required fields
400BadRequest<error message thrown by recordCrash>recordCrash throws — malformed occurred_at, oversized stack_envelope, or an insert failure (caught and returned as 400, not 500)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Record a crash snapshot for a registered device → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-diagnostic-telemetryW1 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/diagnostic/crash"
]
Implemented in
packages/sdk-diagnostic-telemetry/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\diagnostic\crash-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate, not by the route itself
404NotFoundnot foundno crash row exists for the given id
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch a crash by id → expects HTTP 200
Path params
{
  "id": "{{cache:diagnostic.crash.response.data.crash_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "crash_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-diagnostic-telemetryW1 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/devices",
  "POST /api/diagnostic/health"
]
Implemented in
packages/sdk-diagnostic-telemetry/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\diagnostic\health-get.json
Error responses
HTTPCodeMessageWhen it happens
400BadRequestdevice_uuid query param requiredthe device_uuid query param is absent or empty
404NotFoundno snapshots recordedthe device has no health snapshot rows yet
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch the latest health snapshot for a registered device → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
sdk-diagnostic-telemetryW1 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/devices"
]
Implemented in
packages/sdk-diagnostic-telemetry/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\diagnostic\health-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrordevice_uuid and captured_at are requiredthe body omits device_uuid or captured_at
400BadRequest<error message thrown by recordHealth>recordHealth throws — malformed captured_at or an insert failure (caught and returned as 400, not 500)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Record a health snapshot for a registered device → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-diagnostic-telemetryW1 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/devices"
]
Implemented in
packages/sdk-diagnostic-telemetry/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\diagnostic\session-replay-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>" — enforced by the gateway default-deny auth gate, not by the route itself
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim — enforced by the gateway default-deny auth gate, not by the route itself
400ValidationErrordevice_uuid, sanitized_event_kind, occurred_at are requiredthe body omits any one of the three required fields
400BadRequest<error message thrown by recordSessionReplay>recordSessionReplay throws — unknown event kind, oversized payload, or an insert failure (caught and returned as 400, not 500)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Record a sanitized session-replay event for a registered device → expects HTTP 201
Path params
Payload (template)
{
  "device_uuid": "{{cache:devices.create.response.data.device.device_uuid}}",
  "sanitized_event_kind": "tap",
  "payload": {
    "x": 120,
    "y": 340
  },
  "occurred_at": "{{dynamic:pastdatetime}}"
}
Example request
{
  "device_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "sanitized_event_kind": "tap",
  "payload": {
    "x": 120,
    "y": 340
  },
  "occurred_at": "2026-01-15T10:30:00Z"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "success": true
}

sdk-dispatch

2 API(s)
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.

SDK / service
sdk-dispatchW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\dispatch\routes-optimize-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — authGate.ts default-deny gate rejects /api/dispatch/routes/optimize before the handler
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or expired)
400ValidationErrorpersona_id and task_ids[] are requiredBody missing, persona_id absent/empty, task_ids not an array, or task_ids is an empty array
500RouteOptimizeFailed[route-optimizer] no stops have lat/lng — refusing to optimize an empty routeNone of the supplied task_ids resolve to a task with coordinates (unknown task ids, or tasks whose lat/lng are null)
500RouteOptimizeFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Optimize a dispatcher route from queued tasks → expects HTTP 200
Path params
Payload (template)
{
  "persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
  "task_ids": [
    "{{var:dispatch_task_id}}"
  ],
  "start_task_id": "{{var:dispatch_task_id}}"
}
Example request
{
  "persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "task_ids": [
    "{{var:dispatch_task_id}}"
  ],
  "start_task_id": "{{var:dispatch_task_id}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-dispatchW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\dispatch\ws-persona-id-get.json
Error responses
HTTPCodeMessageWhen it happens
404NotFound(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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Subscribe to live dispatch events for a persona → expects HTTP 200
Path params
{
  "persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "ws_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "ws not found"
}
e.g. HTTP 404

sdk-engagement

10 API(s)
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.

SDK / service
sdk-engagementW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/vault/keys"
]
Implemented in
packages/sdk-engagement/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
kind: visit, order, deal, session, capital-call, support
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\encounters\index-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrormissing fieldsany of tenant_id, kind, parent_key_id or region is missing or empty in the body
500InternalServerError<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
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Open an encounter (issues per-encounter Vault key) → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-engagementW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/encounters"
]
Implemented in
packages/sdk-engagement/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\encounters\encounter-id-get.json
Error responses
HTTPCodeMessageWhen it happens
404NotFoundNotFoundno engagement.encounter row matches the :encounter_id path param (unknown, or malformed id)
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch an encounter by id → expects HTTP 200
Path params
{
  "encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "encounter_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-engagementW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/encounters"
]
Implemented in
packages/sdk-engagement/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\encounters\encounter-id-grants-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List active grants for an encounter → expects HTTP 200
Path params
{
  "encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "grant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-engagementW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/encounters",
  "POST /api/personas"
]
Implemented in
packages/sdk-engagement/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\encounters\encounter-id-grants-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrormissing fieldsany 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
500InternalServerError<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
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Issue a scope-bounded encounter grant → expects HTTP 201
Path params
{
  "encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-engagementW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/encounters",
  "POST /api/personas"
]
Implemented in
packages/sdk-engagement/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\encounters\encounter-id-grants-check-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrormissing fieldsgrantee_persona_id or method is missing or empty in the body
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Check whether a grantee may call a method → expects HTTP 200
Path params
{
  "encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}
Payload (template)
{
  "grantee_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
  "method": "chart.read"
}
Example request
{
  "grantee_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "method": "chart.read"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-engagementW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/encounters"
]
Implemented in
packages/sdk-engagement/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\encounters\encounter-id-participants-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List participants for an encounter → expects HTTP 200
Path params
{
  "encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "participant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-engagementW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/encounters",
  "POST /api/personas"
]
Implemented in
packages/sdk-engagement/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\encounters\encounter-id-participants-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrormissing fieldspersona_id or role is missing or empty in the body
500InternalServerError<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
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Add a participant persona to the encounter → expects HTTP 201
Path params
{
  "encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}
Payload (template)
{
  "persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
  "role": "attendee",
  "required": false
}
Example request
{
  "persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "role": "attendee",
  "required": false
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-engagementW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/encounters",
  "POST /api/personas"
]
Implemented in
packages/sdk-engagement/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
to: open, in-progress, closed, sealed
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\encounters\encounter-id-transition-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorinvalid target statebody.to is missing or is not one of 'open', 'in-progress', 'closed', 'sealed'
409InvalidTransitionEncounter <encounter_id> not foundthe :encounter_id path param matches no engagement.encounter row — reported as 409, not 404
409InvalidTransitionInvalid encounter transition <from> -> <to>the requested move is not permitted by VALID_TRANSITIONS (e.g. any transition out of sealed, or closed -> in-progress)
409InvalidTransitionCannot 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
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Transition the encounter open -> in-progress → expects HTTP 200
Path params
{
  "encounter_id": "{{cache:encounters.create.response.data.encounter.encounter_id}}"
}
Payload (template)
{
  "to": "in-progress",
  "actor_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}
Example request
{
  "to": "in-progress",
  "actor_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-engagementW5 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-engagement/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\grants\grant-id-revoke-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
404NotFoundNotFoundNo engagement.encounter_grant row with that grant_id and revoked_at IS NULL — unknown grant, or it was already revoked
500Internal Server ErrorFastify default error payload from the uncaught service throwrevokeGrant throws — grant_id is not a valid UUID, or the UPDATE fails
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Revoke an active encounter grant → expects HTTP 200
Path params
{
  "grant_id": "{{cache:encounters.issue-grant.response.data.grant.grant_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-engagementW5 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-engagement/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\participants\participant-id-leave-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
404NotFoundNotFoundNo engagement.encounter_participant row with that participant_id and left_at IS NULL — unknown participant, or they already left
500Internal Server ErrorFastify default error payload from the uncaught service throwremoveParticipant throws — participant_id is not a valid UUID, or the UPDATE fails
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
A participant leaves the encounter (sets left_at) → expects HTTP 200
Path params
{
  "participant_id": "{{cache:encounters.add-participant.response.data.participant.participant_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-event

4 API(s)
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.

SDK / service
Depends on
[
  "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"
]
Implemented in
packages/sdk-event/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\events\checkin-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrormissing fieldsbody omits qr_token or checked_in_by_persona_id
409CannotCheckInTicket not found, not issued, or already usedthe conditional UPDATE matches no ticket: unknown qr_token, ticket status is not "issued", or the ticket was already checked in
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "event.ticket",
  "field": "status",
  "flow": [
    "issued",
    "used",
    "refunded",
    "void"
  ],
  "transitions": [
    {
      "from": "issued",
      "to": "used",
      "via": "POST /api/events/checkin"
    }
  ]
}
Check in an issued ticket via its qr_token → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "qr_token": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "checked_in_by_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "device_uuid": "gate-scanner-01"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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).

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/vault/keys",
  "POST /api/encounters"
]
Implemented in
packages/sdk-event/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\events\sessions-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrormissing fieldsbody omits tenant_id, encounter_id, title, capacity, starts_at, or ends_at
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create an event session under an encounter → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/vault/keys",
  "POST /api/encounters",
  "POST /api/events/sessions"
]
Implemented in
packages/sdk-event/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\events\sessions-session-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
404NotFoundNotFoundno event.session row exists for the given session_id
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch a session by id → expects HTTP 200
Path params
{
  "session_id": "{{cache:events.sessions.create.response.data.session.session_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "session_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "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"
]
Implemented in
packages/sdk-event/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\events\tickets-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrormissing fieldsbody omits session_id or holder_persona_id
409CannotIssueTicketSession sold out, cancelled, or not foundthe 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Issue a ticket for a session → expects HTTP 201
Path params
Payload (template)
{
  "session_id": "{{cache:events.sessions.create.response.data.session.session_id}}",
  "holder_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
  "price": 50
}
Example request
{
  "session_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "holder_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "price": 50
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-evidence

3 API(s)
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.

SDK / service
sdk-evidenceW7 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/encounters",
  "POST /api/evidence/capture"
]
Implemented in
packages/sdk-evidence/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\evidence\capture-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
400ValidationErrorencounter_id query param requiredthe encounter_id query param is absent or empty
500InternalServerErrorInternal Server ErrorlistCapturesForEncounter 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List captures for an encounter → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "capture_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-evidenceW7 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-evidence/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
retention_class: transient, operational, regulated
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\evidence\capture-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
400ValidationErrormissing 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
409encounter_sealedencounter <encounter_id> is sealed (at <sealed_at>) - no new evidence captures may reference itcaptureEvidence raises EncounterSealedError; the response also carries encounter_id and sealed_at
400CaptureFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Provenance-stamped capture intake for an open encounter → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-evidenceW7 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/evidence/capture"
]
Implemented in
packages/sdk-evidence/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\evidence\capture-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
404NotFoundnot foundgetCapture returns no row for the id
500InternalServerErrorInternal Server ErrorgetCapture 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch a capture by id → expects HTTP 200
Path params
{
  "id": "{{cache:evidence.capture.response.data.capture.capture_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "capture_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-feature-flags

6 API(s)
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.

SDK / service
sdk-feature-flagsW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\flags\list-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List flags → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "flag_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-feature-flagsW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Request field options
kind: boolean, variant, numeric, json
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\flags\put.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrormissing flag_idbody omits flag_id (details: ["missing flag_id"])
400ValidationErrorinvalid kindbody.kind is present but is not one of boolean|variant|numeric|json
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Upsert agent kill-switch flag → expects HTTP 200
Path params
Payload (template)
{
  "flag_id": "agent.cost-steward.enabled",
  "description": "Per-agent kill switch",
  "kind": "boolean",
  "default_value": true,
  "kill_switch": false,
  "schema_ref": null
}
Example request
{
  "flag_id": "agent.cost-steward.enabled",
  "description": "Per-agent kill switch",
  "kind": "boolean",
  "default_value": true,
  "kill_switch": false,
  "schema_ref": null
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-feature-flagsW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "PUT /api/flags"
]
Source spec
tests\api_definitions\flags\flag_id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
404NotFoundNotFoundno feature_flags.flag row exists for the given flag_id
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get flag → expects HTTP 200
Path params
{
  "flag_id": "{{cache:flags.create.response.data.flag.flag_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "flag_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-feature-flagsW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "PUT /api/flags"
]
Source spec
tests\api_definitions\flags\flag_id-evaluate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Evaluate flag for a tenant → expects HTTP 200
Path params
{
  "flag_id": "{{cache:flags.create.response.data.flag.flag_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "persona_id": "{{var:persona_id}}",
  "bu_id": "{{var:bu_id}}",
  "attributes": {}
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "persona_id": "{{var:persona_id}}",
  "bu_id": "{{var:bu_id}}",
  "attributes": {}
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-feature-flagsW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "PUT /api/flags"
]
Source spec
tests\api_definitions\flags\flag_id-kill-switch-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrormissing engagedbody.engaged is absent or is not a JSON boolean (strings such as "true" are rejected)
404NotFoundNotFoundno feature_flags.flag row exists for the given flag_id
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Engage kill switch → expects HTTP 200
Path params
{
  "flag_id": "{{cache:flags.create.response.data.flag.flag_id}}"
}
Payload (template)
{
  "engaged": true
}
Example request
{
  "engaged": true
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-feature-flagsW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "PUT /api/flags"
]
Source spec
tests\api_definitions\flags\flag_id-rollouts-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrormissing valuebody.value is undefined (an explicit null is accepted)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Tenant-scoped rollout → expects HTTP 201
Path params
{
  "flag_id": "{{cache:flags.create.response.data.flag.flag_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "predicate": {},
  "value": true,
  "priority": 100,
  "active": true
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "predicate": {},
  "value": true,
  "priority": 100,
  "active": true
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-geo

6 API(s)
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/geo/canonicalize"
]
Source spec
tests\api_definitions\geo\addresses-address_id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
404NotFoundNotFoundNo geo.address row exists for the supplied address_id (never created, or deleted as the loser of a merge)
500Internal Server Errorinvalid input syntax for type uuidaddress_id is not a valid UUID - the handler has no format guard, so the Postgres 22P02 error escapes as an unhandled 500
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read by canonical id → expects HTTP 200
Path params
{
  "address_id": "{{cache:geo.canonicalize.response.data.address.address_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "address_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\geo\bbox-query-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrormissing bbox coordsmin_lat, min_lng, max_lat or max_lng is absent or not a JSON number
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Query bbox around SF → expects HTTP 200
Path params
Payload (template)
{
  "min_lat": 37.7,
  "min_lng": -122.5,
  "max_lat": 37.8,
  "max_lng": -122.4,
  "limit": 10
}
Example request
{
  "min_lat": 37.7,
  "min_lng": -122.5,
  "max_lat": 37.8,
  "max_lng": -122.4,
  "limit": 10
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/geo-nodes"
]
Source spec
tests\api_definitions\geo\canonicalize-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrormissing fieldsraw_input, street, city or country is absent or empty in the body
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Canonicalize a US address → expects HTTP 200
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\geo\geocode-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrormissing raw_inputraw_input is absent or an empty string
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Geocode raw input → expects HTTP 200
Path params
Payload (template)
{
  "raw_input": "123 Main St, Cityville, US"
}
Example request
{
  "raw_input": "123 Main St, Cityville, US"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Source spec
tests\api_definitions\geo\merge-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrormissing fieldswinner_address_id or loser_address_id is absent or empty
400ValidationErroraddress ids must be uuidswinner_address_id or loser_address_id does not match the UUID regex
404NotFoundwinner or loser address not foundmerge_event insert raises Postgres FK violation 23503 - a referenced address row does not exist
500InternalError<underlying error message>Any other failure during the alias re-point / merge_event insert / loser delete sequence
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Merge two addresses → expects HTTP 200
Path params
Payload (template)
{
  "winner_address_id": "{{var:geo_winner_address_id}}",
  "loser_address_id": "{{var:geo_loser_address_id}}",
  "operator_id": "{{var:operator_id}}"
}
Example request
{
  "winner_address_id": "{{var:geo_winner_address_id}}",
  "loser_address_id": "{{var:geo_loser_address_id}}",
  "operator_id": "{{var:operator_id}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\geo\reverse-geocode-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrormissing lat/lnglat or lng is absent or not a JSON number (numeric strings are rejected)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Reverse-geocode SF → expects HTTP 200
Path params
Payload (template)
{
  "lat": 37.7749,
  "lng": -122.4194
}
Example request
{
  "lat": 37.7749,
  "lng": -122.4194
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-handoff

9 API(s)
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).

SDK / service
sdk-handoffW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-handoff/src/server/routes.ts
Request field options
status: draft, pending, accepted, rejected, completed, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\handoff\handoffs-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List handoffs for the signup tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "handoff_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-handoffW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-handoff/src/server/routes.ts
Source spec
tests\api_definitions\handoff\handoffs-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and from_persona_id are requiredtenant_id or from_persona_id missing from body
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a draft handoff for the signup tenant → expects HTTP 201
Path params
Payload (template)
{
  "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"
  ]
}
Example request
{
  "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"
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-handoffW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/handoffs"
]
Implemented in
packages/sdk-handoff/src/server/routes.ts
Source spec
tests\api_definitions\handoff\handoffs-handoff_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
404NotFoundNotFoundhandoff_id not found for the tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get the created handoff → expects HTTP 200
Path params
{
  "handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "handoff_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-handoffW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/handoffs"
]
Implemented in
packages/sdk-handoff/src/server/routes.ts
Source spec
tests\api_definitions\handoff\handoffs-handoff_id-patch.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing from body
404NotFoundNotFoundhandoff_id not found for the tenant
409NotEditablehandoff in status '<status>' is no longer editablethe handoff is already accepted/rejected/completed/cancelled
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Enrich the draft handoff (owner + milestones) → expects HTTP 200
Path params
{
  "handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-handoffW5 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-handoff/src/server/routes.ts
Request field options
decision: approved, rejected
status: draft, pending, accepted, rejected, completed, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\handoff\handoffs-handoff_id-approval-decision-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and decision are requiredtenant_id or decision missing from body
400ValidationErrordecision must be approved or rejecteddecision is any value other than approved|rejected
401UnauthorizedUnauthorizedAuthorization bearer token missing or invalid
404NotFoundNotFoundhandoff_id not found for the tenant
409InvalidTransitioninvalid transition <from> -> accepted|rejectedthe handoff is not in 'pending' (never submitted for approval, or already decided/terminal)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
TransitionTriggered by
draft -> pendingPOST /api/handoffs/:handoff_id/approval/request (submit for CS review)
pending -> acceptedPOST /api/handoffs/:handoff_id/approval/decision with decision=approved
pending -> rejectedPOST /api/handoffs/:handoff_id/approval/decision with decision=rejected (reject_reason recorded)
accepted -> completedPOST /api/handoffs/:handoff_id/transition with status=completed
CS approves the handoff (pending -> accepted) → expects HTTP 200
Path params
{
  "handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "decision": "approved",
  "reject_reason": "not applicable when approving"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "decision": "approved",
  "reject_reason": "not applicable when approving"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
sdk-handoffW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/handoffs",
  "PATCH /api/handoffs/:handoff_id",
  "POST /api/handoffs/:handoff_id/transition"
]
Implemented in
packages/sdk-handoff/src/server/routes.ts
Request field options
status: draft, pending, accepted, rejected, completed, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\handoff\handoffs-handoff_id-approval-request-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing from body
401UnauthorizedUnauthorizedAuthorization bearer token missing or invalid
404NotFoundNotFoundhandoff_id not found for the tenant
409InvalidTransitioninvalid transition <from> -> pendingthe handoff is in a terminal state (completed/cancelled/rejected) so it cannot be submitted for review
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
TransitionTriggered by
draft -> pendingPOST /api/handoffs/:handoff_id/approval/request (submit for CS review; approval_id stamped)
pending -> acceptedPOST /api/handoffs/:handoff_id/approval/decision with decision=approved
pending -> rejectedPOST /api/handoffs/:handoff_id/approval/decision with decision=rejected
File the CS approval for the submitted handoff → expects HTTP 200
Path params
{
  "handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-handoffW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/handoffs",
  "POST /api/handoffs/:handoff_id/saga/start"
]
Implemented in
packages/sdk-handoff/src/server/routes.ts
Source spec
tests\api_definitions\handoff\handoffs-handoff_id-saga-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the saga projection after starting it → expects HTTP 200
Path params
{
  "handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "saga_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-handoffW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/handoffs"
]
Implemented in
packages/sdk-handoff/src/server/routes.ts
Source spec
tests\api_definitions\handoff\handoffs-handoff_id-saga-start-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing from body
404NotFoundNotFoundhandoff_id not found for the tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Start the saga for the created handoff → expects HTTP 202
Path params
{
  "handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "accepted",
    "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 202.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-handoffW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/handoffs"
]
Implemented in
packages/sdk-handoff/src/server/routes.ts
Request field options
status: draft, pending, accepted, rejected, completed, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\handoff\handoffs-handoff_id-transition-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and status are requiredtenant_id or status missing from body
400ValidationErrorinvalid statusstatus is not one of draft|pending|accepted|rejected|completed|cancelled
404NotFoundNotFoundhandoff_id not found for the tenant
409InvalidTransitioninvalid transition <from> -> <to>the requested transition is not allowed from the current status
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Submit the draft handoff (draft -> pending) → expects HTTP 200
Path params
{
  "handoff_id": "{{cache:handoff.handoffs.create.response.data.handoff.handoff_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "status": "pending"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "pending"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "handoff": {
      "handoff_id": "string",
      "status": "string"
    }
  }
}

sdk-identity

22 API(s)
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.

SDK / service
sdk-identityW0 · test wave
Source spec
tests\api_definitions\identity\jwks-get.json
Error responses
HTTPCodeMessageWhen it happens
500InternalErrorInternalErrorbuildJwks throws while deriving the key set from JWT_SECRET; caught by the route wrapper
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
JWKS keys array → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "jwks.json_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Source spec
tests\api_definitions\identity\openid-configuration-get.json
Error responses
HTTPCodeMessageWhen it happens
500InternalErrorInternalErrorbuildOidcDiscovery throws while assembling the document; caught by the route wrapper
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Discovery doc shape → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "openid_configuration_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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'.

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
protocol: saml, scim, oidc-social
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/admin/identity-federation-configs-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id (UUID) is requiredtenant_id missing or not a UUID
400ValidationErrorprotocol must be saml|scim|oidc-socialprotocol missing or not in the enum
400ValidationErrorscim_bearer_token is required when protocol='scim'protocol='scim' without a scim_bearer_token
401Unauthorizedadmin token requiredmissing or invalid x-admin-ops-token (requireAdmin)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Provision a SCIM federation config with the test bearer → expects HTTP 201
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "protocol": "scim",
  "scim_bearer_token": "{{var:scim_bearer_token}}",
  "jit_enabled": true
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "protocol": "scim",
  "scim_bearer_token": "{{var:scim_bearer_token}}",
  "jit_enabled": true
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\auth\login-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErroremail is required / password is requiredvalidateLoginInput fails - email or password absent or empty; details[] carries every failed rule
401InvalidCredentialsInvalid email or passwordverifyEmailPassword raises InvalidCredentialsError - unknown email or wrong password
403NoMembershipPerson <person_id> has no active membership in tenant <tenant_id>a tenant_id was supplied but the authenticated person has no matching active membership
500InternalErrorInternalErrorany other throw - projection-version read, AppIdentity mint, JWT signing, or a DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Login with previously registered credentials → expects HTTP 200
Path params
Payload (template)
{
  "email": "{{cache:auth.register.response.data.email}}",
  "password": "{{static:DefaultTestPass123!}}"
}
Example request
{
  "email": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "password": "DefaultTestPass123!"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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[].

SDK / service
sdk-identityW0 · test wave
Source spec
tests\api_definitions\auth\register-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErroremail 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 invalidvalidateRegisterInput fails; details[] carries every failed rule
409UserExistsA person with this email already existsregisterPerson raises PersonExistsError because the email alias is already taken
500InternalErrorInternalErrorany other throw - password hashing, JWT signing, or a DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register new user with email, password, name and phone → expects HTTP 201
Path params
Payload (template)
{
  "email": "{{dynamic:email}}",
  "password": "{{static:DefaultTestPass123!}}",
  "given_name": "{{dynamic:name}}",
  "family_name": "{{dynamic:name}}",
  "display_name": "{{dynamic:name}}",
  "phone": "{{dynamic:phone}}"
}
Example request
{
  "email": "qa.user@example.com",
  "password": "DefaultTestPass123!",
  "given_name": "Acme QA Sample",
  "family_name": "Acme QA Sample",
  "display_name": "Acme QA Sample",
  "phone": "+15555550123"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Implemented in
packages/sdk-identity/src/server/routes.ts
Source spec
packages/sdk-identity/src/server/routes.ts
Send (or resend) an email-verification link → expects HTTP 202
Path params
Payload (template)
{
  "userId": "{{optional}}",
  "email": "{{dynamic:email}}"
}
Example request
{
  "userId": "{{optional}}",
  "email": "qa.user@example.com"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "accepted",
    "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 202.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Implemented in
packages/sdk-identity/src/server/routes.ts
Request field options
region: us-east-1, us-west-2, eu-west-1, ap-south-1
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\auth\signup-tenant-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErroremail 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 invalidvalidateSignupTenantInput fails; details[] carries every failed rule
409UserExistsA person with this email already existssignupTenant raises PersonExistsError; the org/app/tenant/membership transaction is rolled back
500InternalErrorInternalErrorany other throw - the org/app/tenant/membership transaction fails, JWT signing fails, or a DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Self-serve signup with company, founder name and phone → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Implemented in
packages/sdk-identity/src/server/routes.ts
Source spec
packages/sdk-identity/src/server/routes.ts
Check whether an email is verified before login → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "verification_statu_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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).

SDK / service
sdk-identityW0 · test wave
Implemented in
packages/sdk-identity/src/server/routes.ts
Source spec
packages/sdk-identity/src/server/routes.ts
Confirm the email-verification token from the link → expects HTTP 200
Path params
Payload (template)
{
  "token": "{{static:SIGNED_EMAIL_VERIFY_JWT}}"
}
Example request
{
  "token": "SIGNED_EMAIL_VERIFY_JWT"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/register"
]
Request field options
kind: email, phone, gov_id, biometric_template_ref, social_idp_subject, saml_nameid
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\identity\aliases-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrorperson_id, kind, value are requiredAny of person_id, kind or value is absent or empty
400ValidationErrorkind must be one of email, phone, gov_id, biometric_template_ref, social_idp_subject, saml_nameidkind is supplied but is not in the allowed alias-kind list
400ValidationError<message containing "At least one">mergeAlias throws a validation error whose message contains "At least one" (insufficient identifying input for the merge)
404NotFound<entity> not foundmergeAlias throws an error whose message contains "not found" — typically person_id has no matching person row
500InternalErrorInternalErrormergeAlias 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Attach a phone alias to the registered person → expects HTTP 201
Path params
Payload (template)
{
  "person_id": "{{cache:auth.register.response.data.userId}}",
  "kind": "phone",
  "value": "{{dynamic:phone}}"
}
Example request
{
  "person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "kind": "phone",
  "value": "+15555550123"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/impersonation/request"
]
Source spec
tests\api_definitions\impersonation\id-approve-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrorAt least one of manager_approval_id or customer_consent_ref must be providedthe body contains neither manager_approval_id nor customer_consent_ref (empty body, or both empty)
404NotFoundImpersonation grant <grant_id> not foundthe UPDATE matches no row for the grant_id
500InternalErrorInternalErrorthe UPDATE throws - a non-UUID grant_id, manager_approval_id or customer_consent_ref failing the ::uuid cast, or any DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Approve with manager approval + customer consent refs → expects HTTP 200
Path params
{
  "grant_id": "{{cache:impersonation.request.response.data.grant.grant_id}}"
}
Payload (template)
{
  "manager_approval_id": "{{dynamic:uuid}}",
  "customer_consent_ref": "{{var:customer_consent_ref}}"
}
Example request
{
  "manager_approval_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "customer_consent_ref": "{{var:customer_consent_ref}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/impersonation/request",
  "POST /api/impersonation/:grant_id/approve"
]
Source spec
tests\api_definitions\impersonation\id-end-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
404NotFoundImpersonation grant <grant_id> not foundthe UPDATE matches no row for the grant_id
500InternalErrorInternalErrorthe UPDATE throws - a non-UUID grant_id failing the uuid comparison, or any DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
End the active impersonation session → expects HTTP 200
Path params
{
  "grant_id": "{{cache:impersonation.request.response.data.grant.grant_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Source spec
tests\api_definitions\impersonation\request-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrorsupport_user_id, target_tenant_id, ticket_ref are requiredany of support_user_id, target_tenant_id or ticket_ref is absent or empty
500InternalErrorInternalErrorthe 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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 engineer requests 30min impersonation → expects HTTP 201
Path params
Payload (template)
{
  "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
}
Example request
{
  "support_user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "target_tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "ticket_ref": "SUP-998",
  "duration_minutes": 30
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Source spec
tests\api_definitions\me\profile-put.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
401UnauthorizedUnauthorizedrequireAuth passed but req.auth.sub is absent, so no person_id can be resolved (payload is { success:false, error:"Unauthorized" })
500InternalError<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> }
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Update name/phone/avatar for the signed-up founder → expects HTTP 200
Path params
Payload (template)
{
  "display_name": "{{dynamic:name}}",
  "given_name": "Ada",
  "family_name": "Lovelace",
  "phone": "{{dynamic:phone}}",
  "avatar": "https://cdn.example.com/avatar.png"
}
Example request
{
  "display_name": "Acme QA Sample",
  "given_name": "Ada",
  "family_name": "Lovelace",
  "phone": "+15555550123",
  "avatar": "https://cdn.example.com/avatar.png"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Implemented in
packages/sdk-identity/src/server/handlers/subscriptionController.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/identity/memberships-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenthe route is behind requireAuth; the subscription list is personal data and is never public
401UnauthorizedInvalid or expired tokenthe token fails verification, so no subject can be trusted from it
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
A signed-in person sees their memberships → expects HTTP 200
Path params
Payload (template)
{}
Expected output ✓
{
  "success": true,
  "data": [
    {
      "membership_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/register"
]
Request field options
kind: totp, webauthn, sms_otp
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\mfa\challenge-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
401UnauthorizedMissing person_idThe JWT verified but carries no sub claim, so the handler cannot bind the challenge to a person
400ValidationErrorkind must be totp|webauthn|sms_otpbody.kind is present but is not one of totp, webauthn, sms_otp
500InternalErrorInternalErrorissueMfaChallenge throws unexpectedly (crypto/UUID or challenge-store failure); caught by the controller fail() helper and by the route wrapper
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Issue a TOTP challenge → expects HTTP 201
Path params
Payload (template)
{
  "kind": "totp"
}
Example request
{
  "kind": "totp"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/mfa/challenge"
]
Source spec
tests\api_definitions\mfa\verify-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorchallenge_id and response are requiredBody is missing either challenge_id or response (empty strings are falsy and count as missing)
401MfaFailedchallenge_not_found_or_expiredNo 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
401MfaFailedinvalid_responseThe 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
500InternalErrorInternalErrorThe identity.credential last_used_at UPDATE fails after a successful match, or verifyMfaChallenge throws unexpectedly
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Verify a 6-digit TOTP response → expects HTTP 200
Path params
Payload (template)
{
  "challenge_id": "{{cache:mfa.challenge.response.data.challenge_id}}",
  "response": "123456"
}
Example request
{
  "challenge_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "response": "123456"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "challenge_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "response": "123456",
    "verify_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\identity\userinfo-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
401UnauthorizedMissing person_id claimThe token verifies but has no sub claim, so no person can be resolved
404NotFoundPerson not foundreadUserinfo returns nothing for the token's sub — the person row was deleted or never existed
500InternalErrorInternalErrorreadUserinfo throws for any reason other than a "not found" message
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Userinfo for the freshly-registered subject (name/email/roles) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "userinfo_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-identity/src/server/handlers/federationController.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\saml\tenant-id-acs-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorSAML_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")
400ValidationErrorSAML response missing NameIDA real adapter parsed the AuthnResponse but the profile carried no NameID (error message contains "NameID")
401SamlSignatureFailed<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")
404NotFound<not found error from consumeSamlAssertion>A downstream lookup reports "not found" while resolving the person/app identity
500InternalErrorInternalErrorAny 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Mock-adapter assertion JIT-provisions a person → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
{
  "name_id": "{{dynamic:email}}",
  "email": "{{dynamic:email}}",
  "groups": [
    "Engineering"
  ],
  "attributes": {}
}
Example request
{
  "name_id": "qa.user@example.com",
  "email": "qa.user@example.com",
  "groups": [
    "Engineering"
  ],
  "attributes": {}
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-identity/src/server/handlers/federationController.ts
Source spec
tests\api_definitions\saml\id-metadata-get.json
Error responses
HTTPCodeMessageWhen it happens
500InternalErrorInternalErrorbuildSamlSpMetadata or the reply serialization throws; caught by the route wrapper, which sends 500 only if the reply has not already been sent
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Metadata XML for a known tenant → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "metadata_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "metadata not found"
}
e.g. HTTP 404
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.

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-identity/src/server/handlers/federationController.ts
Source spec
tests\api_definitions\scim\users-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
401UnauthorizedMissing SCIM Bearer tokenscimBearerAuth finds no Authorization: Bearer <token> header (SCIM error payload with schemas urn:ietf:params:scim:api:messages:2.0:Error)
401UnauthorizedSCIM Bearer token not recognizedNo identity.federation_config row with protocol=scim, jit_enabled=TRUE and a matching (or NULL) scim_bearer_envelope hash
400ValidationErrorNo SCIM Bearer token resolved and no x-tenant-id header setreq.scimContext.tenant_id is unset and no x-tenant-id header was supplied
400ValidationErrorschemas must include SCIM 2.0 UserBody is missing, or schemas[] does not contain urn:ietf:params:scim:schemas:core:2.0:User
400ValidationErrorSCIM user must include at least one emailemails[] is absent or empty, so provisionScimUser cannot derive the email alias (message contains "must include")
404NotFound<not found error from provisionScimUser>A downstream lookup during provisioning reports "not found"
500InternalErrorInternalErrorAny 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)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Provision a new user via SCIM 2.0 → expects HTTP 201
Path params
Payload (template)
{
  "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"
    }
  ]
}
Example request
{
  "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"
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-identityW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /scim/v2/Users"
]
Implemented in
packages/sdk-identity/src/server/handlers/federationController.ts
Source spec
tests\api_definitions\scim\v2-users-person-id-delete.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
401UnauthorizedMissing SCIM Bearer tokenscimBearerAuth finds no Authorization: Bearer <token> header (SCIM error payload with schemas urn:ietf:params:scim:api:messages:2.0:Error)
401UnauthorizedSCIM Bearer token not recognizedNo identity.federation_config row with protocol=scim, jit_enabled=TRUE and a matching (or NULL) scim_bearer_envelope hash
400ValidationErrorNo SCIM Bearer token resolved and no x-tenant-id header setreq.scimContext.tenant_id is unset and no x-tenant-id header was supplied
500InternalErrorInternalErrordeprovisionScimUser throws — person_id or tenant_id is not a valid UUID, or the tenant_membership UPDATE fails
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Deprovision a SCIM-provisioned user (offboards tenant membership) → expects HTTP 204
Path params
{
  "person_id": "{{cache:scim.create.response.data.person_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 204.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-identity-resolver

8 API(s)
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.

SDK / service
sdk-identity-resolverW8 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/sdk-identity-resolver/src/server/routes.ts
Request field options
band: high, medium, low
status: open, merged, rejected, superseded
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\empi\candidate-links-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
500InternalError<error message from queryCandidateLinksByBand>the candidate-link query throws (bad band range, DB failure) — caught and returned as a bare InternalError
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List candidate links in the high-confidence band → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "candidate_link_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-identity-resolverW8 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/empi/candidate-links/:link_id/steward-review"
]
Implemented in
packages/sdk-identity-resolver/src/server/routes.ts
Request field options
decision: approve, reject
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\empi\adjudicate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrorstep_id and decision (approve|reject) are requiredbody.step_id is missing or body.decision is anything other than "approve" or "reject"
500InternalErrorempi: candidate link <link_id> not foundthe link_id in the path matches no candidate-link row — surfaced as a 500, not a 404
500InternalError<error message from adjudicateCandidate>the approval step is already decided, does not belong to the link, or the decision write fails
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Steward approves a candidate link (triggers reversible merge) → expects HTTP 200
Path params
{
  "link_id": "{{cache:empi.steward-review.response.data.link.link_id}}"
}
Payload (template)
{
  "step_id": "{{cache:empi.steward-review.response.data.pending_step_ids.0}}",
  "decision": "approve",
  "reason": "Records confirmed as the same patient"
}
Example request
{
  "step_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "decision": "approve",
  "reason": "Records confirmed as the same patient"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-identity-resolverW8 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/approvals/routes"
]
Implemented in
packages/sdk-identity-resolver/src/server/routes.ts
Source spec
tests\api_definitions\empi\steward-review-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrorroute_id, tenant_id are requiredbody.route_id is missing, or tenant_id is absent from both the body and the JWT tenant claim
500InternalErrorempi: candidate link <link_id> not foundthe link_id in the path matches no candidate-link row — surfaced as a 500 (with the message in details), not a 404
500InternalError<error message from enqueueStewardReview>the approval-step insert or any other service call throws
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Queue a candidate link for steward review → expects HTTP 201
Path params
{
  "link_id": "{{var:link_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "route_id": "{{cache:approvals.routes.create.response.data.route.route_id}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "route_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-identity-resolverW8 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/sdk-identity-resolver/src/server/routes.ts
Source spec
tests\api_definitions\empi\merges-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
400ValidationErrorsurviving_person_id, merged_person_id are requiredbody omits surviving_person_id or merged_person_id
500InternalErrorempi: merge failedthe merge insert returns no row — unknown person ids, a self-merge, or an FK/constraint violation
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "candidate_link",
  "field": "status",
  "flow": [
    "open",
    "merged",
    "rejected",
    "superseded"
  ],
  "transitions": [
    {
      "from": "open",
      "to": "merged",
      "via": "POST /api/empi/merges"
    }
  ]
}
Merge two person records (event-sourced, reversible) → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-identity-resolverW8 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/empi/merges"
]
Implemented in
packages/sdk-identity-resolver/src/server/routes.ts
Source spec
tests\api_definitions\empi\unmerge-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
500InternalErrorempi: merge <merge_id> not foundthe merge_id in the path matches no merge row — surfaced as a 500, not a 404
500InternalErrorempi: unmerge failedthe compensating insert returns no row (e.g. the merge was already reversed)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "candidate_link",
  "field": "status",
  "flow": [
    "open",
    "merged",
    "rejected",
    "superseded"
  ],
  "transitions": [
    {
      "from": "merged",
      "to": "open",
      "via": "POST /api/empi/merges/:merge_id/unmerge"
    }
  ]
}
Reverse a prior merge via compensating event → expects HTTP 200
Path params
{
  "merge_id": "{{cache:empi.merges.create.response.data.merge.merge_id}}"
}
Payload (template)
{
  "reason": "Merge reversed - records belong to distinct persons"
}
Example request
{
  "reason": "Merge reversed - records belong to distinct persons"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-identity-resolverW8 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/sdk-identity-resolver/src/server/routes.ts
Source spec
tests\api_definitions\empi\metrics-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in the form "Bearer <jwt>"
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or past its exp claim
500InternalError<error message from getEmpiMetrics>the calibration/metrics aggregation throws — caught and returned as a bare InternalError
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read EMPI observability metrics → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "metric_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
sdk-identity-resolverW8 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /scim/v2/Users"
]
Request field options
attribute: primary_persona_id, all_persona_ids, effective_role_closure, active_consents, admin_pool_index, app_pool_index, rebac_edges, abac_attributes
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\resolver\explain-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrormissing required fieldsperson_id, app_id, tenant_id or attribute missing/falsy in body
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Explain primary_persona_id provenance → expects HTTP 200
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-identity-resolverW8 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /scim/v2/Users"
]
Source spec
tests\api_definitions\resolver\resolve-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrormissing required fieldsperson_id, app_id or tenant_id missing/falsy in body
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Resolve identity context → expects HTTP 200
Path params
Payload (template)
{
  "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
}
Example request
{
  "person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "bypass_cache": false
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-import

14 API(s)
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.

SDK / service
sdk-importW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/imports/mapping-templates"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
kind: certified, custom
crosswalk_strategy: preserve_existing, add_alias, reject_conflict
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/mapping-templates-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query param is absent
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the tenant's mapping templates → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "mapping_template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-importW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
kind: certified, custom
crosswalk_strategy: preserve_existing, add_alias, reject_conflict
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
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/mapping-templates-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id, slug and name are requiredany required field is missing from the body
409DUPLICATE_TEMPLATE_VERSIONa template with this slug and version already exists for the tenantUNIQUE(tenant_id, slug, version) is violated
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a custom mapping template → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-importW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/imports/mapping-templates"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
crosswalk_strategy: preserve_existing, add_alias, reject_conflict
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
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/mapping-templates-template_id-version-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id is requiredtenant_id missing from the body
404MAPPING_TEMPLATE_NOT_FOUNDmapping template <id> not found for tenantthe template does not exist, or belongs to another tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Publish version 2 of the template → expects HTTP 201
Path params
{
  "template_id": "{{cache:imports.template.create.response.data.template.template_id}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-importW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/imports/runs"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/runs-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query param is absent
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List runs for the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-importW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/runs-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id, source_kind and file_fingerprint are requiredany required field is missing from the body
409DUPLICATE_IMPORT_RUNfile <fingerprint> was already submitted for this sourcea run already exists for this (tenant, file_fingerprint, source_kind)
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Open a run for an uploaded delimited file → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-importW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/imports/runs"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back
action: created, linked, updated, asserted, reversed
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/runs-run_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query param is absent
404IMPORT_RUN_NOT_FOUNDimport run <id> not found for tenantthe run does not exist, or belongs to another tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the run created by the producer → expects HTTP 200
Path params
{
  "run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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[].

SDK / service
sdk-importW4 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/runs-run_id-commit-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id and rows[] are requiredeither field is missing from the body
404IMPORT_RUN_NOT_FOUNDimport run <id> not found for tenantthe run does not exist, or belongs to another tenant
409IMPORT_RUN_LOCKEDrun <id> is already being committed by another workera concurrent commit holds the run lock; retry shortly
409INVALID_RUN_TRANSITIONrun <id> cannot move rolled_back -> committingthe run was already rolled back, or has no confirmed mapping / transform plan
422ATTESTATION_NOT_SIGNEDrun <id> has no signed source-rights attestation — the commit is refusedno attestation covers the source
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Commit the dry-run-verified rows → expects HTTP 200
Path params
{
  "run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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[].

SDK / service
sdk-importW4 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/runs-run_id-dry-run-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id and rows[] are requiredeither field is missing from the body
404IMPORT_RUN_NOT_FOUNDimport run <id> not found for tenantthe run does not exist, or belongs to another tenant
409TRANSFORM_PLAN_REQUIREDbuild the transform plan before running a dry runthe run has no stored transform plan yet
500DRY_RUN_WROTEthe dry run acquired transaction id <xid> — it wrote to the databasethe simulation somehow wrote; surfaced loudly rather than passing silently
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Dry-run the mapped rows → expects HTTP 200
Path params
{
  "run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}
Payload (template)
{
  "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": ""
    }
  ]
}
Example request
{
  "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": ""
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-importW4 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
reason_code: INVALID_VALUE, NEEDS_REVIEW, CONSENT_NOT_EVIDENCED
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/runs-run_id-exceptions-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query param is absent
404IMPORT_RUN_NOT_FOUNDimport run <id> not found for tenantthe run does not exist, or belongs to another tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Download the exception file for the committed run → expects HTTP 200
Path params
{
  "run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "exception_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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[].

SDK / service
sdk-importW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/imports/runs",
  "POST /api/imports/runs/:run_id/preview"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
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
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/runs-run_id-mapping-put.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id and confirmations[] are requiredeither field is missing from the body
404IMPORT_RUN_NOT_FOUNDimport run <id> not found for tenantthe run does not exist, or belongs to another tenant
409PREVIEW_REQUIREDrun the preview before confirming a mappingthe run has no stored preview yet
422UNKNOWN_MAPPING_COLUMNno column named '<name>' in this run's previewa confirmation names a column the preview never produced
422UNKNOWN_MAPPING_TARGET'<target>' is not a canonical mapping targeta confirmation names a target outside the canonical vocabulary
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Confirm every proposed column mapping → expects HTTP 200
Path params
{
  "run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-importW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/imports/runs",
  "POST /api/imports/runs/:run_id/preview"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
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
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/runs-run_id-mapping-suggestions-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id is requiredtenant_id missing from the body
404IMPORT_RUN_NOT_FOUNDimport run <id> not found for tenantthe run does not exist, or belongs to another tenant
409PREVIEW_REQUIREDrun the preview before asking for mapping suggestionsthe run has no stored preview yet
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Ask for mapping suggestions on the previewed run → expects HTTP 200
Path params
{
  "run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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[].

SDK / service
sdk-importW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/imports/runs"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/runs-run_id-preview-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERROReither content or a non-empty rows[] is requiredneither content nor rows[] was supplied
404IMPORT_RUN_NOT_FOUNDimport run <id> not found for tenantthe run does not exist, or belongs to another tenant
409INVALID_RUN_TRANSITIONrun <id> cannot move <from> -> previewingthe run has already moved past the preview stage (e.g. it is complete)
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Preview the uploaded delimited content → expects HTTP 200
Path params
{
  "run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-importW4 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: draft, previewing, mapping, dry_run, committing, complete, quarantined, rolled_back
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/runs-run_id-rollback-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id is requiredtenant_id missing from the body
404IMPORT_RUN_NOT_FOUNDimport run <id> not found for tenantthe run does not exist, or belongs to another tenant
409INVALID_RUN_TRANSITIONrun <id> cannot move <from> -> rolled_backthe run never completed, so there is nothing to reverse
409ROLLBACK_WINDOW_CLOSEDthe rollback window for run <id> closed at <deadline>the derived rollback deadline has passed
409ROLLBACK_BLOCKED_BY_DOWNSTREAM_ACTIONrun <id> cannot be rolled back: <action> already occurred against <kind> <id>a downstream governed action has already touched an entity the run created
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Roll the committed run back inside its window → expects HTTP 200
Path params
{
  "run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "reason": "wrong file uploaded",
  "actor_id": "qa-runner"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "reason": "wrong file uploaded",
  "actor_id": "qa-runner"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-importW4 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/imports/runs",
  "POST /api/imports/runs/:run_id/preview",
  "PUT /api/imports/runs/:run_id/mapping"
]
Implemented in
packages/sdk-import/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
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
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/imports/runs-run_id-transform-plan-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id is requiredtenant_id missing from the body
404IMPORT_RUN_NOT_FOUNDimport run <id> not found for tenantthe run does not exist, or belongs to another tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Build the transform plan for the confirmed mapping → expects HTTP 200
Path params
{
  "run_id": "{{cache:imports.run.create.response.data.run.run_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "enable_source_state_mapping": false,
  "default_calling_region": "44",
  "default_country": "GB"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "enable_source_state_mapping": false,
  "default_calling_region": "44",
  "default_country": "GB"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "run": {
      "run_id": "string"
    },
    "transform_plan": {
      "steps": "array"
    }
  }
}

sdk-incident

8 API(s)
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).

SDK / service
sdk-incidentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-incident/src/server/routes.ts
Request field options
status: open, investigating, mitigated, resolved, closed, cancelled
severity: low, medium, high, critical
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\incident\incidents-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List incidents for the signup tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "incident_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-incidentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-incident/src/server/routes.ts
Request field options
severity: low, medium, high, critical
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\incident\incidents-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, incident_type and title are requiredtenant_id, incident_type or title missing from body
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Open a high-severity data-quality incident → expects HTTP 201
Path params
Payload (template)
{
  "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"
  ]
}
Example request
{
  "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"
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-incidentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/incidents"
]
Implemented in
packages/sdk-incident/src/server/routes.ts
Source spec
tests\api_definitions\incident\incidents-incident_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
404NotFoundNotFoundincident_id not found for the tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get the created incident → expects HTTP 200
Path params
{
  "incident_id": "{{cache:incident.incidents.create.response.data.incident.incident_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "incident_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-incidentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/incidents"
]
Implemented in
packages/sdk-incident/src/server/routes.ts
Request field options
severity: low, medium, high, critical
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\incident\incidents-incident_id-patch.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing from body
404NotFoundNotFoundincident_id not found for the tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Assign an owner + record root cause → expects HTTP 200
Path params
{
  "incident_id": "{{cache:incident.incidents.create.response.data.incident.incident_id}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "owner_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "root_cause": "Upstream feed schema drift",
  "severity": "critical"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-incidentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/incidents",
  "POST /api/incidents/:incident_id/evidence"
]
Implemented in
packages/sdk-incident/src/server/routes.ts
Request field options
kind: detected, root_cause, recovery, verification, note
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\incident\incidents-incident_id-evidence-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
401UnauthorizedUnauthorizedAuthorization bearer token missing or invalid
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the timeline for the incident that has evidence → expects HTTP 200
Path params
{
  "incident_id": "{{cache:incident.incidents.create.response.data.incident.incident_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "evidence_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-incidentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/incidents"
]
Implemented in
packages/sdk-incident/src/server/routes.ts
Request field options
kind: detected, root_cause, recovery, verification, note
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\incident\incidents-incident_id-evidence-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, kind and body are requiredtenant_id, kind or body missing from the request body
400ValidationErrorkind must be one of detected|root_cause|recovery|verification|notekind is outside the allowed enum
401UnauthorizedUnauthorizedAuthorization bearer token missing or invalid
404NotFound[sdk-incident] incident <id> not found for tenantincident_id does not exist for the authenticated tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Record the detection evidence for the created incident → expects HTTP 201
Path params
{
  "incident_id": "{{cache:incident.incidents.create.response.data.incident.incident_id}}"
}
Payload (template)
{
  "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
  }
}
Example request
{
  "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
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-incidentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/incidents"
]
Implemented in
packages/sdk-incident/src/server/routes.ts
Request field options
status: open, investigating, mitigated, resolved, closed, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\incident\incidents-incident_id-transition-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and status are requiredtenant_id or status missing from body
400ValidationErrorinvalid statusstatus is not one of open|investigating|mitigated|resolved|closed|cancelled
404NotFoundNotFoundincident_id not found for the tenant
409InvalidTransitioninvalid transition <from> -> <to>the requested transition is not allowed from the current status
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Start investigating (open -> investigating) → expects HTTP 200
Path params
{
  "incident_id": "{{cache:incident.incidents.create.response.data.incident.incident_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "status": "investigating"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "investigating"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-incidentW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-incident/src/server/routes.ts
Source spec
tests\api_definitions\incident\incidents-sla-breaches-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Scan SLA breaches for the signup tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "sla_breach_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "incidents": []
  }
}

sdk-ingest

3 API(s)
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.

SDK / service
sdk-ingestW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/sdk-ingest/src/server.ts
Request field options
mode: upsert, insert
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\ingest\entity-batch-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
400ValidationErrorentity is requiredthe :entity path segment resolves empty
400ValidationErroridempotency_key is requiredbody.idempotency_key is absent or an empty string
400ValidationErrorrecords must be a non-empty arraybody.records is absent, not an array, or an empty array
400IngestFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Land a one-record batch for an entity (idempotent upsert) → expects HTTP 200
Path params
{
  "entity": "customer"
}
Payload (template)
{
  "mode": "upsert",
  "idempotency_key": "{{dynamic:uuid}}",
  "records": [
    {
      "external_id": "EXT-1001",
      "name": "Demo Record",
      "value": 42
    }
  ]
}
Example request
{
  "mode": "upsert",
  "idempotency_key": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "records": [
    {
      "external_id": "EXT-1001",
      "name": "Demo Record",
      "value": 42
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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[] }.

SDK / service
sdk-ingestW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/sdk-ingest/src/server.ts
Request field options
mode: upsert, insert
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\ingest\batch-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
400ValidationErrorentity is requiredthe :entity path segment resolves empty
400ValidationErroridempotency_key is requiredbody.idempotency_key is absent or an empty string (it defaults to "" which fails the check)
400ValidationErrorrecords must be a non-empty arraybody.records is absent, not an array, or an empty array
400IngestFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Bulk upsert customer records with idempotency key → expects HTTP 200
Path params
Payload (template)
{
  "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"
    }
  ]
}
Example request
{
  "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"
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "imported": 2
}
Reject batch with no records → expects HTTP 400
Path params
Payload (template)
{
  "entity": "customer",
  "mode": "upsert",
  "idempotency_key": "{{dynamic:uuid}}",
  "records": []
}
Example request
{
  "entity": "customer",
  "mode": "upsert",
  "idempotency_key": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "records": []
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 400.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
sdk-ingestW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets"
]
Implemented in
packages/sdk-ingest/src/server.ts
Source spec
tests\api_definitions\ingest\sensor-readings-batch-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
400ValidationErroridempotency_key is requiredbody.idempotency_key is absent or an empty string
400ValidationErrorreadings must be a non-empty arraybody.readings is absent, not an array, or an empty array
400SinkNotConfiguredsensor-reading sink not configuredthe sensor time-series sink is not wired in this deployment
400IngestFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Idempotent batch of catalog-valid sensor readings → expects HTTP 201
Path params
Payload (template)
{
  "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"
    }
  ]
}
Example request
{
  "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"
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-lead-scoring

9 API(s)
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.

SDK / service
sdk-lead-scoringW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-lead-scoring/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\lead-scoring\models-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and vertical are requiredtenant_id or vertical is missing or empty in the body
400CreateModelFailed<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
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create an active lead-scoring model for (tenant, vertical) → expects HTTP 201
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-lead-scoringW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/lead-scoring/models"
]
Implemented in
packages/sdk-lead-scoring/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\lead-scoring\models-id-get.json
Error responses
HTTPCodeMessageWhen it happens
404NotFoundnot foundno lead_scoring.model row matches the :id path param
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch a model by id → expects HTTP 200
Path params
{
  "id": "{{cache:lead-scoring.create.response.data.model_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "model_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-lead-scoringW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/lead-scoring/models"
]
Implemented in
packages/sdk-lead-scoring/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\lead-scoring\models-id-activate-post.json
Error responses
HTTPCodeMessageWhen it happens
404NotFound[sdk-lead-scoring] model <id> not foundthe UPDATE matches no lead_scoring.model row for the :id path param
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "lead_scoring.model",
  "field": "status",
  "flow": [
    "training",
    "active",
    "retired"
  ],
  "transitions": [
    {
      "from": "training",
      "to": "active",
      "via": "POST /api/lead-scoring/models/:id/activate"
    }
  ]
}
Activate a model (flip status to active) → expects HTTP 200
Path params
{
  "id": "{{cache:lead-scoring.create.response.data.model_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-lead-scoringW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/lead-scoring/models"
]
Implemented in
packages/sdk-lead-scoring/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\lead-scoring\models-id-retire-post.json
Error responses
HTTPCodeMessageWhen it happens
404NotFound[sdk-lead-scoring] model <id> not foundthe UPDATE matches no lead_scoring.model row for the :id path param
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "lead_scoring.model",
  "field": "status",
  "flow": [
    "training",
    "active",
    "retired"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "retired",
      "via": "POST /api/lead-scoring/models/:id/retire"
    }
  ]
}
Retire a model (flip status to retired) → expects HTTP 200
Path params
{
  "id": "{{cache:lead-scoring.create.response.data.model_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-lead-scoringW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/lead-scoring/models"
]
Implemented in
packages/sdk-lead-scoring/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\lead-scoring\models-id-weights-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List feature weights for a model → expects HTTP 200
Path params
{
  "id": "{{cache:lead-scoring.create.response.data.model_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "weight_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-lead-scoringW5 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/lead-scoring/models"
]
Implemented in
packages/sdk-lead-scoring/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
feature: proximity, expertise, intent, storm_impact
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\lead-scoring\models-id-weights-feature-put.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorweight must be a non-negative finite numberbody.weight is null/undefined, not a number, NaN, Infinity, or negative
400SetWeightFailed<underlying database error message> | [sdk-lead-scoring] setFeatureWeight failedthe 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
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Tune one feature weight (upsert) → expects HTTP 200
Path params
{
  "id": "{{cache:lead-scoring.create.response.data.model_id}}",
  "feature": "proximity"
}
Payload (template)
{
  "weight": 0.4
}
Example request
{
  "weight": 0.4
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-lead-scoringW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/lead-scoring/models",
  "POST /api/lead-scoring/models/:id/activate"
]
Implemented in
packages/sdk-lead-scoring/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\lead-scoring\models-active-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and vertical query params requiredeither the tenant_id or the vertical query param is absent or empty
404NotFoundno active modelno lead_scoring.model row exists with status='active' for the given (tenant_id, vertical)
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Look up the active model for (tenant, vertical) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "active_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-lead-scoringW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/lead-scoring/models",
  "POST /api/lead-scoring/models/:id/activate"
]
Implemented in
packages/sdk-lead-scoring/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\lead-scoring\next-best-action-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, vertical, contact_id, trace_id are requiredany of tenant_id, vertical, contact_id or trace_id is missing or empty in the body
400NoActiveModel[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
400NextBestActionFailed[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
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Score + recommend next best action against the active model → expects HTTP 200
Path params
Payload (template)
{
  "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
  }
}
Example request
{
  "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
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-lead-scoringW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/lead-scoring/models",
  "POST /api/lead-scoring/models/:id/activate"
]
Implemented in
packages/sdk-lead-scoring/src/server/index.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\lead-scoring\score-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, vertical, contact_id, trace_id are requiredany of tenant_id, vertical, contact_id or trace_id is missing or empty in the body
400NoActiveModel[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
400ScoreFailed[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
401UnauthorizedMissing bearer tokenno Authorization header, or it is not of the form 'Bearer <jwt>' (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenthe bearer JWT fails verifyJwt — bad signature, wrong JWT_SECRET, or exp already elapsed (api-gateway default-deny authGate, AUTH_GATE_MODE=enforce)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Score a contact against the active model (happy path) → expects HTTP 200
Path params
Payload (template)
{
  "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
  }
}
Example request
{
  "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
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "score": "object",
    "components": "object",
    "weights": "object",
    "model_id": "string"
  }
}

sdk-mcp-bridge

6 API(s)
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.

SDK / service
sdk-mcp-bridgeW6 · test wave
Implemented in
packages/sdk-mcp-bridge/src/server/routes.ts
Source spec
tests\api_definitions\mcp\health-get.json
Health probe returns sdk marker → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-mcp-bridgeW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-mcp-bridge/src/server/handlers/mcpController.ts
Source spec
tests\api_definitions\mcp\server-registrations-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400Missing query param: tenant_idMissing query param: tenant_idtenant_id query parameter is absent or an empty string
500List failedList failedlistMcpServers throws (database unavailable, malformed tenant_id reaching a UUID cast, or query error)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List MCP server registrations for a tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "server_registration_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-mcp-bridgeW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-mcp-bridge/src/server/handlers/mcpController.ts
Request field options
transport: http, sse, stdio
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\mcp\server-registrations-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400Required: tenant_id, display_name, transport, endpoint_url, credential_envelope_b64Required: tenant_id, display_name, transport, endpoint_url, credential_envelope_b64Body 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 failedregisterMcpServer throws with a message containing "probe failed" - endpoint_url is unreachable, is not an MCP server, or the supplied credential envelope was rejected
500Register failedRegister failedAny other registerMcpServer failure (database error, envelope decode/storage failure)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Register a synthetic Slack MCP server → expects HTTP 201
Path params
Payload (template)
{
  "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": []
}
Example request
{
  "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": []
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-mcp-bridgeW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/mcp/server-registrations"
]
Implemented in
packages/sdk-mcp-bridge/src/server/handlers/mcpController.ts
Source spec
tests\api_definitions\mcp\server-registrations-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
404server_registration not foundserver_registration not foundNo mcp server_registration row exists for the supplied id
500Lookup failedLookup failedgetMcpServer throws - includes a non-UUID id (Postgres 22P02) and database errors
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read a registered MCP server by id → expects HTTP 200
Path params
{
  "id": "{{cache:mcp-server-registrations.create.response.data.server.registration_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "server_registration_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-mcp-bridgeW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/mcp/server-registrations"
]
Implemented in
packages/sdk-mcp-bridge/src/server/handlers/mcpController.ts
Source spec
tests\api_definitions\mcp\server-registrations-id-disable-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400reason is requiredreason is requiredBody is absent or reason is missing/empty
404<service "not found" message>server_registration not founddisableMcpServer throws an error whose message contains "not found" - no registration exists for the supplied id
500Disable failedDisable failedAny other disableMcpServer failure, including a non-UUID id that fails the Postgres UUID cast
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "mcp.server_registration",
  "field": "status",
  "flow": [
    "active",
    "disabled"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "disabled",
      "via": "POST /api/mcp/server-registrations/:id/disable"
    }
  ]
}
Disable a registered MCP server → expects HTTP 200
Path params
{
  "id": "{{cache:mcp-server-registrations.create.response.data.server.registration_id}}"
}
Payload (template)
{
  "reason": "Decommissioned by automated test"
}
Example request
{
  "reason": "Decommissioned by automated test"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-mcp-bridgeW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/mcp/server-registrations",
  "POST /api/agent-runtime/runs",
  "POST /api/agent-runtime/tokens"
]
Implemented in
packages/sdk-mcp-bridge/src/server/handlers/mcpController.ts
Source spec
tests\api_definitions\mcp\tool-invoke-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400tool_id must be a valid UUIDtool_id must be a valid UUIDThe :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
400Required: agent_run_id, capability_token_id, args, trace_idRequired: agent_run_id, capability_token_id, args, trace_idBody 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 enabledinvokeMcpTool 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 foundinvokeMcpTool throws with a message containing "not found" - the tool_id, agent run or capability token does not exist
500Invoke failedInvoke failedAny other invocation failure (transport error, database error)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Invoke an MCP tool via capability token → expects HTTP 200
Path params
{
  "tool_id": "{{cache:mcp-server-registrations.create.response.data.tools.0.tool_id}}"
}
Payload (template)
{
  "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}}"
}
Example request
{
  "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}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "success": true,
  "data": {
    "invocation_id": "string",
    "outcome": "string",
    "latency_ms": "number"
  }
}

sdk-media

5 API(s)
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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/media/upload-url"
]
Implemented in
packages/sdk-media/src/server/handlers/mediaController.ts
Source spec
tests\api_definitions\media\id-playback-url-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
403ForbiddenJWT missing tenant_id claimThe token verifies but carries no tenant_id claim
401UnauthorizedMissing person_id in JWTThe token carries a tenant_id but no sub claim to attribute the playback access to
400ValidationErrorttl_seconds must be a positive numberttl_seconds is supplied but is non-numeric, NaN/Infinity, zero or negative
403TenantOwnershipissuePlaybackUrl: caller tenant <tid> does not own this blobThe blob's tenant_id differs from the JWT tenant_id claim
404BlobNotFoundBlob <blob_id> not foundNo blob row exists for the supplied blob_id
410GoneBlob <blob_id> has been cryptographically shreddedThe blob key material was shredded, so it can no longer be played back
500InternalErrorInternalErrorAny other issuePlaybackUrl failure - notably no S3 signer registered in production, or a database error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Issue 10min playback URL for the uploaded blob → expects HTTP 200
Path params
{
  "blob_id": "{{cache:media.upload-url.create.response.data.blob_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "playback_url_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/media/upload-url"
]
Implemented in
packages/sdk-media/src/server/handlers/mediaController.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\media\blob-id-ready-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
403ForbiddenJWT missing tenant_id claimThe token verifies but carries no tenant_id claim
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationErrorchecksum_hex must be a 64-char hex SHA-256checksum_hex is missing, or is not 64 hexadecimal characters after stripping an optional 0x prefix
403TenantOwnershipmarkBlobReady: caller tenant <tid> does not own this blobThe blob's tenant_id differs from the JWT tenant_id claim
404BlobNotFoundBlob <blob_id> not foundNo blob row exists for the supplied blob_id
409SealedEncounterEncounter <encounter_id> is sealed; new evidence blocked per FR-MED-5The blob belongs to an encounter that has since been sealed
410GoneBlob <blob_id> has been cryptographically shreddedThe blob key material was shredded, so the object can no longer be committed or read
500InternalErrorInternalErrorAny other markBlobReady failure (storage or database error)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "media.blob",
  "field": "status",
  "flow": [
    "uploading",
    "ready",
    "transcoded",
    "shredded"
  ],
  "transitions": [
    {
      "from": "uploading",
      "to": "ready",
      "via": "POST /api/media/:blob_id/ready"
    }
  ]
}
Commit an uploaded blob with its SHA-256 checksum → expects HTTP 200
Path params
{
  "blob_id": "{{cache:media.upload-url.create.response.data.blob_id}}"
}
Payload (template)
{
  "checksum_hex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
Example request
{
  "checksum_hex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/media/upload-url"
]
Implemented in
packages/sdk-media/src/server/handlers/mediaController.ts
Request field options
pipeline: video-mp4-hls, image-optimize, pdf-thumbnail
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\media\id-transcode-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
403ForbiddenJWT missing tenant_id claimThe token verifies but carries no tenant_id claim
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationErrorpipeline must be one of video-mp4-hls, image-optimize, pdf-thumbnailpipeline is missing or outside the closed set
403TenantOwnershiprequestTranscode: caller tenant <tid> does not own blob <blob_id>The blob's tenant_id differs from the JWT tenant_id claim
404BlobNotFoundBlob <blob_id> not foundNo blob row exists for the supplied blob_id
410GoneBlob <blob_id> has been shredded; cannot transcodeThe blob key material was shredded, so the source object is unrecoverable
500InternalErrorInternalErrorAny other requestTranscode failure (queue or database error)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "media.transcode_job",
  "field": "status",
  "flow": [
    "queued",
    "running",
    "succeeded",
    "failed"
  ],
  "transitions": [
    {
      "from": null,
      "to": "queued",
      "via": "POST /api/media/:blob_id/transcode"
    }
  ]
}
Enqueue image-optimize pipeline → expects HTTP 201
Path params
{
  "blob_id": "{{cache:media.upload-url.create.response.data.blob_id}}"
}
Payload (template)
{
  "pipeline": "image-optimize"
}
Example request
{
  "pipeline": "image-optimize"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/media/upload-url",
  "POST /api/media/:blob_id/transcode"
]
Implemented in
packages/sdk-media/src/server/handlers/mediaController.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
pipeline: video-mp4-hls, image-optimize, pdf-thumbnail
status: queued, running, succeeded, failed
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\media\transcode-jobs-job-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
404NotFoundNo transcode job <job_id>No transcode job row exists for the supplied job_id
500InternalErrorInternalErrorgetTranscodeJob throws - includes a non-UUID job_id (Postgres 22P02) and database errors
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Poll a transcode job by id → expects HTTP 200
Path params
{
  "job_id": "{{cache:media.transcode.response.data.job.job_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "transcode_job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-media/src/server/handlers/mediaController.ts
Source spec
tests\api_definitions\media\upload-url-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
403ForbiddenJWT missing tenant_id claimThe token verifies but carries no tenant_id claim, so no tenant context can be established
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationErrorpersona_id / content_type is requiredpersona_id or content_type is absent or whitespace-only
400ValidationErrorbyte_size must be a positive numberbyte_size is missing, not a number, NaN/Infinity, zero or negative
400ValidationErrorbyte_size cannot exceed 5368709120 bytesbyte_size exceeds the 5 GiB single-upload ceiling
400VaultKeyMissingNo active vault tenant key for tenant <tenant_id>; provision via sdk-vault firstThe tenant has no active vault key to wrap the blob DEK
409SealedEncounterEncounter <encounter_id> is sealed; new evidence blocked per FR-MED-5The supplied encounter_id has a vault.encounter_key_seal row - the encounter is sealed and no new evidence may be attached
500InternalErrorInternalErrorAny other issueUploadUrl failure - notably no S3 signer registered in production, or a database error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Issue upload URL for a 1MB JPEG → expects HTTP 201
Path params
Payload (template)
{
  "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
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "content_type": "image/jpeg",
  "byte_size": 1048576,
  "ttl_seconds": 900
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "blob_id": "string",
    "url_id": "string",
    "upload_url": "string",
    "expires_at": "string",
    "s3_key": "string"
  }
}

sdk-meter

2 API(s)
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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/assets"
]
Source spec
tests\api_definitions\meter\assets-asset-id-usage-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in "Bearer <token>" form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler)
400BadRequesttenant context requiredthe verified JWT carries no tenant_id claim (req.auth.tenant_id falsy)
500InternalServerError<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>}
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read per-robot metered usage → expects HTTP 200
Path params
{
  "asset_id": "{{cache:assets.create.response.data.asset_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "usage_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Source spec
tests\api_definitions\meter\health-get.json
Health probe returns emit-only mode marker → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "sdk": "string",
  "mode": "string",
  "status": "string"
}

sdk-notification

23 API(s)
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.

SDK / service
sdk-notificationW6 · test wave
Source spec
tests\api_definitions\admin\notifications-providers-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
500InternalServerError<provider read error message>getPlatformEmailProvider() throws — the secrets/credential store being unreachable or Postgres unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the platform-default email provider (metadata only) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "provider_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
sdk-notificationW6 · test wave
Request field options
kind: smtp, sendgrid, ses
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\admin\notifications-providers-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedadmin token requiredx-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
400ValidationErrorkind is required (smtp|sendgrid|ses)kind is missing, empty, or not one of smtp, sendgrid, ses
500InternalServerError<provider error message>setPlatformEmailProvider() throws — credential encryption/secret storage failing, a malformed config failing to store, or Postgres unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Set platform-default SMTP email provider → expects HTTP 201
Path params
Payload (template)
{
  "kind": "smtp",
  "from_address": "welcome@projexlight.com",
  "config": {
    "host": "smtp.zoho.com",
    "port": 465,
    "secure": true,
    "user": "welcome@projexlight.com"
  },
  "credential": "{{static:TestSmtpPass123!}}"
}
Example request
{
  "kind": "smtp",
  "from_address": "welcome@projexlight.com",
  "config": {
    "host": "smtp.zoho.com",
    "port": 465,
    "secure": true,
    "user": "welcome@projexlight.com"
  },
  "credential": "TestSmtpPass123!"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/notifications/webhooks/delivery/:provider"
]
Implemented in
packages/sdk-notification/src/server/routes.ts
Request field options
status: queued, sent, delivered, failed, bounced, undelivered, complaint
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\notifications\delivery-receipts-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List delivery receipts → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "delivery_receipt_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-notification/src/server/routes.ts
Request field options
channel: email, sms, whatsapp, push, slack
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\notifications\dispatch-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrortenant_id, channel, destination and body are requiredrequired field missing
400ValidationErrorchannel must be one of email, sms, whatsapp, push, slackinvalid channel
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Dispatch an email through the unified transport → expects HTTP 200
Path params
Payload (template)
{
  "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": {}
}
Example request
{
  "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": {}
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "PUT /api/notifications/frequency-policy"
]
Implemented in
packages/sdk-notification/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/notification/frequency-policy-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query parameter is absent
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token — GET is gated exactly as PUT
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List all policies for the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "frequency_policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-notification/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/notification/frequency-policy-put.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORmax_per_day must be a non-negative integer, or null for uncappedmax_per_day is negative or not an integer; null is accepted and means uncapped
400VALIDATION_ERRORdedup_window_seconds must be an integer between 0 and 604800 (7 days)dedup_window_seconds is negative or exceeds 604800
400VALIDATION_ERRORtenant_id is requiredtenant_id is absent, so the policy could not be scoped to a tenant
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token or tenant-scoped API key on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Cap marketing SMS at 5 a day with a 15-minute dedup window → expects HTTP 200
Path params
Payload (template)
{
  "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"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "channel": "sms",
  "purpose": "marketing",
  "max_per_day": 5,
  "dedup_window_seconds": 900,
  "updated_by": "api-regression"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/notifications/providers"
]
Source spec
tests\api_definitions\notifications\providers-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
403ForbiddenJWT missing tenant_id claimthe verified JWT carries no tenant_id claim
400ValidationError<service error matching unsupported|too short|must be at least|not found|invalid>listEmailProviders threw an error whose message matches the fail() mapper pattern
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the tenant's email providers (metadata only) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "provider_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Request field options
kind: smtp, sendgrid, ses
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\notifications\providers-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrorkind 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[]
400ValidationError<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)
403ForbiddenJWT missing tenant_id claimthe verified JWT carries no tenant_id claim
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Configure a SendGrid email provider for the tenant → expects HTTP 201
Path params
Payload (template)
{
  "kind": "sendgrid",
  "from_address": "{{dynamic:email}}",
  "credential": "{{static:SG.qa-test-api-key-abcd1234}}",
  "config": {},
  "fallback_on_error": true
}
Example request
{
  "kind": "sendgrid",
  "from_address": "qa.user@example.com",
  "credential": "SG.qa-test-api-key-abcd1234",
  "config": {},
  "fallback_on_error": true
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/notifications/providers"
]
Source spec
tests\api_definitions\notifications\providers-provider_id-delete.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationError<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()
403ForbiddenJWT missing tenant_id claimthe verified JWT carries no tenant_id claim
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Revoke the tenant's email provider → expects HTTP 200
Path params
{
  "provider_id": "{{cache:notifications.providers.create.response.data.provider.binding_id}}"
}
Payload (template)
{
  "reason": "{{static:QA revoke test for email provider}}"
}
Example request
{
  "reason": "QA revoke test for email provider"
}
Expected output ✓
{
  "success": true
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/notifications/providers"
]
Source spec
tests\api_definitions\notifications\providers-provider_id-patch.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrorcredential is required (min 4 chars)body.credential is absent or shorter than 4 characters
400ValidationError<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
403ForbiddenJWT missing tenant_id claimthe verified JWT carries no tenant_id claim
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Rotate the tenant's email provider credential → expects HTTP 200
Path params
{
  "provider_id": "{{cache:notifications.providers.create.response.data.provider.binding_id}}"
}
Payload (template)
{
  "credential": "{{static:SG.qa-rotated-api-key-wxyz9876}}"
}
Example request
{
  "credential": "SG.qa-rotated-api-key-wxyz9876"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/notifications/providers"
]
Source spec
tests\api_definitions\notifications\providers-provider_id-verify-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrorto (recipient email) is requiredbody.to is absent or empty after trimming
403ForbiddenJWT missing tenant_id claimthe verified JWT carries no tenant_id claim
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Verify the configured email provider with a test send → expects HTTP 200
Path params
{
  "provider_id": "{{cache:notifications.providers.create.response.data.provider.binding_id}}"
}
Payload (template)
{
  "to": "{{dynamic:email}}"
}
Example request
{
  "to": "qa.user@example.com"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "to": "qa.user@example.com",
    "verify_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Source spec
tests\api_definitions\notifications\quiet-hours-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrorpersona_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[]
403ForbiddenJWT missing tenant_id claimthe verified JWT carries no tenant_id claim
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Set nightly 22:00-06:00 PT quiet window → expects HTTP 200
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/notifications/templates"
]
Request field options
channel: email, sms, whatsapp, push, slack
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\notifications\send-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrortenant_id is required / template_code is required / person_id is required / channel must be one of email, sms, whatsapp, push, slack / destination is requiredany validateSendNotification check fails; all failures are returned together in details[]
403ForbiddenJWT missing tenant_id claimthe verified JWT carries no tenant_id claim, so the handler cannot scope the send
404TemplateNotFoundTemplate <code> not found for channel <channel>template_code (+ channel/locale) resolves to no tenant template and no platform default exists
409ConflictTemplate already exists for that (tenant, code, channel, version)a duplicate-key error escapes the send path and is mapped by the shared fail() handler
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Send email notification with rendered body → expects HTTP 201
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notification
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-notification/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/notifications/send-to-audience-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorauthorization is required — a send with no recorded decision is not permittedauthorization absent, or mode not one of platform/delegated/exempt; also covers missing tenant_id, body, channels, or audience
400ValidationErroraudience exceeds the 50000 recipient ceiling — narrow the audience rather than relying on truncationan explicit persona audience is larger than NOTIFICATION_MAX_AUDIENCE — a 400, never a truncated send
403DecisionExpireddelegated decision <ref> expired at <ts>; re-decide rather than re-sendauthorization.mode is delegated and expires_at is in the past — an expired decision has inherited nothing
403PurposeRequiredplatform authorization requires a registered purposeauthorization.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
403ExemptionUnjustifiedexempt authorization requires both basis and justification — an exemption is recorded, not skippedauthorization.mode is exempt with basis or justification missing
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Send to a role audience under a delegated decision → expects HTTP 200
Path params
Payload (template)
{
  "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"
  }
}
Expected output ✓
Illustrative — standard {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.

SDK / service
sdk-notification
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-notification/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
channel: email, sms, whatsapp, push, slack
policy_source: tenant, platform, builtin
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/notifications/send-window-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
403ForbiddenService token is missing required scope: notification.send-window.writeAn API key or machine token that does not hold notification.send-window.write (or a covering wildcard) is presented
400VALIDATION_ERRORtenant_id is required (absent from the request and from the credential)No tenant_id in the body and the credential carries none
400VALIDATION_ERRORchannel must be one of email, sms, whatsapp, push, slackchannel is absent or not a supported dispatch channel
400VALIDATION_ERRORat must be an ISO-8601 timestamp`at` is supplied but does not parse as a date
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Ask whether an email send is open for a tenant and purpose → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "channel": "email",
  "purpose": "marketing"
}
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-notification
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-notification/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
channel: email, sms, whatsapp, push, slack
policy_source: tenant, platform, builtin
error_code: VALIDATION_ERROR
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/notifications/send-window-bulk-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
403ForbiddenService token is missing required scope: notification.send-window.writeAn API key or machine token that does not hold notification.send-window.write (or a covering wildcard) is presented
400ValidationErrorbody must be an object with an items[] arrayRequest body is absent, not a JSON object, or is itself an array
400ValidationErroritems must not be emptyitems is an empty array
400ValidationErroritems exceeds the per-request maximum of 1000; page the batchMore than 1000 items are supplied
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Two channels for one tenant, plus one item with an unsupported channel → expects HTTP 200
Path params
Payload (template)
{
  "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"
    }
  ]
}
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/notifications/sms-consent"
]
Implemented in
packages/sdk-notification/src/server/routes.ts
Request field options
status: opted_in, opted_out
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\notifications\sms-consent-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List consent states → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "sms_consent_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-notification/src/server/routes.ts
Request field options
action: opt_out, opt_in
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\notifications\sms-consent-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrortenant_id, phone and action are requiredrequired field missing
400ValidationErroraction must be opt_out or opt_ininvalid action
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Opt a number out (STOP) → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "phone": "+1555{{dynamic:number}}",
  "action": "opt_out",
  "source": "api",
  "purpose": "marketing"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "phone": "+1555{{dynamic:number}}",
  "action": "opt_out",
  "source": "api",
  "purpose": "marketing"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/notifications/webhooks/sms/inbound"
]
Implemented in
packages/sdk-notification/src/server/routes.ts
Request field options
intent: opt_out, opt_in, help, none
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\notifications\sms-inbound-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List inbound SMS → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "sms_inbound_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-notification/src/server/routes.ts
Source spec
tests\api_definitions\notifications\sms-settings-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrortenant_id is requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Set the HELP auto-reply → expects HTTP 201
Path params
Payload (template)
{
  "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."
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "help_reply": "Support: reply STOP to opt out, START to opt in. Call 1-800-555-0100."
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Request field options
channel: email, sms, whatsapp, push, slack
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\notifications\templates-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrorcode is required / channel must be one of email, sms, whatsapp, push, slack / locale_bundles is required and must include at least one localeany validateCreateTemplate check fails; all failures are returned together in details[]
403ForbiddenJWT missing tenant_id claimthe verified JWT carries no tenant_id claim
409ConflictTemplate already exists for that (tenant, code, channel, version)a template already exists for the same (tenant_id, code, channel, version) — duplicate key
404TemplateNotFoundTemplate not foundthe service raises TemplateNotFoundError while resolving a base/parent template
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register a tenant-scoped email template → expects HTTP 201
Path params
Payload (template)
{
  "code": "{{dynamic:slug}}",
  "channel": "email",
  "locale_bundles": {
    "en-US": {
      "subject": "Welcome",
      "body": "Hi {name}, welcome!"
    }
  },
  "required_consent_purpose": null,
  "version": "1.0.0"
}
Example request
{
  "code": "sample-slug",
  "channel": "email",
  "locale_bundles": {
    "en-US": {
      "subject": "Welcome",
      "body": "Hi {name}, welcome!"
    }
  },
  "required_consent_purpose": null,
  "version": "1.0.0"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-notification/src/server/routes.ts
Source spec
tests\api_definitions\notifications\webhooks-delivery-provider-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
401InvalidSignaturedelivery callback signature verification failedsigning secret configured and signature mismatch
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Twilio delivered callback (unmatched id handled gracefully) → expects HTTP 200
Path params
{
  "provider": "{{static:twilio}}"
}
Payload (template)
{
  "MessageStatus": "delivered",
  "MessageSid": "SM{{dynamic:uuid}}",
  "To": "+15005550006"
}
Example request
{
  "MessageStatus": "delivered",
  "MessageSid": "SM{{dynamic:uuid}}",
  "To": "+15005550006"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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=.

SDK / service
sdk-notificationW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-notification/src/server/routes.ts
Source spec
tests\api_definitions\notifications\webhooks-sms-inbound-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
400ValidationErrorFrom is requiredFrom missing
401InvalidSignatureinbound SMS signature verification failedsigning secret configured and signature mismatch
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Inbound STOP opts the number out → expects HTTP 200
Path params
Payload (template)
{
  "From": "+1555{{dynamic:number}}",
  "To": "+15005550006",
  "Body": "STOP",
  "MessageSid": "SM{{dynamic:uuid}}"
}
Example request
{
  "From": "+1555{{dynamic:number}}",
  "To": "+15005550006",
  "Body": "STOP",
  "MessageSid": "SM{{dynamic:uuid}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "data": {
    "intent": "string"
  }
}

sdk-offer-catalog

13 API(s)
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.

SDK / service
sdk-offer-catalogW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-offer-catalog/src/server/routes.ts
Source spec
tests\api_definitions\offers\offers-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, name and slug are requiredrequired missing
409Conflictan offer with this slug already existsslug reused
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create an offer → expects HTTP 201
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "name": "{{dynamic:name}}",
  "slug": "offer-{{dynamic:uuid}}",
  "description": "Pro plan"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "name": "Acme QA Sample",
  "slug": "offer-{{dynamic:uuid}}",
  "description": "Pro plan"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-offer-catalog
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/offers"
]
Implemented in
packages/sdk-offer-catalog/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/offers/offer_id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it
400ValidationErrortenant_id query param requiredThe tenant_id query parameter is absent - this route reads the tenant from the query, never from the JWT claim
404NotFoundNotFoundNo 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
500InternalErrorFastify default error payload from the uncaught service throwgetOffer throws - a non-UUID offer_id that fails the Postgres uuid cast, or any database error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch the offer just created → expects HTTP 200
Path params
{
  "offer_id": "{{cache:offers.create.response.data.offer.offer_id}}"
}
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-offer-catalogW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/offers",
  "POST /api/offers/:offer_id/versions"
]
Implemented in
packages/sdk-offer-catalog/src/server/routes.ts
Source spec
tests\api_definitions\offers\offer_id-check-reference-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and offer_version_id are requiredrequired missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Current reference is not stale → expects HTTP 200
Path params
{
  "offer_id": "{{cache:offers.create.response.data.offer.offer_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "offer_version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "offer_version_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-offer-catalogW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/offers",
  "POST /api/offers/:offer_id/versions/:version_id/activate"
]
Implemented in
packages/sdk-offer-catalog/src/server/routes.ts
Request field options
source: live, beta, draft, none
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\offers\offer_id-current-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Resolve current (live after activate) → expects HTTP 200
Path params
{
  "offer_id": "{{cache:offers.create.response.data.offer.offer_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "current_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-offer-catalogW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/offers",
  "POST /api/offers/:offer_id/versions"
]
Implemented in
packages/sdk-offer-catalog/src/server/routes.ts
Source spec
tests\api_definitions\offers\offer_id-version-stamp-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
404NotFoundoffer has no current version to stampno version
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Stamp the current version → expects HTTP 200
Path params
{
  "offer_id": "{{cache:offers.create.response.data.offer.offer_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "version_stamp_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-offer-catalog
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/offers",
  "POST /api/offers/:offer_id/versions"
]
Implemented in
packages/sdk-offer-catalog/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/offers/offer_id-versions-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it
400ValidationErrortenant_id query param requiredThe tenant_id query parameter is absent - this route reads the tenant from the query, never from the JWT claim
500InternalErrorFastify default error payload from the uncaught service throwlistOfferVersions 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the versions of the offer just created → expects HTTP 200
Path params
{
  "offer_id": "{{cache:offers.create.response.data.offer.offer_id}}"
}
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-offer-catalogW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/offers"
]
Implemented in
packages/sdk-offer-catalog/src/server/routes.ts
Source spec
tests\api_definitions\offers\offer_id-versions-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and version are requiredrequired missing
409Conflictthis version already exists for the offerversion reused
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a draft version → expects HTTP 201
Path params
{
  "offer_id": "{{cache:offers.create.response.data.offer.offer_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "version": "v-{{dynamic:uuid}}",
  "title": "Pro v1",
  "price": 49,
  "currency": "USD",
  "body": {}
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "version": "v-{{dynamic:uuid}}",
  "title": "Pro v1",
  "price": 49,
  "currency": "USD",
  "body": {}
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-offer-catalog
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/offers",
  "POST /api/offers/:offer_id/versions"
]
Implemented in
packages/sdk-offer-catalog/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/offers/offer_id-versions-version_id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it
400ValidationErrortenant_id query param requiredThe tenant_id query parameter is absent - this route reads the tenant from the query, never from the JWT claim
404NotFoundNotFoundNo 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
500InternalErrorFastify default error payload from the uncaught service throwgetOfferVersion throws - a non-UUID version_id that fails the Postgres uuid cast, or any database error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch the version just created → expects HTTP 200
Path params
{
  "offer_id": "{{cache:offers.create.response.data.offer.offer_id}}",
  "version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-offer-catalogW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/offers",
  "POST /api/offers/:offer_id/versions"
]
Implemented in
packages/sdk-offer-catalog/src/server/routes.ts
Source spec
tests\api_definitions\offers\offer_id-versions-version_id-activate-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing
404NotFoundoffer version not foundno version
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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)"
    }
  ]
}
Activate the draft version → expects HTTP 200
Path params
{
  "offer_id": "{{cache:offers.create.response.data.offer.offer_id}}",
  "version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-offer-catalogW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/offers",
  "POST /api/offers/:offer_id/versions",
  "POST /api/offers/:offer_id/versions/:version_id/features"
]
Implemented in
packages/sdk-offer-catalog/src/server/routes.ts
Request field options
status: included, excluded, beta, roadmap, add_on, deprecated
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\offers\offer_id-versions-version_id-features-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the feature matrix → expects HTTP 200
Path params
{
  "offer_id": "{{cache:offers.create.response.data.offer.offer_id}}",
  "version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "feature_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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'.

SDK / service
sdk-offer-catalogW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/offers",
  "POST /api/offers/:offer_id/versions"
]
Implemented in
packages/sdk-offer-catalog/src/server/routes.ts
Request field options
status: included, excluded, beta, roadmap, add_on, deprecated
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\offers\offer_id-versions-version_id-features-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, feature_key and name are requiredrequired missing
400ValidationErrorstatus must be one of included, excluded, beta, roadmap, add_on, deprecatedinvalid status
404NotFoundoffer version not foundno version
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Mark a feature included → expects HTTP 201
Path params
{
  "offer_id": "{{cache:offers.create.response.data.offer.offer_id}}",
  "version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "feature_key": "api-access",
  "name": "API Access",
  "status": "included",
  "value": "unlimited",
  "sort_order": 1
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "feature_key": "api-access",
  "name": "API Access",
  "status": "included",
  "value": "unlimited",
  "sort_order": 1
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-offer-catalogW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/offers",
  "POST /api/offers/:offer_id/versions",
  "POST /api/offers/:offer_id/versions/:version_id/publish-request"
]
Implemented in
packages/sdk-offer-catalog/src/server/routes.ts
Request field options
decision: approved, rejected
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\offers\offer_id-versions-version_id-publish-decision-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and decision are requiredrequired missing
400ValidationErrordecision must be approved or rejectedinvalid decision
404NotFoundoffer version not foundno version
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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}"
    }
  ]
}
Approve the publish request → expects HTTP 200
Path params
{
  "offer_id": "{{cache:offers.create.response.data.offer.offer_id}}",
  "version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "decision": "approved"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "decision": "approved"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-offer-catalogW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/offers",
  "POST /api/offers/:offer_id/versions"
]
Implemented in
packages/sdk-offer-catalog/src/server/routes.ts
Source spec
tests\api_definitions\offers\offer_id-versions-version_id-publish-request-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing
404NotFoundoffer version not foundno version
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Request publish approval → expects HTTP 201
Path params
{
  "offer_id": "{{cache:offers.create.response.data.offer.offer_id}}",
  "version_id": "{{cache:offers.version.create.response.data.version.offer_version_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "publish": {
      "approval_ref": "string",
      "approval_status": "string"
    }
  }
}

sdk-parsing

3 API(s)
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.

SDK / service
sdk-parsingW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-parsing/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/parsing/contact-extract-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORraw is required — evidence spans index into itraw 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
400VALIDATION_ERRORsource_kind must be one of: SMART_PASTE, EMAIL_SIGNATURE, BUSINESS_CARD_OCR, VCARD, VCARD_MULTI, MOBILE_CONTACTS, BROWSER_SELECTION, VOICE_TRANSCRIPTsource_kind is not one of the eight registered capture surfaces
400VALIDATION_ERRORtenant_id is requiredtenant_id is absent, so the schema cannot be resolved tenant-first
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token — extraction reads customer text
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Email signature yields multiple qualified handles → expects HTTP 200
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-parsingW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-parsing/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/parsing/contact-extract-batch-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORitems must be a non-empty arrayitems is absent, not an array, or empty
400VALIDATION_ERRORitems may not exceed 100 per requestmore than 100 items are sent — the excess is refused loudly rather than silently truncated
400VALIDATION_ERRORitems[1].source_kind must be one of: SMART_PASTE, EMAIL_SIGNATURE, BUSINESS_CARD_OCR, VCARD, VCARD_MULTI, MOBILE_CONTACTS, BROWSER_SELECTION, VOICE_TRANSCRIPTany item has a missing raw or an unknown source_kind
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Two valid captures both extract → expects HTTP 200
Path params
Payload (template)
{
  "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"
    }
  ]
}
Example request
{
  "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"
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-parsingW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-parsing/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/parsing/contact-schemas-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredtenant_id is absent, which makes tenant-first resolution meaningless
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token — GET is gated exactly as POST
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Resolve the contact schema for a tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "schema_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-payment

5 API(s)
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.

SDK / service
sdk-paymentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/payments/methods",
  "POST /api/payments/charge"
]
Source spec
tests\api_definitions\payments\id-distribute-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
403ForbiddenJWT missing tenant_id claimreq.auth.tenant_id absent after auth
400ValidationErrorcharge_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 numbervalidateDistribute fails
404ChargeNotFoundCharge <charge_id> not foundno charge row for the path charge_id
403TenantOwnershipcaller tenant does not own resource tenantcharge belongs to a different tenant than the JWT tenant
409DistributionOversubscribedcumulative distribution would exceed charge amountprior distributions + this batch exceed the charge amount
500InternalErrorInternalErrordefault fallthrough (also, due to a fail()-matcher case mismatch, a non-captured status or batch-sum-exceeds-amount currently lands here instead of 409)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Split charge between two parties → expects HTTP 201
Path params
{
  "charge_id": "{{cache:payments.charge.response.data.charge.charge_id}}"
}
Payload (template)
{
  "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
    }
  ]
}
Example request
{
  "currency": "USD",
  "splits": [
    {
      "party_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "share": 20
    },
    {
      "party_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "share": 5
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-paymentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/payments/methods",
  "POST /api/payments/charge"
]
Source spec
tests\api_definitions\payments\id-refund-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
403ForbiddenJWT missing tenant_id claimreq.auth.tenant_id is absent after auth
400ValidationErroramount must be a positive number / reason is requiredvalidateRefund fails; details[] carries every failed rule
404ChargeNotFoundCharge <charge_id> not foundrefund raises ChargeNotFoundError for the path charge_id
403TenantOwnershipcaller tenant does not own the chargerefund raises TenantOwnershipError because the charge belongs to a different tenant than the JWT tenant
422InsufficientRefundableAmountrefund amount exceeds the remaining refundable balancerefund raises InsufficientRefundableAmountError - the requested amount plus prior refunds exceeds the charge amount
409InvalidState<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
500InternalErrorInternalErrorrefund throws an unmapped error - provider/gateway failure, approval-gate failure, or any DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Refund $5.00 USD below approval threshold → expects HTTP 201
Path params
{
  "charge_id": "{{cache:payments.charge.response.data.charge.charge_id}}"
}
Payload (template)
{
  "amount": 5,
  "reason": "customer requested partial refund",
  "approval_threshold": 10000
}
Example request
{
  "amount": 5,
  "reason": "customer requested partial refund",
  "approval_threshold": 10000
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-paymentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/payments/methods"
]
Source spec
tests\api_definitions\payments\charge-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
403ForbiddenJWT missing tenant_id claimreq.auth.tenant_id is absent after auth
400ValidationErrortenant_id is required / method_id is required / amount must be a positive number / currency must be ISO-4217 3-lettervalidateCharge fails; details[] carries every failed rule
404PaymentMethodNotFoundPayment method <method_id> not foundcharge raises PaymentMethodNotFoundError for the supplied method_id
403TenantOwnershipcaller tenant does not own the payment methodcharge raises TenantOwnershipError because the method belongs to a different tenant than the JWT tenant
409InvalidState<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
500InternalErrorInternalErrorcharge throws an unmapped error - provider/gateway failure or any DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Charge $25.00 USD → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-paymentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Request field options
provider: stripe, razorpay, plaid, ach
kind: card, bank-account, upi, wallet
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\payments\methods-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
403ForbiddenJWT missing tenant_id claimreq.auth.tenant_id is absent after auth - the token carries no tenant scope
400ValidationErrortenant_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
500InternalErrorInternalErrorattachPaymentMethod throws an unmapped error - provider API failure, an unsatisfied persona foreign key, or any DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Attach Stripe card via tokenized ref → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-paymentW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-payment/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
level: collect, billing
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/payments/provider-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenno Authorization header or an invalid/expired tenant JWT (api-gateway default-deny authGate)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Resolve the tenant's payment collection provider → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "provider_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "level": "string",
    "configured": "boolean",
    "provider": "string",
    "scope": "string",
    "value": "object"
  }
}

sdk-persona

17 API(s)
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant"
]
Source spec
tests\api_definitions\app-identities\create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrormissing fieldsperson_id or app_id is absent or an empty string
500InternalServerErrorInternal Server ErrorcreateAppIdentity 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create app_identity for person + app → expects HTTP 201
Path params
Payload (template)
{
  "person_id": "{{cache:auth.register.response.data.userId}}",
  "app_id": "{{cache:auth.signup-tenant.response.data.app_id}}"
}
Example request
{
  "person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/app-identities"
]
Source spec
tests\api_definitions\app-identities\app_identity_id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
404NotFoundNotFoundno app_identity row matches the app_identity_id
500InternalServerErrorInternal Server ErrorgetAppIdentity 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get app_identity by id → expects HTTP 200
Path params
{
  "app_identity_id": "{{cache:app-identities.create.response.data.app_identity.app_identity_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "app_identity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/app-identities"
]
Source spec
tests\api_definitions\app-identities\app_identity_id-memberships-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
500InternalServerErrorInternal Server ErrorlistMembershipsForAppIdentity 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List memberships → expects HTTP 200
Path params
{
  "app_identity_id": "{{cache:app-identities.create.response.data.app_identity.app_identity_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "membership_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/app-identities"
]
Implemented in
packages/sdk-persona/src/server/routes.ts
Source spec
tests\api_definitions\memberships\create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrormissing fieldsapp_identity_id or tenant_id is absent or an empty string
500InternalServerErrorInternal Server ErrorcreateMembership 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create membership → expects HTTP 201
Path params
Payload (template)
{
  "app_identity_id": "{{cache:app-identities.create.response.data.app_identity.app_identity_id}}",
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Example request
{
  "app_identity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/app-identities",
  "POST /api/memberships"
]
Implemented in
packages/sdk-persona/src/server/routes.ts
Source spec
tests\api_definitions\memberships\membership_id-personas-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
500InternalServerErrorInternal Server ErrorlistPersonasForMembership 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List personas → expects HTTP 200
Path params
{
  "membership_id": "{{cache:memberships.create.response.data.membership.membership_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/app-identities",
  "POST /api/memberships"
]
Implemented in
packages/sdk-persona/src/server/routes.ts
Source spec
tests\api_definitions\memberships\membership_id-terminate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
404NotFoundNotFoundterminateMembership returns no row for the membership_id
500InternalServerErrorInternal Server ErrorterminateMembership 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "membership",
  "field": "status",
  "flow": [
    "active",
    "suspended",
    "terminated"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "terminated",
      "via": "POST /api/memberships/:membership_id/terminate"
    }
  ]
}
Terminate membership → expects HTTP 200
Path params
{
  "membership_id": "{{cache:memberships.create.response.data.membership.membership_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\personas\index-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenGateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent
401UnauthorizedInvalid or expired tokenauthGate ran requireAuth and the JWT failed verification or had expired
400ValidationErrortenant_id required?tenant_id= query param is absent or empty
500InternalError<postgres error text>tenant_id is not a valid UUID (::uuid cast fails), or the membership/profile query errors
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List tenant members for a tenant_id → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships"
]
Implemented in
packages/sdk-persona/src/server/routes.ts
Source spec
tests\api_definitions\personas\create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrormissing fieldsmembership_id or kind is absent/empty
500Internal Server ErrorInternal Server Errormembership_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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create patient persona → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas"
]
Implemented in
packages/sdk-persona/src/server/routes.ts
Source spec
tests\api_definitions\personas\persona_id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
404NotFoundNotFoundno persona row exists for :persona_id
500Internal Server ErrorInternal Server Error:persona_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get persona → expects HTTP 200
Path params
{
  "persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\personas\persona-id-bu-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenGateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent
401UnauthorizedInvalid or expired tokenauthGate ran requireAuth and the JWT failed verification or had expired
500InternalError<postgres error text>:persona_id or bu_id is not a valid UUID, or bu_id violates the business-unit foreign key
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Set persona business unit → expects HTTP 200
Path params
{
  "persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}
Payload (template)
{
  "bu_id": "{{var:bu_id}}"
}
Example request
{
  "bu_id": "{{var:bu_id}}"
}
Expected output ✓
{
  "success": true,
  "data": {
    "bu_id": "{{var:bu_id}}",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\personas\persona-id-deactivate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenGateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent
401UnauthorizedInvalid or expired tokenauthGate ran requireAuth and the JWT failed verification or had expired
500InternalError<postgres error text>:persona_id is not a valid UUID, or the status UPDATE fails
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "persona.persona",
  "field": "status",
  "flow": [
    "active",
    "inactive"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "inactive",
      "via": "POST /api/personas/:persona_id/deactivate"
    }
  ]
}
Deactivate persona → expects HTTP 200
Path params
{
  "persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas",
  "POST /api/role-templates"
]
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\personas\persona-id-role-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenGateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent
401UnauthorizedInvalid or expired tokenauthGate ran requireAuth and the JWT failed verification or had expired
400ValidationErrorrole requiredbody.role is absent or empty
400ValidationErrorrole 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)
500InternalError<postgres error text>:persona_id is not a valid UUID, or role references a role_template_id that does not exist (FK violation)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Set persona role label → expects HTTP 200
Path params
{
  "persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}
Payload (template)
{
  "role": "{{cache:role-templates.create.response.data.role_template.role_template_id}}"
}
Example request
{
  "role": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas"
]
Implemented in
packages/sdk-persona/src/server/routes.ts
Source spec
tests\api_definitions\personas\persona_id-roles-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
500Internal Server ErrorInternal Server Error:persona_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List persona roles → expects HTTP 200
Path params
{
  "persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "role_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas"
]
Implemented in
packages/sdk-persona/src/server/routes.ts
Source spec
tests\api_definitions\personas\persona_id-shred-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
404NotFoundNotFoundno persona row exists for :persona_id
500Internal Server ErrorInternal Server Error:persona_id is not a valid UUID — the route has no try/catch, so the rejection surfaces as Fastify's default 500
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Shred persona independent of person → expects HTTP 200
Path params
{
  "persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\persons\person_id-app-identities-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in "Bearer <token>" form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler)
500InternalServerErrorInternal Server ErrorlistAppIdentitiesForPerson 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List app identities → expects HTTP 200
Path params
{
  "person_id": "{{cache:auth.register.response.data.userId}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "app_identity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-personaW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\persons\person_id-devices-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in "Bearer <token>" form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past) (requireAuth preHandler)
500InternalServerErrorInternal Server ErrorlistDevicesForPerson 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List devices → expects HTTP 200
Path params
{
  "person_id": "{{cache:auth.register.response.data.userId}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "device_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-persona
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-persona/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/role-templates/role_template_id-holders-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param is absent — the read is deliberately refused rather than run unscoped across tenants
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
500Internal Server ErrorInternal 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List role holders for a tenant → expects HTTP 200
Path params
{
  "role_template_id": "{{var:role_template_id}}"
}
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.

sdk-policy

5 API(s)
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.

SDK / service
sdk-policy
Depends on
[
  "POST /api/auth/register"
]
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: draft, active, deprecated, retired
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/policies/index-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorThis credential carries no tenant contextThe JWT carries no tenant_id - for example a person-level token minted by a login that named no tenant
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
500InternalErrorInternalErrorlistPoliciesForScope throws - any database error while reading policy.policy
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the tenant-wide policies for the calling tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-policyW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant"
]
Request field options
obligations.audit_level: none, standard, detailed, forensic
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\policies\create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrorname 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 numbervalidateCreatePolicy fails; details[] carries every failed rule
400IQLParseError<parser error message>createPolicy throws a parse error whose message starts with "Unknown IQL" or "Unexpected", or otherwise contains "IQL"
409Conflictpolicy with this name+version already existsthe INSERT trips a duplicate-key constraint on name+version
500InternalErrorInternalErrorcreatePolicy throws any other error - a DB failure or unmapped service error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a doctor-care-team policy bundle → expects HTTP 201
Path params
Payload (template)
{
  "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
  }
}
Example request
{
  "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
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-policyW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/policies"
]
Source spec
tests\api_definitions\policies\id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
404NotFoundNo policy <policy_id>getPolicy returns no row for the policy_id
500InternalErrorInternalErrorgetPolicy throws - a non-UUID policy_id failing the uuid cast, or any DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch the freshly-created policy → expects HTTP 200
Path params
{
  "policy_id": "{{cache:policies.create.response.data.policy.policy_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-policyW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/policies"
]
Request field options
resource_class: sensitive, low_risk
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\policies\evaluate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrorpolicy_id is required / subject_id is required / purpose is required when purpose_bound is truevalidateEvaluatePolicy fails; details[] carries every failed rule
404NotFound<service message containing "not found">evaluatePolicy throws for an unresolvable policy or a referenced entity it cannot find
500InternalErrorInternalErrorevaluatePolicy throws any other error - IQL evaluation failure, a non-UUID id failing the uuid cast, or any DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Evaluate the doctor-care-team policy for a subject → expects HTTP 200
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-policy
Depends on
[
  "POST /api/auth/register",
  "POST /api/policies"
]
Implemented in
packages/sdk-policy/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
resource_class: sensitive, low_risk
decision: ALLOW, DENY
error_code: VALIDATION_ERROR, POLICY_NOT_FOUND
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/policies/evaluate-bulk-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
400ValidationErrorbody must be an object with an items[] arrayRequest body is absent, not a JSON object, or is itself an array
400ValidationErroritems must not be emptyitems is an empty array
400ValidationErroritems exceeds the per-request maximum of 1000; page the batchMore than 1000 items are supplied
200POLICY_NOT_FOUNDPolicy <policy_id> not foundAn 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.
200VALIDATION_ERRORpolicy_id must be a uuid | subject_id must be a uuid | <field> is requiredAn 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
500InternalErrorInternalErrorevaluatePolicyBulk throws outside a single item (database unavailable, or the batched decision insert fails)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Evaluate one policy for the same subject twice - one bare, one with full context → expects HTTP 200
Path params
Payload (template)
{
  "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}}"
    }
  ]
}
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "data": {
    "results": "array",
    "summary": {
      "requested": "number",
      "succeeded": "number",
      "failed": "number"
    }
  }
}

sdk-pool-router

1 API(s)
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.

SDK / service
sdk-pool-routerW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-pool-router/src/server/routes.ts
Source spec
tests\api_definitions\router\resolve-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — authGate.ts default-deny gate rejects /api/router/resolve before the handler
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or expired)
400ValidationErrortenant_id and app_id are requiredEither query param is absent, empty, or whitespace-only after trim
404NotFoundNo active pool mapping for this tenant and appresolveTenantPool 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)
500InternalErrorInternalErrorresolveTenantPool throws — route-cache backend error, malformed tenant_id rejected by the column type, or the routing schema / DB pool being unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Resolve a signed-up tenant + its default app to the assigned pool → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "resolve_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "pool_index": "string",
    "pool_family": "string",
    "region": "string",
    "primary_endpoint": "string",
    "status": "string"
  }
}

sdk-principal-token

1 API(s)
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.

SDK / service
sdk-principal-tokenW2 · test wave
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\principal-token\mint-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
401UnauthorizedUnauthorizedrequireAuth passed but req.auth.sub is absent, so there is no subject to bind the principal token to
400ValidationErroraudience is requiredBody is missing audience, or audience is an empty/falsy value
500InternalError<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Mint a principal token for the authenticated subject bound to an audience → expects HTTP 201
Path params
Payload (template)
{
  "audience": "sdk-crm",
  "ttl_seconds": 300,
  "purpose": "delegated-read"
}
Example request
{
  "audience": "sdk-crm",
  "ttl_seconds": 300,
  "purpose": "delegated-read"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "token": "string",
    "audience": "string",
    "sub": "string"
  }
}

sdk-profile

6 API(s)
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.

SDK / service
sdk-profileW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/app-identities"
]
Request field options
band_kind: profile, preference, notification_routing
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\profile\bands-put.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrormissing required fieldsapp_identity_id, band_kind or tenant_id is absent or empty
400ValidationErrorinvalid band_kindband_kind is not one of profile, preference, notification_routing
500Internal Server Errorinsert or update on table "band_l2" violates foreign key constraintapp_identity_id or tenant_id references a row that does not exist - the upsert has no pre-check, so the FK violation escapes unhandled
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Upsert profile band → expects HTTP 200
Path params
Payload (template)
{
  "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=="
  }
}
Example request
{
  "app_identity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "band_kind": "profile",
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "fields_envelope": {
    "display_name": "base64envelope=="
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-profileW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/app-identities",
  "PUT /api/profile/bands"
]
Request field options
band_kind: profile, preference, notification_routing
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\profile\bands-app_identity_id-band_kind-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrorinvalid band_kindband_kind path segment is not one of profile, preference, notification_routing
404NotFoundNotFoundNo profile.band_l2 row exists for the (app_identity_id, band_kind) pair
500Internal Server Errorinvalid input syntax for type uuidapp_identity_id is not a valid UUID - no format guard, so Postgres 22P02 escapes as an unhandled 500
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read profile band for app_identity → expects HTTP 200
Path params
{
  "app_identity_id": "{{cache:app-identities.create.response.data.app_identity.app_identity_id}}",
  "band_kind": "profile"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "band_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-profileW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/profile/secure-data/set-field"
]
Source spec
tests\api_definitions\profile\secure-data-person_id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
404NotFoundNotFoundNo profile.secure_data row exists for the person_id (no field has ever been set)
500Internal Server Errorinvalid input syntax for type uuidperson_id is not a valid UUID - no format guard, so Postgres 22P02 escapes as an unhandled 500
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read secure data → expects HTTP 200
Path params
{
  "person_id": "{{cache:auth.register.response.data.userId}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "secure_data_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-profileW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/profile/secure-data/set-field",
  "POST /api/profile/secure-data/shred-field"
]
Source spec
tests\api_definitions\profile\secure-data-person_id-shred-history-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
500Internal Server Errorinvalid input syntax for type uuidperson_id is not a valid UUID - no format guard, so Postgres 22P02 escapes as an unhandled 500
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List shred history → expects HTTP 200
Path params
{
  "person_id": "{{cache:auth.register.response.data.userId}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "shred_history_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-profileW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\profile\secure-data-set-field-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrormissing required fieldsperson_id, field_name or envelope is absent or an empty string
500Internal Server Errorinsert or update on table "secure_data" violates foreign key constraintperson_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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Set PAN field envelope → expects HTTP 200
Path params
Payload (template)
{
  "person_id": "{{cache:auth.register.response.data.userId}}",
  "field_name": "pan",
  "envelope": "cGFuLXNlY3JldA=="
}
Example request
{
  "person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "field_name": "pan",
  "envelope": "cGFuLXNlY3JldA=="
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-profileW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/profile/secure-data/set-field"
]
Request field options
reason: retention-expiry, dsar-erasure, operator-request
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\profile\secure-data-shred-field-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrormissing required fieldsperson_id, field_name or reason is absent or an empty string
400ValidationErrorinvalid reasonreason is not one of retention-expiry, dsar-erasure, operator-request
500Internal Server Errorinvalid input syntax for type uuidperson_id is not a valid UUID - Postgres 22P02 on the UPDATE escapes as an unhandled 500
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Shred PAN field → expects HTTP 200
Path params
Payload (template)
{
  "person_id": "{{cache:auth.register.response.data.userId}}",
  "field_name": "pan",
  "reason": "dsar-erasure",
  "audit_entry_id": "{{var:audit_entry_id}}"
}
Example request
{
  "person_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "field_name": "pan",
  "reason": "dsar-erasure",
  "audit_entry_id": "{{var:audit_entry_id}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-projection

4 API(s)
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.

SDK / service
sdk-projectionW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-projection/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/projection/replay-post.json
Error responses
HTTPCodeMessageWhen it happens
404ASSERTION_NOT_FOUNDNotFoundretract_assertion_id names no assertion for this tenant
400VALIDATION_ERRORsubject_ref, retract_assertion_id or supersede_assertion_id is required for scope=subjectscope defaults to subject but no subject_ref or assertion id is supplied
400VALIDATION_ERRORsuperseded_by is required when supersede_assertion_id is givena supersede is requested without naming what supersedes it, which would record a dangling link
400VALIDATION_ERRORtrigger must be one of: manual, retraction, supersede, rule_change, backfilltrigger is not one of the five recorded causes
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token — replay rewrites a tenant's projection cache
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Replay a single subject → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "subject_ref": "lead:regression-subject",
  "trigger": "manual",
  "reason": "api regression"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "subject_ref": "lead:regression-subject",
  "trigger": "manual",
  "reason": "api regression"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-projectionW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "PUT /api/projection/survivorship-rules"
]
Implemented in
packages/sdk-projection/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/projection/subject-explained-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query parameter is absent, so the read cannot be tenant-scoped
400VALIDATION_ERRORsubject_ref is requiredthe decoded path segment is empty or whitespace-only
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token — this reads a tenant's subject data
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Explained projection for a subject → expects HTTP 200
Path params
{
  "subject_ref": "lead%3Aregression-subject"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "explained_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-projectionW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "PUT /api/projection/survivorship-rules"
]
Implemented in
packages/sdk-projection/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/projection/survivorship-rules-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query parameter is absent
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token — GET is gated exactly as PUT
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List rule sets for the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "survivorship_rule_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-projectionW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-projection/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/projection/survivorship-rules-put.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORcriteria[0].criterion must be one of: verification_state, origin_class, confidence, recencycriteria contains a criterion name the comparator does not implement
400VALIDATION_ERRORcriteria[1] repeats 'confidence' — the later one can never be reachedthe same criterion appears twice; the second is dead configuration the author would wrongly believe applies
400VALIDATION_ERRORcriteria[0] ('origin_class') requires a non-empty 'order' array, best firstverification_state or origin_class is given without an order array, leaving its precedence undefined
400VALIDATION_ERRORcriteria must contain at least one criterioncriteria is an empty array, which would make every contest a bare tie-break
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token — this writes the tenant's precedence policy
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Set a catch-all precedence for the tenant → expects HTTP 200
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-rebac

9 API(s)
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas"
]
Implemented in
packages/sdk-rebac/src/server/routes.ts
Source spec
tests\api_definitions\relationships\create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrordetails[] lists failing fields: kind/persona_a/persona_b required, persona_a and persona_b must differ, or body must be an objectbody not an object, a required field empty/missing, or persona_a === persona_b
400ValidationErrorraw DB check-constraint messagecreateRelationship() throws an error whose message includes "check constraint"
500InternalErrorInternalErrorcreateRelationship() throws any other error or an error escapes the controller
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a care-team relationship → expects HTTP 201
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-rebac
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/relationships"
]
Implemented in
packages/sdk-rebac/src/server/handlers/rebacController.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
trust_state: CONFIRMED, CANDIDATE, DOCUMENTED
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/relationships/relationship_id-attest-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it
400VALIDATION_ERRORtrust_state must be one of CONFIRMED, CANDIDATE, DOCUMENTEDtrust_state is absent or outside the enum - rejected in the controller before attestContextualRole is called
400EVIDENCE_REQUIRED[sdk-rebac] trust_state '<state>' requires at least one evidence_reftrust_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
404RELATIONSHIP_NOT_FOUNDNotFoundNo relationship matches relationship_id
500InternalErrorInternalErrorattestContextualRole throws anything other than the evidence error - a non-UUID relationship_id that fails the Postgres uuid cast, or any database error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Attest the relationship as CONFIRMED with an evidence ref → expects HTTP 200
Path params
{
  "relationship_id": "{{cache:relationships.create.response.data.relationship.relationship_id}}"
}
Payload (template)
{
  "trust_state": "CONFIRMED",
  "evidence_refs": [
    "evidence://qa/attestation-{{dynamic:slug}}"
  ]
}
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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).

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas",
  "POST /api/relationships"
]
Implemented in
packages/sdk-rebac/src/server/routes.ts
Request field options
status: open, active, suspended, terminated, expired
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\relationships\id-scope-put.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrorbody must be an objectRequest body is missing, null, or not a JSON object
400ValidationErrorat least one of scope or status is requiredBody is an object but carries neither a scope object nor a status string
400ValidationErrorstatus must be one of open, active, suspended, terminated, expiredstatus is a string outside the allowed RelationshipStatus set
404NotFoundRelationship <relationship_id> not foundNo rebac.relationship row matches relationship_id, so the UPDATE (or the no-op SELECT) returns zero rows
500InternalErrorInternalErrorAny other failure in updateRelationshipScope — non-UUID relationship_id rejected by Postgres, jsonb cast failure, or cache-invalidation/DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Suspend the relationship and narrow scope → expects HTTP 200
Path params
{
  "relationship_id": "{{cache:relationships.create.response.data.relationship.relationship_id}}"
}
Payload (template)
{
  "status": "suspended",
  "scope": {
    "encounter_kind": "primary-care"
  }
}
Example request
{
  "status": "suspended",
  "scope": {
    "encounter_kind": "primary-care"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas"
]
Implemented in
packages/sdk-rebac/src/server/routes.ts
Source spec
tests\api_definitions\relationships\check-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrordetails[] 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 objectbody not an object, a required field empty/missing, or a partial/non-numeric budget
500InternalErrorInternalErrorcheckRelationship() service throws or an error escapes the controller
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Check care-team reachability with default budget → expects HTTP 200
Path params
Payload (template)
{
  "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
  }
}
Example request
{
  "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
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/relationships/roles"
]
Implemented in
packages/sdk-rebac/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/rebac/relationship-roles-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORpersona_a query param requiredpersona_a is absent, which would make the query span every persona
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token — GET is gated exactly as POST
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Live roles for a pair → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "role_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-rebac/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/rebac/relationship-roles-post.json
Error responses
HTTPCodeMessageWhen it happens
400EVIDENCE_REQUIRED[sdk-rebac] trust_state 'CONFIRMED' requires at least one evidence_reftrust_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
400VALIDATION_ERRORtrust_state must be one of CONFIRMED, CANDIDATE, DOCUMENTEDtrust_state is outside the three recorded states
400VALIDATION_ERRORpersona_b is requiredkind, persona_a or persona_b is absent
409ROLE_ALREADY_LIVEa live role with this kind and label already exists for the pairan identical (pair, kind, role_label) role is already open — a duplicate, not a second role; close the first or use a different label
401Unauthorizedmissing or invalid tenant tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Grant a DOCUMENTED carer role with evidence → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas"
]
Implemented in
packages/sdk-persona/src/server/routes.ts
Source spec
tests\api_definitions\role-assignments\create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrormissing fieldspersona_id or role_template_id is absent, empty, or otherwise falsy in the body
500Internal Server ErrorFastify default error payload from the uncaught service throwassignRole 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Assign role to persona → expects HTTP 201
Path params
Payload (template)
{
  "persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
  "role_template_id": "{{var:role_template_id}}",
  "assigned_by": "sdk-persona.qa-assign"
}
Example request
{
  "persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "role_template_id": "{{var:role_template_id}}",
  "assigned_by": "sdk-persona.qa-assign"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /api/app-identities",
  "POST /api/memberships",
  "POST /api/personas",
  "POST /api/role-assignments"
]
Implemented in
packages/sdk-persona/src/server/routes.ts
Source spec
tests\api_definitions\role-assignments\assignment_id-revoke-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — the gateway default-deny authGate runs requireAuth on every non-allowlisted path
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
404NotFoundNotFoundNo persona.role_assignment row with that assignment_id and revoked_at IS NULL — unknown id, or the assignment was already revoked
500Internal Server ErrorFastify default error payload from the uncaught service throwrevokeRoleAssignment throws — assignment_id is not a valid UUID, or the UPDATE / audit emit fails
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Revoke role assignment → expects HTTP 200
Path params
{
  "assignment_id": "{{cache:role-assignments.create.response.data.assignment.assignment_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "revoke_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Source spec
tests\api_definitions\role-templates\create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo 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.
401UnauthorizedInvalid or expired tokenverifyJwt() rejects the bearer token — bad signature, wrong secret, or expired exp.
400ValidationErrorbody must be an objectRequest body is null, absent, or not a JSON object.
400ValidationErrorapp_id is requiredapp_id is missing, not a string, or empty.
400ValidationErrorname is requiredname is missing, not a string, or empty. Both messages appear together in details[] when both are absent.
400ValidationErrorinsert or update on table "role_template" violates foreign key constraintapp_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.
409Conflictduplicate 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.
500InternalErrorInternalErrorAny 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.
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a tenant-scoped doctor role template → expects HTTP 201
Path params
Payload (template)
{
  "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
  }
}
Example request
{
  "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
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-resource-registry

3 API(s)
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.

SDK / service
sdk-resource-registryW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/resources"
]
Request field options
status: registered, quarantined
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\resources\index-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
500InternalErrorInternalErrorlistResources throws - NaN limit/offset from a non-numeric query value, or any DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List ownership records (registered) after creating one → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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[].

SDK / service
sdk-resource-registryW2 · test wave
Depends on
[
  "POST /api/auth/register"
]
Request field options
environment: dev, staging, prod
status: registered, quarantined
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\resources\create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrorresource_id, resource_type, environment, owner, approved_by are requiredany of resource_id, resource_type, environment, owner or approved_by is absent or empty
500InternalErrorInternalErrorregisterResource throws - a constraint violation or any DB error; the underlying message is echoed in details[]
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register an owned resource with owner + approver → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-resource-registryW2 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/resources"
]
Source spec
tests\api_definitions\resources\id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
404NotFoundNo registry row for <resource_id>getOwnership returns no record for the resource_id
500InternalErrorInternalErrorgetOwnership throws - any DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read ownership for a registered resource → expects HTTP 200
Path params
{
  "resource_id": "{{cache:resources.create.response.data.resource.resource_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "resource": {
      "resource_id": "string",
      "owner": "string"
    }
  }
}

sdk-scheduling

31 API(s)
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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/appointments"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Request field options
status: pending, confirmed, cancelled, completed, no_show
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\scheduling\appointments-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List appointments for a host → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "appointment_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/meeting-types"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Request field options
status: pending, confirmed, cancelled, completed, no_show
location_type: video, phone, in_person, custom
source: internal, public_link, sequence, import, provider_sync
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\scheduling\appointments-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, host_persona_id, title, start_time and end_time are requiredany required field missing from body
400ValidationErrorend_time must be after start_timeend_time <= start_time
409DoubleBookingthe host already has an appointment overlapping this windowthe requested window overlaps an existing non-cancelled appointment for the same host
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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)"
    }
  ]
}
Book a 30-minute confirmed appointment → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/scheduling/appointments"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\appointment_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
404NotFoundappointment not foundno appointment with that id for the tenant
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get a booked appointment by id → expects HTTP 200
Path params
{
  "appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "appointment_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/appointments",
  "POST /api/scheduling/calendar-connections"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\appointments-appointment_id-calendar-push-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and connection_id are requiredrequired field missing
404NotFoundconnection/appointment not foundno connection or appointment for tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Push appointment to Google → expects HTTP 200
Path params
{
  "appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "connection_id": "{{cache:scheduling.calendar-connection.create.response.data.connection.connection_id}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "connection_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/appointments"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\appointments-appointment_id-cancel-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing
404NotFoundappointment not foundno appointment for tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "scheduling.appointment",
  "field": "status",
  "flow": [
    "confirmed",
    "cancelled"
  ],
  "transitions": [
    {
      "from": "confirmed",
      "to": "cancelled",
      "via": "POST /api/scheduling/appointments/:appointment_id/cancel"
    }
  ]
}
Cancel the appointment → expects HTTP 200
Path params
{
  "appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "reason": "Customer requested cancellation"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "reason": "Customer requested cancellation"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/appointments"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\appointments-appointment_id-confirm-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing
404NotFoundappointment not foundno appointment for tenant
409InvalidTransitioncannot confirm an appointment in status 'cancelled'appointment already cancelled/completed
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "scheduling.appointment",
  "field": "status",
  "flow": [
    "confirmed"
  ],
  "transitions": [
    {
      "from": "pending",
      "to": "confirmed",
      "via": "POST /api/scheduling/appointments/:appointment_id/confirm"
    }
  ]
}
Confirm the appointment → expects HTTP 200
Path params
{
  "appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/appointments"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\appointments-appointment_id-events-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List booking events → expects HTTP 200
Path params
{
  "appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "event_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/appointments"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\appointments-appointment_id-ics-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
404NotFoundappointment not foundno appointment for tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Download the ICS invite → expects HTTP 200
Path params
{
  "appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "ics_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/appointments"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\appointments-appointment_id-rebook-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, start_time and end_time are requiredrequired field missing
400ValidationErrorend_time must be after start_timeend<=start
409DoubleBookingthe host already has an appointment overlapping this windownew window overlaps another appointment
404NotFoundappointment not foundno appointment for tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Rebook 3h later → expects HTTP 201
Path params
{
  "appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "start_time": "{{dynamic:futuredatetime+180m}}",
  "end_time": "{{dynamic:futuredatetime+210m}}",
  "timezone": "America/New_York"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/appointments"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\appointments-appointment_id-reminders-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List reminders → expects HTTP 200
Path params
{
  "appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "reminder_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/appointments"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\appointments-appointment_id-reminders-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing
404NotFoundappointment not foundno appointment for tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Schedule 24h/2h/15m reminders → expects HTTP 201
Path params
{
  "appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "offsets_minutes": [
    1440,
    120,
    15
  ]
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "offsets_minutes": [
    1440,
    120,
    15
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/appointments"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\appointments-appointment_id-reschedule-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, start_time and end_time are requiredrequired field missing
400ValidationErrorend_time must be after start_timeend<=start
404NotFoundappointment not foundno appointment
409DoubleBookingthe host already has an appointment overlapping this windownew window overlaps another appointment
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Reschedule 2h later → expects HTTP 200
Path params
{
  "appointment_id": "{{cache:scheduling.appointment.create.response.data.appointment.appointment_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "start_time": "{{dynamic:futuredatetime+120m}}",
  "end_time": "{{dynamic:futuredatetime+150m}}",
  "timezone": "America/New_York"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/availability-rules"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\availability-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, host_persona_id and date query params are requiredany of tenant_id / host_persona_id / date missing
400ValidationErrordate must be an ISO date (YYYY-MM-DD)date not in YYYY-MM-DD format
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get availability for a host on a date for a meeting type → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "availability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-scheduling
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/availability-rules"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
weekday: 0, 1, 2, 3, 4, 5, 6
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/scheduling/availability-rules-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it
400ValidationErrortenant_id and host_persona_id query params requiredEither query parameter is absent - both are required together and produce this same single message
500InternalErrorFastify default error payload from the uncaught service throwlistAvailabilityRules throws - a non-UUID tenant_id or host_persona_id that fails the Postgres uuid cast, or any database error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the availability rules for the host just configured → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Request field options
weekday: 0, 1, 2, 3, 4, 5, 6
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\scheduling\availability-rules-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, host_persona_id and weekday are requiredtenant_id, host_persona_id or weekday missing from body
400ValidationErrorweekday must be 0 (Sunday) through 6 (Saturday)weekday outside 0-6
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Set Monday 09:00-17:00 America/New_York availability → expects HTTP 201
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/scheduling/calendar-connections"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\calendar-connections-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List connections → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "calendar_connection_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Request field options
provider: google, microsoft, caldav
direction: inbound, outbound, both
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\scheduling\calendar-connections-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, host_persona_id and provider are requiredrequired field missing
400ValidationErrorprovider must be google, microsoft or caldavinvalid provider
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Connect a Google calendar → expects HTTP 201
Path params
Payload (template)
{
  "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": {}
}
Example request
{
  "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": {}
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/scheduling/calendar-connections"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\calendar-connections-connection_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
404NotFoundconnection not foundno connection for tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get a connection by id → expects HTTP 200
Path params
{
  "connection_id": "{{cache:scheduling.calendar-connection.create.response.data.connection.connection_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "calendar_connection_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/scheduling/calendar-connections"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\calendar-connections-connection_id-sync-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing
404NotFoundconnection not foundno connection for tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Sync the connection → expects HTTP 200
Path params
{
  "connection_id": "{{cache:scheduling.calendar-connection.create.response.data.connection.connection_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "sync_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/scheduling/meeting-types"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\meeting-types-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List meeting types for the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "meeting_type_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Request field options
location_type: video, phone, in_person, custom
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\scheduling\meeting-types-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, name and slug are requiredtenant_id, name or slug missing from body
409Conflicta meeting type with this slug already exists for the tenantslug already used by another meeting type in the tenant (UNIQUE tenant_id, slug)
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a 30-minute discovery call meeting type → expects HTTP 201
Path params
Payload (template)
{
  "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": {}
}
Example request
{
  "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": {}
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\no-show-scan-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Run a no-show scan → expects HTTP 200
Path params
Payload (template)
{
  "grace_minutes": 10,
  "batch_size": 100
}
Example request
{
  "grace_minutes": 10,
  "batch_size": 100
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "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"
]
Implemented in
packages/sdk-scheduling/src/server/publicRoutes.ts
Request field options
status: pending, confirmed, cancelled, completed, no_show
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\scheduling\public-appointments-public_token-cancel-post.json
Error responses
HTTPCodeMessageWhen it happens
404NotFoundNotFoundthe token is unknown or wrong — including when a raw appointment_id is passed instead of the token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
TransitionTriggered by
pending|confirmed -> cancelledPOST /api/scheduling/public/appointments/:public_token/cancel (stamps cancelled_at + cancel_reason; frees the slot)
Invitee cancels their booking with their token → expects HTTP 200
Path params
{
  "public_token": "{{cache:scheduling.public.book.response.data.public_token}}"
}
Payload (template)
{
  "reason": "Something came up - will rebook next week"
}
Example request
{
  "reason": "Something came up - will rebook next week"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/scheduling-links",
  "POST /api/scheduling/public/links/:slug/book"
]
Implemented in
packages/sdk-scheduling/src/server/publicRoutes.ts
Request field options
status: pending, confirmed, cancelled, completed, no_show
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\scheduling\public-appointments-public_token-confirm-post.json
Error responses
HTTPCodeMessageWhen it happens
404NotFoundNotFoundthe token is unknown or wrong — including when a raw appointment_id is passed instead of the token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
TransitionTriggered by
pending -> confirmedPOST /api/scheduling/public/appointments/:public_token/confirm (stamps confirmed_at, emits the confirmed booking_event)
pending|confirmed -> cancelledPOST /api/scheduling/public/appointments/:public_token/cancel
Invitee confirms the pending booking with their token → expects HTTP 200
Path params
{
  "public_token": "{{cache:scheduling.public.book.response.data.public_token}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/scheduling-links"
]
Implemented in
packages/sdk-scheduling/src/server/publicRoutes.ts
Source spec
tests\api_definitions\scheduling\public-links-slug-get.json
Error responses
HTTPCodeMessageWhen it happens
404NotFoundNotFoundslug unknown, link deactivated, or link expired — all three are indistinguishable by design
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Anonymous visitor loads the booking page for a live link → expects HTTP 200
Path params
{
  "slug": "{{cache:scheduling.scheduling-link.create.response.data.link.slug}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "link_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "link not found"
}
e.g. HTTP 404
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/availability-rules",
  "POST /api/scheduling/scheduling-links"
]
Implemented in
packages/sdk-scheduling/src/server/publicRoutes.ts
Source spec
tests\api_definitions\scheduling\public-links-slug-availability-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrordate query param required (YYYY-MM-DD)the date query param is absent
400BookingWindowErrorstart_time is beyond the link's <n>-day booking windowthe requested date exceeds the link's max_days_ahead
400BookingWindowErrordate is in the pastthe requested date is more than a day in the past
404NotFoundNotFoundslug unknown, link deactivated, or link expired
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Anonymous visitor lists open slots for a date inside the window → expects HTTP 200
Path params
{
  "slug": "{{cache:scheduling.scheduling-link.create.response.data.link.slug}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "availability_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "availability not found"
}
e.g. HTTP 404
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/scheduling/meeting-types",
  "POST /api/scheduling/scheduling-links"
]
Implemented in
packages/sdk-scheduling/src/server/publicRoutes.ts
Request field options
status: pending, confirmed, cancelled, completed, no_show
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\scheduling\public-links-slug-book-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorstart_time, invitee_name and invitee_email are requiredany of the three required fields is missing
400BookingWindowErrorstart_time is beyond the link's <n>-day booking windowstart_time exceeds the link's max_days_ahead
400BookingWindowErrorstart_time is inside the link's <n>-minute minimum noticestart_time is sooner than the link's min_notice_minutes
404NotFoundNotFoundthe slug is unknown, the link is deactivated, or it has expired — all indistinguishable by design
409DoubleBookingthat slot was just takenthe host was booked for that window between page render and submit
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
TransitionTriggered by
(none) -> pendingPOST /api/scheduling/public/links/:slug/book (anonymous booking, email unverified)
pending -> confirmedPOST /api/scheduling/public/appointments/:public_token/confirm (double opt-in; stamps confirmed_at)
pending|confirmed -> cancelledPOST /api/scheduling/public/appointments/:public_token/cancel
Prospect books a slot through the public link → expects HTTP 201
Path params
{
  "slug": "{{cache:scheduling.scheduling-link.create.response.data.link.slug}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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}.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\reminders-tick-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Run a reminder tick → expects HTTP 200
Path params
Payload (template)
{
  "batch_size": 50
}
Example request
{
  "batch_size": 50
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/scheduling/scheduling-links"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\scheduling-links-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List scheduling links → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "scheduling_link_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/scheduling/meeting-types"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\scheduling-links-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, host_persona_id and slug are requiredrequired field missing
409Conflicta scheduling link with this slug already existsslug already used (UNIQUE)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a booking link → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-schedulingW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/scheduling/scheduling-links"
]
Implemented in
packages/sdk-scheduling/src/server/routes.ts
Source spec
tests\api_definitions\scheduling\scheduling-links-link_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id missing
404NotFoundlink not foundno link for tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get a booking link by id → expects HTTP 200
Path params
{
  "link_id": "{{cache:scheduling.scheduling-link.create.response.data.link.link_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "scheduling_link_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "link": {
      "link_id": "string"
    }
  }
}
6 API(s)
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.

SDK / service
sdk-searchW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/search/index"
]
Implemented in
packages/sdk-search/src/server/routes.ts
Source spec
tests\api_definitions\search\search-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenJWT missing tenant_id claimToken verifies but carries no tenant_id claim; authTenant() rejects before any query runs
400ValidationErrortenant_id must be a UUIDThe tenant_id claim injected from the JWT is not a well-formed UUID
400ValidationErrorentity_kind is requiredentity_kind query param is missing or blank
404IndexNotFoundNo search index registered for the requested entity_kindexecuteQuery throws IndexNotFoundError because no index definition exists for tenant_id + entity_kind
500InternalErrorInternalErrorOpenSearch is unreachable or the query throws a non-IndexNotFoundError
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Query encounters with free text → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "search_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-searchW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/search/index"
]
Implemented in
packages/sdk-search/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\search\index-post-1.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenJWT missing tenant_id claimToken verifies but carries no tenant_id claim; authTenant() rejects before any query runs
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationErrortenant_id must be a UUIDThe tenant_id claim injected from the JWT is not a well-formed UUID
400ValidationErrorentity_kind is requiredentity_kind is missing or blank in the body
404IndexNotFoundNo search index registered for the requested entity_kindexecuteQuery throws IndexNotFoundError for tenant_id + entity_kind
500InternalErrorInternalErrorOpenSearch is unreachable or query execution throws an unexpected error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Query encounter entities → expects HTTP 200
Path params
Payload (template)
{
  "entity_kind": "encounter",
  "q": "open",
  "dsl": {
    "query": {
      "term": {
        "status": "open"
      }
    }
  },
  "size": 10,
  "from": 0
}
Example request
{
  "entity_kind": "encounter",
  "q": "open",
  "dsl": {
    "query": {
      "term": {
        "status": "open"
      }
    }
  },
  "size": 10,
  "from": 0
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-search
Implemented in
packages/sdk-search/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/search/health-get.json
Report search backend availability → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {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.

SDK / service
sdk-searchW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-search/src/server/routes.ts
Source spec
tests\api_definitions\search\index-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenJWT missing tenant_id claimToken verifies but carries no tenant_id claim; authTenant() rejects before any query runs
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationErrortenant_id must be a UUIDThe tenant_id claim injected from the JWT is not a well-formed UUID
400ValidationErrorentity_kind is requiredentity_kind is missing or blank in the body
404IndexNotFoundReferenced index could not be resolvedensureIndex throws IndexNotFoundError while resolving the target alias
500InternalErrorInternalErrorOpenSearch index creation fails or the persistence write throws
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Register encounter entity index → expects HTTP 201
Path params
Payload (template)
{
  "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"
      }
    }
  }
}
Example request
{
  "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"
      }
    }
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-searchW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/search/saved-queries"
]
Implemented in
packages/sdk-search/src/server/routes.ts
Source spec
tests\api_definitions\search\saved-queries-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenJWT missing tenant_id claimToken verifies but carries no tenant_id claim; authTenant() rejects before any query runs
400ValidationErrorpersona_id requiredpersona_id query param is missing or empty
404IndexNotFoundReferenced index could not be resolvedlistSavedQueries throws IndexNotFoundError
500InternalErrorInternalErrorThe datastore read fails or throws a non-IndexNotFoundError
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List my saved queries → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "saved_query_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-searchW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-search/src/server/routes.ts
Source spec
tests\api_definitions\search\saved-queries-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenJWT missing tenant_id claimToken verifies but carries no tenant_id claim; authTenant() rejects before any query runs
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationErrortenant_id must be a UUIDThe tenant_id claim injected from the JWT is not a well-formed UUID
400ValidationErrorpersona_id must be a UUIDpersona_id is missing or not a well-formed UUID
400ValidationErrorname is requiredname is missing or blank after trimming
400ValidationErrordsl object is requireddsl is missing or is not a JSON object
404IndexNotFoundReferenced index could not be resolvedcreateSavedQuery throws IndexNotFoundError
500InternalErrorInternalErrorThe saved-query insert fails (e.g. persona_id violates a foreign key) or the datastore is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Save my-open-encounters query → expects HTTP 201
Path params
Payload (template)
{
  "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
  }
}
Example request
{
  "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
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "saved_query": {
      "query_id": "string",
      "tenant_id": "string",
      "persona_id": "string",
      "name": "string",
      "dsl": "object",
      "created_at": "string"
    }
  }
}

sdk-secrets

5 API(s)
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.

SDK / service
sdk-secrets
Depends on
[
  "POST /api/auth/register",
  "POST /api/secrets"
]
Implemented in
packages/sdk-secrets/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/secrets/ref-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrorref query parameter is required, e.g. ?ref=secret://tenant/my-keythe ref query parameter is absent or trims to an empty string
404NotFoundNo SecretRef registered for <ref>retrieveSecret returns no record for that ref
500InternalErrorInternalErrorretrieveSecret throws a DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Retrieve a previously registered SecretRef by ref → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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.

SDK / service
sdk-secretsW1 · test wave
Depends on
[
  "POST /api/auth/register"
]
Request field options
scope: app, pool, tenant
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\secrets\store-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrorref is required / scope is required / scope must be one of <allowed scopes> / kms_key_id is requiredvalidateRegisterInput fails; details[] carries every failed rule
400ValidationErrorInvalid secret reference <ref>storeSecret throws a message starting with "Invalid secret reference" - the ref string does not parse as a valid secret reference
500InternalErrorInternalErrorstoreSecret throws any other error - a duplicate-ref unique-constraint violation, a KMS failure, or any DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register a tenant-scoped SecretRef → expects HTTP 201
Path params
Payload (template)
{
  "ref": "secret://tenant/dev-test-key-001",
  "scope": "tenant",
  "kms_key_id": "mock-key-1"
}
Example request
{
  "ref": "secret://tenant/dev-test-key-001",
  "scope": "tenant",
  "kms_key_id": "mock-key-1"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-secretsW1 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/secrets"
]
Implemented in
packages/sdk-secrets/src/server/routes.ts
Source spec
tests\api_definitions\secrets\ref-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
404NotFoundNo SecretRef registered for <ref>retrieveSecret returns no record for the decoded ref
500InternalErrorInternalErrordecodeURIComponent throws a URIError on a malformed percent-encoded ref, or retrieveSecret throws a DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Retrieve a previously registered SecretRef by ref → expects HTTP 200
Path params
{
  "ref": "secret%3A%2F%2Ftenant%2Fdev-test-key-001"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "secret_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-secretsW1 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/secrets"
]
Source spec
tests\api_definitions\secrets\rotate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
404NotFoundSecret reference not registered: <ref>rotateSecret throws a message starting with "Secret reference not registered" - the decoded ref has no catalog row
500InternalErrorInternalErrordecodeURIComponent throws a URIError on a malformed percent-encoded ref, or rotateSecret throws any other error - KMS rotation failure or a DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Rotate previously registered SecretRef → expects HTTP 200
Path params
{
  "ref": "secret%3A%2F%2Ftenant%2Fdev-test-key-001"
}
Payload (template)
{}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "rotate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-secrets
Depends on
[
  "POST /api/auth/register",
  "POST /api/secrets"
]
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/secrets/rotate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrorref is required in the body, e.g. { "ref": "secret://tenant/my-key" }body.ref is absent or trims to an empty string
404NotFoundSecret reference not registered: <ref>rotateSecret throws a message starting with "Secret reference not registered" - the ref has no catalog row
500InternalErrorInternalErrorrotateSecret throws any other error - KMS rotation failure or a DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Rotate previously registered SecretRef → expects HTTP 200
Path params
Payload (template)
{
  "ref": "{{cache:secrets.store.response.data.ref}}"
}
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "data": {
    "ref": "object",
    "new_key_version": "string"
  }
}

sdk-sequence

12 API(s)
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).

SDK / service
sdk-sequenceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-sequence/src/server/routes.ts
Request field options
channel: email, sms, call, linkedin, task
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\sequence-templates\index-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and name are requiredtenant_id or name missing
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create an email welcome template → expects HTTP 201
Path params
Payload (template)
{
  "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"
  ]
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "name": "Acme QA Sample",
  "channel": "email",
  "subject": "Welcome aboard",
  "body": "Hi {{name}}, welcome!",
  "category": "custom",
  "variables": [
    "name"
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-sequence
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sequences"
]
Implemented in
packages/sdk-sequence/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/sequences/index-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header - requireAuth preHandler, and the gateway default-deny authGate ahead of it
400ValidationErrortenant_id query param requiredThe tenant_id query parameter is absent - this route reads the tenant from the query, never from the JWT claim
500InternalErrorFastify default error payload from the uncaught service throwlistSequences throws - a non-UUID tenant_id that fails the Postgres uuid cast, or any database error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List every sequence in the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
Runner assertion
{
  "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').

SDK / service
sdk-sequenceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-sequence/src/server/routes.ts
Request field options
sequence_type: lead, customer, onboarding, nurture, custom
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\sequences\index-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and name are requiredtenant_id or name missing from body
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a lead welcome cadence → expects HTTP 201
Path params
Payload (template)
{
  "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": {}
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "name": "Acme QA Sample",
  "description": "Welcome cadence for new leads",
  "sequence_type": "lead",
  "is_default": false,
  "metadata": {}
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-sequenceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sequences"
]
Implemented in
packages/sdk-sequence/src/server/routes.ts
Source spec
tests\api_definitions\sequences\sequence_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
404NotFoundNotFoundsequence_id not found for the tenant
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Get the created sequence → expects HTTP 200
Path params
{
  "sequence_id": "{{cache:sequences.create.response.data.sequence.sequence_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "sequence_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
sdk-sequenceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/sequences",
  "POST /api/sequences/:sequence_id/steps"
]
Implemented in
packages/sdk-sequence/src/server/routes.ts
Request field options
event_type: form_submit, reply, stage_change, manual, booking, tag_added
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\sequences\sequence_id-enroll-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and subject_persona_id are requiredtenant_id or subject_persona_id missing
409EnrollFailedsequence has no steps to enroll intothe sequence has no steps yet
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "sequence.execution_step",
  "field": "status",
  "flow": [
    "pending",
    "scheduled",
    "sending",
    "sent"
  ],
  "transitions": [
    {
      "from": "pending",
      "to": "sent",
      "via": "the step-executor tick loop (TK-3614)"
    }
  ]
}
Enroll a persona into the sequence → expects HTTP 201
Path params
{
  "sequence_id": "{{cache:sequences.create.response.data.sequence.sequence_id}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "event_type": "form_submit"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-sequenceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sequences",
  "POST /api/sequence-templates"
]
Implemented in
packages/sdk-sequence/src/server/routes.ts
Request field options
channel: email, sms, call, linkedin, task, wait
action: send, wait, book, task, branch
schedule_mode: delay, absolute, immediate
send_mode: individual, bulk
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\sequences\sequence_id-steps-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and step_number are requiredtenant_id or step_number missing
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Add step 1 (email, 60s delay) → expects HTTP 201
Path params
{
  "sequence_id": "{{cache:sequences.create.response.data.sequence.sequence_id}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-sequenceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sequences"
]
Implemented in
packages/sdk-sequence/src/server/routes.ts
Request field options
event_type: form_submit, reply, stage_change, manual, booking, tag_added
trigger_on: enter, exit
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\sequences\sequence_id-triggers-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id is requiredtenant_id missing
401Unauthorizedmissing or invalid tokenno valid Bearer token
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Enroll on form submission → expects HTTP 201
Path params
{
  "sequence_id": "{{cache:sequences.create.response.data.sequence.sequence_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "event_type": "form_submit",
  "trigger_on": "enter",
  "condition_json": {},
  "enabled": true
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "event_type": "form_submit",
  "trigger_on": "enter",
  "condition_json": {},
  "enabled": true
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-sequenceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/sequences",
  "POST /api/sequences/:sequence_id/steps",
  "POST /api/sequences/:sequence_id/enroll"
]
Implemented in
packages/sdk-sequence/src/server/routes.ts
Request field options
action: pause, resume, stop, replace_cta
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\sequences\enrollments-enrollment_id-control-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id and action are requiredtenant_id or action missing from body
400ValidationErrorinvalid actionaction is not one of pause|resume|stop|replace_cta
400ValidationErrortemplate_id is required for replace_ctaaction is replace_cta but template_id is absent
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Pause the enrollment on inbound reply → expects HTTP 200
Path params
{
  "enrollment_id": "{{cache:sequences.enroll.response.data.enrollment.enrollment_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "action": "pause",
  "reason": "reply",
  "event": "inbound.reply"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "action": "pause",
  "reason": "reply",
  "event": "inbound.reply"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-sequenceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas"
]
Implemented in
packages/sdk-sequence/src/server/routes.ts
Source spec
tests\api_definitions\sequences\guards-check-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, subject_persona_id and channel are requiredtenant_id, subject_persona_id or channel missing from body
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fresh subject is allowed → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "subject_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}",
  "channel": "email"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "subject_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "channel": "email"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-sequenceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-sequence/src/server/routes.ts
Request field options
decision: allow, block
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\sequences\guards-log-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id query param requiredtenant_id query param missing
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the tenant's guard log (empty is valid) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "log_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-sequenceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-sequence/src/server/routes.ts
Source spec
tests\api_definitions\sequences\guards-outcome-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrortenant_id, channel and success (boolean) are requiredtenant_id, channel or success missing/not-a-boolean
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Record a successful send (breaker stays closed) → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "channel": "email",
  "success": true
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "channel": "email",
  "success": true
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-sequenceW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-sequence/src/server/routes.ts
Source spec
tests\api_definitions\sequences\tick-post.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedmissing or invalid tokenno valid Bearer token is supplied
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Run a tick (nothing due -> zero counts) → expects HTTP 200
Path params
Payload (template)
{
  "batch_size": 50
}
Example request
{
  "batch_size": 50
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "claimed": "number",
    "sent": "number",
    "deferred": "number",
    "waited": "number",
    "failed": "number",
    "skipped": "number",
    "enqueued": "number"
  }
}

sdk-service-request

5 API(s)
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.

SDK / service
sdk-service-requestW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-service-request/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\service-request\queues-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrormissing fieldstenant_id or name is absent or empty in the request body
500InternalErrorInternal Server ErrorcreateQueue throws — e.g. tenant_id violates a foreign key, a unique constraint on the queue name is hit, or the database is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a service-request queue (tenant_id + name required) → expects HTTP 201
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "name": "{{dynamic:name}}",
  "priority": 100
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "name": "Acme QA Sample",
  "priority": 100
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-service-requestW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/encounters",
  "POST /api/service-request/queues"
]
Implemented in
packages/sdk-service-request/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
priority: low, normal, high, urgent
severity: trivial, minor, major, critical
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\service-request\tickets-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrormissing fieldsAny of tenant_id, encounter_id or requester_persona_id is absent or empty
500InternalErrorInternal Server ErrorcreateTicket 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Open a ticket (tenant_id + encounter_id + requester_persona_id required) → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-service-requestW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/service-request/tickets"
]
Implemented in
packages/sdk-service-request/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\service-request\tickets-ticket-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
404NotFoundNotFoundgetTicket returns no row for the supplied ticket_id
500InternalErrorInternal Server ErrorgetTicket throws — e.g. ticket_id is not a valid UUID and the query cast fails, or the database is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch a ticket by id → expects HTTP 200
Path params
{
  "ticket_id": "{{cache:service-request.tickets.response.data.ticket.ticket_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "ticket_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-service-requestW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /api/service-request/tickets"
]
Implemented in
packages/sdk-service-request/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\service-request\tickets-ticket-id-assign-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrormissing assignee_persona_idassignee_persona_id is absent or empty in the request body
404NotFoundNotFoundassignTicket finds no ticket for the supplied ticket_id
500InternalErrorInternal Server ErrorassignTicket throws — e.g. assignee_persona_id violates a foreign key, ticket_id is not a valid UUID, or the database is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Assign an existing ticket to a persona (assignee_persona_id required) → expects HTTP 200
Path params
{
  "ticket_id": "{{cache:service-request.tickets.response.data.ticket.ticket_id}}"
}
Payload (template)
{
  "assignee_persona_id": "{{cache:personas.create.response.data.persona.persona_id}}"
}
Example request
{
  "assignee_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-service-requestW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/service-request/tickets"
]
Implemented in
packages/sdk-service-request/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
to: new, in-progress, awaiting-customer, resolved, closed
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\service-request\tickets-ticket-id-transition-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrorinvalid target status`to` is missing or is not one of new|in-progress|awaiting-customer|resolved|closed
404NotFoundNotFoundtransitionTicket finds no ticket for the supplied ticket_id
409InvalidTransitionInvalid ticket transition <current> → <to>The requested status is not reachable from the ticket's current status per the ticket state machine
500InternalErrorInternal Server ErrorThe database is unreachable or the update throws outside the InvalidTransition path
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Transition a new ticket to in-progress (valid transition from default status 'new') → expects HTTP 200
Path params
{
  "ticket_id": "{{cache:service-request.tickets.response.data.ticket.ticket_id}}"
}
Payload (template)
{
  "to": "in-progress"
}
Example request
{
  "to": "in-progress"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-sla

30 API(s)
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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
severity: info, warning, urgent, critical
state: running, paused, satisfied, breached, cancelled
include_overdue: true, false
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/at-risk-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requireda required field is missing from the request
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Everything due within two hours, overdue included → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "at_risk_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks/:clock_id/satisfy"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
dimensions: source, owner, day, hour, reason, policy
reason_code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recorded
state: running, paused, satisfied, breached, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/attainment-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id, from and to query params are requireda required field is missing from the request
400VALIDATION_ERRORunknown dimension(s) region — allowed: source, owner, day, hour, reason, policya dimension outside the allowed set is requested
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Attainment for the last thirty days across every dimension → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "attainment_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/breach-reasons"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recorded
category: capacity, process, external
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/breach-reasons-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requireda required field is missing from the request
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the taxonomy → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "breach_reason_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recorded
category: capacity, process, external
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/breach-reasons-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id and code are requireda required field is missing from the request
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Give the auto-registered code a label and a category → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "code": "no_capacity",
  "label": "No capacity available",
  "category": "capacity"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "code": "no_capacity",
  "label": "No capacity available",
  "category": "capacity"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "GET /api/sla/at-risk"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
state: running, paused, satisfied, breached, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/breach-scan-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id is requireda required field is missing from the request
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Scan the tenant — the ten-day-old clock is well past due → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "limit": 200,
  "actor_id": "qa-runner"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "limit": 200,
  "actor_id": "qa-runner"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks/:clock_id/breach"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
reason_code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recorded
unrecovered_only: true, false
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/breaches-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requireda required field is missing from the request
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List every recorded miss for this tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "breach_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks/:clock_id/breach"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
reason_code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recorded
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/breaches-breach_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requireda required field is missing from the request
404SLA_BREACH_NOT_FOUNDbreach record <id> not found for tenantthe breach record does not exist for this tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the breach recorded above → expects HTTP 200
Path params
{
  "breach_id": "{{cache:sla.breach.record.response.data.breach.breach_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "breach_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks/:clock_id/breach"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
reason_code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recorded
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/breaches-breach_id-recovery-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id and recovery_action are requireda required field is missing from the request
404SLA_BREACH_NOT_FOUNDbreach record <id> not found for tenantthe breach record does not exist for this tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
The backup answered the next morning → expects HTTP 200
Path params
{
  "breach_id": "{{cache:sla.breach.record.response.data.breach.breach_id}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "recovery_action": "backup owner answered at opening and apologised for the delay",
  "recovered_by": "persona:backup"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/calendars"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
weekend_rule: saturday_sunday, friday_saturday, sunday_only, none
is_active: true, false
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/calendars-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requireda required field is missing from the request
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the active calendars for this tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "calendar_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
weekend_rule: saturday_sunday, friday_saturday, sunday_only, none
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/calendars-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id, slug, name and timezone are requireda required field is missing from the request
400VALIDATION_ERRORworking_windows is required — a calendar with no open minute can never make a due dateworking_windows is absent or empty
422FIXED_OFFSET_TIMEZONE_REJECTEDtimezone '+05:30' is a fixed offset, not a zonethe timezone is an offset or an unresolvable zone name
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
A Monday-to-Friday calendar in a named zone with a late-coverage extension → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/calendars"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
weekend_rule: saturday_sunday, friday_saturday, sunday_only, none
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/calendars-calendar_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requireda required field is missing from the request
404SLA_CALENDAR_NOT_FOUNDcalendar <id> not found for tenantthe calendar does not exist for this tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the calendar created above → expects HTTP 200
Path params
{
  "calendar_id": "{{cache:sla.calendar.create.response.data.calendar.calendar_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "calendar_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
state: running, paused, satisfied, breached, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/clocks-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requireda required field is missing from the request
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the running clocks for this tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "clock_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/policies"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
state: running, paused, satisfied, breached, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/clocks-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id, policy_id and subject_ref are requireda required field is missing from the request
404SLA_POLICY_NOT_FOUNDpolicy <id> not found for tenantthe policy does not exist for this tenant
422CALENDAR_NEVER_OPENcalendar <id> is never openthe policy calendar has no open minute, so no due date can be computed
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
A signal that arrived ten days ago — long past its two-hour promise → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
state: running, paused, satisfied, breached, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/clocks-clock_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requireda required field is missing from the request
404SLA_CLOCK_NOT_FOUNDclock <id> not found for tenantthe clock does not exist for this tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the overdue clock created above → expects HTTP 200
Path params
{
  "clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "clock_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/breach-scan"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
reason_code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recorded
state: running, paused, satisfied, breached, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/clocks-clock_id-breach-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id is requireda required field is missing from the request
422BREACH_REASON_REQUIREDa breach cannot be recorded without a reason_codereason_code is absent, empty or whitespace only
404SLA_CLOCK_NOT_FOUNDclock <id> not found for tenantthe clock does not exist for this tenant
409CLOCK_NOT_BREACHEDclock <id> is 'running' and not past duethe clock has not missed anything, so there is nothing to record
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Explain the miss: nobody was available on the roster → expects HTTP 200
Path params
{
  "clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
state: running, paused, satisfied, breached, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/clocks-clock_id-cancel-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id and reason are requireda required field is missing from the request
409INVALID_CLOCK_TRANSITIONclock <id> cannot move satisfied -> cancelledthe clock is already satisfied or cancelled
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Cancel because the subject withdrew → expects HTTP 200
Path params
{
  "clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "reason": "subject withdrew the request",
  "actor_id": "qa-runner"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "reason": "subject withdrew the request",
  "actor_id": "qa-runner"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/tick"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
firing_state: claimed, fired, failed
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/clocks-clock_id-firings-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requireda required field is missing from the request
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the ledger for the ticked clock → expects HTTP 200
Path params
{
  "clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "firing_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks/:clock_id/reassign"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
state: running, paused, satisfied, breached, cancelled
pause_reason: awaiting_subject_reply
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/clocks-clock_id-pause-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id and reason are requireda required field is missing from the request
422PAUSE_REASON_NOT_ALLOWED'because_i_said_so' is not a pause condition on this policythe reason is not listed in the policy pause_conditions
409INVALID_CLOCK_TRANSITIONclock <id> cannot move paused -> pausedthe clock is not running
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Pause on the one reason this policy allows → expects HTTP 200
Path params
{
  "clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "reason": "awaiting_subject_reply",
  "actor_id": "qa-runner"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "reason": "awaiting_subject_reply",
  "actor_id": "qa-runner"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
state: running, paused, satisfied, breached, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/clocks-clock_id-reassign-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id and owner_ref are requireda required field is missing from the request
404SLA_CLOCK_NOT_FOUNDclock <id> not found for tenantthe clock does not exist for this tenant
409INVALID_CLOCK_TRANSITIONclock <id> cannot move satisfied -> satisfiedthe clock is already satisfied, breached or cancelled
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Hand the overdue request to the backup owner → expects HTTP 200
Path params
{
  "clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "owner_ref": "persona:backup-{{dynamic:uuid}}",
  "reason": "backup takeover",
  "actor_id": "qa-runner"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "owner_ref": "persona:backup-{{dynamic:uuid}}",
  "reason": "backup takeover",
  "actor_id": "qa-runner"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks/:clock_id/pause"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
state: running, paused, satisfied, breached, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/clocks-clock_id-resume-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id is requireda required field is missing from the request
409INVALID_CLOCK_TRANSITIONclock <id> cannot move running -> runningthe clock is not paused
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Resume the paused clock → expects HTTP 200
Path params
{
  "clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "actor_id": "qa-runner"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "actor_id": "qa-runner"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/breaches/:breach_id/recovery"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
state: running, paused, satisfied, breached, cancelled
evidence_kind: outbound_reply, resolution
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/clocks-clock_id-satisfy-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id is requireda required field is missing from the request
422SATISFACTION_EVIDENCE_INSUFFICIENTclock <id> cannot be satisfied: an evidence reference is required by this policythe offered evidence does not meet the policy satisfaction contract — the body names every unmet requirement
409INVALID_CLOCK_TRANSITIONclock <id> cannot move satisfied -> satisfiedthe clock is already satisfied, breached or cancelled
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Satisfy with an accepted evidence kind, a reference and a named actor → expects HTTP 200
Path params
{
  "clock_id": "{{cache:sla.clock.create.response.data.clock.clock_id}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
state: running, paused, satisfied, breached, cancelled
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/clocks-merge-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id, surviving_clock_id and merged_clock_id are requireda required field is missing from the request
404SLA_CLOCK_NOT_FOUNDclock <id> not found for tenanteither clock does not exist for this tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Merge the duplicate request into the original → expects HTTP 200
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/policies"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
is_active: true, false
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/policies-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requireda required field is missing from the request
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List active request policies → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/calendars"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
predicate_op: eq, ne, in, not_in, gte, lte, exists
accepted_kinds: outbound_reply, resolution
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/policies-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id, slug, name, subject_kind, duration_minutes and calendar_id are requireda required field is missing from the request
400VALIDATION_ERRORduration_minutes must be greater than zeroduration_minutes is zero or negative
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
A two-hour response promise with one legal pause and a real satisfaction contract → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/policies"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
predicate_op: eq, ne, in, not_in, gte, lte, exists
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/policies-policy_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requireda required field is missing from the request
404SLA_POLICY_NOT_FOUNDpolicy <id> not found for tenantthe policy does not exist for this tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the policy created above → expects HTTP 200
Path params
{
  "policy_id": "{{cache:sla.policy.create.response.data.policy.policy_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/policies/:policy_id/rungs"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
severity: info, warning, urgent, critical
audience_kind: owner, refs, on_call
include_inactive: true, false
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/policies-policy_id-rungs-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requireda required field is missing from the request
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the ladder including retired rungs → expects HTTP 200
Path params
{
  "policy_id": "{{cache:sla.policy.create.response.data.policy.policy_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "rung_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/policies"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
severity: info, warning, urgent, critical
audience_kind: owner, refs, on_call
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/policies-policy_id-rungs-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id, rung_index and action are requireda required field is missing from the request
422INVALID_RUNG_OFFSETgive exactly one of offset_minutes, minutes_before_due or minutes_after_dueno offset or more than one offset is supplied, or minutes_before_due exceeds the policy duration
404SLA_POLICY_NOT_FOUNDpolicy <id> not found for tenantthe policy does not exist for this tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Warn the owner thirty business minutes before the deadline → expects HTTP 201
Path params
{
  "policy_id": "{{cache:sla.policy.create.response.data.policy.policy_id}}"
}
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/policies/:policy_id/rungs"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
severity: info, warning, urgent, critical
is_active: true, false
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/rungs-rung_id-patch.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id and is_active (boolean) are requireda required field is missing from the request
404SLA_LADDER_RUNG_NOT_FOUNDladder rung <id> not found for tenantthe rung does not exist for this tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Restore the rung (idempotent when it is already active) → expects HTTP 200
Path params
{
  "rung_id": "{{cache:sla.rung.create.response.data.rung.rung_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "is_active": true
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "is_active": true
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks/:clock_id/breach"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
reason_code: awaiting_capacity, no_capacity, missed_handoff, upstream_outage, provider_down, process_gap, cause_not_recorded
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/systemic-incidents-open-pending-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id is requireda required field is missing from the request
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Drain the pending queue (zero when every group already opened) → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "limit": 25
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "limit": 25
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/sla/clocks/:clock_id/resume"
]
Implemented in
packages/sdk-sla/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
severity: info, warning, urgent, critical
firing_state: claimed, fired, failed
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/sla/tick-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id is requireda required field is missing from the request
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Tick the tenant — the overdue clock has a rung waiting → expects HTTP 200
Path params
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "limit": 100,
  "actor_id": "qa-runner"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "limit": 100,
  "actor_id": "qa-runner"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "clocks_scanned": "number",
    "rungs_due": "number",
    "rungs_fired": "number",
    "rungs_failed": "number",
    "rungs_skipped_duplicate": "number",
    "rungs_retried": "number",
    "errors": "array"
  }
}

sdk-social

4 API(s)
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.

SDK / service
sdk-socialW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-identity/src/server/routes.ts
Request field options
provider: google, apple, microsoft
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\social\provider-callback-post.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrorprovider must be google|apple|microsoftThe :provider path segment is not one of the three supported providers
400ValidationErrortenant_id and verified_claims.sub are requiredThe body omits tenant_id, or verified_claims is missing or has no sub
400ValidationError<message containing "must include" or "required">consumeSocialIdToken throws a validation error about missing or malformed claims
404NotFound<entity> not foundconsumeSocialIdToken throws an error whose message contains "not found" — e.g. tenant_id does not resolve, or the provider is not configured for that tenant
500InternalErrorInternalErrorconsumeSocialIdToken throws for any other reason (identity linking failure, database unreachable)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Google callback with verified id_token claims → expects HTTP 200
Path params
{
  "provider": "google"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "verified_claims": {
    "sub": "{{dynamic:name}}",
    "email": "{{dynamic:email}}",
    "email_verified": true
  }
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "verified_claims": {
    "sub": "Acme QA Sample",
    "email": "qa.user@example.com",
    "email_verified": true
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-socialW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-social/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
network: twitter, linkedin, instagram, facebook, tiktok
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\social\handles-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrormissing fieldsAny of tenant_id, network, external_handle_id or authorized_persona_id is absent or empty
400ValidationErrorinvalid networknetwork is supplied but is not one of twitter|linkedin|instagram|facebook|tiktok
500InternalErrorInternal Server ErrorauthorizeHandle 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Authorize a social handle for a tenant → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "network": "twitter",
  "external_handle_id": "Acme QA Sample",
  "authorized_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-socialW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/social/handles"
]
Implemented in
packages/sdk-social/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
kind: dm, comment, mention, review
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\social\interactions-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrormissing fieldsAny of handle_id, kind or author_external_id is absent or empty
400ValidationErrorinvalid kindkind is supplied but is not one of dm|comment|mention|review
500InternalErrorInternal Server ErroringestInteraction throws — e.g. handle_id violates a foreign key because the handle was never authorized, or the database is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Ingest an inbound social interaction → expects HTTP 201
Path params
Payload (template)
{
  "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!"
}
Example request
{
  "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!"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-socialW5 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/social/interactions"
]
Implemented in
packages/sdk-social/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\social\interactions-interaction-id-capture-lead-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrormissing contact_idcontact_id is absent or empty in the request body
404NotFoundNotFoundcaptureLead finds no interaction for the supplied interaction_id
500InternalErrorInternal Server ErrorcaptureLead throws — e.g. contact_id violates a foreign key, interaction_id is not a valid UUID, or the database is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Capture an interaction as a CRM lead → expects HTTP 200
Path params
{
  "interaction_id": "{{cache:social.interactions.create.response.data.interaction.interaction_id}}"
}
Payload (template)
{
  "contact_id": "{{dynamic:uuid}}"
}
Example request
{
  "contact_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "interaction": {
      "interaction_id": "string",
      "captured_lead_contact_id": "string"
    }
  }
}

sdk-source-record

13 API(s)
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.

SDK / service
sdk-source-recordW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/source-records",
  "POST /api/source-assertions"
]
Implemented in
packages/sdk-source-record/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINED
status: SURVIVES, ASSERTION, SUPERSEDED, PRIMARY
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/source-record/source-assertions-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query param is absent
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List every coexisting claim for the subject and attribute → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "source_assertion_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-source-recordW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/source-records"
]
Implemented in
packages/sdk-source-record/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINED
status: SURVIVES, ASSERTION, SUPERSEDED, PRIMARY
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/source-record/source-assertions-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id, subject_ref, attribute, value and origin_class are requiredany required field is missing from the body
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Write a licensed third-party email claim → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-source-recordW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/source-records",
  "POST /api/source-assertions"
]
Implemented in
packages/sdk-source-record/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINED
status: SURVIVES, ASSERTION, SUPERSEDED, PRIMARY
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/source-record/source-assertions-assertion_id-supersede-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id and the replacement value are requiredeither field missing from the body
404ASSERTION_NOT_FOUNDassertion <id> not found for tenantthe assertion does not exist for this tenant
409ASSERTION_ALREADY_SUPERSEDEDassertion <id> was already superseded by <id>the claim already names a successor — supersede is one-way
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Supersede the licensed claim with a corrected one → expects HTTP 200
Path params
{
  "assertion_id": "{{cache:source-record.assertion.create.response.data.assertion.assertion_id}}"
}
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-source-recordW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/source-records"
]
Implemented in
packages/sdk-source-record/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
trust_state: P0_CAPTURED, P1_NORMALIZED, P2_CANDIDATE, P3_LINKED, P4_DIRECT
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINED
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/source-record/source-records-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query param is absent
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List captures for the tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "source_record_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-source-recordW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-source-record/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINED
evidence_kind: RAW_PAYLOAD, API_RESPONSE, DOCUMENT, SCREENSHOT, LICENSE_TERMS, CONSENT_RECEIPT, SIGNATURE, OTHER
trust_state: P0_CAPTURED, P1_NORMALIZED, P2_CANDIDATE, P3_LINKED, P4_DIRECT
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/source-record/source-records-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id, source_system and raw_evidence are requiredany of the three required fields is missing from the body
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Capture a public-record payload at P0_CAPTURED → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-source-recordW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/source-records"
]
Implemented in
packages/sdk-source-record/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
trust_state: P0_CAPTURED, P1_NORMALIZED, P2_CANDIDATE, P3_LINKED, P4_DIRECT
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINED
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/source-record/source-records-capture_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query param is absent
404SOURCE_RECORD_NOT_FOUNDcapture <id> not found for tenantthe capture does not exist, or belongs to another tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the capture created by the producer → expects HTTP 200
Path params
{
  "capture_id": "{{cache:source-record.capture.response.data.source_record.capture_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "source_record_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-source-recordW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/source-records"
]
Implemented in
packages/sdk-source-record/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/source-record/source-records-capture_id-crosswalks-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id, external_system and external_id are requiredany of the three required fields is missing
404SOURCE_RECORD_NOT_FOUNDcapture <id> not found for tenantthe capture does not exist for this tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Link the capture to its external system identifier → expects HTTP 201
Path params
{
  "capture_id": "{{cache:source-record.capture.response.data.source_record.capture_id}}"
}
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-source-recordW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/source-records"
]
Implemented in
packages/sdk-source-record/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
trust_state: P0_CAPTURED, P1_NORMALIZED, P2_CANDIDATE, P3_LINKED, P4_DIRECT
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/source-record/source-records-capture_id-normalize-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id is requiredtenant_id missing from the body
404SOURCE_RECORD_NOT_FOUNDcapture <id> not found for tenantthe capture does not exist for this tenant
409RECORD_QUARANTINEDcapture <id> is quarantined and cannot be promotedthe capture landed UNKNOWN_QUARANTINED because its provenance was never established
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Normalize the capture to P1_NORMALIZED → expects HTTP 200
Path params
{
  "capture_id": "{{cache:source-record.capture.response.data.source_record.capture_id}}"
}
Payload (template)
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}",
  "actor_id": "qa-runner",
  "purpose": "normalization",
  "causation_id": "{{dynamic:uuid}}"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "actor_id": "qa-runner",
  "purpose": "normalization",
  "causation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-source-recordW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/source-records",
  "POST /api/source-records/:capture_id/normalize"
]
Implemented in
packages/sdk-source-record/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
to_state: P0_CAPTURED, P1_NORMALIZED, P2_CANDIDATE, P3_LINKED, P4_DIRECT
evidence_origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINED
trust_state: P0_CAPTURED, P1_NORMALIZED, P2_CANDIDATE, P3_LINKED, P4_DIRECT
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/source-record/source-records-capture_id-promote-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id and to_state are requiredeither field missing from the body
404SOURCE_RECORD_NOT_FOUNDcapture <id> not found for tenantthe capture does not exist for this tenant
409RECORD_QUARANTINEDcapture <id> is quarantined and cannot be promotedthe capture has no established provenance (UNKNOWN_QUARANTINED)
409INVALID_TRUST_TRANSITION<from> -> <to> is not a legal promotionthe requested state skips a rung, repeats one or moves down the ladder
422NORMALIZATION_REQUIREDcannot promote <id> P1_NORMALIZED -> P2_CANDIDATE: missing normalized payloadpromoting to P2_CANDIDATE before the capture has been normalized
422SUBJECT_REF_REQUIREDcannot promote <id> P2_CANDIDATE -> P3_LINKED: missing subject_refpromoting to P3_LINKED with no subject to link to
422FIRST_PARTY_EVIDENCE_REQUIREDcannot promote <id> P3_LINKED -> P4_DIRECT: missing first-party evidence referencepromoting to P4_DIRECT with no evidence_ref, or with evidence whose origin is not first-party
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Promote the normalized capture to P2_CANDIDATE → expects HTTP 200
Path params
{
  "capture_id": "{{cache:source-record.capture.response.data.source_record.capture_id}}"
}
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-source-recordW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/source-records",
  "POST /api/source-rights/attestations"
]
Implemented in
packages/sdk-source-record/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINED
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/source-record/source-rights-attestations-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query param is absent
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List attestations for the capture → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "attestation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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[].

SDK / service
sdk-source-recordW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/source-records"
]
Implemented in
packages/sdk-source-record/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINED
evidence_kind: RAW_PAYLOAD, API_RESPONSE, DOCUMENT, SCREENSHOT, LICENSE_TERMS, CONSENT_RECEIPT, SIGNATURE, OTHER
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/source-record/source-rights-attestations-post.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id, attestor_principal, origin_class and permitted_uses[] are requiredany required field is missing, or permitted_uses is not an array
422ATTESTATION_EVIDENCE_REQUIREDorigin_class LICENSED_THIRD_PARTY requires an evidence blob reference before it can be attestedorigin_class is LICENSED_THIRD_PARTY or PARTNER_PROVIDED and no evidence blob reference resolved
422SOURCE_FINGERPRINT_REQUIREDan attestation needs either a capture_id to read the fingerprint from, or an explicit source_fingerprintneither capture_id nor source_fingerprint identifies what the signature covers
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Sign a licensed-source attestation with its licence evidence → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-source-recordW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/source-records",
  "POST /api/source-rights/attestations"
]
Implemented in
packages/sdk-source-record/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
origin_class: USER_PROVIDED, FIRST_PARTY_DIRECT, TENANT_FIRST_PARTY_CRM, USER_AUTHORIZED_CONTACT_STORE, PUBLIC_RECORD, LICENSED_THIRD_PARTY, PARTNER_PROVIDED, UNKNOWN_QUARANTINED
evidence_kind: RAW_PAYLOAD, API_RESPONSE, DOCUMENT, SCREENSHOT, LICENSE_TERMS, CONSENT_RECEIPT, SIGNATURE, OTHER
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/source-record/source-rights-attestations-attestation_id-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id query param requiredthe tenant_id query param is absent
404ATTESTATION_NOT_FOUNDattestation <id> not found for tenantthe attestation does not exist, or belongs to another tenant
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read the attestation just signed → expects HTTP 200
Path params
{
  "attestation_id": "{{cache:source-record.attestation.create.response.data.attestation.attestation_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "attestation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-source-recordW3 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/source-records",
  "POST /api/source-rights/attestations"
]
Implemented in
packages/sdk-source-record/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/source-record/source-rights-permitted-use-get.json
Error responses
HTTPCodeMessageWhen it happens
400VALIDATION_ERRORtenant_id and purpose query params are requiredeither query param is absent
401Unauthorizedmissing or invalid tokenno valid tenant Bearer token on the request
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Check an attested purpose against the signed rights → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "permitted_use_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "permitted": "boolean",
    "permitted_uses": "array"
  }
}

sdk-storm

1 API(s)
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).

SDK / service
Implemented in
services/api-gateway/src/app.ts
Source spec
tests\api_definitions\storm\overlay-get.json
Error responses
HTTPCodeMessageWhen it happens
400ValidationErrormin_lat, min_lng, max_lat, max_lng are required floatsAny of the four bbox query params is absent, empty, or not parseable by parseFloat (result is not finite)
400ValidationErrormin must be less than max for both lat and lngAll four parse as finite numbers but the box is inverted — min_lat > max_lat or min_lng > max_lng
500StormQueryFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Query storm events + intensity cell counts overlapping a bbox → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "overlay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "events": "array",
  "cell_count": "number"
}
Missing bbox floats returns 400 → expects HTTP 400
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "overlay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 400.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "events": "array",
  "cell_count": "number"
}
min greater than max returns 400 → expects HTTP 400
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "overlay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 400.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "events": "array",
  "cell_count": "number"
}

sdk-taxonomy

4 API(s)
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.

SDK / service
sdk-taxonomyW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-taxonomy/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\taxonomy\extraction-schemas-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrorMissing query param: document_kindThe document_kind query param is absent
404NotFoundNo active extraction schema for document_kindNo active extraction schema exists for the document_kind at either the tenant or the global level
500InternalErrorLookup failedlookupExtractionSchema throws — database unreachable or the query fails
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Lookup active extraction schema by document_kind (tenant with platform fallback) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "extraction_schema_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Missing required document_kind query param → expects HTTP 400
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "extraction_schema_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 400.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
sdk-taxonomyW6 · test wave
Implemented in
packages/sdk-taxonomy/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\taxonomy\health-get.json
Health check returns sdk + status ok → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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".

SDK / service
sdk-taxonomyW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-taxonomy/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\taxonomy\prompt-templates-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrorMissing query param: purpose_tagThe purpose_tag query param is absent
404NotFoundNo active prompt template for purpose_tagNo active prompt template matches the purpose_tag (and name, when supplied) at either the tenant or the global level
500InternalErrorLookup failedlookupPromptTemplate throws — database unreachable or the query fails
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Lookup active prompt template by purpose_tag (tenant with platform fallback) → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "prompt_template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Missing required purpose_tag query param → expects HTTP 400
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "prompt_template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 400.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
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.

SDK / service
sdk-taxonomyW6 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-taxonomy/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\taxonomy\versions-taxonomy-version-id-activate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
400ValidationErrorMissing path param: taxonomy_version_idThe :taxonomy_version_id path segment resolves to an empty value (not normally reachable — Fastify would not match the route)
404NotFound<error message containing "not found">activateTaxonomyVersion throws an error whose message contains "not found" — the taxonomy version id does not exist
500InternalErrorActivation failedactivateTaxonomyVersion throws for any other reason (invalid state transition, constraint violation, database unreachable)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Activate a taxonomy version (demotes prior active version to deprecated) → expects HTTP 200
Path params
{
  "taxonomy_version_id": "{{var:taxonomy_version_id}}"
}
Payload (template)
{
  "taxonomy_version_id": "{{var:taxonomy_version_id}}"
}
Example request
{
  "taxonomy_version_id": "{{var:taxonomy_version_id}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-tenant

10 API(s)
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.

SDK / service
sdk-tenantW0 · test wave
Depends on
[
  "POST /api/auth/register"
]
Request field options
kind: region, country, state, city, locality
residency_class: open, regulated, sovereign
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\geo-nodes\create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo 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.
401UnauthorizedInvalid or expired tokenverifyJwt() rejects the token — bad signature, wrong JWT_SECRET, or expired exp.
400ValidationErrorbody must be an objectThe request body is null, absent, or a non-object JSON scalar/array.
400ValidationErrorname is requiredname is missing, not a string, or an empty string (validateCreateGeoNode collects this in details[]).
400ValidationErrorkind must be one of region, country, state, city, localitykind is missing or outside the enum. Returned alongside any other collected errors in the same details[] array.
400ValidationErrorresidency_class must be one of open, regulated, sovereignresidency_class is supplied as a string outside the enum. A missing residency_class is fine — it defaults to 'open'.
400ValidationErrorinsert or update on table "geo_node" violates foreign key constraintparent_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.
409Conflictduplicate key value violates unique constraintAny 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.
500InternalErrorInternalErrorAny 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.
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a US region geo node → expects HTTP 201
Path params
Payload (template)
{
  "kind": "region",
  "code": "us-east-1",
  "name": "{{dynamic:name}}",
  "residency_class": "open"
}
Example request
{
  "kind": "region",
  "code": "us-east-1",
  "name": "Acme QA Sample",
  "residency_class": "open"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-tenantW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Request field options
invoice_aggregation: per-tenant, consolidated
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\resellers\create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo 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.
401UnauthorizedInvalid or expired tokenverifyJwt() throws on the presented token — tampered signature or expired exp.
400ValidationErrorbody must be an objectRequest body is null, missing, or not a JSON object.
400ValidationErrororg_id is requiredorg_id is absent, not a string, or empty.
400ValidationErrorbrand_name is requiredbrand_name is absent, not a string, or empty. Returned in the same details[] array as any other collected validation errors.
400ValidationErrorinvoice_aggregation must be one of per-tenant, consolidatedinvoice_aggregation is supplied as a string outside the enum. Omitting it is valid — the service defaults to 'per-tenant'.
400ValidationErrorinsert or update on table "reseller" violates foreign key constraintorg_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.
409Conflictduplicate key value violates unique constraintAny 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.
500InternalErrorInternalErrorAny 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.
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a reseller with brand + commission rules → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-tenantW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-tenant/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/role-templates/index-get.json
Error responses
HTTPCodeMessageWhen it happens
401Unauthorizedauthentication requiredrole templates describe who may do what inside a tenant, so the list is never public
400ValidationErrorapp_id is requiredtemplates are per-app; an unscoped list would mix roles from apps the caller may not administer
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List role templates for an app → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "role_template_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-tenantW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Request field options
isolation_tier: S, P, G
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\tenants\create-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrorapp_id is required / display_name is required / region is required / isolation_tier must be one of S, P, Gany validateCreateTenant check fails; all failures are returned together in details[]
400ValidationError<pg error: violates foreign key constraint ...>reseller_id, geo_node_id or parent_tenant_id references a row that does not exist
409Conflict<pg error: duplicate key value violates unique constraint ...>a unique constraint (e.g. brand_domain) is violated
404NotFound<error text containing 'not found'>the service throws a not-found error while resolving a referenced entity
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a root tenant under the self-serve app → expects HTTP 201
Path params
Payload (template)
{
  "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": []
}
Example request
{
  "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": []
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-tenantW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-tenant/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\tenants\tenant-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
404NotFoundNo tenant with id <tenant_id>no tenant row matches :tenant_id
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch the self-serve tenant by id → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-tenantW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Source spec
tests\api_definitions\tenants\id-bus-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrorname is required / kind is requiredvalidateCreateBu fails; all failures are returned together in details[]
400ValidationError<pg error: violates foreign key constraint ...>:tenant_id or parent_bu_id references a row that does not exist
409Conflict<pg error: duplicate key value violates unique constraint ...>a BU unique constraint (e.g. name within the tenant) is violated
404NotFound<error text containing 'not found'>the service throws a not-found error while resolving the tenant or parent BU
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a Region root BU → expects HTTP 201
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
{
  "name": "{{dynamic:name}}",
  "kind": "region"
}
Example request
{
  "name": "Acme QA Sample",
  "kind": "region"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-tenantW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Source spec
tests\api_definitions\tenants\tenant-id-contact-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenGateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent
401UnauthorizedInvalid or expired tokenauthGate ran requireAuth and the JWT failed verification or had expired
403consent_absentreading 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
404NotFoundNo active member found for tenantthe tenant has no identity.tenant_membership row with status=active
500InternalError<postgres error text>:tenant_id is not a valid UUID (::uuid cast fails), or the contact/consent query errors
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Resolve the founding member as the tenant contact → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "contact_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "contact not found"
}
e.g. HTTP 404
Runner assertion
{
  "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.

SDK / service
sdk-tenantW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Request field options
period_kind: year, quarter, month, week
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\tenants\id-fiscal-calendar-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErroryear_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, weekany validateSetFiscalCalendar check fails; all failures are returned together in details[]
400ValidationError<pg error: violates foreign key constraint ...>:tenant_id references a tenant that does not exist
409Conflict<pg error: duplicate key value violates unique constraint ...>fiscal periods already exist for that tenant/year and are re-generated
404NotFound<error text containing 'not found'>the service throws a not-found error while resolving the tenant
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Set fiscal year starting in April with USD → expects HTTP 201
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
{
  "year_start_month": 4,
  "base_currency": "USD",
  "period_kind": "quarter"
}
Example request
{
  "year_start_month": 4,
  "base_currency": "USD",
  "period_kind": "quarter"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-tenantW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/resellers"
]
Source spec
tests\api_definitions\tenants\id-reseller-attach-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrorreseller_id is requiredbody.reseller_id is absent or empty after trimming
400ValidationError<pg error: violates foreign key constraint ...>:tenant_id or reseller_id references a row that does not exist
409Conflict<pg error: duplicate key value violates unique constraint ...>re-attaching a reseller that is already attached trips a unique constraint
404NotFound<error text containing 'not found'>the service throws a not-found error while resolving the tenant or reseller
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Attach a reseller to a tenant with custom commission → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
{
  "reseller_id": "{{cache:resellers.create.response.data.reseller.reseller_id}}",
  "commission_rules": {
    "default_pct": 12
  }
}
Example request
{
  "reseller_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "commission_rules": {
    "default_pct": 12
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-tenantW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Request field options
placement: share, tier-p, tier-g
isolation_tier: S, P, G
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\tenants\id-sub-tenants-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrorapp_id is required / display_name is required / region is required / placement must be one of share, tier-p, tier-gany validateCreateSubTenant check fails; all failures are returned together in details[]
400ValidationError<pg error: violates foreign key constraint ...>:tenant_id (the parent), reseller_id or geo_node_id references a row that does not exist
409Conflict<pg error: duplicate key value violates unique constraint ...>a unique constraint (e.g. brand_domain) is violated
404NotFound<error text containing 'not found'>the service throws a not-found error while resolving the parent tenant or a referenced entity
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Create a sub-tenant that shares parent pool → expects HTTP 201
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
{
  "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"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-tenant-lifecycle

5 API(s)
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.

SDK / service
sdk-tenant-lifecycleW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-tenant-lifecycle/src/server/routes.ts
Request field options
current_state: active, suspended, offboarding, offboarded, sandbox
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\tenant-lifecycle\offboard-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenTenant ownership check failedThe JWT's tenant_id and parent_tenant_id both differ from the :tenant_id in the path
400ValidationErrordeadline_at must be ISO-8601deadline_at is present but does not parse to a valid date (new Date(...) yields NaN)
409InvalidTransitionInvalid tenant lifecycle transition <from> → offboardingThe tenant's current lifecycle state has no legal edge to the offboarding state
500InternalErrorInternal Server ErrorThe database is unreachable or the state write throws outside the InvalidTransition path
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Begin offboarding (active -> offboarding) → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
{
  "deadline_at": "{{dynamic:futuredatetime}}"
}
Example request
{
  "deadline_at": "2026-01-15T10:30:00Z"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-tenant-lifecycleW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/tenant-lifecycle/:tenant_id/suspend"
]
Implemented in
packages/sdk-tenant-lifecycle/src/server/routes.ts
Request field options
current_state: active, suspended, offboarding, offboarded, sandbox
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\tenant-lifecycle\tenant-id-reinstate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenTenant ownership check failedThe JWT's tenant_id and parent_tenant_id both differ from the :tenant_id in the path
409InvalidTransitionInvalid tenant lifecycle transition <from> → activeThe tenant is not in a state from which reinstatement is allowed (e.g. already active, or offboarded)
500InternalErrorInternal Server ErrorThe database is unreachable or the state write throws outside the InvalidTransition path
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Reinstate tenant (suspended -> active) → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-tenant-lifecycleW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/tenant-lifecycle/:tenant_id/suspend"
]
Implemented in
packages/sdk-tenant-lifecycle/src/server/routes.ts
Request field options
current_state: active, suspended, offboarding, offboarded, sandbox
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\tenant-lifecycle\tenant-id-state-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenTenant ownership check failedThe JWT's tenant_id and parent_tenant_id both differ from the :tenant_id in the path
404NotFoundNotFoundgetState returns no lifecycle row for the supplied tenant_id
500InternalErrorInternal Server ErrorThe database is unreachable or the state read throws
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read state after a transition → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "state_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-tenant-lifecycleW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-tenant-lifecycle/src/server/routes.ts
Request field options
current_state: active, suspended, offboarding, offboarded, sandbox
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\tenant-lifecycle\suspend-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenTenant ownership check failedThe JWT's tenant_id and parent_tenant_id both differ from the :tenant_id in the path
400ValidationErrorreason is requiredThe body omits reason or supplies an empty value
409InvalidTransitionInvalid tenant lifecycle transition <from> → suspendedThe tenant's current lifecycle state cannot move to suspended (already suspended, or offboarded)
500InternalErrorInternal Server ErrorThe database is unreachable or the state write throws outside the InvalidTransition path
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Suspend tenant (active -> suspended) → expects HTTP 200
Path params
{
  "tenant_id": "{{cache:auth.signup-tenant.response.data.tenant_id}}"
}
Payload (template)
{
  "reason": "non-payment"
}
Example request
{
  "reason": "non-payment"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-tenant-lifecycleW0 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-tenant-lifecycle/src/server/routes.ts
Request field options
current_state: active, suspended, offboarding, offboarded, sandbox
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\tenant-lifecycle\sandbox-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer <jwt> header on a requireAuth route
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or exp in the past)
403ForbiddenCaller must have tenant_idThe verified JWT carries no tenant_id claim, so no parent tenant can be derived
400ValidationErrorexpires_at must be ISO-8601expires_at is present but does not parse to a valid date
500InternalErrorInternal Server ErrorcreateSandboxTenant throws — e.g. the parent tenant row is missing, the sanitization policy is rejected downstream, or the database is unreachable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Create sandbox sub-tenant from caller's tenant_id → expects HTTP 201
Path params
Payload (template)
{
  "expires_at": "{{dynamic:futuredatetime}}",
  "sanitization_policy": "default-mask-pii"
}
Example request
{
  "expires_at": "2026-01-15T10:30:00Z",
  "sanitization_policy": "default-mask-pii"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-trace

4 API(s)
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.

SDK / service
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/sdk-trace/src/server/handlers/traceController.ts
Source spec
tests\api_definitions\trace\trace_id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400MissingPathParamMissing path param: trace_idtrace_id resolves falsy (defensive - an empty path segment normally route-misses to 404 first)
404NotFound<service message containing "not found">getTraceTimeline throws for an unknown trace_id
500LookupFailedLookup failedgetTraceTimeline throws any other error - a non-UUID trace_id failing the uuid cast, or any DB error
500InternalErrorInternalErrorthe handler throws outside its own try/catch and the route wrapper catch fires
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Timeline renders trace header + spans → expects HTTP 200
Path params
{
  "trace_id": "{{var:trace_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "trace_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/personas",
  "POST /policy/:id/evaluate"
]
Implemented in
packages/sdk-trace/src/server/handlers/traceController.ts
Request field options
format: pdf, json
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\trace\exports-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrorRequired: tenant_id, requestor_persona_id, trace_id, format (pdf|json)any of tenant_id/requestor_persona_id/trace_id/format missing
400ValidationErrorformat must be pdf or jsonformat present but not 'pdf' or 'json'
404NotFoundtrace_id <id> not foundexportTrace/getTraceTimeline throws an Error whose message includes 'not found'
500ExportFailedExport failedany other error thrown by exportTrace (insert failure, etc.)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
PDF export persists trace.export row with signature → expects HTTP 201
Path params
Payload (template)
{
  "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"
}
Example request
{
  "tenant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "requestor_persona_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "trace_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "format": "pdf"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Implemented in
packages/sdk-trace/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\trace\health-get.json
Health probe returns sdk + status → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/sdk-trace/src/server/handlers/traceController.ts
Request field options
expected_layers: gateway, identity, consent, pool-router, vault, policy, rebac, meter, sdk-body, tool, agent, lineage
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\trace\regression-assert-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrorRequired: 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)
500RegressionAssertFailedRegression assert failedregressionAssert 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
500InternalErrorInternalErrorthe handler throws outside its own try/catch and the route wrapper catch fires
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Regression assert returns matched/missing/extra layers → expects HTTP 200
Path params
Payload (template)
{
  "trace_id": "{{var:trace_id}}",
  "expected_layers": [
    "gateway",
    "identity",
    "policy",
    "meter"
  ]
}
Example request
{
  "trace_id": "{{var:trace_id}}",
  "expected_layers": [
    "gateway",
    "identity",
    "policy",
    "meter"
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "success": true
}

sdk-vault

11 API(s)
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.

SDK / service
sdk-vault
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
_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, 5
grant_status: active, revoking, revoked, degraded
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/admin/byok-bindings-get.json
Error responses
HTTPCodeMessageWhen it happens
401admin token requiredadmin token requiredThe x-admin-ops-token header is absent, empty, or does not match ADMIN_OPS_TOKEN
500InternalErrorInternalErrorThe vault.byok_binding query fails — database unavailable or the pool is exhausted
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Operator lists every CMK binding → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {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.

SDK / service
sdk-vault
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
_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, 3
tier: root, app, pool, tenant, person, device, encounter
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests/api_definitions/admin/vault-keys-get.json
Error responses
HTTPCodeMessageWhen it happens
401admin token requiredadmin token requiredThe x-admin-ops-token header is absent, empty, or does not match ADMIN_OPS_TOKEN
500InternalErrorInternalErrorThe vault.key query fails — database unavailable, or tenant_id is supplied in a form the ::uuid cast rejects
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Operator lists root-tier keys → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {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.

SDK / service
sdk-vault
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/admin/vault-kms-status-get.json
Error responses
HTTPCodeMessageWhen it happens
401admin token requiredadmin token requiredThe x-admin-ops-token header is absent, empty, or does not match ADMIN_OPS_TOKEN
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Operator reads live KMS provider status → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/secrets",
  "POST /api/vault/encrypt"
]
Implemented in
packages/sdk-vault/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\vault\decrypt-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationError<field> is requiredAny of ref, ciphertext_b64, wrapped_dek_b64, iv_b64, tag_b64 is absent, not a string, or an empty string
400ValidationErrorSecret reference not registered ... / Invalid secret reference ...envelopeDecrypt rejects ref - the secret reference is malformed or has never been registered
500InternalErrorInternalErrorAny other envelopeDecrypt failure - notably GCM authentication failure from a tampered/mismatched bundle, and decryption under a shredded key
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Decrypt an envelope bundle produced by /api/vault/encrypt → expects HTTP 200
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/secrets"
]
Source spec
tests\api_definitions\vault\encrypt-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationErrorref is requiredref is absent or whitespace-only
400ValidationErrorplaintext_b64 is requiredplaintext_b64 is absent or an empty string
400ValidationErrorSecret reference not registered ... / Invalid secret reference ...envelopeEncrypt rejects ref - the secret reference is malformed or has never been registered
500InternalErrorInternalErrorAny other envelopeEncrypt failure (KMS unavailable, wrap failure)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Encrypt a base64 plaintext → expects HTTP 200
Path params
Payload (template)
{
  "ref": "{{cache:secrets.store.response.data.ref}}",
  "plaintext_b64": "aGVsbG8td29ybGQ="
}
Example request
{
  "ref": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "plaintext_b64": "aGVsbG8td29ybGQ="
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
Implemented in
packages/sdk-vault/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\vault\health-get.json
Liveness probe returns sdk + status → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "health_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Runner assertion
{
  "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.

SDK / service
sdk-vault
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/vault/keys"
]
Implemented in
packages/sdk-vault/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/vault/keys-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization header, or not in Bearer form — rejected by the gateway default-deny authGate before requireAuth
401UnauthorizedInvalid or expired tokenThe bearer token fails JWT verification or has expired
403no tenant scopeThis token carries no tenant, so no key scope can be derivedThe JWT verifies but carries no tenant_id claim — a platform-level token cannot list tenant keys and must use the admin route
500InternalErrorInternalErrorThe vault.key query fails — database unavailable or the pool is exhausted
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List the calling tenant's keys → expects HTTP 200
Path params
Payload (template)
Expected output ✓
Illustrative — standard {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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-vault/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
tier: root, app, pool, tenant, person, device, encounter
algorithm: AES-256-GCM, ChaCha20-Poly1305
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\vault\keys-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrorbody must be an objectRequest body is absent or not a JSON object
400ValidationErrortier is required / tier must be one of <KEY_TIERS>tier is missing or is not one of the canonical key tiers
400ValidationErrorkms_ref is requiredkms_ref is absent or whitespace-only
400ValidationErrorregion is requiredregion is absent or whitespace-only
400ValidationErrorparent_key_id is required for non-root tierstier is not "root" and parent_key_id is not a string
400ValidationErrorInvalid 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
500InternalErrorInternalErrorAny other issueKey failure (KMS unavailable, database error, hierarchy trigger failure)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Issue a root-tier vault key → expects HTTP 201
Path params
Payload (template)
{
  "tier": "root",
  "kms_ref": "kms-root-001",
  "algorithm": "AES-256-GCM",
  "region": "us-east-1"
}
Example request
{
  "tier": "root",
  "kms_ref": "kms-root-001",
  "algorithm": "AES-256-GCM",
  "region": "us-east-1"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
sdk-vault
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/vault/keys"
]
Implemented in
packages/sdk-vault/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests/api_definitions/vault/keys-key-id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization header, or not in Bearer form — rejected by the gateway default-deny authGate before requireAuth
403no tenant scopeThis token carries no tenant, so no key scope can be derivedThe JWT verifies but carries no tenant_id claim
404Key not foundKey not foundNo key with that id within the calling tenant — covers both a genuinely unknown id and a key owned by another tenant, deliberately indistinguishable
500InternalErrorInternalErrorkey_id is not a valid UUID so the ::uuid cast raises 22P02, or the database is unavailable
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
A root-tier key is NOT visible to a tenant token → expects HTTP 404
Path params
{
  "key_id": "{{cache:vault.keys.create.response.data.key_id}}"
}
Payload (template)
Expected output ✓
Illustrative — standard {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.

SDK / service
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/vault/keys"
]
Implemented in
packages/sdk-vault/src/server/routes.ts
Source spec
tests\api_definitions\vault\keys-rotate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
404NotFoundkey not found / key is not in a rotatable staterotateKey throws with a message containing "not found" or "not in a rotatable" - the key does not exist or its current status forbids rotation
500InternalErrorInternalErrorAny other rotateKey failure, including a non-UUID key_id (Postgres 22P02) and KMS/database errors
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "vault.key",
  "field": "state",
  "flow": [
    "issued",
    "active",
    "rotated",
    "shredded"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "rotated",
      "via": "POST /api/vault/keys/:key_id/rotate"
    }
  ]
}
Rotate a freshly-issued key → expects HTTP 200
Path params
{
  "key_id": "{{cache:vault.keys.create.response.data.key_id}}"
}
Payload (template)
{
  "reason": "scheduled"
}
Example request
{
  "reason": "scheduled"
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "reason": "scheduled",
    "rotate_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
Depends on
[
  "POST /api/auth/register",
  "POST /api/vault/keys"
]
Implemented in
packages/sdk-vault/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\vault\keys-key-id-shred-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth / gateway default-deny authGate)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification or is expired (requireAuth / gateway default-deny authGate)
400ValidationErrorreason is required for shredBody is absent or reason is missing/empty
404NotFoundkey not found / key already shreddedshredKey throws with a message containing "not found" or "already shredded"
500InternalErrorInternalErrorAny other shredKey failure, including a non-UUID key_id (Postgres 22P02) and KMS/database errors
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Cryptographic-shred an issued key → expects HTTP 200
Path params
{
  "key_id": "{{cache:vault.keys.create.response.data.key_id}}"
}
Payload (template)
{
  "reason": "compliance-shred"
}
Example request
{
  "reason": "compliance-shred"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401

sdk-webhook

7 API(s)
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.

SDK / service
sdk-webhookW7 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-webhook/src/server/routes.ts
Source spec
tests\api_definitions\webhooks\deliveries-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrortenant_id required?tenant_id= query param is absent
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List DLQ for tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "delivery_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-webhookW7 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-webhook/src/server/routes.ts
Source spec
tests\api_definitions\webhooks\deliveries-id-replay-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
404DeliveryNotInDlqDelivery <delivery_id> is not in DLQno delivery row matches :delivery_id, OR the row exists but its status is not dlq (already delivered, still pending, or already replayed)
409DlqWindowExpiredDelivery <delivery_id> replay window has expiredthe delivery is in dlq but its dlq_until timestamp is in the past
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "webhook.delivery",
  "field": "status",
  "flow": [
    "pending",
    "delivering",
    "succeeded",
    "failed",
    "dlq"
  ],
  "transitions": [
    {
      "from": "dlq",
      "to": "pending",
      "via": "POST /api/webhooks/deliveries/:delivery_id/replay"
    }
  ]
}
Replay a single DLQ delivery → expects HTTP 200
Path params
{
  "delivery_id": "{{var:dlq_delivery_id}}"
}
Payload (template)
{}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "completed",
    "replay_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-webhookW7 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\webhooks\dlq-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenGateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent
401UnauthorizedInvalid or expired tokenauthGate ran requireAuth and the JWT failed verification or had expired
400ValidationErrortenant_id required?tenant_id= query param is absent or empty
500InternalError<postgres error text>tenant_id is not a valid UUID, or listDlq fails
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List DLQ deliveries for tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "dlq_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-webhookW7 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant"
]
Implemented in
services/api-gateway/src/app.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\webhooks\endpoints-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenGateway default-deny authGate (AUTH_GATE_MODE=enforce): path is not on the public allowlist and no bearer token was sent
401UnauthorizedInvalid or expired tokenauthGate ran requireAuth and the JWT failed verification or had expired
400ValidationErrortenant_id required?tenant_id= query param is absent or empty
500InternalError<postgres error text>tenant_id is not a valid UUID, or listEndpointsForTenant fails
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List endpoints for tenant → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "endpoint_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-webhookW7 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-webhook/src/server/routes.ts
Request field options
signing_algo: hmac-sha256, hmac-sha512
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\webhooks\endpoints-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrortenant_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-sha512any validateRegisterEndpoint check fails; all failures are returned together in details[]
500InternalErrorInternalErrorregisterEndpoint threw WebhookUrlRejectedError (url rejected by the external/SSRF validator) — fail() does not map it, so it degrades to 500 — or the insert failed
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register HTTPS endpoint → expects HTTP 201
Path params
Payload (template)
{
  "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}}"
}
Example request
{
  "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}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-webhookW7 · test wave
Depends on
[
  "POST /api/auth/signup-tenant",
  "POST /api/webhooks/endpoints"
]
Implemented in
packages/sdk-webhook/src/server/routes.ts
Request field options
event_type: billing.invoice.finalized.v1, billing.invoice.paid.v1, billing.dunning.advanced.v1, billing.reprice.dry-run.completed.v1
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\webhooks\endpoints-id-subscribe-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrorendpoint_id must be a UUID / event_type is requiredvalidateSubscribe fails; all failures are returned together in details[]
400UnregisteredEventTypeEvent type <event_type> is not registeredthe event_type has no entry in the event-type registry
404EndpointNotFoundEndpoint <endpoint_id> not found:endpoint_id is a valid UUID but no webhook endpoint row matches
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Subscribe to billing.invoice.finalized.v1 → expects HTTP 201
Path params
{
  "endpoint_id": "{{cache:webhooks.register-endpoint.response.data.endpoint.endpoint_id}}"
}
Payload (template)
{
  "event_type": "billing.invoice.finalized.v1",
  "filter_predicate": {}
}
Example request
{
  "event_type": "billing.invoice.finalized.v1",
  "filter_predicate": {}
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-webhookW7 · test wave
Depends on
[
  "POST /api/auth/signup-tenant"
]
Implemented in
packages/sdk-webhook/src/server/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
event_type: billing.invoice.finalized.v1, billing.invoice.paid.v1, billing.dunning.advanced.v1, billing.reprice.dry-run.completed.v1
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\webhooks\publish-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in 'Bearer <jwt>' form (requireAuth preHandler)
401UnauthorizedInvalid or expired tokenJWT signature invalid, malformed, or exp has elapsed
400ValidationErrortenant_id must be a UUID / event_type is required / event_id is required / payload object is requiredany validatePublish check fails; all failures are returned together in details[]
500InternalErrorInternalErrorUnexpected service/DB failure (connection loss, or a constraint the handler does not map)
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Publish billing.invoice.finalized.v1 for tenant → expects HTTP 202
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "success": true,
  "data": {
    "status": "accepted",
    "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 202.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "deliveries_enqueued": "number",
    "delivery_ids": "array"
  }
}

sdk-workflow

4 API(s)
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.

SDK / service
sdk-workflowW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/workflows/definitions",
  "POST /api/workflows/start"
]
Implemented in
packages/sdk-workflow/src/server/handlers/workflowController.ts
Source spec
tests\api_definitions\workflows\id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
404NotFoundRun <run_id> not foundgetRun returns no record for the run_id
409InvalidState<error message containing "not found" or "not in running">getRun throws an error whose message matches the fail() state matcher
500InternalErrorInternalErrorgetRun throws any other error - a non-UUID run_id failing the uuid cast, or any DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Query run state → expects HTTP 200
Path params
{
  "run_id": "{{cache:workflows.start.response.data.run_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "workflow_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-workflowW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/workflows/definitions",
  "POST /api/workflows/start"
]
Implemented in
packages/sdk-workflow/src/server/handlers/workflowController.ts
Source spec
tests\api_definitions\workflows\id-signal-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrorsignal_name is requiredvalidateSignal fails - signal_name absent or empty
400StepHandlerNotFoundNo step handler registered for '<name>'the signal advances the run to a step whose handler is not registered in this process (StepHandlerNotFoundError)
400WorkflowMissingHandlersWorkflow definition missing step handlers: <names>the run definition resolves to steps with no registered handlers (WorkflowDefinitionMissingHandlersError)
404WorkflowDefinitionNotFoundWorkflow definition <name> not foundsignal raises WorkflowDefinitionNotFoundError because the run definition is no longer registered
409InvalidState<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
500InternalErrorInternalErrorsignal throws any other error - engine failure or a DB error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Send approval signal to a run → expects HTTP 200
Path params
{
  "run_id": "{{cache:workflows.start.response.data.run_id}}"
}
Payload (template)
{
  "signal_name": "approve",
  "payload": {
    "approver_id": "{{cache:auth.register.response.data.userId}}"
  }
}
Example request
{
  "signal_name": "approve",
  "payload": {
    "approver_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
sdk-workflowW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
packages/sdk-workflow/src/server/handlers/workflowController.ts
Source spec
tests\api_definitions\workflows\definitions-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrorname 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
400WorkflowMissingHandlersWorkflow definition missing step handlers: <names>registerWorkflow raises WorkflowDefinitionMissingHandlersError - one or more steps named in step_specs have no handler registered in this process
400StepHandlerNotFoundNo step handler registered for '<name>'registerWorkflow raises StepHandlerNotFoundError while resolving a step or compensation handler
409InvalidState<error message containing "not found" or "not in running">a service error whose message matches the fail() state matcher
500InternalErrorInternalErrorregisterWorkflow throws any other error - a DB failure or unmapped service error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register a 2-step workflow using boot-registered step handlers → expects HTTP 201
Path params
Payload (template)
{
  "name": "qa-workflow-demo",
  "version": "1.0.0",
  "namespace": "admin",
  "step_specs": [
    {
      "name": "dunning.send-reminder",
      "compensate": "dunning.rollback-reminder"
    },
    {
      "name": "dunning.write-off"
    }
  ]
}
Example request
{
  "name": "qa-workflow-demo",
  "version": "1.0.0",
  "namespace": "admin",
  "step_specs": [
    {
      "name": "dunning.send-reminder",
      "compensate": "dunning.rollback-reminder"
    },
    {
      "name": "dunning.write-off"
    }
  ]
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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).

SDK / service
sdk-workflowW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/personas",
  "POST /policy/:id/evaluate",
  "POST /scim/v2/Users",
  "POST /api/workflows/definitions"
]
Implemented in
packages/sdk-workflow/src/server/handlers/workflowController.ts
Request field options
envelope.actor.kind: human, service, agent, support_impersonator
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\workflows\start-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not Bearer form (requireAuth)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification (requireAuth)
400ValidationErrordetails[]: name is required / body must be an objectvalidateStartRun fails
404WorkflowDefinitionNotFoundNo active workflow definition for name=... version=... namespace=...startRun finds no active definition
400StepHandlerNotFoundstep handler not registeredexecuteRun hits a step with no in-process handler
500InternalErrorInternalErrorany other unmapped error
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Start qa-workflow-demo run (platform-scoped) → expects HTTP 201
Path params
Payload (template)
{
  "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"
  }
}
Example request
{
  "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"
  }
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 201.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "run_id": "string",
    "status": "string",
    "steps": "array",
    "output": "object"
  }
}

semantic-service

13 API(s)
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 } }.

SDK / service
semantic-serviceW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Source spec
tests\api_definitions\build-plan\plan-post.json
Error responses
HTTPCodeMessageWhen it happens
401unauthorizedunauthorizedThe 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.
400IntentRequiredintent is requiredbody.intent is missing, null, or trims to the empty string (whitespace-only counts as empty).
400IntentTooLongintent exceeds 2000 charactersThe trimmed intent is longer than 2000 characters — oversized-input guard that keeps the compose prompt bounded.
422NoCandidateSdksno candidate SDKs matched the intent; try describing the app differentlyAfter retrieve (top-K=20) + foundation injection + dependency expansion, resolveCandidates() returns an empty list — nothing in the catalog scored against the intent.
500CatalogLoadFailedfailed to load SDK catalog: <underlying error>loadCatalogWithMeta() throws while reading sdk-capability.json manifests off disk (unreadable dir, malformed manifest JSON).
500CatalogEmptyno 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/.
502PlanComposeFailed<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.
503LlmProviderUnavailableNo LLM provider configured / <PROVIDER>_API_KEY is not setgenerateBuildPlan() 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.
500InternalErrorInternal Server Errorawait 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.
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Compose a plan for a financial accounting app → expects HTTP 200
Path params
Payload (template)
{
  "intent": "A financial accounting system with invoices, AR/AP and monthly close"
}
Example request
{
  "intent": "A financial accounting system with invoices, AR/AP and monthly close"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "plan": {
    "recommended_sdks": []
  }
}
Reject empty intent → expects HTTP 400
Path params
Payload (template)
{
  "intent": ""
}
Example request
{
  "intent": ""
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 400.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400
Reject unauthenticated request → expects HTTP 401
Path params
Payload (template)
{
  "intent": "a dispatch app"
}
Example request
{
  "intent": "a dispatch app"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 401.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
semantic-serviceW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
services/semantic-service/src/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\bridge\index-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — /bridge is not on the authGate.ts public allowlist, so requireAuth rejects the request before the route handler
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or expired)
500Internal Server ErrorInternal Server ErrorlistBridges() throws (semantic schema missing, DB pool unavailable) — uncaught, so Fastify's default error handler responds
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List all registered cross-domain bridges → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "bridge_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
semantic-serviceW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /ontology/register"
]
Implemented in
services/semantic-service/src/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
access_mode: read-only, read-write
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\bridge\index-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — /bridge is not on the authGate.ts public allowlist, so requireAuth rejects before the handler
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or expired)
400ValidationErrorfrom_object_type_id and to_object_type_id requiredBody is missing/empty, or either id is absent or an empty string
400BridgeCreateFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register a cross-domain bridge between two object types → expects HTTP 200
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
semantic-serviceW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /ontology/register"
]
Implemented in
services/semantic-service/src/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: proposed, approved, executing, completed, abandoned
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\intent\plan-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — authGate.ts default-deny gate rejects /intent/plan before the handler
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or expired)
400ValidationErrorintent missing required fieldsBody missing, `intent` absent, or any of intent.tenant_id / ontology_id / goal / subject / trace_id is missing or falsy
400PlanFailed[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)
400PlanFailed[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
400PlanFailed[sdk-semantic] cannot persist intent — subject type '<type>' not registeredensureIntentRow cannot resolve the subject object_type_id when creating the backing semantic.intent row
400PlanFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Plan a goal that matches a seeded capability edge (subject Patient) → expects HTTP 200
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
semantic-serviceW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /ontology/register"
]
Implemented in
services/semantic-service/src/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\ontology\index-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenMounted 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
500InternalErrorInternal Server ErrorlistOntologies throws (database unreachable); the route has no try/catch, so the failure surfaces through the default Fastify error handler
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List all registered ontologies → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "ontology_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
semantic-serviceW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /ontology/register"
]
Implemented in
services/semantic-service/src/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\ontology\id-deprecate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenMounted 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
404NotFound<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "semantic.ontology",
  "field": "status",
  "flow": [
    "draft",
    "active",
    "deprecated",
    "retired"
  ],
  "transitions": [
    {
      "from": "active",
      "to": "deprecated",
      "via": "POST /ontology/:id/deprecate"
    }
  ]
}
Deprecate a registered ontology version → expects HTTP 200
Path params
{
  "id": "{{cache:ontology.register.response.data.ontology.ontology_id}}"
}
Payload (template)
{
  "reason": "superseded by healthcare-core 2.0.0"
}
Example request
{
  "reason": "superseded by healthcare-core 2.0.0"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
semantic-serviceW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
services/semantic-service/src/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\ontology\name-active-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenMounted 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
404NotFound<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Fetch the active ontology by name (seeded always-active ontology) → expects HTTP 200
Path params
{
  "name": "{{var:active_ontology_name}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "active_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
semantic-serviceW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
services/semantic-service/src/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
cardinality: 1:1, 1:N, N:N
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\ontology\register-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenMounted 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
400ValidationErrorbundle and bundle_ref requiredThe body is missing bundle or bundle_ref
400RegistrationError<error message from registerOntology>registerOntology throws — invalid bundle structure, duplicate bundle_ref, or an unresolvable reference inside the bundle. The raw message is returned
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Register a self-contained domain ontology bundle (activate on register) → expects HTTP 200
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
semantic-serviceW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /ontology/register",
  "POST /intent/plan"
]
Implemented in
services/semantic-service/src/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\plan\id-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — authGate.ts default-deny gate rejects /plan/:id before the handler
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or expired)
404NotFoundplan <id> not foundgetPlan returns null — the plan_id is a valid UUID but no semantic.intent_plan row matches
500Internal Server ErrorInternal Server ErrorgetPlan 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Read a plan produced by POST /intent/plan → expects HTTP 200
Path params
{
  "id": "{{cache:intent.plan.response.data.plan_id}}"
}
Payload (template)
Expected output ✓
{
  "success": true,
  "data": {
    "plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "active",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
semantic-serviceW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /ontology/register",
  "POST /intent/plan"
]
Implemented in
services/semantic-service/src/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Request field options
status: proposed, approved, executing, completed, abandoned
Allowed values (enum / DB-CHECK) for these request fields you send — QA should exercise each option.
Source spec
tests\api_definitions\plan\id-status-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenNo Authorization: Bearer header — authGate.ts default-deny gate rejects /plan/:id/status before the handler
401UnauthorizedInvalid or expired tokenBearer token fails verifyJwt (bad signature, malformed, or expired)
400ValidationErrorstatus requiredBody missing entirely, or body.status absent / empty string
404NotFound[sdk-semantic] plan '<id>' not foundThe UPDATE matches no row — plan_id is a valid UUID but does not exist
404PlanStatusUpdateFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "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"
    }
  ]
}
Advance a freshly-proposed plan to approved → expects HTTP 200
Path params
{
  "id": "{{cache:intent.plan.response.data.plan_id}}"
}
Payload (template)
{
  "status": "approved"
}
Example request
{
  "status": "approved"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
semantic-serviceW6 · test wave
Depends on
[
  "POST /api/auth/register"
]
Implemented in
services/semantic-service/src/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\policy\index-get.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
500InternalServerErrorInternal Server ErrorlistPolicies 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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
List registered semantic policies → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "policy_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
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.

SDK / service
semantic-serviceW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /policy/register"
]
Implemented in
services/semantic-service/src/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\policy\id-evaluate-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
400ValidationErrorsubject_type, action, resource_type, trace_id requiredany of subject_type, action, resource_type or trace_id is absent or empty
404EvaluateFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Evaluate an active semantic policy (happy path) → expects HTTP 200
Path params
{
  "id": "{{cache:policy.register.response.data.policy_id}}"
}
Payload (template)
{
  "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}}"
}
Example request
{
  "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}}"
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "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.

SDK / service
semantic-serviceW6 · test wave
Depends on
[
  "POST /api/auth/register",
  "POST /api/auth/signup-tenant",
  "POST /ontology/register"
]
Implemented in
services/semantic-service/src/routes.ts
Status
auto-generated from route scan — review payload/response before enabling automated tests
Source spec
tests\api_definitions\policy\register-post.json
Error responses
HTTPCodeMessageWhen it happens
401UnauthorizedMissing bearer tokenAuthorization header absent or not in Bearer form - gateway default-deny authGate onRequest hook (AUTH_GATE_MODE=enforce)
401UnauthorizedInvalid or expired tokenBearer token fails JWT verification in the gateway authGate
400ValidationErrorontology_id, name, iql_source requiredany of ontology_id, name or iql_source is absent or empty
400RegisterFailed<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
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Status transitions
{
  "entity": "semantic.policy",
  "field": "status",
  "flow": [
    "draft",
    "active",
    "deprecated"
  ],
  "transitions": [
    {
      "from": "draft",
      "to": "active",
      "via": "POST /policy/register"
    }
  ]
}
Register + activate a Doctor→Prescription semantic policy → expects HTTP 200
Path params
Payload (template)
{
  "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
}
Example request
{
  "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
}
Expected output ✓
{
  "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"
  }
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "unauthorized: missing or invalid token"
}
e.g. HTTP 401
Runner assertion
{
  "data": {
    "policy_id": "string",
    "tenant_id": "string",
    "ontology_id": "string",
    "name": "string",
    "status": "string"
  }
}

telemetry

1 API(s)
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.

SDK / service
Source spec
tests\api_definitions\observability\metrics-get.json
Error responses
HTTPCodeMessageWhen it happens
500InternalServerErrorInternal Server ErrormetricsRegistry.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.
Enumerated from the handler — every error the endpoint can return, with the condition that triggers it.
Scrape Prometheus metrics → expects HTTP 200
Path params
Payload (template)
Expected output ✓
{
  "success": true,
  "data": [
    {
      "metric_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": "active"
    }
  ],
  "total": 1
}
Illustrative — standard {success,data} envelope derived from the request contract; assert shape + HTTP 200.
On error
{
  "success": false,
  "error": "validation failed: a required field is missing or invalid"
}
e.g. HTTP 400