{"openapi":"3.0.3","info":{"title":"Airbrx Admin API","version":"2.0.0","description":"Admin API for managing Airbrx Data Proxy configurations, users, tenants, cache metadata, and AI-powered rule recommendations.\n\n## Authentication\n\nThe API supports multiple authentication methods:\n- **Google OAuth 2.0** - For user authentication via `/login` and `/callback` endpoints\n- **Bearer JWT Token** - Airbrx JWT tokens issued after OAuth authentication\n- **Personal Access Tokens (PAT)** - Service-account credentials exchanged for a short-lived JWT via `POST /oauth/token` (`grant_type=pat_exchange`); the returned JWT is the bearer\n\n## Authorization (RBAC)\n\nRole-Based Access Control is enforced via JWT scopes.\nEach user has a primary role (e.g., `admin`, `user`, `guest`) that determines access to endpoints.\n\n## Request limits\n\nRequest bodies on `POST`, `PUT`, and `PATCH` are capped at 1 MiB (1048576 bytes). An oversized body is rejected with `413` before it is read, parsed, or authenticated, so this response can be returned on any endpoint regardless of credentials.","contact":{"name":"Airbrx Support","url":"https://airbrx.com"},"license":{"name":"Proprietary"}},"servers":[{"url":"https://api.airbrx.ai","description":"Production server"}],"security":[{"BearerAuth":[]},{"OAuth2":[]}],"tags":[{"name":"API","description":"API metadata and discovery"},{"name":"Authentication","description":"OAuth 2.0 authentication flows using Descope (`/auth/login`, `/auth/callback`) supporting multiple identity providers (Google, Office 365)."},{"name":"Users","description":"User management endpoints"},{"name":"Roles","description":"User role management"},{"name":"Tenants","description":"Tenant assignment for users"},{"name":"Configuration","description":"Tenant configuration management"},{"name":"Cache","description":"Cache metadata management"},{"name":"Summaries","description":"Daily and yearly usage summaries"},{"name":"AI","description":"AI-powered rule recommendations"},{"name":"Logs","description":"Log summarization and management"},{"name":"Permissions","description":"Delegated permission grant management. Allows admins to grant tenant access to other users with optional delegation rights."},{"name":"Markers","description":"Cache invalidation marker management. Markers signal that cached query results are stale and should be refreshed."},{"name":"Accounts","description":"Account management. Accounts represent billing entities that can own multiple tenants and registered domains."},{"name":"Domains","description":"Domain registration management. Domains are registered to accounts and used to validate FQDN assignments."},{"name":"FQDNs","description":"FQDN (Fully Qualified Domain Name) management. FQDNs are assigned to tenants and used for proxy routing."}],"paths":{"/":{"get":{"tags":["API"],"summary":"Get OpenAPI contract","description":"Returns the OpenAPI 3.0 specification for this API","operationId":"getOpenApiContract","security":[],"responses":{"200":{"description":"OpenAPI contract","content":{"application/json":{"schema":{"type":"object","description":"OpenAPI 3.0 specification"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/auth/login":{"get":{"tags":["Authentication"],"summary":"Initiate Descope login flow","description":"Redirects to Descope hosted authentication page. Supports multiple identity providers (Google, Office 365) configured in Descope dashboard. After user authenticates, Descope redirects to `/auth/callback`.","operationId":"authLogin","security":[],"parameters":[{"name":"redirect","in":"query","description":"URL path to redirect to after successful login","schema":{"type":"string","default":"/"},"example":"/dashboard"},{"name":"return_to","in":"query","description":"Alternative parameter name for redirect path","schema":{"type":"string"}}],"responses":{"302":{"description":"Redirect to Descope authentication page","headers":{"Location":{"schema":{"type":"string"},"description":"Descope authentication URL"}}},"500":{"description":"Descope not configured"}}}},"/auth/callback":{"get":{"tags":["Authentication"],"summary":"Descope OAuth callback endpoint","description":"Handles Descope callback, validates session token, creates/updates user profile in S3, generates short-lived access token and long-lived refresh token, and redirects to original URL with tokens. Access token expires in 1 hour (configurable via ACCESS_TOKEN_EXPIRY), refresh token expires in 30 days (configurable via REFRESH_TOKEN_EXPIRY). Use POST /oauth/token with grant_type=refresh_token to get new access tokens. Works with any identity provider configured in Descope (Google, Office 365, etc.).","operationId":"authCallback","security":[],"parameters":[{"name":"code","in":"query","required":true,"description":"Authorization code from Descope","schema":{"type":"string"}},{"name":"state","in":"query","description":"Base64-encoded JSON with redirectTo and timestamp","schema":{"type":"string"}}],"responses":{"302":{"description":"Redirect to original URL with access and refresh tokens","headers":{"Location":{"schema":{"type":"string"},"description":"Original URL with access_token, refresh_token, and expires_in query parameters. Example: https://dashboard.airbrx.com/?access_token=eyJ...&refresh_token=airbrx_refresh_eyJ...&expires_in=3600"}}},"400":{"description":"Missing authorization code"},"401":{"description":"Authentication failed"},"500":{"description":"Descope not configured"}}}},"/users":{"get":{"tags":["Users"],"summary":"List all users","description":"Returns a list of user objects stored in S3 with HAL-style links. Optional `?userid` query parameter narrows the response to userids whose email contains the given substring (case-insensitive). For an exact-match lookup, call `GET /users/{email}` directly.","operationId":"listUsers","parameters":[{"name":"userid","in":"query","required":false,"description":"Case-insensitive substring filter against the userid (email). When omitted or empty, every user the caller can see is returned.","schema":{"type":"string"},"example":"mich"}],"responses":{"200":{"description":"List of users with HAL links","content":{"application/json":{"schema":{"type":"object","properties":{"_links":{"type":"object","properties":{"self":{"type":"object","properties":{"href":{"type":"string","example":"/users"}}},"parent":{"type":"object","properties":{"href":{"type":"string","example":"/"}}},"items":{"type":"array","items":{"type":"object","properties":{"email":{"type":"string","format":"email","example":"user@example.com"},"type":{"type":"string","enum":["user"]},"href":{"type":"string","example":"/users/user@example.com"}}}}}}}}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tos":{"get":{"tags":["ToS"],"summary":"Get the active Terms of Service (markdown)","description":"Returns the active ToS as raw markdown. Public — no authentication required so the dashboard can render the modal before the user has any token. The X-Tos-Hash response header carries the MD5 the dashboard should POST back to /users/{email}/tos.","operationId":"getCurrentTos","responses":{"200":{"description":"Active ToS markdown","headers":{"X-Tos-Hash":{"description":"MD5 (hex) of the normalized active ToS — POST this exact value back when acknowledging.","schema":{"type":"string","pattern":"^[0-9a-f]{32}$"}}},"content":{"text/markdown":{"schema":{"type":"string"}}}},"503":{"description":"API has not yet loaded the ToS (boot-ordering edge case)"}}},"post":{"tags":["ToS"],"summary":"Publish a new active Terms of Service","description":"Makes the submitted markdown the active ToS. Requires a platform-wide credential (a PAT carrying wildcard accounts or tenants) — the ToS is a single global document, not scoped to a tenant or account, so ordinary role permissions do not grant it.\n\nWrites in a fixed order: the history object, then the publish audit record, then current.md last. A publish that fails partway therefore leaves an orphan history object rather than a live doc whose hash cannot be rendered.\n\nPublishing changes the hash, which makes every user's recorded acknowledgement stale and re-prompts them at their next token mint. Other running containers pick the new document up within their refresh interval.","operationId":"publishTos","requestBody":{"required":true,"description":"JSON wrapping the full markdown to publish.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TosPublishRequest"}}}},"responses":{"200":{"description":"Submitted document is already active — no write performed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TosPublishResult"}}}},"201":{"description":"New ToS published and now active","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TosPublishResult"}}}},"400":{"description":"Body missing markdown, or the document is empty after normalization"},"403":{"description":"Caller is not a platform-wide principal"}}}},"/tos/{md5}":{"get":{"tags":["ToS"],"summary":"Get a historical ToS by content hash","description":"Returns the markdown of a previously-published ToS version. Used by audit/support flows that need to render the exact document a user accepted on a given date. The hash must be the 32-char hex MD5 of the normalized markdown; lookups are content-addressed against tos/history/{md5}.md.","operationId":"getHistoricalTos","parameters":[{"name":"md5","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9a-f]{32}$"},"description":"MD5 (hex) of the historical ToS version"}],"responses":{"200":{"description":"Historical ToS markdown","headers":{"X-Tos-Hash":{"schema":{"type":"string","pattern":"^[0-9a-f]{32}$"}}},"content":{"text/markdown":{"schema":{"type":"string"}}}},"400":{"description":"Malformed hash (must be 32-char hex)"},"404":{"description":"No historical ToS found for the supplied hash"}}}},"/users/{email}":{"get":{"tags":["Users"],"summary":"Get user by email","description":"Retrieve complete user profile including roles, tenants, and OAuth data","operationId":"getUser","parameters":[{"$ref":"#/components/parameters/EmailParam"}],"responses":{"200":{"description":"User profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"put":{"tags":["Users"],"summary":"Update user profile","description":"Update or create a user profile. Stores directly to S3.","operationId":"updateUser","parameters":[{"$ref":"#/components/parameters/EmailParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}},"responses":{"200":{"description":"User updated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"delete":{"tags":["Users"],"summary":"Delete user","description":"Permanently delete a user profile from S3","operationId":"deleteUser","parameters":[{"$ref":"#/components/parameters/EmailParam"}],"responses":{"200":{"description":"User deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"User user@example.com deleted successfully"}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/users/{email}/roles":{"get":{"tags":["Roles"],"summary":"Get user roles","description":"Retrieve the roles object for a user","operationId":"getUserRoles","parameters":[{"$ref":"#/components/parameters/EmailParam"}],"responses":{"200":{"description":"User roles","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Roles"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"put":{"tags":["Roles"],"summary":"Replace user roles","description":"Replace the entire roles object for a user (full replace). Realigned from POST to follow standard REST verb semantics.","operationId":"replaceUserRoles","parameters":[{"$ref":"#/components/parameters/EmailParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Roles"}}}},"responses":{"200":{"description":"Roles replaced successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Roles"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"patch":{"tags":["Roles"],"summary":"Merge user roles","description":"Update user roles by merging with existing roles. Realigned from PUT to follow standard REST verb semantics.","operationId":"mergeUserRoles","parameters":[{"$ref":"#/components/parameters/EmailParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Roles"}}}},"responses":{"200":{"description":"Roles merged successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Roles"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/users/{email}/tenants":{"get":{"tags":["Tenants"],"summary":"Get user tenant memberships","description":"Retrieve the structured tenant memberships for a user. Returns [{tenantId, role}] where role is owner|admin|member.","operationId":"getUserTenants","parameters":[{"$ref":"#/components/parameters/EmailParam"}],"responses":{"200":{"description":"User tenant memberships","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantMembershipList"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"put":{"tags":["Tenants"],"summary":"Replace user tenant memberships","description":"Replace the entire tenants array for a user with a structured [{tenantId, role}] array. Realigned from POST to follow standard REST verb semantics.","operationId":"replaceUserTenants","parameters":[{"$ref":"#/components/parameters/EmailParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantMembershipList"}}}},"responses":{"200":{"description":"Tenant memberships replaced","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantMembershipList"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"patch":{"tags":["Tenants"],"summary":"Append user tenant memberships","description":"Add tenant memberships to the existing list. tenantId collisions preserve the existing entry (no silent role demotion). Realigned from PUT to follow standard REST verb semantics.","operationId":"appendUserTenants","parameters":[{"$ref":"#/components/parameters/EmailParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantMembershipList"}}}},"responses":{"200":{"description":"Tenant memberships appended","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantMembershipList"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/users/{email}/tos":{"get":{"tags":["ToS"],"summary":"List a user's ToS acknowledgement history","description":"Returns the user's ToS acknowledgements newest-first plus the active hash so the caller can tell at a glance whether the user is up-to-date. Restricted to the user themselves under normal RBAC. Reachable by a tos_required bridge token so the dashboard can show 'last accepted' on the modal.","operationId":"listUserTosAcks","parameters":[{"$ref":"#/components/parameters/EmailParam"}],"responses":{"200":{"description":"Acknowledgement history","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TosAckList"}}}},"403":{"description":"Caller may only view their own acknowledgements"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"post":{"tags":["ToS"],"summary":"Acknowledge the active Terms of Service","description":"Records the user's acceptance of the active ToS. Body is JSON: `{ \"markdown\": \"<the raw markdown the user was shown>\" }`. The server normalizes (CRLF→LF, trims trailing whitespace per line, strips edge blank lines) and computes MD5. On hash match, writes users/{email}/tos/{md5}.json plus updates users/{email}.json with `tos: <md5>` and returns 204. On mismatch, returns 409 with the current hash so the dashboard can re-fetch /tos and re-prompt. Reachable by a tos_required bridge token; subsequent token refresh drops the bridge claim.","operationId":"acknowledgeTos","parameters":[{"$ref":"#/components/parameters/EmailParam"}],"requestBody":{"required":true,"description":"JSON wrapping the raw ToS markdown the user was shown.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TosAckRequest"}}}},"responses":{"204":{"description":"Acknowledgement recorded; dashboard should refresh its access token to drop the tos_required claim."},"400":{"description":"Empty or non-string body"},"409":{"description":"Submitted markdown does not match the active ToS — re-fetch /tos and re-prompt","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TosHashMismatch"}}}},"500":{"description":"User record missing — cannot record acknowledgement"},"503":{"description":"API has not yet loaded the active ToS"}}}},"/config/tenants":{"get":{"tags":["Configuration"],"summary":"List accessible tenants","description":"Returns a stable HATEOAS list of tenants the caller has access to. Each item is `{ name: <tenantId>, href: /config/tenants/<tenantId> }` — the data-proxy gateway boots from this shape, so it must not drift. Backed by `config/tenants/manifest.json` (single GetObject), filtered to the JWT/PAT's tenant scope. Tombstones are always excluded. For search/filter (substring on `tenantName`, tombstone visibility, incremental sync), call `GET /config/tenants/manifest`.","operationId":"listTenants","responses":{"200":{"description":"HATEOAS list of accessible tenants","content":{"application/json":{"schema":{"type":"object","properties":{"_links":{"type":"object","properties":{"self":{"type":"object","properties":{"href":{"type":"string","example":"/config/tenants"}}},"parent":{"type":"object","properties":{"href":{"type":"string","example":"/"}}},"items":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","format":"uuid","description":"The tenantId.","example":"ac8b55ba-610f-49c0-b183-9b4f37aee059"},"href":{"type":"string","example":"/config/tenants/ac8b55ba-610f-49c0-b183-9b4f37aee059"}},"required":["name","href"]}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}},"post":{"tags":["Configuration"],"summary":"Create a new tenant","description":"Creates a new tenant with a server-generated UUID. Requires a valid accountId and enforces FQDN lifecycle rules.","operationId":"createTenant","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantCreate"}}}},"responses":{"201":{"description":"Tenant created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantConfig"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Account not found"},"409":{"description":"Conflict: FQDN already claimed or tombstoned, or tenant name not unique within account"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/tenants/manifest":{"get":{"tags":["Configuration"],"summary":"Tenant change manifest (gateway change feed + admin search)","description":"Returns a list of every tenant the caller is authorized to see, with the latest `modifiedAt` and (when known) `tenantName` for each. Drives the data-proxy gateway's pull-based sync — the gateway polls with `?since=<lastSync>` to learn which tenants have moved since its last poll and refetches only those. Also serves the admin search surface (moved here from `/config/tenants`): `?name=<substring>` does a case-insensitive substring match against `tenantName`; entries that have not yet been backfilled with a `tenantName` are silently excluded from filtered results until the next read or write hydrates them. Tombstones (`deleted: true`) live for 30 days; excluded by default, surfaced with `?includeDeleted=true`. Results are filtered by the caller's tenant access: gateway/GOD PATs (`tenants: ['*']`) get the full manifest; scoped PATs and user JWTs see only their own tenants. Sorted newest-first by `modifiedAt`.","operationId":"getTenantManifest","parameters":[{"name":"since","in":"query","required":false,"description":"ISO 8601 timestamp. When supplied, only entries with `modifiedAt > since` are returned. Filtering short-circuits because the manifest is sorted newest-first.","schema":{"type":"string","format":"date-time"},"example":"2026-04-30T22:00:00Z"},{"name":"name","in":"query","required":false,"description":"Case-insensitive substring filter against `tenantName`. When omitted or empty, every tenant the caller can see is returned. Entries that have not yet been backfilled with a `tenantName` are silently excluded from filtered results until the next read or write hydrates them.","schema":{"type":"string"},"example":"acme"},{"name":"includeDeleted","in":"query","required":false,"description":"When `true`, manifest tombstones (recently-deleted tenants, retained 30 days) are included in the response with `deleted: true` on the entry.","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Tenant manifest snapshot","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantManifest"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/tenants/{tenant}":{"get":{"tags":["Configuration"],"summary":"Get tenant HATEOAS links","description":"Returns HATEOAS-style links to the tenant's config and rules endpoints","operationId":"getTenantLinks","parameters":[{"$ref":"#/components/parameters/TenantParam"}],"responses":{"200":{"description":"Tenant HATEOAS links","content":{"application/json":{"schema":{"type":"object","properties":{"_links":{"type":"object","properties":{"self":{"type":"object","properties":{"href":{"type":"string","example":"/config/tenants/demo.app.airbrx.com"}}},"parent":{"type":"object","properties":{"href":{"type":"string","example":"/config/tenants"}}},"config":{"type":"object","properties":{"href":{"type":"string","example":"/config/tenants/demo.app.airbrx.com/config"}}},"rules":{"type":"object","properties":{"href":{"type":"string","example":"/config/tenants/demo.app.airbrx.com/rules"}}}}},"tenantId":{"type":"string","example":"demo.app.airbrx.com"}}}}}}}},"delete":{"tags":["Configuration"],"summary":"Delete tenant","description":"Permanently delete a tenant and all its configuration files from the admin bucket. This removes the tenant folder and all files underneath (config, rules, etc.). Does not affect tenant data in the SaaS bucket.","operationId":"deleteTenant","parameters":[{"$ref":"#/components/parameters/TenantParam"}],"responses":{"200":{"description":"Tenant deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Tenant demo.app.airbrx.com deleted successfully"},"deletedFiles":{"type":"integer","description":"Number of files deleted","example":2}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/tenants/{tenant}/config":{"get":{"tags":["Configuration"],"summary":"Get tenant configuration","description":"Retrieve the config for a specific tenant","operationId":"getTenantConfig","parameters":[{"$ref":"#/components/parameters/TenantParam"}],"responses":{"200":{"description":"Tenant configuration","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantConfig"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"put":{"tags":["Configuration"],"summary":"Update tenant configuration","description":"Update or create config for a tenant","operationId":"updateTenantConfig","parameters":[{"$ref":"#/components/parameters/TenantParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantConfig"}}}},"responses":{"200":{"description":"Configuration updated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantConfig"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"409":{"$ref":"#/components/responses/Conflict"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/tenants/{tenant}/rules":{"get":{"tags":["Configuration"],"summary":"Get tenant cache rules","description":"Retrieve the rules for a specific tenant","operationId":"getTenantRules","parameters":[{"$ref":"#/components/parameters/TenantParam"}],"responses":{"200":{"description":"Tenant cache rules","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantRules"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"put":{"tags":["Configuration"],"summary":"Update tenant cache rules","description":"Update or create rules for a tenant","operationId":"updateTenantRules","parameters":[{"$ref":"#/components/parameters/TenantParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantRules"}}}},"responses":{"200":{"description":"Rules updated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantRules"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"409":{"description":"The stored ruleset changed after this request was validated. The write was rejected rather than overwriting the other edit; re-read the ruleset and retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/history":{"get":{"tags":["History"],"summary":"Search change history across tenants and accounts","description":"Every recorded change to rules, tenant config and account config, newest first.\n\nUse this to answer \"who changed what, and when\" across the system — filter by time window, by individual, or by kind. Rows are scoped to what the caller is authorized to see: a row whose tenant or account the caller has no grant on is excluded.\n\nCost tracks the window requested rather than total history, so a narrow `from`/`to` is cheap regardless of how much history exists.","operationId":"searchChangeHistory","parameters":[{"name":"from","in":"query","schema":{"type":"string","format":"date-time"},"description":"Inclusive lower bound (ISO-8601). Rejected if unparseable rather than silently ignored."},{"name":"to","in":"query","schema":{"type":"string","format":"date-time"},"description":"Inclusive upper bound (ISO-8601)"},{"name":"userId","in":"query","schema":{"type":"string"},"description":"Normalized email of the person whose changes to return"},{"name":"kind","in":"query","schema":{"type":"string","enum":["rules","config","account"]},"description":"Restrict to one document kind"},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200},"description":"Maximum rows to return (capped at 200)"}],"responses":{"200":{"description":"Matching changes","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeHistoryPage"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/tenants/{tenant}/rules/history":{"parameters":[{"name":"tenant","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Tenant UUID"}],"get":{"tags":["History"],"summary":"Ruleset change timeline","description":"This tenant's ruleset changes, newest first. Served from the tenant's own log, so cost is independent of system-wide history volume.","operationId":"listRulesHistory","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200},"description":"Maximum revisions to return (default 50, capped at 200)"}],"responses":{"200":{"description":"Ruleset timeline","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeHistoryPage"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/tenants/{tenant}/rules/history/diff":{"parameters":[{"name":"tenant","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Tenant UUID"}],"get":{"tags":["History"],"summary":"Compare two rulesets","description":"Rule-level delta between two revisions, computed on read. Rules are matched by id, so reordering alone is not reported as a change.","operationId":"diffRulesHistory","parameters":[{"name":"from","in":"query","required":true,"schema":{"type":"integer","minimum":0},"description":"Revision number to compare from"},{"name":"to","in":"query","required":true,"schema":{"type":"integer","minimum":0},"description":"Revision number to compare to"}],"responses":{"200":{"description":"Delta between the two revisions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeDiff"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/tenants/{tenant}/rules/history/{seq}":{"parameters":[{"name":"tenant","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Tenant UUID"},{"name":"seq","in":"path","required":true,"schema":{"type":"integer","minimum":0},"description":"Revision number"}],"get":{"tags":["History"],"summary":"Fetch a stored ruleset revision","operationId":"getRulesRevision","responses":{"200":{"description":"The stored revision","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRevision"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/tenants/{tenant}/rules/history/{seq}/restore":{"parameters":[{"name":"tenant","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Tenant UUID"},{"name":"seq","in":"path","required":true,"schema":{"type":"integer","minimum":0},"description":"Revision number to restore"}],"post":{"tags":["History"],"summary":"Restore a previous ruleset","description":"Stores the named revision as the current ruleset.\n\nThis is a normal write, not a rewind. The revision is re-validated and re-linted exactly as a PUT would be, so a ruleset that no longer passes today's schema is rejected rather than resurrected, and the result is recorded as a new change pointing back at its source.\n\nThe cache-bust token always moves FORWARD, never back to a value that was live before. Cross-rule invalidation markers are keyed by rule version, so a write that invalidated cached entries under the newer token left no marker under the older one — reusing it would serve data the warehouse has since changed. The warm cache is lost; that is the correct trade.","operationId":"restoreRulesRevision","responses":{"200":{"description":"Restored ruleset, with its new cache-bust token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantRules"}}}},"400":{"description":"The revision no longer passes validation or linting","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The ruleset changed while the restore was being prepared. Re-read and retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/tenants/{tenant}/config/history":{"parameters":[{"name":"tenant","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Tenant UUID"}],"get":{"tags":["History"],"summary":"Tenant config change timeline","operationId":"listTenantConfigHistory","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200},"description":"Maximum revisions to return (default 50, capped at 200)"}],"responses":{"200":{"description":"Tenant config timeline","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeHistoryPage"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/tenants/{tenant}/config/history/diff":{"parameters":[{"name":"tenant","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Tenant UUID"}],"get":{"tags":["History"],"summary":"Compare two tenant configs","operationId":"diffTenantConfigHistory","parameters":[{"name":"from","in":"query","required":true,"schema":{"type":"integer","minimum":0},"description":"Revision number to compare from"},{"name":"to","in":"query","required":true,"schema":{"type":"integer","minimum":0},"description":"Revision number to compare to"}],"responses":{"200":{"description":"Fields that differ between the two revisions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeDiff"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/tenants/{tenant}/config/history/{seq}":{"parameters":[{"name":"tenant","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Tenant UUID"},{"name":"seq","in":"path","required":true,"schema":{"type":"integer","minimum":0},"description":"Revision number"}],"get":{"tags":["History"],"summary":"Fetch a stored tenant config revision","operationId":"getTenantConfigRevision","responses":{"200":{"description":"The stored revision","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRevision"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/tenants/{tenant}/config/history/{seq}/restore":{"parameters":[{"name":"tenant","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Tenant UUID"},{"name":"seq","in":"path","required":true,"schema":{"type":"integer","minimum":0},"description":"Revision number to restore"}],"post":{"tags":["History"],"summary":"Restore a previous tenant config","description":"Stores the named revision as the current tenant config.\n\nRe-enters the normal write path, so FQDN lifecycle, accountId immutability, plan limits and tenantName uniqueness are all enforced against today's state. A config that claims an FQDN or a name that has since moved to another tenant is rejected rather than resurrected.","operationId":"restoreTenantConfigRevision","responses":{"200":{"description":"Restored tenant config","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantConfig"}}}},"400":{"description":"The revision no longer passes validation, or conflicts with current FQDN, plan or name state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The tenant config changed while the restore was being prepared. Re-read and retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/accounts/{accountId}/history":{"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Account UUID"}],"get":{"tags":["History"],"summary":"Account change timeline","description":"This account's changes, newest first. The timeline starts at the account's creation.","operationId":"listAccountHistory","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200},"description":"Maximum revisions to return (default 50, capped at 200)"}],"responses":{"200":{"description":"Account timeline","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeHistoryPage"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/accounts/{accountId}/history/{seq}":{"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Account UUID"},{"name":"seq","in":"path","required":true,"schema":{"type":"integer","minimum":0},"description":"Revision number"}],"get":{"tags":["History"],"summary":"Fetch a stored account revision","operationId":"getAccountRevision","responses":{"200":{"description":"The stored revision","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRevision"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/rules/validate":{"post":{"tags":["Configuration"],"summary":"Validate (lint) a candidate ruleset without persisting","description":"Stateless dry-run lint of a candidate ruleset. Touches no tenant and no storage — POST a ruleset, get the findings back. Runs the same checks the write path enforces — structural schema validation, staleWhileRevalidate cross-field rules, and the semantic linter (typo'd condition keys that silently match-all, unknown operators, uncompilable regexes, dangling invalidate references) — and returns them instead of saving. The rule-removed deletion-delta is not run here because there is no stored baseline to diff against; that guarantee lives on the write path. Open to any authenticated caller; the reusable surface the data-proxy gateway (pre-load) and the MCP rule-ops copilot (pre-submit) call to gate on the same findings as PUT.","operationId":"validateRules","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantRules"}}}},"responses":{"200":{"description":"The ruleset is clean — `valid` is true and `errors` is empty. `warnings` may still be present; warnings never make a ruleset invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RuleLintResult"}}}},"400":{"description":"The ruleset has blocking errors and would be rejected by a write. The body is the full lint result (`valid: false`) so the caller can see every reason. A grossly malformed payload that is not a well-formed ruleset is also rejected with 400, but with the generic Error shape from the upstream schema validation rather than a lint result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RuleLintResult"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/cachekeys/{cachekey}":{"get":{"tags":["Cache"],"summary":"Get cache metadata","description":"Retrieve metadata for a specific cache key","operationId":"getCacheMetadata","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"cachekey","in":"path","required":true,"description":"Cache key (hash)","schema":{"type":"string"},"example":"abc123def456"}],"responses":{"200":{"description":"Cache metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CacheMetadata"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"put":{"tags":["Cache"],"summary":"Update cache metadata","description":"Update metadata for a specific cache key","operationId":"updateCacheMetadata","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"cachekey","in":"path","required":true,"description":"Cache key (hash)","schema":{"type":"string"},"example":"abc123def456"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CacheMetadata"}}}},"responses":{"200":{"description":"Cache metadata updated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CacheMetadata"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/query":{"post":{"tags":["Query"],"summary":"Query tenant columnar logs","description":"Run a read-only SQL query against this tenant's columnar proxy-log store (the fixed proxy_logs view). Execution is delegated to a locked-down DuckDB service and results are capped with an enforced LIMIT.","operationId":"queryTenantLogs","parameters":[{"$ref":"#/components/parameters/TenantParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRequest"}}}},"responses":{"200":{"description":"Query results","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"description":"Caller is not a member of the tenant"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/invalidation-markers":{"get":{"tags":["Markers"],"summary":"List invalidation markers","description":"Retrieve all invalidation markers for a tenant with optional filtering","operationId":"listMarkers","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"status","in":"query","required":false,"description":"Filter by marker status","schema":{"type":"string","enum":["active","expired"]}},{"name":"targetRuleId","in":"query","required":false,"description":"Filter by target rule ID","schema":{"type":"string"}}],"responses":{"200":{"description":"List of markers","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkerSummary"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}},"post":{"tags":["Markers"],"summary":"Create invalidation marker","description":"Create a new invalidation marker (manual cache invalidation)","operationId":"createMarker","parameters":[{"$ref":"#/components/parameters/TenantParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkerInstance"}}}},"responses":{"201":{"description":"Marker created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkerInstance"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/invalidation-markers/{markerId}":{"get":{"tags":["Markers"],"summary":"Get marker by ID","description":"Retrieve a specific invalidation marker","operationId":"getMarker","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"$ref":"#/components/parameters/MarkerIdParam"}],"responses":{"200":{"description":"Marker details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkerInstance"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"put":{"tags":["Markers"],"summary":"Update marker","description":"Update an existing invalidation marker","operationId":"updateMarker","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"$ref":"#/components/parameters/MarkerIdParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkerUpdate"}}}},"responses":{"200":{"description":"Marker updated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkerInstance"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"delete":{"tags":["Markers"],"summary":"Delete marker","description":"Delete a specific invalidation marker","operationId":"deleteMarker","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"$ref":"#/components/parameters/MarkerIdParam"}],"responses":{"200":{"description":"Marker deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"}}}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/summaries/{year}":{"get":{"tags":["Summaries"],"summary":"Get yearly summary","description":"Retrieve yearly summary data for a tenant","operationId":"getYearlySummary","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"year","in":"path","required":true,"description":"Year (e.g., 2025)","schema":{"type":"integer"},"example":2025}],"responses":{"200":{"description":"Yearly summary data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/YearlySummary"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/summaries/{year}/rules":{"get":{"tags":["Summaries"],"summary":"Get rule effectiveness summary","description":"Retrieve cache rule effectiveness statistics for a year","operationId":"getRuleEffectiveness","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"year","in":"path","required":true,"description":"Year (e.g., 2025)","schema":{"type":"integer"},"example":2025}],"responses":{"200":{"description":"Rule effectiveness data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RuleEffectiveness"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/summaries/{year}/opportunities":{"get":{"tags":["Summaries"],"summary":"Get cache optimization opportunities","description":"Retrieve AI-identified opportunities for cache optimization","operationId":"getCacheOpportunities","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"year","in":"path","required":true,"description":"Year (e.g., 2025)","schema":{"type":"integer"},"example":2025}],"responses":{"200":{"description":"Cache optimization opportunities","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CacheOpportunities"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/summaries/{year}/{month}":{"get":{"tags":["Summaries"],"summary":"Get monthly summary","description":"Retrieve monthly summary data for a tenant","operationId":"getMonthlySummary","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"year","in":"path","required":true,"description":"Year (e.g., 2025)","schema":{"type":"integer"},"example":2025},{"name":"month","in":"path","required":true,"description":"Month (01-12)","schema":{"type":"string","pattern":"^(0[1-9]|1[0-2])$"},"example":"04"}],"responses":{"200":{"description":"Monthly summary data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MonthlySummary"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/summaries/{year}/{month}/{day}":{"get":{"tags":["Summaries"],"summary":"Get daily summary","description":"Retrieve daily summary data for a specific date","operationId":"getDailySummary","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"year","in":"path","required":true,"description":"Year (e.g., 2025)","schema":{"type":"integer"},"example":2025},{"name":"month","in":"path","required":true,"description":"Month (01-12)","schema":{"type":"string","pattern":"^(0[1-9]|1[0-2])$"},"example":"10"},{"name":"day","in":"path","required":true,"description":"Day of month (01-31)","schema":{"type":"string","pattern":"^(0[1-9]|[12][0-9]|3[01])$"},"example":"30"}],"responses":{"200":{"description":"Daily summary data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DailySummary"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/logs/{year}/{month}/{day}/{hour}/{file}":{"get":{"tags":["Logs"],"summary":"Get a raw tenant log object","description":"Retrieve a single raw request-log object. Raw logs are date-partitioned as logs/YYYY/MM/DD/HH/<file>.json. The caller must be a member of the tenant (GOD PAT and wildcard tenant scopes pass); each access is audit-logged.","operationId":"getTenantLogObject","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"year","in":"path","required":true,"description":"Year (e.g., 2026)","schema":{"type":"string","pattern":"^\\d{4}$"},"example":"2026"},{"name":"month","in":"path","required":true,"description":"Month (01-12)","schema":{"type":"string","pattern":"^(0[1-9]|1[0-2])$"},"example":"05"},{"name":"day","in":"path","required":true,"description":"Day of month (01-31)","schema":{"type":"string","pattern":"^(0[1-9]|[12][0-9]|3[01])$"},"example":"19"},{"name":"hour","in":"path","required":true,"description":"Hour of day (00-23)","schema":{"type":"string","pattern":"^([01][0-9]|2[0-3])$"},"example":"14"},{"name":"file","in":"path","required":true,"description":"Log file name (ends in .json)","schema":{"type":"string"},"example":"a1b2c3d4.json"}],"responses":{"200":{"description":"Raw log object","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/logs/{year}/{month}":{"get":{"tags":["Logs"],"summary":"List raw tenant logs under a date prefix","description":"Return a one-level HATEOAS listing of the raw-log tree under a date prefix. Any partial prefix lists its immediate children: /logs/{year} lists months, /logs/{year}/{month} lists days, /logs/{year}/{month}/{day} lists hours, and /logs/{year}/{month}/{day}/{hour} lists the log files for that hour. A leaf path ending in .json is an object read instead (see getTenantLogObject). The caller must be a member of the tenant (GOD PAT and wildcard tenant scopes pass); each access is audit-logged.","operationId":"listTenantLogs","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"year","in":"path","required":true,"description":"Year (e.g., 2026)","schema":{"type":"string","pattern":"^\\d{4}$"},"example":"2026"},{"name":"month","in":"path","required":true,"description":"Month (01-12)","schema":{"type":"string","pattern":"^(0[1-9]|1[0-2])$"},"example":"05"}],"responses":{"200":{"description":"HATEOAS listing of immediate child folders and log files","content":{"application/json":{"schema":{"type":"object","properties":{"_links":{"type":"object","properties":{"self":{"type":"object","properties":{"href":{"type":"string","example":"/tenants/{tenant}/logs/2026/05"}}},"parent":{"type":"object","properties":{"href":{"type":"string","example":"/tenants/{tenant}/logs/2026"}}},"items":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","example":"19"},"href":{"type":"string","example":"/tenants/{tenant}/logs/2026/05/19"}}}}}},"tenantId":{"type":"string"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/users":{"get":{"tags":["Summaries"],"summary":"Get all users summary index","description":"Retrieve summary statistics for all users of a tenant","operationId":"getUsersIndex","parameters":[{"$ref":"#/components/parameters/TenantParam"}],"responses":{"200":{"description":"Users summary index","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsersSummaryIndex"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/users/{userId}":{"get":{"tags":["Summaries"],"summary":"Get users summary index","description":"Retrieve summary statistics for all users of a tenant. The userId parameter is accepted but returns the full users index.","operationId":"getUserSummary","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"userId","in":"path","required":true,"description":"User ID (returns full users index)","schema":{"type":"string"},"example":"user@example.com"}],"responses":{"200":{"description":"Users summary index","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsersSummaryIndex"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/users/{userId}/{year}":{"get":{"tags":["Summaries"],"summary":"Get user yearly summary","description":"Retrieve yearly summary for a specific user","operationId":"getUserYearlySummary","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"userId","in":"path","required":true,"description":"User ID","schema":{"type":"string"},"example":"user@example.com"},{"name":"year","in":"path","required":true,"description":"Year (e.g., 2025)","schema":{"type":"integer"},"example":2025}],"responses":{"200":{"description":"User yearly summary data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserYearlySummary"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/users/{userId}/{year}/{month}":{"get":{"tags":["Summaries"],"summary":"List user daily summaries for a month","description":"List all available daily summary files for a specific user in a given month, returned in HATEOAS format","operationId":"listUserMonthlySummaries","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"userId","in":"path","required":true,"description":"User ID","schema":{"type":"string"},"example":"user@example.com"},{"name":"year","in":"path","required":true,"description":"Year (e.g., 2025)","schema":{"type":"integer"},"example":2025},{"name":"month","in":"path","required":true,"description":"Month (01-12)","schema":{"type":"string","pattern":"^(0[1-9]|1[0-2])$"},"example":"02"}],"responses":{"200":{"description":"HATEOAS listing of daily summary files","content":{"application/json":{"schema":{"type":"object","properties":{"_links":{"type":"object","properties":{"self":{"type":"object","properties":{"href":{"type":"string"}}},"parent":{"type":"object","properties":{"href":{"type":"string"}}},"items":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"href":{"type":"string"}}}}}},"tenantId":{"type":"string"},"userId":{"type":"string"},"year":{"type":"string"},"month":{"type":"string"}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenant}/users/{userId}/{year}/{month}/{day}":{"get":{"tags":["Summaries"],"summary":"Get user daily summary","description":"Retrieve daily summary for a specific user","operationId":"getUserDailySummary","parameters":[{"$ref":"#/components/parameters/TenantParam"},{"name":"userId","in":"path","required":true,"description":"User ID","schema":{"type":"string"},"example":"user@example.com"},{"name":"year","in":"path","required":true,"description":"Year (e.g., 2025)","schema":{"type":"integer"},"example":2025},{"name":"month","in":"path","required":true,"description":"Month (01-12)","schema":{"type":"string","pattern":"^(0[1-9]|1[0-2])$"},"example":"10"},{"name":"day","in":"path","required":true,"description":"Day of month (01-31)","schema":{"type":"string","pattern":"^(0[1-9]|[12][0-9]|3[01])$"},"example":"30"}],"responses":{"200":{"description":"User daily summary data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserDailySummary"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/airbrxai/rules-evaluator":{"post":{"tags":["AI"],"summary":"Generate cache rule recommendations","description":"Analyzes query patterns and generates AI-powered cache rule recommendations using Claude API. Requires `ANTHROPIC_API_KEY` environment variable to be set.","operationId":"evaluateRules","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RulesEvaluatorRequest"}}}},"responses":{"200":{"description":"AI-generated rule recommendations","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RulesEvaluatorResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"500":{"description":"AI service error or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/logs/summarize":{"post":{"tags":["Logs"],"summary":"Trigger log summarization","description":"Invokes the log-summarizer Lambda (in Lambda environment) or local module (in Docker/local) to process and summarize log data across all tenants. Supports incremental processing, full reset, or lock clearing.","operationId":"triggerLogSummarize","requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogSummarizeRequest"}}}},"responses":{"200":{"description":"Summarization completed successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogSummarizeResponse"}}}},"400":{"description":"Invalid mode specified","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Invalid mode: foo. Allowed: incremental, reset, unlock"}}}},"500":{"description":"Summarization failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/oauth/pats":{"post":{"tags":["OAuth"],"summary":"Create a Personal Access Token","description":"Create a new scoped PAT. Users can only grant permissions they have themselves.","operationId":"createPAT","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PATCreateRequest"}}}},"responses":{"201":{"description":"PAT created successfully. The 'pat' field contains the secret credential — it is returned only here, never stored, and cannot be recovered later.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PATCreateResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"409":{"description":"Conflict - a PAT with this label already exists in one of the requested tenants"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"get":{"tags":["OAuth"],"summary":"List user's Personal Access Tokens","description":"Returns all PATs belonging to the authenticated user with their status (valid/expired)","operationId":"listPATs","responses":{"200":{"description":"List of PATs","content":{"application/json":{"schema":{"type":"object","properties":{"pats":{"type":"array","items":{"$ref":"#/components/schemas/PATWithStatus"}}}}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/oauth/pats/{patId}":{"get":{"tags":["OAuth"],"summary":"Get PAT details","description":"Get full details of a PAT. Users can only view their own PATs unless they have full access.","operationId":"getPAT","parameters":[{"name":"patId","in":"path","required":true,"description":"PAT storage id — the 64-character hex hash returned in the create/list responses. Not the human-readable label.","schema":{"type":"string","pattern":"^[0-9a-f]{64}$"}}],"responses":{"200":{"description":"PAT details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PATWithStatus"}}}},"403":{"description":"Forbidden - can only view your own PATs"},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"tags":["OAuth"],"summary":"Revoke a Personal Access Token","description":"Delete/revoke a PAT. Users can only revoke their own PATs.","operationId":"revokePAT","parameters":[{"name":"patId","in":"path","required":true,"description":"PAT storage id — the 64-character hex hash returned in the create/list responses. Not the human-readable label.","schema":{"type":"string","pattern":"^[0-9a-f]{64}$"}}],"responses":{"200":{"description":"PAT revoked successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"}}}}}},"403":{"description":"Forbidden - can only revoke your own PATs"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/oauth/introspect":{"post":{"tags":["OAuth"],"summary":"Introspect a token (RFC 7662)","description":"Validate and get metadata for a token. Accepts the raw PAT secret (`airbrx_pat_<hex>`), a refresh token (`airbrx_refresh_<jwt>`), or a bare JWT (user or PAT-derived access token). The raw-PAT path is the only way for a PAT holder to ask 'what does this credential grant?' without first minting an access token — the storage key is `sha256(secret + JWT_SECRET)` so only the server can derive it. No authentication required — the token itself is the credential. Inactive responses are uniform across branches (no distinction between miss / invalid signature) to avoid existence leaks.","operationId":"introspectToken","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string","description":"Token to introspect. Accepts `airbrx_pat_<hex>` (raw PAT secret), `airbrx_refresh_<jwt>` (refresh token), or a bare JWT."}}}}}},"responses":{"200":{"description":"Token introspection result","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntrospectionResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/oauth/token":{"post":{"tags":["OAuth"],"summary":"Exchange PAT for access/refresh tokens or refresh an access token","description":"Token exchange endpoint supporting two grant types: 'pat_exchange' to exchange a PAT name for access and refresh tokens, and 'refresh_token' to get a new access token using a refresh token. The refresh_token grant supports both PAT-derived refresh tokens (sub='refresh', contains pat_hash) and user refresh tokens (sub='user_refresh', contains email) from the /auth/callback flow. PAT-derived access tokens include scopes for granular endpoint permissions; user access tokens use RBAC roles. No authentication required - credentials are passed in the request body.","operationId":"tokenExchange","security":[],"requestBody":{"required":true,"description":"OAuth clients (MCP SDK / Claude Code) send the authorization_code exchange as application/x-www-form-urlencoded per RFC 6749. The pat_exchange / refresh_token grants used by Airbrx's own clients accept application/json. Both encodings map to the same TokenExchangeRequest shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenExchangeRequest"}},"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/TokenExchangeRequest"}}}},"responses":{"200":{"description":"Token exchange successful","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenExchangeResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/.well-known/oauth-authorization-server":{"get":{"tags":["OAuth"],"summary":"OAuth 2.0 authorization server metadata (RFC 8414)","description":"Discovery document letting an OAuth/MCP client auto-find the authorization, token, and registration endpoints plus the supported PKCE method. No authentication required.","operationId":"oauthAuthorizationServerMetadata","security":[],"responses":{"200":{"description":"Authorization server metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthServerMetadata"}}}}}}},"/.well-known/jwks.json":{"get":{"tags":["OAuth"],"summary":"JSON Web Key Set (RFC 7517)","description":"Public ES256 key(s) used to sign user JWTs, so external clients can verify tokens offline without a shared secret. Returns an empty key set when asymmetric signing is not configured. No authentication required.","operationId":"jwks","security":[],"responses":{"200":{"description":"JSON Web Key Set","content":{"application/json":{"schema":{"type":"object","required":["keys"],"properties":{"keys":{"type":"array","items":{"type":"object","properties":{"kty":{"type":"string","example":"EC"},"crv":{"type":"string","example":"P-256"},"x":{"type":"string"},"y":{"type":"string"},"use":{"type":"string","example":"sig"},"alg":{"type":"string","example":"ES256"},"kid":{"type":"string"}}}}}}}}}}}},"/.well-known/openid-configuration":{"get":{"tags":["OAuth"],"summary":"OpenID-style discovery metadata for public-key verification","description":"Advertises the jwks_uri and issuer so an external verifier can auto-discover the public key used to sign user JWTs. No authentication required.","operationId":"openidConfiguration","security":[],"responses":{"200":{"description":"Discovery metadata","content":{"application/json":{"schema":{"type":"object","required":["issuer","jwks_uri"],"properties":{"issuer":{"type":"string","example":"airbrx.com"},"jwks_uri":{"type":"string","example":"https://api.airbrx.ai/.well-known/jwks.json"},"id_token_signing_alg_values_supported":{"type":"array","items":{"type":"string","example":"ES256"}},"subject_types_supported":{"type":"array","items":{"type":"string","example":"public"}},"claims_supported":{"type":"array","items":{"type":"string"}}}}}}}}}},"/register":{"post":{"tags":["OAuth"],"summary":"Dynamic client registration (RFC 7591)","description":"Register a public PKCE client and receive a client_id. No client_secret is issued (public clients authenticate via PKCE). Each redirect_uri must be https or an http loopback (localhost/127.0.0.1/::1). No authentication required.","operationId":"registerOAuthClient","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientRegistrationRequest"}}}},"responses":{"201":{"description":"Client registered","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientRegistrationResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/authorize":{"get":{"tags":["OAuth"],"summary":"OAuth 2.1 authorization endpoint (authorization_code + PKCE)","description":"Starts the authorization_code flow. Validates the client and redirect_uri, requires a PKCE S256 code_challenge, then redirects the browser to the dashboard consent page (login + approval). After redirect_uri validation, errors are delivered back to the client as an OAuth error redirect. No authentication required here — the consent page authenticates the user.","operationId":"oauthAuthorize","security":[],"parameters":[{"name":"response_type","in":"query","required":true,"schema":{"type":"string","enum":["code"]}},{"name":"client_id","in":"query","required":true,"schema":{"type":"string"}},{"name":"redirect_uri","in":"query","required":true,"description":"Must exactly match a registered redirect_uri","schema":{"type":"string"}},{"name":"code_challenge","in":"query","required":true,"description":"PKCE challenge: base64url(SHA-256(code_verifier))","schema":{"type":"string"}},{"name":"code_challenge_method","in":"query","required":true,"schema":{"type":"string","enum":["S256"]}},{"name":"state","in":"query","required":false,"schema":{"type":"string"}},{"name":"scope","in":"query","required":false,"schema":{"type":"string"}},{"name":"resource","in":"query","required":false,"description":"RFC 8707 resource indicator (recorded, not enforced in v1)","schema":{"type":"string"}}],"responses":{"302":{"description":"Redirect to the dashboard consent page, or back to redirect_uri with an OAuth error"},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/authorize/consent":{"post":{"tags":["OAuth"],"summary":"Approve or deny an authorization request and issue a code","description":"Called by the dashboard consent page with the user's JWT. On approve, mints a scoped PAT bounded to the user's grantable permissions and issues a one-time authorization code; on deny, returns an access_denied redirect. Returns the loopback redirect URL for the browser to navigate to. Requires JWT authentication (not a PAT).","operationId":"oauthAuthorizeConsent","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsentRequest"}}}},"responses":{"200":{"description":"Redirect URL (carrying code or error) for the browser to navigate to","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsentResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/oauth/available-permissions":{"get":{"tags":["OAuth"],"summary":"Get available permissions for PAT creation","description":"Returns the permissions and tenants the authenticated user can grant to a PAT","operationId":"getAvailablePermissions","responses":{"200":{"description":"Available permissions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvailablePermissions"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/permissions/grants":{"post":{"tags":["Permissions"],"summary":"Create a permission grant","description":"Grant access to a tenant to another user. Base admins can grant with delegation rights; delegators can only grant without delegation rights (level-2).","operationId":"createPermissionGrant","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantCreateRequest"}}}},"responses":{"201":{"description":"Grant created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"grant":{"$ref":"#/components/schemas/PermissionGrant"},"grantId":{"type":"string","example":"grant_abc123def456"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"description":"Forbidden - insufficient permissions to create grant"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/permissions/grants/by-me":{"get":{"tags":["Permissions"],"summary":"List grants made by me","description":"Returns all active permission grants created by the authenticated user","operationId":"listGrantsByMe","responses":{"200":{"description":"List of grants","content":{"application/json":{"schema":{"type":"object","properties":{"grants":{"type":"array","items":{"$ref":"#/components/schemas/PermissionGrant"}}}}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/permissions/grants/to-me":{"get":{"tags":["Permissions"],"summary":"List grants made to me","description":"Returns all active permission grants where the authenticated user is the grantee","operationId":"listGrantsToMe","responses":{"200":{"description":"List of grants","content":{"application/json":{"schema":{"type":"object","properties":{"grants":{"type":"array","items":{"$ref":"#/components/schemas/PermissionGrant"}}}}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/permissions/grants/tenant/{tenantId}":{"get":{"tags":["Permissions"],"summary":"List all grants for a tenant","description":"Returns all active grants for a specific tenant. Requires base admin access to the tenant.","operationId":"listGrantsByTenant","parameters":[{"name":"tenantId","in":"path","required":true,"description":"Tenant ID","schema":{"type":"string"},"example":"acme.app.airbrx.com"}],"responses":{"200":{"description":"List of grants","content":{"application/json":{"schema":{"type":"object","properties":{"grants":{"type":"array","items":{"$ref":"#/components/schemas/PermissionGrant"}}}}}}},"403":{"description":"Forbidden - only base administrators can list tenant grants"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/permissions/grants/account/{accountId}":{"get":{"tags":["Permissions"],"summary":"List all grants for an account","description":"Returns all active grants for a specific account. Requires active owner of the account — base owner or owner-grant recipient. GOD PATs bypass.","operationId":"listGrantsByAccount","parameters":[{"name":"accountId","in":"path","required":true,"description":"Account ID","schema":{"type":"string"}}],"responses":{"200":{"description":"List of grants","content":{"application/json":{"schema":{"type":"object","properties":{"grants":{"type":"array","items":{"$ref":"#/components/schemas/PermissionGrant"}}}}}}},"403":{"description":"Forbidden - only account owners can list account grants"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/permissions/grants/{grantId}":{"get":{"tags":["Permissions"],"summary":"Get grant details","description":"Retrieve details of a specific permission grant. Accessible by grantor, grantee, root admin, or tenant admin.","operationId":"getPermissionGrant","parameters":[{"name":"grantId","in":"path","required":true,"description":"Grant ID","schema":{"type":"string"},"example":"grant_abc123def456"}],"responses":{"200":{"description":"Grant details","content":{"application/json":{"schema":{"type":"object","properties":{"grant":{"$ref":"#/components/schemas/PermissionGrant"}}}}}},"403":{"description":"Forbidden - not authorized to view this grant"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"delete":{"tags":["Permissions"],"summary":"Revoke a grant","description":"Revoke a permission grant. If the grant has delegation rights, all child grants are cascade-revoked. Accessible by grantor, root admin, or tenant admin.","operationId":"revokePermissionGrant","parameters":[{"name":"grantId","in":"path","required":true,"description":"Grant ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Grant revoked successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"revokedAt":{"type":"string","format":"date-time"},"cascadeRevoked":{"type":"array","items":{"type":"string"},"description":"IDs of child grants that were cascade-revoked"}}}}}},"403":{"description":"Forbidden - not authorized to revoke this grant"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"patch":{"tags":["Permissions"],"summary":"Update a grant","description":"Update grant properties (expiration, notes, delegation rights). Only root admin or tenant admin can change delegation rights on level-1 grants.","operationId":"updatePermissionGrant","parameters":[{"name":"grantId","in":"path","required":true,"description":"Grant ID","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantUpdateRequest"}}}},"responses":{"200":{"description":"Grant updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"grant":{"$ref":"#/components/schemas/PermissionGrant"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"description":"Forbidden - not authorized to update this grant"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/permissions/grants/{grantId}/invitees":{"get":{"tags":["Permissions"],"summary":"List invitees under a grant","description":"Returns all active child grants (level-2) created by the grantee of this grant using their delegation rights.","operationId":"listGrantInvitees","parameters":[{"name":"grantId","in":"path","required":true,"description":"Grant ID","schema":{"type":"string"}}],"responses":{"200":{"description":"List of invitee grants","content":{"application/json":{"schema":{"type":"object","properties":{"invitees":{"type":"array","items":{"$ref":"#/components/schemas/PermissionGrant"}}}}}}},"403":{"description":"Forbidden - not authorized to view invitees"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/permissions/grants/{grantId}/elevate":{"post":{"tags":["Permissions"],"summary":"Elevate a grant to level-1","description":"Promote a level-2 grant (created by a delegator) to level-1 (direct grant from base admin). Only base admins can elevate grants. The old grant is revoked with reason 'elevated'.","operationId":"elevatePermissionGrant","parameters":[{"name":"grantId","in":"path","required":true,"description":"Grant ID to elevate","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"canDelegate":{"type":"boolean","description":"Whether the elevated grant should have delegation rights","default":false}}}}}},"responses":{"200":{"description":"Grant elevated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"oldGrant":{"$ref":"#/components/schemas/PermissionGrant"},"newGrant":{"$ref":"#/components/schemas/PermissionGrant"}}}}}},"403":{"description":"Forbidden - only base administrators can elevate grants"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/accounts":{"get":{"tags":["Accounts"],"summary":"List accessible accounts","description":"Returns a stable HATEOAS list of accounts the caller has access to. Each item is `{ name: <accountId>, href: /config/accounts/<accountId> }`. Backed by `config/accounts/manifest.json` (single GetObject), filtered to the JWT/PAT's account scope. Tombstones are always excluded. For search/filter (substring on `name`, plan filter, tombstone visibility, incremental sync), call `GET /config/accounts/manifest`.","operationId":"listAccounts","responses":{"200":{"description":"HATEOAS list of accessible accounts","content":{"application/json":{"schema":{"type":"object","properties":{"_links":{"type":"object","properties":{"self":{"type":"object","properties":{"href":{"type":"string","example":"/config/accounts"}}},"parent":{"type":"object","properties":{"href":{"type":"string","example":"/"}}},"items":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","format":"uuid","description":"The accountId.","example":"c5019f80-7397-46a5-a575-2e398de21306"},"href":{"type":"string","example":"/config/accounts/c5019f80-7397-46a5-a575-2e398de21306"}},"required":["name","href"]}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"post":{"tags":["Accounts"],"summary":"Create a new account","description":"Creates a new account with a generated UUID","operationId":"createAccount","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountCreate"}}}},"responses":{"201":{"description":"Account created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Account"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"description":"Account name already exists"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/accounts/manifest":{"get":{"tags":["Accounts"],"summary":"Account change manifest (admin search + change feed)","description":"Returns the account manifest scoped to the caller's account access. Items carry `accountId`, `name`, `plan`, `modifiedAt`, and `deleted` (when surfaced). `?name=<substring>` does a case-insensitive substring match against `name`; `?plan=<plan>` does exact-match (single value or comma-separated set, AND'd with name); `?includeDeleted=true` surfaces tombstones; `?since=<ISO>` filters to entries with `modifiedAt > cutoff`. GOD PATs see every account; non-god callers see only their own. Sorted newest-first by `modifiedAt`.","operationId":"getAccountManifest","parameters":[{"name":"since","in":"query","required":false,"description":"ISO 8601 timestamp. When supplied, only entries with `modifiedAt > since` are returned.","schema":{"type":"string","format":"date-time"},"example":"2026-04-30T22:00:00Z"},{"name":"name","in":"query","required":false,"description":"Case-insensitive substring filter against the account name.","schema":{"type":"string"},"example":"acme"},{"name":"plan","in":"query","required":false,"description":"Exact-match plan filter. Single value (e.g. `professional`) or comma-separated set (e.g. `professional,enterprise`). Combines with `?name` as AND.","schema":{"type":"string"},"example":"professional,enterprise"},{"name":"includeDeleted","in":"query","required":false,"description":"When `true`, manifest tombstones (soft-deleted accounts, retained 30 days with their historical `name` and `plan`) are included with `deleted: true` on the entry.","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Account manifest snapshot","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountManifest"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/accounts/{accountId}":{"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Account UUID"}],"get":{"tags":["Accounts"],"summary":"Get account by ID","description":"Returns a specific account by UUID","operationId":"getAccount","responses":{"200":{"description":"Account details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Account"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"put":{"tags":["Accounts"],"summary":"Update account","description":"Updates an existing account. System accounts cannot be modified.","operationId":"updateAccount","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountUpdate"}}}},"responses":{"200":{"description":"Account updated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Account"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"System accounts cannot be modified"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Account name already exists"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"delete":{"tags":["Accounts"],"summary":"Delete account (soft)","description":"Soft deletes an account by setting status to 'deleted'. System accounts cannot be deleted.","operationId":"deleteAccount","responses":{"200":{"description":"Account deleted successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"System accounts cannot be deleted"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The account changed after this request read it. The soft delete was rejected rather than overwriting the other edit; re-read the account and retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/accounts/{accountId}/tenants":{"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Account UUID"}],"get":{"tags":["Accounts"],"summary":"List tenants for account","description":"Returns all tenants associated with this account (programmatic aggregation)","operationId":"listAccountTenants","responses":{"200":{"description":"List of tenants","content":{"application/json":{"schema":{"type":"object","properties":{"_links":{"$ref":"#/components/schemas/HATEOASLinks"},"tenants":{"type":"array","items":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"name":{"type":"string"},"fqdns":{"type":"array","items":{"type":"object"}}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/accounts/{accountId}/detail":{"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Account UUID"}],"get":{"tags":["Accounts"],"summary":"Get platform-admin detail for an account","description":"Platform-admin view of one account for the admin Accounts drawer: the account's creator plus per-tenant context (created-at, backend warehouse host, and members with roles), so an operator can identify an account before a plan change. Requires platform-administrator authority: a GOD PAT, a wildcard account membership (`{accountId: \"*\"}`) with owner or admin role, or a PAT scoped to all accounts. Membership in the target account is NOT sufficient — this discloses creator identity and member rosters across every tenant in the account.","operationId":"getAccountDetail","responses":{"200":{"description":"Account detail","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountDetail"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Forbidden - platform administrator access required"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/tenants/{tenantId}/transfers":{"parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"tags":["Transfers"],"summary":"Initiate a tenant transfer","description":"Start a consent-based transfer of this tenant to another user by email. Requires owner or admin of the tenant's current account. Recipient chooses their destination account at accept time.","operationId":"initiateTenantTransfer","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["toUserEmail"],"properties":{"toUserEmail":{"type":"string","format":"email","description":"Recipient's email address"},"message":{"type":"string","description":"Optional message shown to recipient"}}}}}},"responses":{"201":{"description":"Transfer initiated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantTransfer"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"description":"Forbidden - owner or admin of the source account required"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"A pending transfer already exists for this tenant"}}}},"/config/accounts/{accountId}/transfers":{"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Transfers"],"summary":"List transfers originating from this account","description":"Returns all transfers where this account is the source. Destination is not knowable at initiate time (recipients are identified by email); per-user inbox lives at /config/transfers/to-me.","operationId":"listTransfersFromAccount","responses":{"200":{"description":"List of transfers","content":{"application/json":{"schema":{"type":"object","properties":{"transfers":{"type":"array","items":{"$ref":"#/components/schemas/TenantTransfer"}}}}}}},"403":{"description":"Forbidden - owner or admin required"}}}},"/config/transfers/by-me":{"get":{"tags":["Transfers"],"summary":"List transfers I initiated","operationId":"listTransfersByMe","responses":{"200":{"description":"List of transfers","content":{"application/json":{"schema":{"type":"object","properties":{"transfers":{"type":"array","items":{"$ref":"#/components/schemas/TenantTransfer"}}}}}}}}}},"/config/transfers/to-me":{"get":{"tags":["Transfers"],"summary":"List transfers offered to me","description":"Inbox of pending and historical transfers where the authenticated user's email matches toUserEmail.","operationId":"listTransfersToMe","responses":{"200":{"description":"List of transfers","content":{"application/json":{"schema":{"type":"object","properties":{"transfers":{"type":"array","items":{"$ref":"#/components/schemas/TenantTransfer"}}}}}}}}}},"/config/transfers/{transferId}":{"parameters":[{"name":"transferId","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Transfers"],"summary":"Read a transfer","operationId":"getTransfer","responses":{"200":{"description":"Transfer record","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantTransfer"}}}},"403":{"description":"Forbidden - not a party to this transfer"},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"tags":["Transfers"],"summary":"Cancel a pending transfer (initiator only)","operationId":"cancelTransfer","responses":{"200":{"description":"Transfer canceled","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantTransfer"}}}},"403":{"description":"Forbidden - only initiator can cancel"},"409":{"description":"Transfer is not in a cancellable state"}}}},"/config/transfers/{transferId}/accept":{"parameters":[{"name":"transferId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Transfers"],"summary":"Accept a tenant transfer","description":"Recipient accepts the transfer onto one of their own accounts. Caller email must match toUserEmail AND caller must be owner (not just admin) of the supplied toAccountId — accepting changes the destination account's billing.","operationId":"acceptTransfer","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["toAccountId"],"properties":{"toAccountId":{"type":"string","format":"uuid","description":"Destination account (caller must be owner of it)"}}}}}},"responses":{"200":{"description":"Transfer accepted; tenant moved","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantTransfer"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"description":"Forbidden - caller is not the recipient or not owner of destination account"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Transfer in wrong state, stale, or destination over capacity"}}}},"/config/transfers/{transferId}/reject":{"parameters":[{"name":"transferId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Transfers"],"summary":"Reject a tenant transfer","operationId":"rejectTransfer","responses":{"200":{"description":"Transfer rejected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantTransfer"}}}},"403":{"description":"Forbidden - only the recipient can reject"},"409":{"description":"Transfer not in a rejectable state"}}}},"/config/accounts/{accountId}/domains":{"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Account UUID"}],"get":{"tags":["Domains"],"summary":"List domains for account","description":"Returns all domains registered to this account","operationId":"listAccountDomains","responses":{"200":{"description":"List of domains","content":{"application/json":{"schema":{"type":"object","properties":{"_links":{"$ref":"#/components/schemas/HATEOASLinks"},"domains":{"type":"array","items":{"$ref":"#/components/schemas/DomainEntry"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"post":{"tags":["Domains"],"summary":"Add domain to account","description":"Registers a new domain to this account. Domains must be globally unique.","operationId":"addAccountDomain","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["domain"],"properties":{"domain":{"type":"string","description":"Domain name (e.g., acme.com)","example":"acme.com"}}}}}},"responses":{"201":{"description":"Domain added successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DomainEntry"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Domain already registered to another account"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/accounts/{accountId}/domains/{domain}":{"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Account UUID"},{"name":"domain","in":"path","required":true,"schema":{"type":"string"},"description":"Domain name"}],"get":{"tags":["Domains"],"summary":"Get domain details","description":"Returns details for a specific domain registered to this account","operationId":"getAccountDomain","responses":{"200":{"description":"Domain details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DomainEntry"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"delete":{"tags":["Domains"],"summary":"Remove domain from account","description":"Unregisters a domain from this account","operationId":"removeAccountDomain","responses":{"200":{"description":"Domain removed successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The account changed after this request read it. The domain removal was rejected rather than overwriting the other edit; re-read the account and retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/domains":{"get":{"tags":["Domains"],"summary":"List all domains","description":"Returns all domains across all accounts (programmatic aggregation)","operationId":"listAllDomains","responses":{"200":{"description":"List of all domains","content":{"application/json":{"schema":{"type":"object","properties":{"_links":{"$ref":"#/components/schemas/HATEOASLinks"},"domains":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/DomainEntry"},{"type":"object","properties":{"accountId":{"type":"string","format":"uuid"},"accountName":{"type":"string"}}}]}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/config/domains/{domain}":{"parameters":[{"name":"domain","in":"path","required":true,"schema":{"type":"string"},"description":"Domain name"}],"get":{"tags":["Domains"],"summary":"Find domain owner","description":"Finds which account owns a specific domain (programmatic scan)","operationId":"getDomain","responses":{"200":{"description":"Domain details with owner","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/DomainEntry"},{"type":"object","properties":{"accountId":{"type":"string","format":"uuid"},"accountName":{"type":"string"},"_links":{"$ref":"#/components/schemas/HATEOASLinks"}}}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenantId}/fqdns":{"parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Tenant UUID"}],"get":{"tags":["FQDNs"],"summary":"List FQDNs for tenant","description":"Returns all FQDNs assigned to this tenant","operationId":"listTenantFqdns","responses":{"200":{"description":"List of FQDNs","content":{"application/json":{"schema":{"type":"object","properties":{"_links":{"$ref":"#/components/schemas/HATEOASLinks"},"fqdns":{"type":"array","items":{"$ref":"#/components/schemas/FqdnEntry"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"post":{"tags":["FQDNs"],"summary":"Add FQDN to tenant","description":"Assigns a new FQDN to this tenant. FQDNs must be globally unique.","operationId":"addTenantFqdn","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["fqdn"],"properties":{"fqdn":{"type":"string","description":"Fully qualified domain name","example":"acme-prod.app.airbrx.com"},"isPrimary":{"type":"boolean","description":"Whether this is the primary FQDN for the tenant","default":false}}}}}},"responses":{"201":{"description":"FQDN added successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FqdnEntry"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"FQDN already assigned to another tenant"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/tenants/{tenantId}/fqdns/{fqdn}":{"parameters":[{"name":"tenantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Tenant UUID"},{"name":"fqdn","in":"path","required":true,"schema":{"type":"string"},"description":"Fully qualified domain name"}],"get":{"tags":["FQDNs"],"summary":"Get FQDN details","description":"Returns details for a specific FQDN assigned to this tenant","operationId":"getTenantFqdn","responses":{"200":{"description":"FQDN details","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/FqdnEntry"},{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"_links":{"$ref":"#/components/schemas/HATEOASLinks"}}}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"delete":{"tags":["FQDNs"],"summary":"Remove FQDN from tenant","description":"Unassigns an FQDN from this tenant","operationId":"removeTenantFqdn","responses":{"200":{"description":"FQDN removed successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/fqdns":{"get":{"tags":["FQDNs"],"summary":"List all FQDNs","description":"Returns all FQDNs across all tenants (programmatic aggregation)","operationId":"listAllFqdns","responses":{"200":{"description":"List of all FQDNs","content":{"application/json":{"schema":{"type":"object","properties":{"_links":{"$ref":"#/components/schemas/HATEOASLinks"},"fqdns":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/FqdnEntry"},{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"tenantName":{"type":"string"}}}]}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"post":{"tags":["FQDNs"],"summary":"Create FQDN index entry","description":"Create a new FQDN-to-tenant mapping. Returns 409 if the FQDN is already in use or has been tombstoned (permanently retired).","operationId":"createFqdn","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["fqdn","tenantId"],"properties":{"fqdn":{"type":"string","description":"The fully qualified domain name to assign","example":"analytics.acme.com"},"tenantId":{"type":"string","format":"uuid","description":"Tenant UUID to assign this FQDN to"},"isPrimary":{"type":"boolean","description":"Whether this is the tenant's primary FQDN","default":false},"assignedBy":{"type":"string","description":"Email or identifier of the person making the assignment"}}}}}},"responses":{"201":{"description":"FQDN index entry created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FqdnEntry"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"409":{"$ref":"#/components/responses/Conflict"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/fqdns/{fqdn}":{"parameters":[{"name":"fqdn","in":"path","required":true,"schema":{"type":"string"},"description":"Fully qualified domain name"}],"get":{"tags":["FQDNs"],"summary":"Find FQDN owner","description":"Finds which tenant owns a specific FQDN (programmatic scan)","operationId":"getFqdn","responses":{"200":{"description":"FQDN details with owner","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/FqdnEntry"},{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"tenantName":{"type":"string"},"_links":{"$ref":"#/components/schemas/HATEOASLinks"}}}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"put":{"tags":["FQDNs"],"summary":"Update FQDN metadata","description":"Update metadata on an active FQDN index entry (e.g., isPrimary). Cannot change tenantId (returns 409).","operationId":"updateFqdn","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"isPrimary":{"type":"boolean"},"assignedBy":{"type":"string"},"note":{"type":"string","description":"Audit note for this update"}}}}}},"responses":{"200":{"description":"FQDN updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FqdnEntry"}}}},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"delete":{"tags":["FQDNs"],"summary":"Tombstone an FQDN","description":"Permanently retire an FQDN. Creates a tombstone record and removes the active index. The FQDN can never be reassigned.","operationId":"deleteFqdn","responses":{"200":{"description":"FQDN tombstoned","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"FQDN 'analytics.acme.com' has been permanently retired"},"tombstone":{"$ref":"#/components/schemas/FqdnTombstone"}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/oauth/device_authorization":{"post":{"tags":["OAuth"],"summary":"Begin an OAuth 2.0 Device Authorization Grant","security":[],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["client_id","resource"],"properties":{"client_id":{"type":"string"},"resource":{"type":"string","format":"uri"}}}},"application/json":{"schema":{"type":"object","required":["client_id","resource"],"properties":{"client_id":{"type":"string"},"resource":{"type":"string","format":"uri"}}}}}},"responses":{"200":{"description":"Device authorization response (RFC 8628)"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/oauth/device":{"get":{"tags":["OAuth"],"summary":"Verify and approve a device authorization request","security":[],"parameters":[{"in":"query","name":"user_code","required":true,"schema":{"type":"string"}}],"responses":{"302":{"description":"Redirect to first-party login and consent"},"400":{"$ref":"#/components/responses/BadRequest"}}}}},"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Airbrx JWT token. Can be a user JWT (from OAuth login) or a PAT-derived access token (from pat_exchange). Include in Authorization header as: `Bearer <token>`"},"OAuth2":{"type":"oauth2","description":"Google OAuth 2.0 authentication","flows":{"authorizationCode":{"authorizationUrl":"https://accounts.google.com/o/oauth2/v2/auth","tokenUrl":"https://oauth2.googleapis.com/token","scopes":{"openid":"OpenID Connect","profile":"User profile information","email":"User email address"}}}}},"parameters":{"EmailParam":{"name":"email","in":"path","required":true,"description":"User email address","schema":{"type":"string","format":"email"},"example":"user@example.com"},"TenantParam":{"name":"tenant","in":"path","required":true,"description":"Tenant ID (domain)","schema":{"type":"string"},"example":"demo.app.airbrx.com"},"MarkerIdParam":{"name":"markerId","in":"path","required":true,"description":"Marker identifier","schema":{"type":"string"},"example":"default-cache.1"}},"schemas":{"OAuthServerMetadata":{"type":"object","description":"OAuth 2.0 authorization server metadata (RFC 8414).","properties":{"issuer":{"type":"string"},"authorization_endpoint":{"type":"string"},"token_endpoint":{"type":"string"},"registration_endpoint":{"type":"string"},"response_types_supported":{"type":"array","items":{"type":"string"}},"grant_types_supported":{"type":"array","items":{"type":"string"}},"code_challenge_methods_supported":{"type":"array","items":{"type":"string"}},"token_endpoint_auth_methods_supported":{"type":"array","items":{"type":"string"}},"scopes_supported":{"type":"array","items":{"type":"string"}},"device_authorization_endpoint":{"type":"string"}}},"ClientRegistrationRequest":{"type":"object","description":"RFC 7591 dynamic client registration request. Additional metadata fields are accepted and ignored.","required":["redirect_uris"],"additionalProperties":true,"properties":{"redirect_uris":{"type":"array","minItems":1,"items":{"type":"string"},"description":"Allowed redirect URIs. Each must be https or an http loopback (localhost/127.0.0.1/::1)."},"client_name":{"type":"string"},"grant_types":{"type":"array","items":{"type":"string"}},"response_types":{"type":"array","items":{"type":"string"}},"token_endpoint_auth_method":{"type":"string"}}},"ClientRegistrationResponse":{"type":"object","description":"RFC 7591 registration response for a public PKCE client (no client_secret).","properties":{"client_id":{"type":"string"},"client_id_issued_at":{"type":"integer"},"redirect_uris":{"type":"array","items":{"type":"string"}},"grant_types":{"type":"array","items":{"type":"string"}},"response_types":{"type":"array","items":{"type":"string"}},"token_endpoint_auth_method":{"type":"string"},"client_name":{"type":"string"}}},"ConsentRequest":{"type":"object","description":"Consent decision posted by the dashboard. `request` is the signed authorization-request state from the /authorize redirect. On approve, scopes is required and bounds the minted PAT (stripped to the user's grantable set).","required":["request","decision"],"additionalProperties":true,"properties":{"request":{"type":"string","description":"Signed authorization-request state JWT"},"decision":{"type":"string","enum":["approve","deny"]},"scopes":{"type":"object","description":"Per-method path scopes to grant the client (PATScopes shape).","properties":{"GET":{"type":"array","items":{"type":"string"}},"POST":{"type":"array","items":{"type":"string"}},"PUT":{"type":"array","items":{"type":"string"}},"DELETE":{"type":"array","items":{"type":"string"}}}},"tenants":{"type":"array","items":{"type":"string"}},"accounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","description":"Optional label for the minted PAT"},"expiresAt":{"type":"string","description":"Optional ISO-8601 expiry, 'never', or null"}}},"ConsentResponse":{"type":"object","description":"Loopback redirect URL for the browser to navigate to, plus any permissions the server stripped from the request.","required":["redirect"],"properties":{"redirect":{"type":"string","description":"redirect_uri with ?code=... (approve) or ?error=access_denied (deny), carrying state when present"},"stripped":{"type":"object","description":"Scopes/tenants/accounts dropped because the user could not grant them (empty when the request was honored as-is)"}}},"User":{"type":"object","description":"User profile with OAuth data and Airbrx-specific fields","properties":{"email":{"type":"string","format":"email","description":"User email (unique identifier)"},"name":{"type":"string","description":"Full name from OAuth provider"},"picture":{"type":"string","description":"Base64-encoded data URL of profile picture"},"roles":{"$ref":"#/components/schemas/Roles"},"tenants":{"$ref":"#/components/schemas/TenantMembershipList","description":"Structured tenant memberships. Each entry pairs a tenantId with the base role (owner|admin|member). Use tenantId \"*\" to grant the role across all tenants."},"accounts":{"$ref":"#/components/schemas/AccountMembershipList","description":"Structured account memberships. Each entry pairs an accountId with the base role (owner|admin|member). Use accountId \"*\" to grant the role across all accounts."},"lastLogin":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of last login"},"displayName":{"type":"string","description":"Display name for the user"},"authProvider":{"type":"string","description":"Authentication provider (e.g. descope, google)"},"delegatedAccess":{"type":"array","description":"Delegated access grants for this user","items":{"type":"object","properties":{"tenantId":{"type":"string","description":"Tenant ID the grant applies to"},"role":{"type":"string","description":"Role granted for the tenant"},"grantId":{"type":"string","description":"ID of the permission grant"},"grantedBy":{"type":"string","description":"Email of the user who granted access"},"canDelegate":{"type":"boolean","description":"Whether the grantee can further delegate"}}},"default":[]},"scopes":{"$ref":"#/components/schemas/PATScopes","description":"Explicit per-method endpoint scopes. When set, these override role-based scope expansion in the JWT."},"tos":{"type":"string","pattern":"^[0-9a-f]{32}$","description":"MD5 hash (hex) of the most recent Terms of Service version this user accepted. The login path compares this against the in-memory current ToS hash; mismatch mints a tos_required bridge token that restricts the JWT to the ToS endpoints until the user re-acknowledges."}},"required":["email"]},"Roles":{"type":"object","description":"User role configuration","properties":{"primary":{"type":"string","description":"Primary role name (used for RBAC)","enum":["admin","user","guest","viewer","operator"],"default":"guest"}},"additionalProperties":{"type":"string"},"example":{"primary":"admin","secondary":"analyst"}},"TenantConfig":{"type":"object","description":"Tenant configuration (v2.0 schema with UUID-based tenantId and account support)","required":["tenantId","tenantName","accountId","primaryFqdn","dataAdapter","status","version"],"properties":{"tenantId":{"type":"string","format":"uuid","description":"Unique tenant identifier (UUID v4)"},"tenantName":{"type":"string","description":"Display name (unique within account)","minLength":1,"maxLength":255},"accountId":{"type":"string","format":"uuid","description":"Parent account reference (UUID v4)"},"primaryFqdn":{"type":"string","description":"Main FQDN for request routing (globally unique)"},"aliasFqdns":{"type":"array","items":{"type":"string"},"description":"Additional FQDNs that route to this tenant"},"dataAdapter":{"description":"Data warehouse connection configuration. For Databricks, http_path is optional — when omitted or empty, the proxy lazy-derives the warehouse from client traffic. The whole field may also be an empty string as a placeholder before the dashboard collects warehouse wiring; null is not permitted.","oneOf":[{"type":"object","required":["type","server_hostname"],"properties":{"type":{"type":"string","enum":["databricks","snowflake"],"description":"Warehouse backend type"},"server_hostname":{"type":"string","description":"Backend warehouse hostname"},"http_path":{"type":"string","description":"Databricks SQL endpoint path (e.g. /sql/1.0/warehouses/abc123). Optional — inferred at runtime when empty.","pattern":"^$|^/sql/1\\.0/warehouses/[A-Za-z0-9_-]+$"},"cloudFilesBaseUrl":{"type":"string","nullable":true,"description":"CloudFile proxy base URL"}},"oneOf":[{"description":"Databricks: server_hostname must be a Databricks workspace host","properties":{"type":{"enum":["databricks"]},"server_hostname":{"type":"string","pattern":"^(?:[a-z0-9-]+\\.cloud\\.databricks\\.com|dbc-[a-z0-9-]+\\.cloud\\.databricks\\.com|[a-z0-9-]+\\.azuredatabricks\\.net|adb-[a-z0-9-]+\\.azuredatabricks\\.net|[a-z0-9-]+\\.gcp\\.databricks\\.com)$"}}},{"description":"Snowflake: shape validation handled by the adapter layer","properties":{"type":{"enum":["snowflake"]}}}]},{"type":"object","required":["type","host","database","authMode"],"description":"PostgreSQL: raw-TCP wire-protocol adapter — uses host/database instead of server_hostname/http_path, since it isn't an HTTP-based warehouse connection. Routing-only config — there is no stored backend credential; authMode determines how the gateway authenticates the connecting client, and (for cleartext/relay) the same credential is used against the real backend.","properties":{"type":{"type":"string","enum":["postgresql"],"description":"Warehouse backend type"},"host":{"type":"string","description":"Postgres server hostname"},"port":{"type":"integer","minimum":1,"maximum":65535,"description":"Postgres server port. Defaults to 5432 when omitted."},"database":{"type":"string","description":"Database name"},"sslMode":{"type":"string","enum":["disable","require","verify-ca","verify-full"],"description":"Backend-leg TLS mode (how the gateway verifies the real backend's cert), mirroring libpq's sslmode: 'disable' (plaintext), 'require' (encrypt but do NOT verify the cert -- a MITM is still possible, an explicit downgrade), 'verify-ca' (verify the cert chains to a trusted CA), 'verify-full' (verify CA + hostname). Optional; the gateway defaults to 'verify-full' (the secure tier) when omitted, so a downgrade must be an explicit choice."},"sslRootCert":{"type":"string","description":"CA to trust for the verify-ca/verify-full tiers: an inline PEM or a filesystem path to one. Omit to use the built-in public trust store. Unused for disable/require."},"queryTimeoutMs":{"type":"integer","minimum":1,"description":"Query timeout in milliseconds. Defaults to 30000 when omitted."},"maxCacheableResultRows":{"type":"integer","minimum":1,"description":"Max rows retained/cached per statement. Defaults to 10000 when omitted."},"authMode":{"type":"string","enum":["cleartext","verifier-proxy","relay"],"description":"Client-facing auth mode, required. 'cleartext' -- the gateway uses the connecting client's own real Postgres credential for both legs, never a stored one. 'relay' -- the gateway forwards the entire auth handshake verbatim between the client and the real backend, for token-based backends (RDS IAM / Azure Entra) or zero-credential-custody tenants; the gateway never derives, holds, or inspects any credential material. 'verifier-proxy' -- the gateway runs real SCRAM-SHA-256 against the client using a verifier fetched from the real backend's pg_authid, recovering clientKey for the outbound leg without ever touching the plaintext password. All three modes are implemented and live-verified against real managed providers."},"authLookupSecretRef":{"type":"string","description":"SecretsManager reference (never a literal credential) for the auth-lookup principal used by 'verifier-proxy' mode. Required when authMode is 'verifier-proxy' (enforced by the gateway's validateConfig, not this schema -- same pattern as Snowflake's shape validation below); unused otherwise."},"provider":{"type":"string","enum":["self-hosted","neon","supabase","cloud-sql","alloydb","rds","aurora","azure-flexible"],"description":"Managed-provider identity, required when authMode is 'verifier-proxy' (enforced by the gateway's validateConfig, not this schema -- same pattern as authLookupSecretRef above); unused for 'cleartext'/'relay'. 'verifier-proxy' needs direct pg_authid/pg_shadow readability, which only 'self-hosted'/'neon'/'supabase' currently support -- the gateway rejects 'cloud-sql'/'alloydb' as unverified and 'rds'/'aurora'/'azure-flexible' outright (no access path exists)."}}},{"type":"string","enum":[""],"description":"Placeholder for a tenant that has not yet supplied warehouse wiring"}]},"storage":{"type":"object","description":"Operational data storage (cache, sessions, operations, tokens, users). Optional on the request body — the server backfills from existingConfig.storage on PUT, or from the storageS3 secret default. Every persisted tenant record ultimately carries a storage block.","properties":{"type":{"type":"string","enum":["s3express","s3","filesystem"],"description":"Storage backend type","default":"s3express"},"bucket":{"type":"string","description":"S3 bucket name"},"region":{"type":"string","description":"AWS region","default":"us-east-1"},"endpoint":{"type":"string","description":"Custom S3 endpoint (for LocalStack/MinIO)"}}},"logStorage":{"type":"object","description":"Log storage configuration. Inherits from account defaults if omitted.","properties":{"type":{"type":"string","enum":["s3","s3express","filesystem"],"description":"Storage backend type","default":"s3"},"bucket":{"type":"string","description":"S3 bucket name"},"region":{"type":"string","description":"AWS region","default":"us-east-1"},"endpoint":{"type":"string","description":"Custom S3 endpoint (for LocalStack/MinIO)"}}},"cacheStorage":{"type":"object","description":"Cache storage for cached result payloads. Distinct from storage and logStorage by design; the server seeds it at create time.","properties":{"type":{"type":"string","enum":["s3"],"description":"Storage backend type. General-purpose S3 only -- the gateway rejects a cacheStorage block of any other type and fails the whole tenant load.","default":"s3"},"bucket":{"type":"string","description":"S3 bucket name. Must be a general-purpose bucket -- an S3 Express directory bucket name (ending --<zone>--x-s3) is rejected here, because cache payloads on Express fail the whole tenant load in the gateway.","pattern":"^(?!.*--[a-z0-9-]+--x-s3$).+$"},"region":{"type":"string","description":"AWS region","default":"us-east-1"},"endpoint":{"type":"string","description":"Custom S3 endpoint (for LocalStack/MinIO)"}}},"status":{"type":"string","enum":["active","pending","suspended","deleted"],"description":"Tenant status. `pending` marks a tenant that has been provisioned but is not yet ready to serve traffic (typically awaiting dataAdapter wiring — either a signup-provisioned tenant or an empty-dataAdapter placeholder)."},"createdAt":{"type":"string","format":"date-time","description":"Creation timestamp (UTC)"},"updatedAt":{"type":"string","format":"date-time","description":"Last update timestamp (UTC)"},"version":{"type":"string","description":"Schema version","enum":["2.0"],"default":"2.0"}},"example":{"tenantId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","tenantName":"Acme Production","accountId":"550e8400-e29b-41d4-a716-446655440000","primaryFqdn":"analytics.acme.com","aliasFqdns":["acme-prod.app.airbrx.com"],"dataAdapter":{"type":"databricks","server_hostname":"acme-prod.cloud.databricks.com","http_path":"/sql/1.0/warehouses/abc123"},"storage":{"type":"s3express","bucket":"airbrx-cache-prod--use1-az4--x-s3"},"logStorage":{"type":"s3","bucket":"airbrx-logs-prod"},"cacheStorage":{"type":"s3","bucket":"prod-airbrx-gateway-cache","region":"us-east-1"},"status":"active","createdAt":"2026-01-15T10:30:00.000Z","updatedAt":"2026-01-20T14:22:00.000Z","version":"2.0"}},"ChangeHistoryRow":{"type":"object","description":"One recorded change. Fixed width regardless of document size: it carries a pointer to the revision and the facts needed to filter a timeline, never a delta. Fields that do not apply to a kind are absent rather than null.","required":["at","kind","seq","sha","actorType","userId"],"properties":{"at":{"type":"string","format":"date-time","description":"When the change was recorded (UTC)"},"kind":{"type":"string","enum":["rules","config","account"],"description":"Which document changed"},"seq":{"type":"integer","minimum":0,"description":"Revision number for this document. Sequential from 0, and the id used to fetch, diff or restore the revision."},"sha":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"Fingerprint of the stored document (sha256 of its canonical form). Not an address — it is how an unchanged re-write is recognized, and how a revision can be checked against the row that points at it."},"tenantId":{"type":"string","format":"uuid","description":"Present on rules and config rows"},"accountId":{"type":"string","format":"uuid","description":"Present on account rows"},"actorType":{"type":"string","enum":["user","pat","god"],"description":"A person, an automated token, or an ops-override GOD PAT"},"userId":{"type":"string","description":"Normalized email of whoever made the change. For a PAT this is its issuedBy, not the token identity."},"via":{"type":"string","description":"How the change arrived (dashboard, mcp, api, restore)"},"version":{"type":"string","description":"Rules only: the cache-bust token live at this revision. Stamped on every row even when unchanged, so the timeline can be bisected against a cache keyspace path."},"ruleCount":{"type":"integer","description":"Rules only: number of rules in the ruleset"},"warnCount":{"type":"integer","description":"Rules only: lint warnings raised by the write"},"restoredFrom":{"type":"integer","minimum":0,"description":"Present when this change was a restore, naming the revision it came from"}}},"ChangeHistoryPage":{"type":"object","properties":{"_links":{"$ref":"#/components/schemas/HATEOASLinks"},"kind":{"type":"string","enum":["rules","config","account"]},"tenantId":{"type":"string","format":"uuid"},"accountId":{"type":"string","format":"uuid"},"from":{"type":"string","format":"date-time","description":"Lower bound applied, when one was requested"},"to":{"type":"string","format":"date-time","description":"Upper bound applied, when one was requested"},"count":{"type":"integer"},"truncated":{"type":"boolean","description":"More rows matched than the limit returned"},"revisions":{"type":"array","items":{"$ref":"#/components/schemas/ChangeHistoryRow"}}}},"ChangeRevision":{"type":"object","description":"A stored revision. Immutable, sequentially numbered per document, and self-describing: it carries the actor and timestamp that produced it, so the change logs can be rebuilt from the revisions if an index write is ever lost.","required":["seq","kind","at","actor","document"],"properties":{"seq":{"type":"integer","minimum":0,"description":"Revision number for this document, sequential from 0"},"kind":{"type":"string","enum":["rules","config","account"]},"tenantId":{"type":"string","format":"uuid"},"accountId":{"type":"string","format":"uuid"},"at":{"type":"string","format":"date-time","description":"When this revision was written (UTC)"},"actor":{"type":"object","description":"Who wrote it","properties":{"type":{"type":"string","enum":["user","pat","god"]},"userId":{"type":"string","description":"Normalized email"},"via":{"type":"string","description":"dashboard, mcp, api or restore"}}},"sha":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"Fingerprint of the document below"},"document":{"type":"object","description":"The document exactly as it was stored"}}},"ChangeDiff":{"type":"object","description":"Delta between two revisions, computed on read. Deltas are deliberately not stored — a ruleset with hundreds of modified rules would not fit a fixed-width index row.","properties":{"from":{"type":"integer","minimum":0},"to":{"type":"integer","minimum":0},"kind":{"type":"string","enum":["rules","config","account"]},"rules":{"type":"object","description":"Rules kind only. Rules are matched by id, so reordering alone is not a change.","properties":{"added":{"type":"array","items":{"type":"string"}},"removed":{"type":"array","items":{"type":"string"}},"modified":{"type":"array","items":{"type":"string"}}}},"fields":{"type":"object","description":"Config and account kinds: top-level keys that differ.","properties":{"added":{"type":"array","items":{"type":"string"}},"removed":{"type":"array","items":{"type":"string"}},"modified":{"type":"array","items":{"type":"string"}}}}}},"TenantRules":{"type":"object","description":"Cache rules configuration (UUID-based tenantId)","required":["version","tenantId","rules"],"properties":{"version":{"type":"string","description":"Per-tenant cache-bust token, embedded directly in the gateway's S3 cache key path (`cache/rules-version-{engineVersion}.{version}/...`). Changing it reroutes/invalidates all of this tenant's cached responses. Any short token is valid (counter, date stamp, label) as long as it is safe to embed in a storage key.","minLength":1,"maxLength":11,"pattern":"^[A-Za-z0-9_.-]{1,11}$"},"tenantId":{"type":"string","format":"uuid","description":"Tenant identifier (UUID v4)"},"defaults":{"type":"object","description":"Default values for rules that don't specify them","properties":{"cacheKeyElements":{"type":"array","items":{"type":"string","enum":["userId","userRole","standardizedSql","statement","catalog","schema","tables","columns","warehouse","tenantId"]},"description":"Default cache key elements"},"ttlSeconds":{"type":"integer","description":"Default TTL in seconds"},"version":{"type":"integer","description":"Default cache key version"}}},"rules":{"type":"array","items":{"type":"object","required":["id","priority","conditions","actions"],"properties":{"id":{"type":"string","description":"Unique rule identifier (kebab-case recommended)"},"name":{"type":"string","description":"Human-readable rule name"},"description":{"type":"string","description":"Detailed explanation of rule purpose"},"priority":{"type":"integer","description":"Evaluation order (1 = highest priority, lower wins)","minimum":1,"maximum":100},"enabled":{"type":"boolean","description":"Whether rule is active","default":true},"mode":{"type":"string","description":"Condition matching mode","enum":["all","either"],"default":"all"},"respectSqlHints":{"type":"boolean","description":"Honor SQL cache hints (__AIRBRX_CACHE__, __AIRBRX_NOCACHE__)","default":true},"conditions":{"type":"object","description":"Conditions that must match for rule to apply","properties":{"statementType":{"type":"object","description":"SQL statement type (SELECT, INSERT, UPDATE, DELETE, etc.). Operators: equals, in, matches"},"tables":{"type":"object","description":"Tables in query. Operators: includes, includesAny, includesAll, notIncludes"},"schema":{"type":"object","description":"Schema name. Operators: equals, in, matches"},"catalog":{"type":"object","description":"Catalog name. Operators: equals, matches"},"columns":{"type":"object","description":"Columns in query. Operators: includes, includesAll"},"standardizedSql":{"type":"object","description":"Normalized SQL. Operators: matches, contains, startsWith"},"statement":{"type":"object","description":"Raw SQL text. Operators: matches, contains, startsWith"},"hasParameters":{"type":"boolean","description":"Whether query has parameter placeholders"},"parameters":{"type":"object","description":"Parameter value conditions. Operators: equals, notEquals, in, notIn, matches, exists, greaterThan, lessThan"},"userId":{"type":"object","description":"User identifier. Operators: equals, in, matches"},"userRole":{"type":"object","description":"User role. Operators: equals, in"},"httpHeaders":{"type":"object","description":"HTTP headers. Operators: equals, in, matches, contains, exists"},"clientIp":{"type":"object","description":"Trusted client IP address. Operators: equals, in, notIn. Each value is a bare IPv4/IPv6 address or a CIDR block (e.g. \"10.0.0.0/8\")."},"timeOfDay":{"type":"object","description":"UTC wall-clock window. Operator: between, taking [start, end] as 24-hour \"HH:MM\" times. Start-inclusive, end-exclusive; a start later than the end wraps midnight (overnight window). Times are UTC only - no timezone is accepted or converted."},"dayOfWeek":{"type":"object","description":"UTC day of week. Operator: in, taking day names (case-insensitive) or integers 0-6 where 0 = Sunday."}},"additionalProperties":true},"actions":{"type":"object","description":"Actions when rule matches","properties":{"cacheKeyElements":{"type":"array","items":{"type":"string","enum":["userId","userRole","standardizedSql","statement","catalog","schema","tables","columns","warehouse","tenantId"]},"description":"Elements to include in cache key hash"},"cache":{"type":"object","description":"Cache configuration","properties":{"ttlSeconds":{"type":"integer","description":"Time-to-live in seconds (0 = no cache)","minimum":0},"staleWhileRevalidate":{"type":"object","description":"Serve stale cache entries while revalidating in the background. When enabled=true, windowSeconds is required: null = unbounded staleness, or a non-negative finite number = bounded staleness window in seconds.","properties":{"enabled":{"type":"boolean","description":"Whether SWR is on for this rule","default":false},"windowSeconds":{"type":"integer","nullable":true,"description":"Max age (seconds) of a stale entry that may still be served while revalidating. null = unbounded. Required when enabled=true.","minimum":0}}}}},"version":{"type":"integer","description":"Cache key version for invalidation","minimum":1},"deleteCache":{"type":"boolean","description":"Bypass caching for matching queries -- equivalent to setting cache.ttlSeconds to 0 (no cache key is built, so nothing is written to or served from cache). Cache entries already written before this rule matched are retained in storage, not removed. There is currently no rule action that deletes existing cache data -- invalidateRules (on a DML-side rule) also does not remove entries; it marks them stale so they stop being served on the next lookup, the same non-removal guarantee as this flag, just applied retroactively instead of by blocking new writes."}}},"invalidateRules":{"type":"array","items":{"type":"string"},"description":"Rule IDs whose caches to invalidate when this DML rule matches","default":[]},"requireInvalidation":{"type":"boolean","description":"If true, DML fails when invalidation fails (for security-critical operations)","default":false},"tags":{"type":"array","items":{"type":"string"},"description":"Categorization tags for rule organization","default":[]}}}}},"example":{"version":"2.0","tenantId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","defaults":{"cacheKeyElements":["userId","warehouse","standardizedSql"],"ttlSeconds":3600,"version":1},"rules":[{"id":"select-customer-data","name":"Cache Customer Queries","priority":10,"conditions":{"statementType":{"equals":"SELECT"},"tables":{"includes":"CUSTOMER"}},"actions":{"cache":{"ttlSeconds":3600}}},{"id":"insert-orders","name":"Invalidate on Order Insert","priority":5,"conditions":{"statementType":{"equals":"INSERT"},"tables":{"includes":"ORDERS"}},"actions":{"cache":{"ttlSeconds":0}},"invalidateRules":["select-orders","orders-summary"]}]}},"RuleLintFinding":{"type":"object","description":"A single ruleset lint finding.","required":["severity","ruleId","path","code","message"],"properties":{"severity":{"type":"string","description":"error blocks a PUT; warn rides along on a successful write.","enum":["error","warn"]},"ruleId":{"type":"string","nullable":true,"description":"Id of the rule the finding concerns, or null when it is not tied to one rule."},"path":{"type":"string","description":"JSON path to the offending location, e.g. rules[0].conditions.tabels."},"code":{"type":"string","description":"Machine-readable finding code.","enum":["unknown-condition-key","unknown-operator","empty-condition","match-all-collapse","invalid-regex","invalid-ip-or-cidr","invalid-time-of-day","invalid-time-window","zero-width-time-window","invalid-day-of-week","dangling-invalidate-ref","unreachable-rule","rule-removed","cache-without-key","invalid-stale-while-revalidate","defaults-version-shadowed"]},"message":{"type":"string","description":"Human-readable explanation, with a did-you-mean suggestion where available."}}},"RuleLintResult":{"type":"object","description":"Result of dry-run linting a candidate ruleset.","required":["valid","errors","warnings"],"properties":{"valid":{"type":"boolean","description":"True when there are no errors. Warnings do not affect validity."},"errors":{"type":"array","description":"Blocking findings — a PUT of this ruleset would be rejected with these.","items":{"$ref":"#/components/schemas/RuleLintFinding"}},"warnings":{"type":"array","description":"Non-blocking findings — surfaced on a successful write for the author to review.","items":{"$ref":"#/components/schemas/RuleLintFinding"}}}},"CacheMetadata":{"type":"object","description":"Cache entry metadata","properties":{"version":{"type":"integer","description":"Metadata format version"},"cacheKey":{"type":"string","description":"Cache key hash"},"statement":{"type":"string","description":"Original SQL statement"},"user":{"type":"string","description":"Username"},"warehouse":{"type":"string","description":"Warehouse identifier"},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when cache entry was created"},"expiresAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when cache expires"},"lastAccessed":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of last access"},"hitCount":{"type":"integer","description":"Number of cache hits"},"size":{"type":"integer","description":"Cache entry size in bytes"}},"additionalProperties":true},"QueryRequest":{"type":"object","description":"A read-only SQL query against the tenant's proxy_logs columnar store","required":["sql"],"additionalProperties":false,"properties":{"sql":{"type":"string","minLength":1,"description":"A single read-only SELECT statement against the fixed proxy_logs view. File/extension functions and multiple statements are rejected."}}},"QueryResponse":{"type":"object","description":"Capped result of a columnar log query","properties":{"tenantId":{"type":"string"},"columns":{"type":"array","description":"Result columns in order","items":{"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string"}}}},"rows":{"type":"array","description":"Result rows as objects keyed by column name","items":{"type":"object"}},"rowCount":{"type":"integer","description":"Number of rows returned (after the enforced cap)"},"truncated":{"type":"boolean","description":"True if the row cap truncated the result set"}}},"MarkerInstance":{"type":"object","description":"Cache invalidation marker instance. targetRuleId and targetRuleVersion identify the cache rule this marker invalidates -- airbrx-gateway looks up a marker by exactly this pair (InvalidationMarker.key), never by markerId, so at most one marker can be active per rule version. Used for POST requests and all responses; PUT requests use MarkerUpdate instead, since an update can't change targetRuleId/targetRuleVersion/expiresAt (see MarkerUpdate description).","required":["targetRuleId","targetRuleVersion"],"properties":{"markerId":{"type":"string","description":"Marker identifier. Server-derived from targetRuleId + targetRuleVersion on create (matching airbrx-gateway's own InvalidationMarker key derivation) -- any value supplied in a create request is ignored.","readOnly":true},"sourceRuleId":{"type":"string","description":"DML rule that triggered this marker (null if manually created)"},"sourceRuleName":{"type":"string","description":"Display name of the DML rule that triggered this marker (null if manually created). Feeds into the response-only invalidatedBy.ruleName."},"sourceRuleVersion":{"type":"integer","description":"Version of the DML rule that triggered this marker (null if manually created). Feeds into the response-only invalidatedBy.ruleVersion."},"targetRuleId":{"type":"string","description":"Cache rule being invalidated"},"targetRuleVersion":{"type":"integer","minimum":1,"description":"Version of the cache rule being invalidated (the rule's actions.version). Required alongside targetRuleId -- together they form the marker's identity and match what airbrx-gateway's cache-miss check looks up."},"tenantId":{"type":"string","description":"Tenant identifier","readOnly":true},"status":{"type":"string","enum":["active","expired"],"description":"Marker status. Settable on create; in GET/list responses it is derived at read time from expiresAt rather than the stored value verbatim -- a marker past its expiresAt reports \"expired\" here even though nothing writes to it at the moment it lapses.","default":"active"},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when marker was created","readOnly":true},"invalidatedAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of the invalidation moment. Read directly by airbrx-gateway's InvalidationMarker.isInvalidatedBefore() -- this is the field that actually makes the gateway treat a cache entry created before this moment as a miss.","readOnly":true},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"ISO 8601 timestamp when marker expires, or null for no expiry. Server-derived from the target rule's own cache TTL at create time (matching CrossRuleInvalidator's DML-triggered semantics) -- not settable by the caller on create or update.","readOnly":true},"ttlSeconds":{"type":"integer","description":"TTL used to compute expiresAt, copied from the target rule's actions.cache.ttlSeconds at create time (0 if the rule has no TTL configured).","readOnly":true},"invalidatedBy":{"type":"object","description":"The rule that triggered this invalidation, as read by airbrx-gateway's InvalidationMarker.getInvalidatedBy(). All fields null for a manually created marker. This is the response shape; supply the flat sourceRuleId/sourceRuleName/sourceRuleVersion fields above to set it on create.","readOnly":true,"properties":{"ruleId":{"type":"string","nullable":true},"ruleName":{"type":"string","nullable":true},"ruleVersion":{"type":"integer","nullable":true}}},"metadata":{"type":"object","description":"Additional context about the marker","properties":{"triggerStatement":{"type":"string","description":"DML statement that triggered the marker"},"userId":{"type":"string","description":"User who triggered the invalidation"},"reason":{"type":"string","description":"Reason for manual marker creation/update"}},"additionalProperties":true}},"additionalProperties":true},"MarkerUpdate":{"type":"object","description":"Fields updatable via PUT on an existing invalidation marker. Deliberately excludes targetRuleId/targetRuleVersion (a marker's identity and storage location are derived from these at creation; retarget by deleting and creating a new marker), expiresAt (derived server-side from the target rule's TTL, same as on create -- accepting it from a caller would bypass that), and sourceRuleId/sourceRuleName/sourceRuleVersion (the display-only counterpart of the gateway-read invalidatedBy object set at creation; updating one without the other would desync what GET/list report from what the gateway actually used).","properties":{"status":{"type":"string","enum":["active","expired"],"description":"Marker status. API-display-only -- airbrx-gateway never reads it. Note that GET/list report a derived status regardless of what's stored here once expiresAt passes; see MarkerInstance.status."},"metadata":{"type":"object","description":"Additional context about the marker. Merged into existing metadata, not replaced.","additionalProperties":true}},"additionalProperties":true},"MarkerSummary":{"type":"object","description":"Summary of markers for a tenant","properties":{"tenantId":{"type":"string","description":"Tenant identifier"},"totalCount":{"type":"integer","description":"Total number of markers"},"activeCount":{"type":"integer","description":"Number of active markers"},"expiredCount":{"type":"integer","description":"Number of expired markers"},"markers":{"type":"array","items":{"$ref":"#/components/schemas/MarkerInstance"},"description":"List of markers"}}},"YearlySummary":{"type":"object","description":"Yearly summary statistics for a tenant","properties":{"tenantSummaryReportID":{"type":"string","format":"uuid","description":"Unique identifier for this summary report"},"tenantId":{"type":"string","description":"Tenant identifier"},"reportyears":{"type":"array","items":{"type":"string"},"description":"Years included in this report"},"totalUsers":{"type":"integer","description":"Total number of unique users"},"totalQueries":{"type":"integer","description":"Total number of queries"},"totalCacheHits":{"type":"integer","description":"Total number of cache hits"},"totalHits":{"type":"integer","description":"Total number of requests"},"cacheSize":{"type":"integer","description":"Total cache size in bytes"},"cacheElements":{"type":"integer","description":"Total number of cached elements"},"last365daysQueryActivity":{"type":"array","description":"Daily activity for up to 365 days","items":{"$ref":"#/components/schemas/DailyActivity"}}},"additionalProperties":true},"DailyActivity":{"type":"object","description":"Daily activity statistics within a yearly summary","properties":{"date":{"type":"string","description":"Date in YYYY/MM/DD format","example":"2025/10/30"},"queries":{"type":"integer","description":"Number of unique queries"},"users":{"type":"integer","description":"Number of unique users"},"cacheHits":{"type":"integer","description":"Number of cache hits"},"totalHits":{"type":"integer","description":"Total number of requests"},"dataTransfer":{"type":"integer","description":"Data transfer in bytes"},"cacheSize":{"type":"integer","description":"Cache size in bytes"},"cacheElements":{"type":"integer","description":"Number of cached elements"},"peakHour":{"type":"integer","minimum":0,"maximum":23,"description":"Hour with highest query volume (0-23, UTC)"},"cacheHitRate":{"type":"number","minimum":0,"maximum":1,"description":"Cache hit rate for the day (0.0 to 1.0)"}}},"MonthlySummary":{"type":"object","description":"Monthly summary statistics for a tenant, written by log-summary-v2 to {tenantId}/summaries/{year}/{month}/summary.json. The URL path uses zero-padded month (e.g. '04'), but the body emits month as an integer. Empty hourly buckets emit null (not 0) for the two averages — distinct from DailySummary, which emits 0.","properties":{"tenantId":{"type":"string","description":"Tenant identifier"},"year":{"type":"integer","description":"Year covered by the summary","example":2026},"month":{"type":"integer","minimum":1,"maximum":12,"description":"Month covered by the summary (1-12, not zero-padded)","example":4},"totalQueries":{"type":"integer","description":"Number of unique queries in the month"},"totalUsers":{"type":"integer","description":"Number of unique users active in the month"},"totalCacheHits":{"type":"integer","description":"Total cache hits in the month"},"totalCacheMisses":{"type":"integer","description":"Total non-HIT events for the month (MISS + BYPASS + PASSTHROUGH)"},"totalHits":{"type":"integer","description":"Total requests in the month"},"totalCachedBytes":{"type":"integer","description":"Sum of responseSize across HIT events for the month"},"totalPassThroughBytes":{"type":"integer","description":"Sum of responseSize across non-HIT events for the month"},"cacheHitAverageResponseTime":{"type":"integer","description":"Mean response time in ms across HIT events for the month"},"cacheMissAverageResponseTime":{"type":"integer","description":"Mean response time in ms across non-HIT events for the month"},"averageResponseTime":{"type":"integer","description":"Mean response time in ms across all events for the month"},"minResponseTime":{"type":"integer","description":"Minimum response time in ms observed during the month"},"maxResponseTime":{"type":"integer","description":"Maximum response time in ms observed during the month"},"hourlyActivity":{"type":"array","description":"24 elements representing hour-of-day activity aggregated across the month, index 0 = midnight UTC","items":{"type":"object","properties":{"hour":{"type":"integer","minimum":0,"maximum":23,"description":"Hour of day (0-23, UTC)"},"queries":{"type":"integer","description":"Number of unique queries in this hour-of-day"},"totalHits":{"type":"integer","description":"Total requests in this hour-of-day"},"cacheHits":{"type":"integer","description":"Cache hits in this hour-of-day"},"cacheMisses":{"type":"integer","description":"Non-HIT events in this hour-of-day (MISS + BYPASS + PASSTHROUGH)"},"users":{"type":"integer","description":"Unique users in this hour-of-day"},"cachedBytes":{"type":"integer","description":"Sum of responseSize across HIT events in this hour-of-day"},"passThroughBytes":{"type":"integer","description":"Sum of responseSize across non-HIT events in this hour-of-day"},"cacheHitAverageResponseTime":{"type":"integer","nullable":true,"description":"Mean response time in ms across HIT events in this hour-of-day. null when no data."},"cacheMissAverageResponseTime":{"type":"integer","nullable":true,"description":"Mean response time in ms across non-HIT events in this hour-of-day. null when no data."}}}},"daysActivity":{"type":"array","description":"Per-day activity for days within the month that had traffic","items":{"type":"object","properties":{"date":{"type":"string","description":"Date in YYYY/MM/DD format","example":"2026/04/16"},"queries":{"type":"integer","description":"Number of unique queries on this day"},"users":{"type":"integer","description":"Unique users on this day"},"cacheHits":{"type":"integer","description":"Cache hits on this day"},"cacheMisses":{"type":"integer","description":"Non-HIT events on this day (MISS + BYPASS + PASSTHROUGH)"},"totalHits":{"type":"integer","description":"Total requests on this day"},"dataTransfer":{"type":"integer","description":"Total responseSize in bytes for the day"},"cacheSize":{"type":"integer","description":"Cache size in bytes attributable to the day"},"cacheElements":{"type":"integer","description":"Cached elements attributable to the day"},"cachedBytes":{"type":"integer","description":"Sum of responseSize across HIT events on this day"},"passThroughBytes":{"type":"integer","description":"Sum of responseSize across non-HIT events on this day"},"averageResponseTime":{"type":"integer","description":"Mean response time in ms across all events on this day"},"minResponseTime":{"type":"integer","description":"Minimum response time in ms observed on this day"},"maxResponseTime":{"type":"integer","description":"Maximum response time in ms observed on this day"},"cacheHitAverageResponseTime":{"type":"integer","description":"Mean response time in ms across HIT events on this day"},"cacheMissAverageResponseTime":{"type":"integer","description":"Mean response time in ms across non-HIT events on this day"},"peakHour":{"type":"integer","minimum":0,"maximum":23,"description":"Hour with highest query volume on this day (0-23, UTC)"},"cacheHitRate":{"type":"number","minimum":0,"maximum":1,"description":"Cache hit rate for the day (0.0 to 1.0)"}}}},"generatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp when the summary was produced"}},"additionalProperties":true},"DailySummary":{"type":"object","description":"Daily summary statistics with query details. Fields cacheMisses, cachedBytes, passThroughBytes, cacheHitAverageResponseTime, and cacheMissAverageResponseTime were added later and are not present on summary files written before that change landed — clients should treat them as optional. Empty buckets emit 0 (not null) for the two new averages; treat 0 as 'no data', not real 0ms latency.","properties":{"queries":{"type":"array","description":"Array of query statistics","items":{"$ref":"#/components/schemas/QuerySummary"}},"uniqueUsers":{"type":"integer","description":"Number of unique users"},"cacheHits":{"type":"integer","description":"Total cache hits"},"cacheMisses":{"type":"integer","description":"Total non-HIT events for the day (MISS + BYPASS + PASSTHROUGH). Mirrors the per-query and per-hour cacheMisses semantic — non-HIT, not strict-MISS."},"totalHits":{"type":"integer","description":"Total number of requests"},"cachedBytes":{"type":"integer","description":"Sum of responseSize across events where cacheStatus = HIT."},"passThroughBytes":{"type":"integer","description":"Sum of responseSize across non-HIT events (MISS + BYPASS + PASSTHROUGH)."},"averageResponseTime":{"type":"integer","description":"Average response time in milliseconds"},"cacheHitAverageResponseTime":{"type":"integer","description":"Mean response time in ms across HIT events. 0 means no data for the day, not real 0ms latency."},"cacheMissAverageResponseTime":{"type":"integer","description":"Mean response time in ms across non-HIT events (MISS + BYPASS + PASSTHROUGH). 0 means no data for the day, not real 0ms latency."},"minResponseTime":{"type":"integer","description":"Minimum response time in milliseconds"},"maxResponseTime":{"type":"integer","description":"Maximum response time in milliseconds"},"hourlyActivity":{"type":"array","description":"24 elements representing hourly activity breakdown, index 0 = midnight UTC","items":{"type":"object","properties":{"hour":{"type":"integer","minimum":0,"maximum":23,"description":"Hour of day (0-23, UTC)"},"queries":{"type":"integer","description":"Number of unique queries in this hour"},"totalHits":{"type":"integer","description":"Total number of requests in this hour"},"cacheHits":{"type":"integer","description":"Number of cache hits in this hour"},"cacheMisses":{"type":"integer","description":"Number of non-HIT events in this hour (MISS + BYPASS + PASSTHROUGH); not strict-MISS"},"cachedBytes":{"type":"integer","description":"Sum of responseSize across HIT events in this hour."},"passThroughBytes":{"type":"integer","description":"Sum of responseSize across non-HIT events in this hour."},"cacheHitAverageResponseTime":{"type":"integer","description":"Mean response time in ms across HIT events in this hour. 0 = no data, not real 0ms latency."},"cacheMissAverageResponseTime":{"type":"integer","description":"Mean response time in ms across non-HIT events in this hour. 0 = no data, not real 0ms latency."},"users":{"type":"integer","description":"Number of unique users in this hour"}}}}},"additionalProperties":true},"QuerySummary":{"type":"object","description":"Statistics for a specific query","properties":{"queryHash":{"type":"string","description":"Hash of the SQL statement"},"statement":{"type":"string","description":"SQL query text"},"cacheKey":{"type":"string","description":"Cache key or filename"},"cacheHits":{"type":"integer","description":"Number of cache hits for this query"},"responseCode":{"type":"integer","description":"HTTP response code"},"averageResponseTime":{"type":"integer","description":"Average response time in milliseconds"},"responseSize":{"type":"integer","description":"Response size in bytes"},"count":{"type":"integer","description":"Number of times this query was executed"},"firstRequestTime":{"type":"string","description":"Timestamp of first request"},"lastRequestTime":{"type":"string","description":"Timestamp of last request"},"matchedRule":{"type":["string","null"],"description":"Rule ID that matched this query, null if no rule matched"},"cacheStatus":{"type":["string","null"],"enum":["HIT","MISS","BYPASS","PASSTHROUGH","SPOOFED","TELEMETRY","ERROR","INVALIDATION_ERROR","AUTH_ERROR","EXECUTOR","HOLD","BLOCKED",null],"description":"Cache status of this query's most recent request. Null when that request carried no status. Only HIT, MISS, BYPASS and PASSTHROUGH take part in the hit/miss counts; the rest are error, hold and telemetry outcomes that still served a request."},"cacheMisses":{"type":"integer","description":"Number of cache misses for this query"},"warehouseExecutionTimeMs":{"type":["integer","null"],"description":"Total warehouse execution time in milliseconds, null if query was never sent to warehouse"}}},"RuleEffectiveness":{"type":"object","description":"Cache rule effectiveness statistics for a year","properties":{"tenantId":{"type":"string","description":"Tenant identifier"},"year":{"type":"integer","description":"Year of the summary"},"generatedAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when this summary was generated"},"totalQueries":{"type":"integer","description":"Total number of queries"},"uncachedQueries":{"type":"integer","description":"Number of queries that were not cached"},"bypassedQueries":{"type":"integer","description":"Number of queries that bypassed cache"},"rules":{"type":"array","description":"Per-rule effectiveness statistics","items":{"type":"object","properties":{"ruleId":{"type":"string","description":"Rule identifier"},"ruleName":{"type":"string","description":"Human-readable rule name"},"queriesMatched":{"type":"integer","description":"Number of unique queries that matched this rule"},"totalExecutions":{"type":"integer","description":"Total executions of queries matching this rule"},"cacheHits":{"type":"integer","description":"Number of cache hits for this rule"},"cacheMisses":{"type":"integer","description":"Number of cache misses for this rule"},"hitRate":{"type":"number","minimum":0,"maximum":1,"description":"Cache hit rate (0.0 to 1.0)"},"warehouseTimeSavedMs":{"type":"integer","description":"Estimated warehouse time saved in milliseconds"}}}}}},"CacheOpportunities":{"type":"object","description":"AI-identified opportunities for cache optimization","properties":{"tenantId":{"type":"string","description":"Tenant identifier"},"year":{"type":"integer","description":"Year of the analysis"},"generatedAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when this analysis was generated"},"repeatMisses":{"type":"array","description":"Queries with repeated cache misses that could benefit from caching","items":{"$ref":"#/components/schemas/CacheOpportunity"}},"highCostUncached":{"type":"array","description":"High-cost queries that are not cached","items":{"$ref":"#/components/schemas/CacheOpportunity"}},"lowHitRate":{"type":"array","description":"Cached queries with low hit rates","items":{"$ref":"#/components/schemas/CacheOpportunity"}}}},"CacheOpportunity":{"type":"object","description":"A single cache optimization opportunity","properties":{"queryHash":{"type":"string","description":"Hash of the SQL statement"},"statement":{"type":"string","description":"SQL query text"},"executionCount":{"type":"integer","description":"Number of times this query was executed"},"cacheMisses":{"type":"integer","description":"Number of cache misses"},"totalWarehouseTimeMs":{"type":"integer","description":"Total warehouse execution time in milliseconds"},"matchedRule":{"type":["string","null"],"description":"Rule ID that matched, null if no rule matched"},"suggestedAction":{"type":"string","enum":["CREATE_RULE","EXTEND_TTL","REDUCE_TTL","INVESTIGATE"],"description":"Recommended action to improve caching"}}},"UsersSummaryIndex":{"type":"object","description":"Summary index of all users for a tenant","properties":{"tenantId":{"type":"string","description":"Tenant identifier"},"generatedAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when this summary was generated"},"totalUsers":{"type":"integer","description":"Total number of users"},"users":{"type":"array","description":"Array of user summary statistics","items":{"$ref":"#/components/schemas/UserSummaryItem"}}}},"UserSummaryItem":{"type":"object","description":"Summary statistics for a single user","properties":{"userId":{"type":"string","description":"User identifier"},"uniqueQueries":{"type":"integer","description":"Number of unique queries by this user"},"cacheHits":{"type":"integer","description":"Total cache hits for this user"},"totalHits":{"type":"integer","description":"Total requests by this user"},"averageResponseTime":{"type":"integer","description":"Average response time in milliseconds"},"minResponseTime":{"type":"integer","description":"Minimum response time in milliseconds"},"maxResponseTime":{"type":"integer","description":"Maximum response time in milliseconds"}}},"UserSummary":{"type":"object","description":"Aggregate summary for a specific user across all time periods","properties":{"userId":{"type":"string","description":"User identifier"},"totalQueries":{"type":"integer","description":"Total number of queries"},"totalCacheHits":{"type":"integer","description":"Total cache hits"},"totalHits":{"type":"integer","description":"Total requests"},"averageResponseTime":{"type":"integer","description":"Average response time in milliseconds"},"minResponseTime":{"type":"integer","description":"Minimum response time in milliseconds"},"maxResponseTime":{"type":"integer","description":"Maximum response time in milliseconds"}},"additionalProperties":true},"UserYearlySummary":{"type":"object","description":"Yearly summary statistics for a specific user","properties":{"userSummaryReportID":{"type":"string","format":"uuid","description":"Unique identifier for this summary report"},"tenantId":{"type":"string","description":"Tenant identifier"},"userId":{"type":"string","description":"User identifier"},"reportyears":{"type":"array","items":{"type":"string"},"description":"Years included in this report"},"totalQueries":{"type":"integer","description":"Total number of queries"},"totalCacheHits":{"type":"integer","description":"Total cache hits"},"totalHits":{"type":"integer","description":"Total requests"},"last365daysQueryActivity":{"type":"array","description":"Daily activity for up to 365 days","items":{"$ref":"#/components/schemas/UserDailyActivity"}}},"additionalProperties":true},"UserDailyActivity":{"type":"object","description":"Daily activity statistics for a user within a yearly summary","properties":{"date":{"type":"string","description":"Date in YYYY/MM/DD format","example":"2025/10/30"},"queries":{"type":"integer","description":"Number of queries"},"cacheHits":{"type":"integer","description":"Number of cache hits"},"totalHits":{"type":"integer","description":"Total requests"}}},"UserDailySummary":{"type":"object","description":"Daily summary statistics for a specific user with query details","properties":{"userId":{"type":"string","description":"User identifier"},"uniqueQueries":{"type":"integer","description":"Number of unique queries"},"cacheHits":{"type":"integer","description":"Total cache hits"},"totalHits":{"type":"integer","description":"Total requests"},"averageResponseTime":{"type":"integer","description":"Average response time in milliseconds"},"minResponseTime":{"type":"integer","description":"Minimum response time in milliseconds"},"maxResponseTime":{"type":"integer","description":"Maximum response time in milliseconds"},"queries":{"type":"array","description":"Array of query statistics","items":{"$ref":"#/components/schemas/QuerySummary"}}},"additionalProperties":true},"RulesEvaluatorRequest":{"type":"object","description":"Request body for AI rules evaluator","required":["queries"],"properties":{"queries":{"type":"array","description":"Array of query objects to analyze","items":{"type":"object","properties":{"statement":{"type":"string","description":"SQL query text"},"user":{"type":"string","description":"Username"},"frequency":{"type":"integer","description":"Number of times this query was executed"},"avgDuration":{"type":"number","description":"Average execution time in milliseconds"}},"required":["statement"]}}},"example":{"queries":[{"statement":"SELECT * FROM sales WHERE date > '2025-01-01'","user":"analyst@example.com","frequency":150,"avgDuration":2300},{"statement":"SELECT COUNT(*) FROM users","user":"admin@example.com","frequency":500,"avgDuration":120}]}},"RulesEvaluatorResponse":{"type":"object","description":"AI-generated rule recommendations","properties":{"rules":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"priority":{"type":"integer","description":"Evaluation order (lower = higher priority)"},"enabled":{"type":"boolean","description":"Whether rule is active"},"mode":{"type":"string","enum":["all","either"],"description":"Condition matching mode"},"conditions":{"type":"object","description":"Conditions that must match for rule to apply","additionalProperties":true},"actions":{"type":"object","description":"Actions to take when rule matches","properties":{"cacheKeyElements":{"type":"array","items":{"type":"string"}},"cache":{"type":"object","properties":{"ttlSeconds":{"type":"integer"}}},"version":{"type":"integer"}}},"rationale":{"type":"string","description":"AI explanation for the recommendation"}}}},"error":{"type":"string","description":"Error message if JSON parsing failed"},"rawResponse":{"type":"string","description":"Raw AI response if JSON parsing failed"}},"example":{"rules":[{"id":"rule_001","name":"Cache frequent sales queries","description":"Cache sales dashboard queries with high frequency","enabled":true,"priority":10,"mode":"all","conditions":{"tables":{"includes":"SALES"},"statement":{"contains":"FROM sales"},"userId":{"matches":".*@example\\.com"}},"actions":{"cacheKeyElements":["userId","standardizedSql"],"cache":{"ttlSeconds":3600},"version":1},"rationale":"This query runs 150 times per day with consistent results. Caching for 1 hour would reduce warehouse load significantly."}]}},"LogSummarizeRequest":{"type":"object","description":"Log summarization request","properties":{"mode":{"type":"string","description":"Summarization mode","enum":["incremental","reset","unlock"],"default":"incremental"}},"example":{"mode":"incremental"}},"LogSummarizeResponse":{"type":"object","description":"Log summarization response","properties":{"success":{"type":"boolean","description":"Whether summarization completed successfully"},"tenantsProcessed":{"type":"integer","description":"Number of tenants processed"},"duration":{"type":"integer","description":"Execution time in milliseconds"},"mode":{"type":"string","description":"Mode that was used","enum":["incremental","reset","unlock"]},"error":{"type":"string","description":"Error message if success is false"}},"example":{"success":true,"tenantsProcessed":5,"duration":2340,"mode":"incremental"}},"Error":{"type":"object","properties":{"error":{"type":"string","description":"Error message"}},"required":["error"]},"PATScopes":{"type":"object","description":"PAT permission scopes by HTTP method","properties":{"GET":{"type":"array","items":{"type":"string"},"description":"Paths allowed for GET requests"},"POST":{"type":"array","items":{"type":"string"},"description":"Paths allowed for POST requests"},"PUT":{"type":"array","items":{"type":"string"},"description":"Paths allowed for PUT requests"},"DELETE":{"type":"array","items":{"type":"string"},"description":"Paths allowed for DELETE requests"}},"example":{"GET":["/tenants/{tenantId}/**"],"POST":[],"PUT":[],"DELETE":[]}},"PATCreateRequest":{"type":"object","description":"Request body for creating a PAT. The 'name' field is a human-readable label for management UIs (per-tenant unique). The credential itself is generated server-side and returned only in the create response.","required":["name","scopes"],"properties":{"name":{"type":"string","description":"Human-readable label for the PAT. Unique within each tenant the PAT is scoped to. Not a credential."},"expiresAt":{"type":"string","format":"date-time","description":"Optional expiration date (ISO 8601). Pass 'never' or null for no expiry. Defaults to 1 year from creation."},"scopes":{"$ref":"#/components/schemas/PATScopes"},"tenants":{"type":"array","items":{"type":"string"},"description":"Optional explicit list of tenants (auto-extracted from scope paths if not provided). Tenants the caller cannot grant are silently stripped — see 'stripped' in the response."},"accounts":{"type":"array","items":{"type":"string"},"description":"Optional explicit list of accounts. Accounts the caller cannot grant are silently stripped. When omitted, derived from the final tenants list."}},"example":{"name":"CI/CD Pipeline Token","expiresAt":"2026-12-02T10:00:00Z","scopes":{"GET":["/tenants/acme.app.airbrx.com/**"],"POST":[],"PUT":[],"DELETE":[]}}},"PAT":{"type":"object","description":"Personal Access Token metadata. The 'id' is the storage identifier used by the management endpoints; the 'name' is a human-readable label. Neither is a credential — the secret is only ever returned in the PATCreateResponse.","required":["name","issuedBy","createdAt","scopes","tenants","accounts"],"properties":{"id":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"Storage identifier (64-char hex). Used in /oauth/pats/{patId} URLs. Cannot be reversed back to the secret. Optional on read for backwards-compat with out-of-band-provisioned GOD PATs; always present on PATs created via POST /oauth/pats."},"name":{"type":"string","description":"Human-readable label for management UIs. Per-tenant unique. Not a credential."},"issuedBy":{"type":"string","format":"email","description":"Email of user who created the PAT"},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"Expiration timestamp. Null means never expires."},"lastUsedAt":{"type":"string","format":"date-time","nullable":true,"description":"Last usage timestamp. Null if never used."},"scopes":{"$ref":"#/components/schemas/PATScopes"},"tenants":{"type":"array","items":{"type":"string"},"description":"Tenant IDs this PAT can access. Use [\"*\"] for all tenants (god-PAT only)."},"accounts":{"type":"array","items":{"type":"string"},"description":"Account IDs this PAT can access. Use [\"*\"] for all accounts. When omitted on create, derived from tenants."}}},"PATWithStatus":{"allOf":[{"$ref":"#/components/schemas/PAT"},{"type":"object","properties":{"status":{"type":"string","enum":["valid","expired"],"description":"Computed status based on expiration. To revoke a PAT, delete it."}}}]},"TosAckRequest":{"type":"object","description":"Request body for POST /users/{email}/tos. The dashboard wraps the markdown the user was shown so the API ingests JSON like every other endpoint — the server still normalizes (CRLF→LF, trim trailing whitespace per line, strip edge blank lines) before hashing, so transport and clipboard variation does not affect the comparison.","required":["markdown"],"properties":{"markdown":{"type":"string","description":"The raw markdown the user was shown — the same bytes returned by GET /tos. The server normalizes and MD5s this and compares against the in-memory current ToS hash."}}},"TosPublishRequest":{"type":"object","description":"Request body for POST /tos. Same wrapper shape as the acknowledgement endpoint. The server normalizes before hashing, so line-ending and trailing-whitespace variation cannot produce a spurious new version.","required":["markdown"],"properties":{"markdown":{"type":"string","description":"The full markdown to publish. Must be non-empty after normalization."}}},"TosPublishResult":{"type":"object","description":"Response for POST /tos. `previous` lets the caller record what was replaced without a separate read.","required":["md5","unchanged","publishedAt"],"properties":{"md5":{"type":"string","pattern":"^[0-9a-f]{32}$","description":"MD5 (hex) of the normalized published document — the hash users must now acknowledge."},"previous":{"type":"string","nullable":true,"description":"Hash that was active before this publish, or null if none was loaded."},"unchanged":{"type":"boolean","description":"True when the submitted document was already active, in which case nothing was written."},"publishedAt":{"type":"string","format":"date-time","description":"Timestamp recorded in tos/published/{md5}.json."}}},"TosPublishRecord":{"type":"object","description":"Publisher-side audit record written to tos/published/{md5}.json. The counterpart to the per-user acknowledgement records — answers which document became active, when, and at whose hand.","required":["md5","publishedAt"],"properties":{"md5":{"type":"string","pattern":"^[0-9a-f]{32}$","description":"Hash of the normalized document that became active."},"publishedAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of the publish."},"actor":{"type":"string","nullable":true,"description":"Authenticated identity that published, taken from the caller's token."},"previous":{"type":"string","nullable":true,"description":"Hash this version replaced."}}},"TosAckRecord":{"type":"object","description":"Per-acknowledgement record written to users/{email}/tos/{md5}.json. Audit trail; retained even after the user accepts a newer ToS version.","required":["email","md5","acceptedAt"],"properties":{"email":{"type":"string","format":"email","description":"Email of the user who acknowledged"},"md5":{"type":"string","pattern":"^[0-9a-f]{32}$","description":"MD5 (hex) of the normalized ToS content the user accepted"},"acceptedAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when the acknowledgement was recorded"},"ip":{"type":"string","nullable":true,"description":"Client IP at acceptance time (X-Forwarded-For leftmost hop, falling back to socket remote address)"},"userAgent":{"type":"string","nullable":true,"description":"User-Agent header sent with the POST"}}},"TosAckList":{"type":"object","description":"Response shape for GET /users/{email}/tos — the user's acknowledgement history newest-first plus the currently-active hash.","required":["email","acknowledgements"],"properties":{"email":{"type":"string","format":"email"},"current":{"type":"string","nullable":true,"pattern":"^[0-9a-f]{32}$","description":"MD5 (hex) of the active ToS at the time of the request, or null if the API has not loaded a ToS yet"},"acknowledgements":{"type":"array","items":{"type":"object","required":["md5"],"properties":{"md5":{"type":"string","pattern":"^[0-9a-f]{32}$"},"acceptedAt":{"type":"string","format":"date-time","nullable":true}}}}}},"TosHashMismatch":{"type":"object","description":"409 response body for POST /users/{email}/tos when the submitted markdown does not hash to the active ToS. The dashboard must re-fetch GET /tos and re-prompt.","required":["error","current"],"properties":{"error":{"type":"string","enum":["tos_hash_mismatch"]},"current":{"type":"string","pattern":"^[0-9a-f]{32}$","description":"MD5 of the active ToS the client should re-render and POST back"},"message":{"type":"string"}}},"TosRequiredError":{"type":"object","description":"403 response body when an Airbrx JWT carrying the tos_required claim attempts an endpoint outside the ToS bridge whitelist. The dashboard interprets this as 'show the ToS modal' and routes the user through GET /tos → POST /users/{email}/tos → token refresh.","required":["error","current"],"properties":{"error":{"type":"string","enum":["tos_required"]},"current":{"type":"string","pattern":"^[0-9a-f]{32}$","description":"MD5 of the active ToS the user must accept"},"message":{"type":"string"}}},"PATCreateResponse":{"description":"Response from POST /oauth/pats. Wraps the PAT metadata with the secret and any stripped permissions. The 'pat' field is the credential — it is returned only here, never persisted, and cannot be recovered later. Pass it in the 'pat' field of /oauth/token (grant_type=pat_exchange) to obtain a Bearer access token.","allOf":[{"$ref":"#/components/schemas/PAT"},{"type":"object","required":["id","pat","stripped"],"properties":{"pat":{"type":"string","pattern":"^airbrx_pat_[0-9a-f]{64}$","description":"The PAT secret. `airbrx_pat_` prefix followed by 64 hex chars. ONE-TIME RETURN — store it now or you will have to create a new PAT. The prefix exists so the credential is grep-able in env files / log redaction patterns; the bytes after the prefix are the entropy."},"stripped":{"type":"object","description":"Permissions the caller asked for but could not grant. Empty object means the request was honored as-is.","properties":{"scopes":{"$ref":"#/components/schemas/PATScopes"},"tenants":{"type":"array","items":{"type":"string"}},"accounts":{"type":"array","items":{"type":"string"}}}}}}]},"IntrospectionResponse":{"type":"object","description":"Token introspection response (RFC 7662)","properties":{"active":{"type":"boolean","description":"Whether the token is valid and not expired"},"token_type":{"type":"string","description":"Token type (Bearer)"},"client_type":{"type":"string","enum":["jwt","access_token","refresh_token","pat"],"description":"Type of token. jwt=user JWT, access_token=PAT-derived JWT, refresh_token=refresh, pat=raw PAT secret."},"token_format":{"type":"string","enum":["jwt","opaque"],"description":"Format of the token. jwt for JWTs (any flavor) and refresh tokens; opaque for raw PAT secrets."},"sub":{"type":"string","description":"Subject (user email or token type)"},"iss":{"type":"string","description":"Issuer"},"exp":{"type":"integer","description":"Expiration time (Unix timestamp)"},"iat":{"type":"integer","description":"Issued at time (Unix timestamp)"},"scope":{"$ref":"#/components/schemas/PATScopes"},"tenants":{"type":"array","description":"Tenant access. For user JWTs: structured [{tenantId, role}]. For PAT-derived access tokens: flat list of tenant ids (or ['*'] for GOD PATs). See docs/jwt-claims.md.","items":{"oneOf":[{"type":"string"},{"type":"object","properties":{"tenantId":{"type":"string"},"role":{"type":"string","enum":["owner","admin","member"]}},"required":["tenantId","role"]}]}},"accounts":{"type":"array","description":"Account access. For user JWTs: structured [{accountId, role}]. For PAT-derived access tokens: flat list of account ids (or ['*']). Not present for refresh tokens. See docs/jwt-claims.md.","items":{"oneOf":[{"type":"string"},{"type":"object","properties":{"accountId":{"type":"string"},"role":{"type":"string","enum":["owner","admin","member"]}},"required":["accountId","role"]}]}},"displayName":{"type":"string","description":"Human-readable name of the principal."},"email":{"type":"string","format":"email","description":"Email of the principal."},"pat_id":{"type":"string","description":"PAT identifier (storage hash). Present on PAT-derived access tokens and raw PAT introspection."},"pat_name":{"type":"string","description":"PAT human-readable label. Present on PAT-derived access tokens and raw PAT introspection."},"pat_hash":{"type":"string","description":"PAT hash (for refresh tokens only)"},"issuedBy":{"type":"string","format":"email","description":"Email of the user who minted the PAT. Present on raw PAT introspection only."},"lastUsedAt":{"type":"string","format":"date-time","nullable":true,"description":"ISO timestamp of the PAT's most recent use, or null if never used. Present on raw PAT introspection only."},"reason":{"type":"string","enum":["expired","unknown_format"],"description":"When `active: false`, optionally explains why. `expired` for a token that verified but is past its exp; `unknown_format` for inputs that don't match any recognized prefix or JWT shape. Omitted entirely when the inactivity reason would leak existence info (e.g. raw PAT lookup miss)."}}},"TokenExchangeRequest":{"description":"Request body for the token exchange endpoint. The grant_type selects one of four shapes: pat_exchange and refresh_token (Airbrx clients, JSON), authorization_code (OAuth 2.1 + PKCE), or the RFC 8628 device_code grant.","oneOf":[{"type":"object","required":["grant_type","pat"],"properties":{"grant_type":{"type":"string","enum":["pat_exchange"],"description":"Grant type for PAT exchange"},"pat":{"type":"string","description":"The PAT name to exchange (e.g., 'my_api_token')"}}},{"type":"object","required":["grant_type","refresh_token"],"properties":{"grant_type":{"type":"string","enum":["refresh_token"],"description":"Grant type for token refresh"},"refresh_token":{"type":"string","description":"The refresh token (airbrx_refresh_...). Supports both PAT-derived refresh tokens (from pat_exchange) and user refresh tokens (from /auth/callback)"}}},{"type":"object","required":["grant_type","code","redirect_uri","code_verifier"],"properties":{"grant_type":{"type":"string","enum":["authorization_code"],"description":"Grant type for the OAuth 2.1 authorization_code + PKCE flow"},"code":{"type":"string","description":"The one-time authorization code issued by /authorize/consent"},"redirect_uri":{"type":"string","description":"Must exactly match the redirect_uri from the authorization request"},"code_verifier":{"type":"string","description":"PKCE code_verifier whose SHA-256 matches the code_challenge sent to /authorize"},"client_id":{"type":"string","description":"The registered client_id (optional but verified against the code when present)"},"resource":{"type":"string","description":"RFC 8707 resource indicator sent by some OAuth clients; accepted and ignored"}}},{"type":"object","required":["grant_type","device_code","client_id"],"properties":{"grant_type":{"type":"string","enum":["urn:ietf:params:oauth:grant-type:device_code"],"description":"RFC 8628 device authorization grant type"},"device_code":{"type":"string","description":"The opaque device code issued by /oauth/device_authorization"},"client_id":{"type":"string","description":"The device client identifier"},"resource":{"type":"string","description":"Optional RFC 8707 resource indicator; when present it must match the authorization request"}}}]},"TokenExchangeResponse":{"type":"object","description":"Response from the token exchange endpoint. User access tokens carry structured `tenants` and `accounts` claims with per-resource roles. PAT-derived access tokens keep flat `tenantIds` / `accounts` plus an explicit `scopes` object. See docs/jwt-claims.md for the authoritative claim reference.","properties":{"access_token":{"type":"string","description":"JWT access token. User tokens contain: iss, sub (email), email, displayName, tenants (structured [{tenantId, role}]), accounts (structured [{accountId, role}]), delegatedAccess. PAT-derived tokens contain: iss, sub (email), email, displayName, tenantIds (flat, or ['*'] for GOD PATs), accounts (flat, or ['*']), delegatedAccess: [], scopes, pat: true. No roles claim. See docs/jwt-claims.md."},"refresh_token":{"type":"string","description":"JWT refresh token (airbrx_refresh_...) - only returned for pat_exchange grant type, not for refresh_token grant type"},"token_type":{"type":"string","enum":["Bearer"],"description":"Token type"},"expires_in":{"type":"integer","description":"Access token lifetime in seconds"},"scope":{"$ref":"#/components/schemas/PATScopes","description":"Scopes inherited from the PAT - only returned for pat_exchange grant type, not for refresh_token grant type"},"tenants":{"type":"array","items":{"type":"string"},"description":"Tenants inherited from the PAT - only returned for pat_exchange grant type, not for refresh_token grant type"}},"required":["access_token","token_type","expires_in"]},"AvailablePermissions":{"type":"object","description":"Permissions available for the authenticated user to grant to a PAT","properties":{"permissions":{"$ref":"#/components/schemas/PATScopes"},"tenants":{"type":"array","items":{"type":"string"},"description":"Tenants the user can grant access to"}}},"GrantDelegation":{"type":"object","description":"Delegation chain information for a permission grant","properties":{"isRoot":{"type":"boolean","description":"True if this is a level-1 grant from a base admin"},"rootAdminEmail":{"type":"string","format":"email","description":"Email of the base admin at the root of the delegation chain"},"rootGrantId":{"type":"string","nullable":true,"description":"ID of the parent grant (null for root grants)"},"canDelegate":{"type":"boolean","description":"Whether the grantee can create sub-grants (always false for level-2)"},"childGrantIds":{"type":"array","items":{"type":"string"},"description":"IDs of grants created by this grantee using delegation rights"}}},"PermissionGrant":{"type":"object","description":"A delegated permission grant","properties":{"id":{"type":"string","description":"Unique grant ID","example":"grant_abc123def456"},"grantorEmail":{"type":"string","format":"email","description":"Email of user who created this grant"},"granteeEmail":{"type":"string","format":"email","description":"Email of user receiving the grant"},"tenantId":{"type":"string","description":"Tenant ID being granted access to (mutually exclusive with accountId)"},"accountId":{"type":"string","description":"Account ID being granted access to (mutually exclusive with tenantId)"},"delegation":{"$ref":"#/components/schemas/GrantDelegation"},"permissions":{"type":"object","description":"Permissions granted (role or fine-grained scopes). Role enum is scope-dependent: tenant-scoped grants use [guest|cacheadmin|rulesadmin|admin]; account-scoped grants use [owner|admin|member].","properties":{"role":{"type":"string","description":"Role granted. Scope-dependent: tenant=[guest|cacheadmin|rulesadmin|admin], account=[owner|admin|member].","enum":["guest","cacheadmin","rulesadmin","admin","owner","member"]},"scopes":{"$ref":"#/components/schemas/PATScopes"}}},"createdAt":{"type":"string","format":"date-time","description":"When the grant was created"},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"When the grant expires (null = no expiration)"},"revokedAt":{"type":"string","format":"date-time","nullable":true,"description":"When the grant was revoked (null = active)"},"revokedBy":{"type":"string","format":"email","nullable":true,"description":"Email of user who revoked the grant"},"revokedReason":{"type":"string","nullable":true,"description":"Reason for revocation (direct, cascade, elevated)"},"notes":{"type":"string","nullable":true,"description":"Optional notes about the grant"}},"example":{"id":"grant_abc123def456","grantorEmail":"alice@company.com","granteeEmail":"bob@example.com","tenantId":"acme.app.airbrx.com","delegation":{"isRoot":true,"rootAdminEmail":"alice@company.com","rootGrantId":null,"canDelegate":true,"childGrantIds":["grant_xyz789"]},"permissions":{"role":"guest"},"createdAt":"2025-01-15T10:00:00.000Z","expiresAt":"2026-01-15T10:00:00.000Z","revokedAt":null,"revokedBy":null,"notes":"Temporary access for project review"}},"TenantMembership":{"type":"object","description":"A single tenant membership on a user record. Pairs a tenantId with the base role the user holds on that tenant. tenantId \"*\" is a wildcard granting the role across all tenants.","required":["tenantId","role"],"additionalProperties":false,"properties":{"tenantId":{"type":"string","minLength":1},"role":{"type":"string","enum":["owner","admin","member"]}}},"TenantMembershipList":{"type":"array","description":"User's tenant memberships. Each entry carries tenantId + per-tenant base role.","items":{"$ref":"#/components/schemas/TenantMembership"}},"AccountMembership":{"type":"object","description":"A single account membership on a user record. Pairs an accountId with the base role the user holds on that account. accountId \"*\" is a wildcard granting the role across all accounts.","required":["accountId","role"],"additionalProperties":false,"properties":{"accountId":{"type":"string","minLength":1},"role":{"type":"string","enum":["owner","admin","member"]}}},"AccountMembershipList":{"type":"array","description":"User's account memberships. Each entry carries accountId + per-account base role.","items":{"$ref":"#/components/schemas/AccountMembership"}},"TenantTransfer":{"type":"object","description":"A consent-based tenant transfer record. Recipient is addressed by email; they pick their destination account at accept time.","properties":{"transferId":{"type":"string","example":"xfer_abc123def456"},"tenantId":{"type":"string","format":"uuid"},"tenantName":{"type":"string","nullable":true,"description":"Cached at initiate time for inbox rendering"},"fromAccountId":{"type":"string","format":"uuid"},"toUserEmail":{"type":"string","format":"email"},"toAccountId":{"type":"string","format":"uuid","nullable":true,"description":"Filled in at accept time when recipient picks destination"},"status":{"type":"string","enum":["pending","accepted","rejected","canceled","expired"]},"initiatedBy":{"type":"string","format":"email"},"initiatedAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time"},"message":{"type":"string","nullable":true},"acceptedBy":{"type":"string","format":"email","nullable":true},"acceptedAt":{"type":"string","format":"date-time","nullable":true},"rejectedBy":{"type":"string","format":"email","nullable":true},"rejectedAt":{"type":"string","format":"date-time","nullable":true},"canceledBy":{"type":"string","format":"email","nullable":true},"canceledAt":{"type":"string","format":"date-time","nullable":true},"version":{"type":"string","example":"1.0"}}},"GrantCreateRequest":{"type":"object","description":"Request body for creating a permission grant. Exactly one of tenantId or accountId must be supplied. Tenant-scope roles: guest/cacheadmin/rulesadmin/admin. Account-scope roles: owner/admin/member — owner and admin have identical rights today (owner is reserved for future billing-scoped authority); member is read-only.","required":["granteeEmail","role"],"oneOf":[{"type":"object","required":["tenantId"]},{"type":"object","required":["accountId"]}],"properties":{"granteeEmail":{"type":"string","format":"email","description":"Email of user to grant access to"},"tenantId":{"type":"string","description":"Tenant ID to grant access to (mutually exclusive with accountId)"},"accountId":{"type":"string","description":"Account ID to grant access to (mutually exclusive with tenantId)."},"role":{"type":"string","description":"Role to grant. Scope-dependent: tenant=[guest|cacheadmin|rulesadmin|admin], account=[owner|admin|member].","enum":["guest","cacheadmin","rulesadmin","admin","owner","member"]},"canDelegate":{"type":"boolean","description":"Whether grantee can create sub-grants. Tenant scope: only valid for base admins. Account scope: implicit peer-owner delegation; value is ignored.","default":false},"expiresAt":{"type":"string","format":"date-time","description":"Optional expiration date"},"notes":{"type":"string","description":"Optional notes about the grant"},"scopes":{"$ref":"#/components/schemas/PATScopes","description":"Optional fine-grained scopes (tenant scope only; rejected for account-scoped grants in v1)"}},"example":{"granteeEmail":"bob@example.com","tenantId":"acme.app.airbrx.com","role":"guest","canDelegate":true,"expiresAt":"2026-01-15T10:00:00.000Z","notes":"Temporary access for Q1 project"}},"GrantUpdateRequest":{"type":"object","description":"Request body for updating a permission grant","properties":{"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"New expiration date (null to remove expiration)"},"notes":{"type":"string","nullable":true,"description":"Updated notes"},"canDelegate":{"type":"boolean","description":"Update delegation rights (only for level-1 grants, requires admin)"}},"example":{"expiresAt":"2026-06-15T10:00:00.000Z","notes":"Extended for phase 2"}},"Account":{"type":"object","description":"Account representing a billing entity","properties":{"accountId":{"type":"string","format":"uuid","description":"Unique account identifier"},"name":{"type":"string","description":"Account name (globally unique)","minLength":1,"maxLength":255},"status":{"type":"string","enum":["active","suspended","deleted"],"description":"Account status"},"plan":{"type":"string","enum":["applied","free","starter","professional","enterprise"],"description":"Subscription plan"},"contacts":{"type":"object","properties":{"primary":{"type":"string","format":"email","description":"Primary contact email"},"billing":{"type":"string","format":"email","nullable":true,"description":"Billing contact email"}},"required":["primary"]},"createdBy":{"type":"string","format":"email","nullable":true,"description":"Email of the user who created the account. For a non-GOD create this equals contacts.primary, which the create path pins to the authenticated caller; for a GOD-provisioned account it is the operator, who is not the first owner. Null on accounts created before this field was recorded."},"settings":{"type":"object","properties":{"maxTenants":{"type":"integer","nullable":true,"description":"Maximum tenants allowed (null for unlimited)"},"defaultStorageRegion":{"type":"string","description":"Default AWS region for storage"},"defaultCacheBucket":{"type":"string","description":"Default S3 bucket for cache"},"defaultLogBucket":{"type":"string","description":"Default S3 bucket for logs"}}},"registeredDomains":{"type":"array","items":{"type":"string"},"description":"Domain names registered to this account (e.g., ['acme.com', 'acme-analytics.com'])"},"isSystemAccount":{"type":"boolean","description":"System accounts cannot be modified or deleted","default":false},"createdAt":{"type":"string","format":"date-time","description":"Creation timestamp"},"updatedAt":{"type":"string","format":"date-time","description":"Last update timestamp"},"version":{"type":"string","description":"Schema version"}},"required":["accountId","name","status","plan","contacts"]},"AccountDetail":{"type":"object","description":"Platform-admin detail view of one account. Backs the admin Accounts drawer: enough context to identify an account before a plan change, since name is not unique and the GUID prefix is not informational.","properties":{"_links":{"$ref":"#/components/schemas/HATEOASLinks"},"accountId":{"type":"string","format":"uuid"},"name":{"type":"string","nullable":true,"description":"Account name"},"plan":{"type":"string","nullable":true,"description":"Subscription plan"},"creatorEmail":{"type":"string","format":"email","nullable":true,"description":"Account creator. Reads the account's createdBy, falling back to contacts.primary for accounts created before createdBy was recorded — for a self-serve account those are the same email, and for a GOD-provisioned one the fallback is the first owner. Null when neither is present."},"tenants":{"type":"array","description":"Tenants owned by this account, from the account-side ownership index.","items":{"type":"object","properties":{"tenantId":{"type":"string","format":"uuid"},"tenantName":{"type":"string","description":"Tenant name; falls back to the ownership record's name, then the tenantId"},"createdAt":{"type":"string","format":"date-time","nullable":true,"description":"When the tenant was created, from the ownership record"},"warehouseHost":{"type":"string","nullable":true,"description":"Backend warehouse hostname (dataAdapter.server_hostname). Connection metadata, not a secret. Null when the tenant has no warehouse wiring yet."},"members":{"type":"array","description":"Users holding a membership on this tenant. Served from the in-memory roster built at startup and updated on each user write.","items":{"type":"object","properties":{"email":{"type":"string","format":"email"},"role":{"type":"string","nullable":true,"enum":["owner","admin","member",null],"description":"Membership role on the tenant; null when the record carries no role"}}}}}}}},"required":["accountId","tenants"]},"AccountCreate":{"type":"object","description":"Request body for creating an account","properties":{"name":{"type":"string","description":"Account name (globally unique)","minLength":1,"maxLength":255},"plan":{"type":"string","enum":["applied","free","starter","professional","enterprise"],"description":"Subscription plan"},"contacts":{"type":"object","properties":{"primary":{"type":"string","format":"email","description":"Primary contact email"},"billing":{"type":"string","format":"email","nullable":true,"description":"Billing contact email"}},"required":["primary"]},"settings":{"type":"object","properties":{"maxTenants":{"type":"integer","nullable":true},"defaultStorageRegion":{"type":"string"}}}},"required":["name","plan","contacts"],"example":{"name":"Acme Corporation","plan":"enterprise","contacts":{"primary":"admin@acme.com","billing":"billing@acme.com"},"settings":{"maxTenants":10,"defaultStorageRegion":"us-east-1"}}},"TenantManifest":{"type":"object","description":"Snapshot of the tenant change manifest. Sorted newest-first by `modifiedAt`. The data-proxy gateway uses `generatedAt` from the response as its next `lastSync` value. Admin search filters: `?name=`, `?includeDeleted=`.","required":["generatedAt","tenants"],"properties":{"generatedAt":{"type":"string","format":"date-time","nullable":true,"description":"Timestamp at which this manifest snapshot was produced. `null` only when the manifest object does not yet exist (no tenant has ever been written).","example":"2026-04-30T23:00:00Z"},"tenants":{"type":"array","description":"Tenant entries newest-first. May include tombstones (`deleted: true`) when `?includeDeleted=true`.","items":{"type":"object","required":["tenantId","modifiedAt"],"properties":{"tenantId":{"type":"string","description":"Tenant UUID"},"tenantName":{"type":"string","description":"Human-readable tenant name. Lazy-backfilled from the canonical config on read; absent until the entry has been hydrated.","example":"Acme Production"},"modifiedAt":{"type":"string","format":"date-time","description":"Timestamp of the most recent mutation under `config/tenants/{tenantId}/*` (config or rules)."},"deleted":{"type":"boolean","description":"Present and `true` only on tombstones; omitted on live entries.","example":true}}}}},"example":{"generatedAt":"2026-04-30T23:00:00Z","tenants":[{"tenantId":"abc","tenantName":"Acme Production","modifiedAt":"2026-04-30T22:59:01Z"},{"tenantId":"def","tenantName":"Beta Workspace","modifiedAt":"2026-04-30T22:55:14Z"},{"tenantId":"ghi","tenantName":"Old Gamma","modifiedAt":"2026-04-30T22:30:00Z","deleted":true}]}},"AccountManifest":{"type":"object","description":"Snapshot of the account change manifest. Sorted newest-first by `modifiedAt`. Tombstones preserve `name` and `plan` so audit / recently-deleted lookups still resolve.","required":["generatedAt","accounts"],"properties":{"generatedAt":{"type":"string","format":"date-time","nullable":true,"description":"Timestamp at which this manifest snapshot was produced. `null` only when the manifest object does not yet exist (no account has ever been written).","example":"2026-04-30T23:00:00Z"},"accounts":{"type":"array","description":"Account entries newest-first. May include tombstones (`deleted: true`) when `?includeDeleted=true`.","items":{"type":"object","required":["accountId","modifiedAt"],"properties":{"accountId":{"type":"string","description":"Account UUID"},"name":{"type":"string","description":"Human-readable account name. Preserved on tombstones.","example":"Acme Inc."},"plan":{"type":"string","enum":["applied","free","starter","professional","enterprise"],"description":"Account plan tier. Preserved on tombstones."},"modifiedAt":{"type":"string","format":"date-time","description":"Timestamp of the most recent mutation to the account record."},"deleted":{"type":"boolean","description":"Present and `true` only on tombstones; omitted on live entries.","example":true}}}}},"example":{"generatedAt":"2026-04-30T23:00:00Z","accounts":[{"accountId":"c5019f80-7397-46a5-a575-2e398de21306","name":"Acme Inc.","plan":"professional","modifiedAt":"2026-04-30T22:59:01Z"},{"accountId":"9b4d2e10-d7bf-4d0a-a2b6-66e4aa02b1f4","name":"Beta LLC","plan":"starter","modifiedAt":"2026-04-30T22:55:14Z"},{"accountId":"1d2e3f40-aaaa-bbbb-cccc-ddddeeeeffff","name":"Old Gamma","plan":"free","modifiedAt":"2026-04-30T22:30:00Z","deleted":true}]}},"TenantCreate":{"type":"object","description":"Request body for creating a tenant. Server generates tenantId, createdAt, updatedAt, and version. For user-JWT callers, the server auto-fills accountId from the JWT accounts claim (when the user has exactly one account) and primaryFqdn as a 3-word slug under the gateway base URL.","required":["tenantName","dataAdapter"],"properties":{"tenantName":{"type":"string","description":"Display name (unique within account)","minLength":1,"maxLength":255},"accountId":{"type":"string","format":"uuid","description":"Parent account reference (must exist and be active)"},"primaryFqdn":{"type":"string","description":"Main FQDN for request routing (globally unique, checked against tombstones)"},"aliasFqdns":{"type":"array","items":{"type":"string"},"description":"Additional FQDNs that route to this tenant"},"dataAdapter":{"description":"Data warehouse connection configuration. For Databricks, http_path is optional — when omitted or empty, the proxy lazy-derives the warehouse from client traffic. The whole field may also be an empty string as a placeholder before the dashboard collects warehouse wiring; null is not permitted.","oneOf":[{"type":"object","required":["type","server_hostname"],"properties":{"type":{"type":"string","enum":["databricks","snowflake"],"description":"Warehouse backend type"},"server_hostname":{"type":"string","description":"Backend warehouse hostname"},"http_path":{"type":"string","description":"Databricks SQL endpoint path (e.g. /sql/1.0/warehouses/abc123). Optional — inferred at runtime when empty.","pattern":"^$|^/sql/1\\.0/warehouses/[A-Za-z0-9_-]+$"},"cloudFilesBaseUrl":{"type":"string","nullable":true,"description":"CloudFile proxy base URL"}},"oneOf":[{"description":"Databricks: server_hostname must be a Databricks workspace host","properties":{"type":{"enum":["databricks"]},"server_hostname":{"type":"string","pattern":"^(?:[a-z0-9-]+\\.cloud\\.databricks\\.com|dbc-[a-z0-9-]+\\.cloud\\.databricks\\.com|[a-z0-9-]+\\.azuredatabricks\\.net|adb-[a-z0-9-]+\\.azuredatabricks\\.net|[a-z0-9-]+\\.gcp\\.databricks\\.com)$"}}},{"description":"Snowflake: shape validation handled by the adapter layer","properties":{"type":{"enum":["snowflake"]}}}]},{"type":"object","required":["type","host","database","authMode"],"description":"PostgreSQL: raw-TCP wire-protocol adapter — uses host/database instead of server_hostname/http_path, since it isn't an HTTP-based warehouse connection. Routing-only config — there is no stored backend credential; authMode determines how the gateway authenticates the connecting client, and (for cleartext/relay) the same credential is used against the real backend.","properties":{"type":{"type":"string","enum":["postgresql"],"description":"Warehouse backend type"},"host":{"type":"string","description":"Postgres server hostname"},"port":{"type":"integer","minimum":1,"maximum":65535,"description":"Postgres server port. Defaults to 5432 when omitted."},"database":{"type":"string","description":"Database name"},"sslMode":{"type":"string","enum":["disable","require","verify-ca","verify-full"],"description":"Backend-leg TLS mode (how the gateway verifies the real backend's cert), mirroring libpq's sslmode: 'disable' (plaintext), 'require' (encrypt but do NOT verify the cert -- a MITM is still possible, an explicit downgrade), 'verify-ca' (verify the cert chains to a trusted CA), 'verify-full' (verify CA + hostname). Optional; the gateway defaults to 'verify-full' (the secure tier) when omitted, so a downgrade must be an explicit choice."},"sslRootCert":{"type":"string","description":"CA to trust for the verify-ca/verify-full tiers: an inline PEM or a filesystem path to one. Omit to use the built-in public trust store. Unused for disable/require."},"queryTimeoutMs":{"type":"integer","minimum":1,"description":"Query timeout in milliseconds. Defaults to 30000 when omitted."},"maxCacheableResultRows":{"type":"integer","minimum":1,"description":"Max rows retained/cached per statement. Defaults to 10000 when omitted."},"authMode":{"type":"string","enum":["cleartext","verifier-proxy","relay"],"description":"Client-facing auth mode, required. 'cleartext' -- the gateway uses the connecting client's own real Postgres credential for both legs, never a stored one. 'relay' -- the gateway forwards the entire auth handshake verbatim between the client and the real backend, for token-based backends (RDS IAM / Azure Entra) or zero-credential-custody tenants; the gateway never derives, holds, or inspects any credential material. 'verifier-proxy' -- the gateway runs real SCRAM-SHA-256 against the client using a verifier fetched from the real backend's pg_authid, recovering clientKey for the outbound leg without ever touching the plaintext password. All three modes are implemented and live-verified against real managed providers."},"authLookupSecretRef":{"type":"string","description":"SecretsManager reference (never a literal credential) for the auth-lookup principal used by 'verifier-proxy' mode. Required when authMode is 'verifier-proxy' (enforced by the gateway's validateConfig, not this schema -- same pattern as Snowflake's shape validation below); unused otherwise."},"provider":{"type":"string","enum":["self-hosted","neon","supabase","cloud-sql","alloydb","rds","aurora","azure-flexible"],"description":"Managed-provider identity, required when authMode is 'verifier-proxy' (enforced by the gateway's validateConfig, not this schema -- same pattern as authLookupSecretRef above); unused for 'cleartext'/'relay'. 'verifier-proxy' needs direct pg_authid/pg_shadow readability, which only 'self-hosted'/'neon'/'supabase' currently support -- the gateway rejects 'cloud-sql'/'alloydb' as unverified and 'rds'/'aurora'/'azure-flexible' outright (no access path exists)."}}},{"type":"string","enum":[""],"description":"Placeholder for a tenant that has not yet supplied warehouse wiring"}]},"storage":{"type":"object","description":"Operational data storage. Optional on the request body — server fills from the storageS3 secret default when omitted. Caller-supplied values are plan-gated by RBAC.","properties":{"type":{"type":"string","enum":["s3express","s3","filesystem"]},"bucket":{"type":"string"},"region":{"type":"string"},"endpoint":{"type":"string"}}},"logStorage":{"type":"object","description":"Log storage configuration. Optional — inherits from account defaults if omitted.","properties":{"type":{"type":"string","enum":["s3","s3express","filesystem"]},"bucket":{"type":"string"},"region":{"type":"string"},"endpoint":{"type":"string"}}},"cacheStorage":{"type":"object","description":"Cache storage for cached result payloads. Optional on the request body -- the server fills it from the cacheStorage secret default when omitted.","properties":{"type":{"type":"string","enum":["s3"],"description":"Storage backend type. General-purpose S3 only -- the gateway rejects a cacheStorage block of any other type and fails the whole tenant load.","default":"s3"},"bucket":{"type":"string","description":"S3 bucket name. Must be a general-purpose bucket -- an S3 Express directory bucket name (ending --<zone>--x-s3) is rejected here, because cache payloads on Express fail the whole tenant load in the gateway.","pattern":"^(?!.*--[a-z0-9-]+--x-s3$).+$"},"region":{"type":"string","description":"AWS region","default":"us-east-1"},"endpoint":{"type":"string","description":"Custom S3 endpoint (for LocalStack/MinIO)"}}},"status":{"type":"string","enum":["active","pending","suspended","deleted"],"default":"active","description":"Tenant status (defaults to active). `pending` is auto-set by the server when dataAdapter is empty — either signup-provisioned tenants awaiting warehouse wiring or an explicit empty-string placeholder on POST."}},"example":{"tenantName":"Acme Production","accountId":"550e8400-e29b-41d4-a716-446655440000","primaryFqdn":"analytics.acme.com","aliasFqdns":["acme-prod.app.airbrx.com"],"dataAdapter":{"type":"databricks","server_hostname":"acme-prod.cloud.databricks.com","http_path":"/sql/1.0/warehouses/abc123"}}},"AccountUpdate":{"type":"object","description":"Request body for updating an account","properties":{"name":{"type":"string","minLength":1,"maxLength":255},"status":{"type":"string","enum":["active","suspended","deleted"]},"plan":{"type":"string","enum":["applied","free","starter","professional","enterprise"]},"contacts":{"type":"object","properties":{"primary":{"type":"string","format":"email"},"billing":{"type":"string","format":"email","nullable":true}}},"settings":{"type":"object"}}},"DomainEntry":{"type":"object","description":"A domain registered to an account with audit history","required":["domain","accountId"],"properties":{"domain":{"type":"string","description":"Root domain name (e.g., acme.com)"},"accountId":{"type":"string","format":"uuid","description":"Current owning account (UUID v4)"},"registeredAt":{"type":"string","format":"date-time","description":"Current registration timestamp"},"registeredBy":{"type":"string","description":"Email of user who registered/transferred the domain"},"history":{"type":"array","description":"Audit trail of domain changes","items":{"type":"object","required":["action","accountId","timestamp","by"],"properties":{"action":{"type":"string","enum":["registered","transferred"],"description":"Type of action performed"},"accountId":{"type":"string","format":"uuid","description":"Target account ID"},"fromAccountId":{"type":"string","format":"uuid","description":"Source account ID (for transfers)"},"timestamp":{"type":"string","format":"date-time","description":"When action occurred"},"by":{"type":"string","description":"User who performed action"},"note":{"type":"string","description":"Optional description"}}}}},"example":{"domain":"acme.com","accountId":"550e8400-e29b-41d4-a716-446655440000","registeredAt":"2026-01-15T10:30:00.000Z","registeredBy":"admin@acme.com","history":[{"action":"registered","accountId":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2026-01-15T10:30:00.000Z","by":"admin@acme.com","note":"Initial domain registration"}]}},"FqdnEntry":{"type":"object","description":"An FQDN assigned to a tenant with audit history","required":["fqdn","tenantId","isPrimary"],"properties":{"fqdn":{"type":"string","description":"Fully qualified domain name"},"tenantId":{"type":"string","format":"uuid","description":"Current owning tenant (UUID v4)"},"isPrimary":{"type":"boolean","description":"Whether this is the tenant's primaryFqdn","default":false},"assignedAt":{"type":"string","format":"date-time","description":"Current assignment timestamp"},"assignedBy":{"type":"string","description":"Email of user who assigned/migrated the FQDN"},"history":{"type":"array","description":"Audit trail of FQDN changes","items":{"type":"object","required":["action","tenantId","isPrimary","timestamp","by"],"properties":{"action":{"type":"string","enum":["assigned","updated","migrated","unassigned","tombstoned"],"description":"Type of action performed"},"tenantId":{"type":"string","format":"uuid","description":"Target tenant ID"},"fromTenantId":{"type":"string","format":"uuid","description":"Source tenant ID (for migrations)"},"isPrimary":{"type":"boolean","description":"Primary status at time of action"},"timestamp":{"type":"string","format":"date-time","description":"When action occurred"},"by":{"type":"string","description":"User who performed action"},"note":{"type":"string","description":"Optional description"}}}}},"example":{"fqdn":"analytics.acme.com","tenantId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","isPrimary":true,"assignedAt":"2026-01-15T10:30:00.000Z","assignedBy":"admin@acme.com","history":[{"action":"assigned","tenantId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","isPrimary":true,"timestamp":"2026-01-15T10:30:00.000Z","by":"admin@acme.com","note":"Initial assignment as primary FQDN"}]}},"FqdnTombstone":{"type":"object","description":"Permanently retired FQDN stored at fqdns/tombstones/{fqdn}.json. Tombstones are never deleted.","required":["fqdn","originalTenantId","assignedAt","removedAt"],"properties":{"fqdn":{"type":"string","description":"The retired fully qualified domain name"},"originalTenantId":{"type":"string","format":"uuid","description":"UUID of the tenant this FQDN was originally assigned to"},"assignedAt":{"type":"string","format":"date-time","description":"When the FQDN was originally assigned"},"removedAt":{"type":"string","format":"date-time","description":"When the FQDN was tombstoned"},"removedBy":{"type":"string","description":"Email or identifier of the person who retired this FQDN"},"history":{"type":"array","description":"Full audit trail including assignment and tombstoning","items":{"type":"object","properties":{"action":{"type":"string","enum":["assigned","updated","tombstoned"]},"tenantId":{"type":"string","format":"uuid"},"timestamp":{"type":"string","format":"date-time"},"by":{"type":"string"},"note":{"type":"string"}}}}},"example":{"fqdn":"analytics.acme.com","originalTenantId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","assignedAt":"2026-01-15T10:30:00.000Z","removedAt":"2026-06-20T14:00:00.000Z","removedBy":"admin@acme.com","history":[{"action":"assigned","tenantId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","timestamp":"2026-01-15T10:30:00.000Z","by":"admin@acme.com","note":"Initial assignment"},{"action":"tombstoned","tenantId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","timestamp":"2026-06-20T14:00:00.000Z","by":"admin@acme.com","note":"FQDN retired"}]}},"HATEOASLinks":{"type":"object","description":"HATEOAS-style navigation links","properties":{"self":{"type":"object","properties":{"href":{"type":"string"}}},"parent":{"type":"object","properties":{"href":{"type":"string"}}},"items":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"href":{"type":"string"}}}}}},"SuccessResponse":{"type":"object","description":"Generic success response","properties":{"success":{"type":"boolean"},"message":{"type":"string"}}}},"responses":{"BadRequest":{"description":"Bad request (invalid parameters or JSON)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Request body must be a valid JSON object"}}}},"Unauthorized":{"description":"Authentication required or invalid token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Unauthorized: missing token"}}}},"Forbidden":{"description":"Insufficient permissions for this operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"PAT does not have GET permission for path: /config/tenants"}}}},"NotFound":{"description":"Resource not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Not found"}}}},"InternalServerError":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Internal server error"}}}},"Conflict":{"description":"Conflict - FQDN already in use or permanently retired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"FQDN 'analytics.acme.com' was previously assigned to tenant '7c9e6679-...' and has been permanently retired"}}}}}}}