API reference
The contract, rendered: every operation with the credential, module and role it requires, its parameters, bodies and responses, and every schema.
Multi-cloud resource inventory, derived health status, and permanent status-transition history.
This document is the contract, not documentation written afterwards. It
generates the Go server interface (internal/api/gen/) and the TypeScript
client (web/src/api/schema.d.ts), both committed, and CI fails on drift.
An endpoint that is not here does not exist — including the operational ones
(/healthz, /readyz, /metrics), which are declared because the server
answers them, even though no generated client should call them.
Four audiences, four authentication schemes
- Public —
/healthz,/readyzand/public/{tenantSlug}/statusneed no credentials. The public status endpoint has its own response type and exposes deliberately little; seePublicStatus. - Session — the web app, via an
HttpOnly; Secure; SameSite=Laxcookie. - Collector bearer — collectors, which POST snapshots and never touch the database directly.
- Agent bearer — provisioning agents running inside customers' own AWS
accounts, on
/provisioning/agent/*only. A session cookie is never accepted there and an agent token is never accepted anywhere else: the two audiences share no operation, which is why they are two schemes and not one with a wider scope.
Errors
Every non-2xx response is an ErrorResponse. error.code is a stable
machine string that the frontend and the collector both branch on, so it is
API surface: renaming one is a breaking change. error.message is for a
human and never echoes request payload or internal error text.
Tenancy
Every authenticated request is scoped to exactly one tenant, established by the session or the bearer token. No endpoint takes a tenant identifier as a parameter, except the public status page, which takes a slug precisely because it has no credential to derive one from.
Modules
An operation carrying x-module belongs to that module, and a tenant that
has not bought it gets 403 module_not_entitled before the handler runs.
The declaration is here rather than in the server because the server
derives its middleware from this document: an endpoint group added without
it would be served to every tenant regardless of what they pay for, and
nothing would say so.
Operations without x-module are platform-wide — authentication, the
account registry, entitlements themselves, and the probes. Entitlement is
not authorization: what a tenant bought and what a user may do are separate
questions, answered separately.
An operation may also carry x-entitlement-access, which says what it asks
of the licence at its boundary (PLATFORM.md §7, 2026-09-06): view (the
default, and the only value a session route may carry) is locked the
moment the licence expires; start — an agent registering or claiming —
likewise, so nothing new begins unlicensed; collect — the collection
write path — keeps answering through the fourteen-day grace, for the
modules the licence listed, then stops; settle — an agent's heartbeat,
logs and status for an attempt it already holds — is never locked by
expiry, because cloud state has already changed and the platform must
learn the outcome, and is bounded by the fence rather than by the licence.
Grace and settlement widen when, never what: a module the licence
never listed is refused under every access.
An operation may also carry x-capability: a feature the licence names
beside its modules (performance, p7_74, which the Professional and
Enterprise bundles carry). A tenant whose licence does not carry it gets
403 capability_not_entitled, after the module check and before the role
check. The product reads the capability, never the tier's name, and the
capability locks with the modules at expiry.
Authentication and policy
Three credentials, one per kind of caller, and a route accepts exactly the one it declares: a person's sessionCookie from POST /auth/login; a collector's bearerIngest token; a provisioning agent's bearerAgent token. Routes marked public take none. Each operation below says which credential it takes, which module the tenant must be entitled to, and which role a person must hold; the server derives the same three from this document, so they cannot disagree.
health
Liveness and readiness, for orchestrators.
GET /healthz
Liveness
The process is running and its HTTP stack works. Deliberately does NOT touch the database: if it did, one database blip would fail liveness on every pod at once and the orchestrator would restart the whole deployment while the database was already struggling.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | HealthStatus | The process is alive. |
429 | ErrorResponse | Rate limited. |
GET /metrics
Prometheus metrics
Included because it is genuinely served on this listener, and a contract that omits an endpoint the server answers is the drift this document exists to prevent.
Not part of the product API: no client generated from this spec should call it, and the response is Prometheus text, not JSON. It shares the main listener unless METRICS_LISTEN gives serve a port of its own, as the Kubernetes manifests do; then this route answers 404, because a scrape discloses queue depth, error rates and traffic volume (p7_43).
Responses
| Status | Body | Meaning |
|---|---|---|
200 | string | Metrics in the Prometheus text exposition format. |
404 | — | METRICS_LISTEN has moved the metrics to a port of their own; the API listener does not serve them. |
429 | ErrorResponse | Rate limited. |
GET /readyz
Readiness
This instance can serve traffic: the database answers, its schema is at the version this binary was built against, and the configured database roles are the ones expected.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | HealthStatus | Ready. |
429 | ErrorResponse | Rate limited. |
503 | NotReadyStatus | Not ready. reason names the cause and never contains a credential,
a host or a role name — this endpoint is scraped and logged by other
systems. |
meta
This document, served by the server it describes.
GET /docs
API reference UI
A single static HTML page that renders /openapi.json. Read by humans
only; no generated client should call it, and the response is HTML, not
JSON.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | string | The documentation page. |
429 | ErrorResponse | Rate limited. |
GET /openapi.json
This document
The specification embedded in the binary at build time, as JSON. It is the same artefact the Go server interface was generated from, so the documented API and the running API cannot disagree: they cannot be regenerated independently.
Served unauthenticated. The contract describes shapes, not data, and every consumer of it — the docs UI below, a collector author, a customer writing against the ingest API — needs it before it has a credential.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | object | The OpenAPI 3.0.3 document. Schema is object rather than a
transcription of the OpenAPI meta-schema: describing this document's
own structure inside itself buys nothing a reader does not already
have, and generating types for it would be noise in every client. |
429 | ErrorResponse | Rate limited. |
public
Unauthenticated. Deliberately minimal.
GET /public/tenant
The tenant this host shows
The front door (p7_92): a visitor with no session lands on the public status page, and this says whose — the tenant the request host names by its first label (never the deployment's own host), or the one tenant of a single-tenant deployment. On the bare host of a deployment with several tenants there is none, and the answer is 404 — which says nothing about how many there are. The slug is what the status page's own URL carries anyway; the tenant's name is not in the answer.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | PublicTenant | The tenant whose status page this host shows. |
404 | ErrorResponse | not_found |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /public/{tenantSlug}/status
Public status page
Everything the non-technical audience sees. What it exposes is a
decision, not an accident: never resource names, resource IDs, account
IDs, regions, IP addresses, ARNs or additionalInfo. Timeline entries
are aggregated counts, never lists of resources.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
tenantSlugrequired | path | string | The only place a tenant is named in a request. Everywhere else the tenant comes from the credential; here there is none. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | PublicStatus | Current status summary and recent aggregated timeline. |
404 | ErrorResponse | not_found |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
auth
Login, logout, and the current user.
GET /admin/sso
The tenant's identity provider
The registration, without its client secret (p5_5).
Responses
| Status | Body | Meaning |
|---|---|---|
200 | IdentityProvider | The registration. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PUT /admin/sso
Register or change the tenant's identity provider
An OpenID Connect provider (p5_5): its https issuer, the client's id and secret, the role a first sign-in gets, whether passwords stop working for everyone but break-glass administrators, and the email domains a first sign-in may come from. It asks for the password again: enforcing single sign-on can lock people out.
Request body application/json
| Field | Type | Description |
|---|---|---|
clientIdrequired | string | |
clientSecret | string | Required the first time; absent keeps the stored one. |
defaultRolerequired | string enum | One of viewer, engineer, admin. |
emailDomainsrequired | array of string | |
enforcedrequired | boolean | |
groupsClaim | string | The claim the groups are read from; empty or absent keeps groups. |
issuerrequired | string |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | IdentityProvider | The registration. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
409 | ErrorResponse | request_state_conflict — accounts are bound to another
provider's issuer (P55-R2-F03): POST /admin/sso/release first. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
DELETE /admin/sso
Remove the tenant's identity provider
Passwords work again for everyone, and sign-ins in flight fail (p5_5).
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | Removed. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /admin/sso/release
Release every account's binding to a provider
Clears every account's provider identity (P55-R2-F03; the owner's
decision of 2026-09-14): the way to change the registration's issuer,
which is refused while accounts are bound to another. Each account
binds again, by its email, at its first sign-in through the provider
then registered; break-glass passwords work throughout. Audited as
auth.identity_provider.release, with how many accounts and their
issuers.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | SignOnIdentitiesReleased | How many accounts were released. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /auth/inbox
Your inbox
The person's own notifications (p7_89), newest first, with how many
are unread. Entries are kept ninety days, read or not. A page ends
where more says older ones follow; the next page is before the
last entry's id.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
before | query | integer (int64) | Only entries older than this id. |
limit | query | integer | At most this many entries; 100 when absent. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | InboxPage | A page of the person's inbox. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /auth/inbox/read
Mark inbox entries read
Marks the named entries read, or every one with all (p7_89). An id
that is not the person's is passed over, never an error: it reveals
nothing about whose it is.
Request body application/json
| Field | Type | Description |
|---|---|---|
all | boolean | Every entry of the person's, whatever ids says. |
ids | array of integer (int64) |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | InboxUnread | How many of the person's entries stay unread. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | origin_not_trusted — the browser boundary (p7_18). This is an unsafe
request (anything but GET, HEAD, OPTIONS) on a route a browser calls
with the person's own session — or on login/logout, which touch
it — from a browser context this deployment has not named. Refused
before the body is read and before the credential is looked at. What counts as the deployment's own: Sec-Fetch-Site: same-origin
(or none, a user-initiated navigation); same-site or
cross-site only when the Origin is listed in CORS_ORIGINS;
without Fetch Metadata, an Origin (or a Referer's origin) that is
this server's own host or listed. Origin: null is never trusted. A
request carrying none of these is not a browser's and is not refused
on this ground. Every session-authenticated mutation in this
contract can answer it; the shared 403 responses below say so. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /auth/login
Log in
Tenant-qualified. An email address does not identify a user — users is
unique on (tenant_id, email), so the same address may exist in several
tenants. The tenant is resolved from the request host, or from
tenantSlug when the host does not identify one.
When the tenant enforces single sign-on (p5_5), a right password for any
account but a break-glass administrator is answered 403 with
sso_required: the account signs in through the tenant's identity
provider. Said only after the password verified, so it tells nothing to
anyone without it.
Request body application/json
| Field | Type | Description |
|---|---|---|
emailrequired | string | The longest address RFC 5321 permits. Deliberately NOT format: email. Syntax beyond "not blank" is not
validated — an address that an over-strict pattern rejects is a
customer who cannot log in — and declaring a format the server does
not enforce is a contract that lies to whoever generates a client
from it. |
passwordrequired | string (password) | Bounded because verifying it is an argon2 derivation over whatever was sent, and this endpoint is unauthenticated. |
tenantSlug | string | Optional. Needed only when the request host does not identify a
tenant, because an email address alone does not identify a user. A DNS label, because it is a subdomain: acme is
acme.cloudpanorama.example. Matched case-insensitively. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | User | Logged in. Sets an HttpOnly; Secure; SameSite=Lax session cookie. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | Wrong credentials, unknown tenant, or a disabled user — deliberately indistinguishable, so this endpoint cannot be used to enumerate tenants or accounts. |
403 | ErrorResponse | origin_not_trusted — the browser boundary (p7_18). This is an unsafe
request (anything but GET, HEAD, OPTIONS) on a route a browser calls
with the person's own session — or on login/logout, which touch
it — from a browser context this deployment has not named. Refused
before the body is read and before the credential is looked at. What counts as the deployment's own: Sec-Fetch-Site: same-origin
(or none, a user-initiated navigation); same-site or
cross-site only when the Origin is listed in CORS_ORIGINS;
without Fetch Metadata, an Origin (or a Referer's origin) that is
this server's own host or listed. Origin: null is never trusted. A
request carrying none of these is not a browser's and is not refused
on this ground. Every session-authenticated mutation in this
contract can answer it; the shared 403 responses below say so. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /auth/logout
Log out
Deletes the session server-side and clears the cookie. Idempotent, and accepted without a session: repeating it, or calling it after the session expired, is not an error.
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | Logged out. Repeating this is not an error. |
403 | ErrorResponse | origin_not_trusted — the browser boundary (p7_18). This is an unsafe
request (anything but GET, HEAD, OPTIONS) on a route a browser calls
with the person's own session — or on login/logout, which touch
it — from a browser context this deployment has not named. Refused
before the body is read and before the credential is looked at. What counts as the deployment's own: Sec-Fetch-Site: same-origin
(or none, a user-initiated navigation); same-site or
cross-site only when the Origin is listed in CORS_ORIGINS;
without Fetch Metadata, an Origin (or a Referer's origin) that is
this server's own host or listed. Origin: null is never trusted. A
request carrying none of these is not a browser's and is not refused
on this ground. Every session-authenticated mutation in this
contract can answer it; the shared 403 responses below say so. |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /auth/me
Current user
Who the session belongs to, which role they hold, and which tenant they are scoped to. The web app calls this on load to decide what to render; a 401 here is how it learns the session has expired.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | User | The authenticated user, their role and their tenant. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PATCH /auth/me
Change your own display name and preferences
The person's own (p7_89): a display name, shown where the portal names people, and the portal's preferences, which it applies for them — time zone, date and number formats, and theme. Only the fields present change; an empty display name clears it, and preferences are replaced whole. Nothing here changes what the person may do.
Request body application/json
| Field | Type | Description |
|---|---|---|
displayName | string | |
preferences | Preferences | The person's portal preferences (p7_89), applied by the portal; an absent field is the browser's own. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | User | The person, as GET /auth/me answers. |
400 | ErrorResponse | invalid_request — a field breaks its rule; the message names the field and the rule. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | origin_not_trusted — the browser boundary (p7_18). This is an unsafe
request (anything but GET, HEAD, OPTIONS) on a route a browser calls
with the person's own session — or on login/logout, which touch
it — from a browser context this deployment has not named. Refused
before the body is read and before the credential is looked at. What counts as the deployment's own: Sec-Fetch-Site: same-origin
(or none, a user-initiated navigation); same-site or
cross-site only when the Origin is listed in CORS_ORIGINS;
without Fetch Metadata, an Origin (or a Referer's origin) that is
this server's own host or listed. Origin: null is never trusted. A
request carrying none of these is not a browser's and is not refused
on this ground. Every session-authenticated mutation in this
contract can answer it; the shared 403 responses below say so. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /auth/me/notifications
Your notification choices
What the person hears about in their inbox (p7_89), in the portal only: status alerts, their teams' budgets and cost anomalies, and their own provisioning requests. A choice they never made is its default — alerts and their own requests on, budgets and anomalies off.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | NotificationChoices | The person's choices. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PUT /auth/me/notifications
Change your notification choices
Replaces the person's choices whole (p7_89). They decide what reaches their own inbox and nothing else: the tenant's channels are the administrators'.
Request body application/json
| Field | Type | Description |
|---|---|---|
alertsrequired | boolean | Status alerts on the accounts the person may see — their teams', or every account for an administrator. An account no team owns reaches the administrators alone. |
anomaliesrequired | boolean | Cost anomalies on the person's teams and their teams' accounts; every one, for an administrator. |
budgetsrequired | boolean | Budget thresholds of the person's teams; every team's, for an administrator. |
myRequestsrequired | boolean | Their own provisioning requests — approvals, successes and failures. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | NotificationChoices | The person's choices, as stored. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | origin_not_trusted — the browser boundary (p7_18). This is an unsafe
request (anything but GET, HEAD, OPTIONS) on a route a browser calls
with the person's own session — or on login/logout, which touch
it — from a browser context this deployment has not named. Refused
before the body is read and before the credential is looked at. What counts as the deployment's own: Sec-Fetch-Site: same-origin
(or none, a user-initiated navigation); same-site or
cross-site only when the Origin is listed in CORS_ORIGINS;
without Fetch Metadata, an Origin (or a Referer's origin) that is
this server's own host or listed. Origin: null is never trusted. A
request carrying none of these is not a browser's and is not refused
on this ground. Every session-authenticated mutation in this
contract can answer it; the shared 403 responses below say so. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /auth/me/teams
Your teams
The teams the person belongs to and their role in each (p7_89), by name. Read-only here: an administrator changes membership.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | OwnTeamList | The person's teams. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /auth/password
Change the current user's password
Verifies the current password, stores the new one, ends every session
the user holds — including the one making this request — and sets a
fresh cookie for this browser, so the person is not logged out of the
tab they typed into but every other tab is (p7_20). A wrong current
password counts against the login throttle for this account, so this
endpoint cannot be used to guess a password an authenticated session
does not know. Audited as auth.password.change.
A new password is at least 12 characters and at most 1024.
Request body application/json
| Field | Type | Description |
|---|---|---|
currentPasswordrequired | string | |
newPasswordrequired | string |
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | Changed. A fresh session cookie is set. |
400 | ErrorResponse | invalid_request — the current password is wrong, or the new one
is too short or too long. One code for the wrong password and the
weak replacement: the message says which. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | origin_not_trusted — the browser boundary (p7_18). This is an unsafe
request (anything but GET, HEAD, OPTIONS) on a route a browser calls
with the person's own session — or on login/logout, which touch
it — from a browser context this deployment has not named. Refused
before the body is read and before the credential is looked at. What counts as the deployment's own: Sec-Fetch-Site: same-origin
(or none, a user-initiated navigation); same-site or
cross-site only when the Origin is listed in CORS_ORIGINS;
without Fetch Metadata, an Origin (or a Referer's origin) that is
this server's own host or listed. Origin: null is never trusted. A
request carrying none of these is not a browser's and is not refused
on this ground. Every session-authenticated mutation in this
contract can answer it; the shared 403 responses below say so. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /auth/password/reset
Redeem a password reset
Sets the user's password from a reset an administrator issued (p7_20).
The token is burnt first — it works exactly once, before its expiry —
then the password is written, every session the user holds is ended,
and every other outstanding reset for them is withdrawn. An invalid,
expired or already-used token is one answer (400 invalid_request),
so a holder cannot tell which. Audited as auth.password.reset.
Request body application/json
| Field | Type | Description |
|---|---|---|
newPasswordrequired | string | |
tokenrequired | string |
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | The password is set. Log in with it. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
403 | ErrorResponse | origin_not_trusted — the browser boundary (p7_18). This is an unsafe
request (anything but GET, HEAD, OPTIONS) on a route a browser calls
with the person's own session — or on login/logout, which touch
it — from a browser context this deployment has not named. Refused
before the body is read and before the credential is looked at. What counts as the deployment's own: Sec-Fetch-Site: same-origin
(or none, a user-initiated navigation); same-site or
cross-site only when the Origin is listed in CORS_ORIGINS;
without Fetch Metadata, an Origin (or a Referer's origin) that is
this server's own host or listed. Origin: null is never trusted. A
request carrying none of these is not a browser's and is not refused
on this ground. Every session-authenticated mutation in this
contract can answer it; the shared 403 responses below say so. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /auth/sessions
Your sessions
The person's own live sessions (p7_89), the most recently used first: when each began and was last used, the address and browser it began from, and which one is making this request. Never a token: each has an id derived from its token's hash, which ends that session and looks nothing else up.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | OwnSessionList | The person's live sessions. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
DELETE /auth/sessions
Sign out everywhere else
Ends every session of the person's but the one making this request (p7_89).
Responses
| Status | Body | Meaning |
|---|---|---|
200 | SessionsEnded | How many sessions ended. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | origin_not_trusted — the browser boundary (p7_18). This is an unsafe
request (anything but GET, HEAD, OPTIONS) on a route a browser calls
with the person's own session — or on login/logout, which touch
it — from a browser context this deployment has not named. Refused
before the body is read and before the credential is looked at. What counts as the deployment's own: Sec-Fetch-Site: same-origin
(or none, a user-initiated navigation); same-site or
cross-site only when the Origin is listed in CORS_ORIGINS;
without Fetch Metadata, an Origin (or a Referer's origin) that is
this server's own host or listed. Origin: null is never trusted. A
request carrying none of these is not a browser's and is not refused
on this ground. Every session-authenticated mutation in this
contract can answer it; the shared 403 responses below say so. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
DELETE /auth/sessions/{sessionId}
Sign out one of your sessions
Ends one of the person's other sessions (p7_89). The session making the request is ended by logging out, and naming it here is refused; a session that is not the person's is not found.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
sessionIdrequired | path | string | A session's id, as GET /auth/sessions gives it. |
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | The session ended. |
400 | ErrorResponse | invalid_request — the session named is the one making the request. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | origin_not_trusted — the browser boundary (p7_18). This is an unsafe
request (anything but GET, HEAD, OPTIONS) on a route a browser calls
with the person's own session — or on login/logout, which touch
it — from a browser context this deployment has not named. Refused
before the body is read and before the credential is looked at. What counts as the deployment's own: Sec-Fetch-Site: same-origin
(or none, a user-initiated navigation); same-site or
cross-site only when the Origin is listed in CORS_ORIGINS;
without Fetch Metadata, an Origin (or a Referer's origin) that is
this server's own host or listed. Origin: null is never trusted. A
request carrying none of these is not a browser's and is not refused
on this ground. Every session-authenticated mutation in this
contract can answer it; the shared 403 responses below say so. |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /auth/sso/callback
The identity provider's redirect back
Takes the attempt the state names, once, and only for the browser that started it (its attempt cookie, P55-R1-F02); exchanges the code with its
PKCE verifier; verifies the ID token and its nonce; finds the account
by its email — vouched for by the provider or by the tenant's domains,
and bound to the provider's subject on its first sign-in — or creates
it with the provider's default role; and issues a session on the same
path a password does, redirecting to the return path. Any failure
redirects to the login page with sso=failed, and the log says why.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
state | query | string | |
code | query | string | |
error | query | string | The provider's error, when the person cancelled or was refused. |
Responses
| Status | Body | Meaning |
|---|---|---|
302 | — | To the return path with the session cookie, or to the login page. |
GET /auth/sso/options
Whether this tenant signs in through an identity provider
For the login page, before anyone signs in (p5_5): whether the tenant the
request is for — by its host, tenantSlug, or the only tenant — has an
identity provider, and whether it is enforced. Never the issuer or the
client. A tenant that cannot be resolved has neither.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
tenantSlug | query | string |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | SingleSignOnOptions | The tenant's single sign-on options. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /auth/sso/start
Begin a sign-in through the tenant's identity provider
A browser navigation, not a call (p5_5): it redirects to the tenant's
provider with a single-use state, a nonce and a PKCE challenge, kept
for ten minutes. When the tenant has no provider, or the provider
cannot be reached, it redirects back to the login page with
sso=unavailable.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
tenantSlug | query | string | |
returnTo | query | string | A path on this site to return to; anything else is the portal's root. |
Responses
| Status | Body | Meaning |
|---|---|---|
302 | — | To the identity provider, or back to the login page. |
POST /auth/step-up
Confirm the password before a destructive action
Verifies the current user's password again and records the check on
this session (p7_72). An operation marked x-step-up: true (removing
a user from a team or a team from an account, archiving or renaming a
team, archiving a template, revoking a user's sessions or an agent's
token, disabling an account, a channel or a budget, deleting a channel
or a budget, changing a user, issuing a password reset) answers
403 step_up_required unless the session calling it did this in the
last 10 minutes. Only this session is confirmed: another device or
browser profile of the same user is asked again. Tabs of one browser
profile share its session cookie, and so share the check. A wrong
password counts against the login throttle for the account, as a
failed login does.
Request body application/json
| Field | Type | Description |
|---|---|---|
passwordrequired | string | The current user's password. |
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | Confirmed. For 10 minutes this session may take destructive actions. |
400 | ErrorResponse | invalid_request — the password is wrong, or longer than a
password can be. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | origin_not_trusted — the browser boundary (p7_18). This is an unsafe
request (anything but GET, HEAD, OPTIONS) on a route a browser calls
with the person's own session — or on login/logout, which touch
it — from a browser context this deployment has not named. Refused
before the body is read and before the credential is looked at. What counts as the deployment's own: Sec-Fetch-Site: same-origin
(or none, a user-initiated navigation); same-site or
cross-site only when the Origin is listed in CORS_ORIGINS;
without Fetch Metadata, an Origin (or a Referer's origin) that is
this server's own host or listed. Origin: null is never trusted. A
request carrying none of these is not a browser's and is not refused
on this ground. Every session-authenticated mutation in this
contract can answer it; the shared 403 responses below say so. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /auth/step-up/sso
Confirm at the identity provider before a destructive action
The step-up of an account that signs in through the tenant's identity
provider (p5_5, P55-R1-F03), which may have no password anyone knows:
answers the provider's authorization URL for a sign-in that asks it to
authenticate the person afresh (prompt=login, max_age=0), and sets
the browser's attempt cookie. The browser goes there; the provider's
redirect back to /auth/sso/callback confirms this session, as
POST /auth/step-up does, when the person the provider names is this
session's account (and, where the provider says when, it authenticated
them in the last five minutes), then returns to returnTo.
Request body application/json
| Field | Type | Description |
|---|---|---|
returnTo | string | A path on this site to come back to after the provider; anything else is the portal's root. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | StepUpAtProvider | Where the browser goes to confirm. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | origin_not_trusted — the browser boundary (p7_18). This is an unsafe
request (anything but GET, HEAD, OPTIONS) on a route a browser calls
with the person's own session — or on login/logout, which touch
it — from a browser context this deployment has not named. Refused
before the body is read and before the credential is looked at. What counts as the deployment's own: Sec-Fetch-Site: same-origin
(or none, a user-initiated navigation); same-site or
cross-site only when the Origin is listed in CORS_ORIGINS;
without Fetch Metadata, an Origin (or a Referer's origin) that is
this server's own host or listed. Origin: null is never trusted. A
request carrying none of these is not a browser's and is not refused
on this ground. Every session-authenticated mutation in this
contract can answer it; the shared 403 responses below say so. |
404 | ErrorResponse | not_found — the tenant has no identity provider. |
409 | ErrorResponse | request_state_conflict — this account has never signed in through
the tenant's provider; it confirms with its password. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
resources
Inventory and its render metadata.
GET /resource-types
Render metadata for every known resource type
How a field is displayed is decided by the backend, next to the collector that produces it — not by the frontend. That makes the render schema part of this contract; otherwise it becomes a second, untyped interface and drifts.
The renderer implements exactly the format values in FieldFormat and
renders anything unknown as json, so a backend that adds a field
cannot break the UI.
Cacheable: this changes only on deploy, and the web app needs it before it can render anything, so it is on the critical path of every cold load.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
If-None-Match | header | string | A previously returned ETag. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ResourceTypeList | Descriptors for every registered resource type. |
304 | — | The client's If-None-Match still matches. No body. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /resources
List resources
Tombstoned resources are excluded by the shared filter builder, not by
each caller. A resource whose bucket has not been collected inside its
freshness window — the module's cadence plus the scheduler's jitter
and the configured headroom — is included and marked stale, with
collectedAt saying when it was last confirmed: not looking lately is
not the same as gone (p7_12). stale=true or stale=false narrows to
one or the other.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountId | query | array of string | Repeatable. Any of the given accounts. |
service | query | array of string | Repeatable. |
resourceType | query | array of string | Repeatable. |
region | query | array of string | Repeatable. |
system | query | array of string enum | The cloud the resource's account is of — aws or gcp — repeatable
(p7_91). A list that mixes clouds narrows to one; the value is the
resource key's system, the same word the account registry carries. |
status | query | array of TransitionStatus | Repeatable. On /timeline this filters on the destination status, where
deleted is meaningful; on resource endpoints it filters live health,
where it is not. |
stale | query | boolean | true narrows to resources whose bucket has no successful collection
inside its freshness window, false to those confirmed lately.
Absent, both are returned and each carries stale (p7_12). |
ownership | query | string enum | Narrows to resources the ownership resolver (p8_2) calls owned,
unowned or in conflict. Absent, all are returned. It narrows what
is shown, never what may be seen: the team scope stays the authority. |
ownerTeamId | query | string (uuid) | Narrows to resources the resolver gives to this team. |
tag | query | array of string | Repeatable, key:value, and ANDed rather than ORed — "production
things owned by nobody" is the question people ask, and it is two tags. Split on the last colon. AWS allows : in both keys and values, so
no separator is unambiguous and the rule has to be stated: this one
keeps namespaced keys working (aws:cloudformation:stack-name:my-stack
is that key with the value my-stack, and panorama:request_id:9f2c is the
provisioning join key) at the cost of a value that itself contains a
colon, which cannot be expressed here. Anything without a colon, or with an empty key, is a 400; the value
may be empty, because a tag with no value is a tag AWS will happily
return. Keys and values match exactly, case included. Inventory stores tags as
the provider returned them (see Resource.tags), so folding case here
would make the filter disagree with what the response shows. |
page | query | integer | 1-based. Normalised server-side rather than rejected: < 1 becomes 1,
and a page past the end is clamped to totalPages. Deliberately no minimum: a schema constraint would make the generated
server reject page=0 before the handler could normalise it, which
contradicts the normalisation this very description promises. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ResourcePage | A page of resources. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /resources/acknowledgements
The acknowledged alerts
The alerts a member has acknowledged and nobody has reversed, whose resource has not changed status since (p7_84): the Alerts page's Acknowledged view, newest first, narrowed by the page's filters (P784-R1-F03). A member sees only their teams' accounts'; an administrator every account's (the owner, 2026-09-14).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountId | query | array of string | Repeatable. Any of the given accounts. |
service | query | array of string | Repeatable. |
status | query | array of TransitionStatus | Repeatable. On /timeline this filters on the destination status, where
deleted is meaningful; on resource endpoints it filters live health,
where it is not. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | AcknowledgementList | The active acknowledgements. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /resources/acknowledgements
Acknowledge an alert
Acknowledges a failed or degraded resource's newest transition, with a required note (p7_84). The alert leaves the Alerts page until the resource's status changes again, and that transition's notification, if it has not been sent, is held. Any member may acknowledge an alert on one of their teams' accounts, an administrator any. Recorded in the audit log.
Request body application/json
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
noterequired | string | |
regionrequired | string | |
resourceIdrequired | string | |
resourceTyperequired | string | |
servicerequired | string | |
systemrequired | string |
Responses
| Status | Body | Meaning |
|---|---|---|
201 | Acknowledgement | The acknowledgement. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | The resource is not failed or degraded, or its alert is acknowledged already. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /resources/acknowledgements/reverse
Reverse an acknowledgement
Reverses the active acknowledgement of a resource's alert (p7_84): it returns to the Alerts page. The acknowledgement is kept, with who reversed it and when, and the audit log records it. The same team rule as acknowledging.
Request body application/json
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
regionrequired | string | |
resourceIdrequired | string | |
resourceTyperequired | string | |
servicerequired | string | |
systemrequired | string |
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | Reversed. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
409 | ErrorResponse | The alert is not acknowledged. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /resources/detail
One resource, in full
Identified by its natural key rather than a surrogate id, because that is what the collector knows and what the UI has in hand.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountIdrequired | query | string | |
regionrequired | query | string | |
systemrequired | query | string | |
servicerequired | query | string | |
resourceTyperequired | query | string | |
resourceIdrequired | query | string |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ResourceDetail | The resource, with its recent snapshots and transitions. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /resources/neighbours
A resource's relationships
The resource's neighbours at depth one or two (p8_12), each edge with
its kind, its category (structural or operational), its confidence
(a collector observed it, a person declared it, or it was inferred)
and the evidence it rests on — read at request time from the
inventory, provisioning, the declared apps and the ownership
resolver. Unknown edges are absent; lookedFor lists the kinds
tried. Depth two is capped: truncated with per-kind counts when a
cap is hit. A member sees a neighbour, and passes through an
intermediate node, only when their teams reach its account;
inadmissible nodes are counted in elided, never rendered.
at asks for the graph as of a time. A past time is not
reconstructed in this round: every kind looked for is reported in
unavailable with its reason (not retained, or current-only source for app membership, ownership and provenance), and no edge of
today is shown for it.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountIdrequired | query | string | |
regionrequired | query | string | |
systemrequired | query | string | |
servicerequired | query | string | |
resourceTyperequired | query | string | |
resourceIdrequired | query | string | |
depth | query | integer | One or two; anything else reads as one. |
kind | query | array of NeighbourEdgeKind | Repeatable. Only these kinds; omitted, every kind. |
at | query | string (date-time) | The graph as of this time; omitted, now. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ResourceNeighbours | The graph around the resource. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /resources/search
Search resources
The same filters as /resources, plus free text and sorting, and always
with statusCounts — computed over the full filtered set, not the
current page, so the UI can say "3 failed" while showing page 2 of 13.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountId | query | array of string | Repeatable. Any of the given accounts. |
service | query | array of string | Repeatable. |
resourceType | query | array of string | Repeatable. |
region | query | array of string | Repeatable. |
system | query | array of string enum | The cloud the resource's account is of — aws or gcp — repeatable
(p7_91). A list that mixes clouds narrows to one; the value is the
resource key's system, the same word the account registry carries. |
status | query | array of TransitionStatus | Repeatable. On /timeline this filters on the destination status, where
deleted is meaningful; on resource endpoints it filters live health,
where it is not. |
stale | query | boolean | true narrows to resources whose bucket has no successful collection
inside its freshness window, false to those confirmed lately.
Absent, both are returned and each carries stale (p7_12). |
ownership | query | string enum | Narrows to resources the ownership resolver (p8_2) calls owned,
unowned or in conflict. Absent, all are returned. It narrows what
is shown, never what may be seen: the team scope stays the authority. |
ownerTeamId | query | string (uuid) | Narrows to resources the resolver gives to this team. |
acknowledged | query | boolean | false narrows to resources whose newest transition nobody has
acknowledged — the Alerts page's default (p7_84) — and true to those
a member has, and nobody has reversed. Absent, both are returned.
Set, the result is the Alerts page's, and a member's is narrowed to
their teams' accounts (the owner, 2026-09-14); the Resources list,
which does not set it, stays the tenant's. |
tag | query | array of string | Repeatable, key:value, and ANDed rather than ORed — "production
things owned by nobody" is the question people ask, and it is two tags. Split on the last colon. AWS allows : in both keys and values, so
no separator is unambiguous and the rule has to be stated: this one
keeps namespaced keys working (aws:cloudformation:stack-name:my-stack
is that key with the value my-stack, and panorama:request_id:9f2c is the
provisioning join key) at the cost of a value that itself contains a
colon, which cannot be expressed here. Anything without a colon, or with an empty key, is a 400; the value
may be empty, because a tag with no value is a tag AWS will happily
return. Keys and values match exactly, case included. Inventory stores tags as
the provider returned them (see Resource.tags), so folding case here
would make the filter disagree with what the response shows. |
query | query | string | Free-text match against resource name, resource ID or account ID, case-insensitively. |
sortBy | query | string enum | A closed set, not a column name. Anything interpolated into SQL goes through an allowlist. |
sortDirection | query | string enum | |
page | query | integer | 1-based. Normalised server-side rather than rejected: < 1 becomes 1,
and a page past the end is clamped to totalPages. Deliberately no minimum: a schema constraint would make the generated
server reject page=0 before the handler could normalise it, which
contradicts the normalisation this very description promises. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
cursor | query | string | The previous page's nextCursor. The page is then the rows after
it in the order, read from an index rather than counted past —
the same cost on the thousandth page as on the second (p7_44).
Only the default order, by resource name, issues and accepts one;
page still says which page number this is — the reader's place
in the walk, which a cursor read does not clamp to the count, since
the rows after the cursor are what it reads. A cursor that does
not decode, or that was issued for the other direction, is a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ResourceSearchPage | A page of matching resources, with counts over the whole match. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /resources/snapshots
A resource's observations, newest first
Every retained observation of one resource (p8_9), paged by a cursor,
so the detail page can offer any two to diff. Identified by the
natural key, as /resources/detail is. A tombstoned or stale
resource is not found here either.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountIdrequired | query | string | |
regionrequired | query | string | |
systemrequired | query | string | |
servicerequired | query | string | |
resourceTyperequired | query | string | |
resourceIdrequired | query | string | |
cursor | query | string | The previous page's nextCursor. |
pageSize | query | integer |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | SnapshotPage | One page of observations, newest first. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /resources/snapshots/diff
What changed between two observations
The canonical diff (p8_9) of any two retained observations of one
resource, named by the ids /resources/snapshots returns. The two
may be given in either order; the response is always older to newer
and says when the selection was reversed. An id that is not one of
this resource's retained observations is not found.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountIdrequired | query | string | |
regionrequired | query | string | |
systemrequired | query | string | |
servicerequired | query | string | |
resourceTyperequired | query | string | |
resourceIdrequired | query | string | |
fromrequired | query | integer (int64) | |
torequired | query | integer (int64) |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | SnapshotDiff | The diff, older to newer. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
timeline
Status-transition history.
GET /timeline
Status transitions
Status is derived once at ingest and stored, so every change is materialised. This reads that history; it never recomputes status.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
from | query | string (date-time) | Inclusive lower bound on occurredAt. |
to | query | string (date-time) | Exclusive upper bound on occurredAt. The window defaults to the
last 7 days and may not exceed 90 days; a wider request is a 400. |
accountId | query | array of string | Repeatable. Any of the given accounts. |
service | query | array of string | Repeatable. |
status | query | array of TransitionStatus | Repeatable. On /timeline this filters on the destination status, where
deleted is meaningful; on resource endpoints it filters live health,
where it is not. |
cursor | query | string | Opaque keyset cursor from the previous page's nextCursor. Omit for
the first page. Keyset rather than page numbers, and not merely for speed: this
table only grows and is written to continuously, so an offset
silently skips or repeats rows whenever a transition is inserted
mid-pagination. The cursor encodes (occurredAt, id), which is
stable under concurrent inserts. OFFSET 100000 also gets slower
forever. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | TimelinePage | A page of transitions, newest first. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
changes
The change feed (p8_10): one timeline of everything the product knows changed, and the markers customers' systems post to it.
GET /changes
The change feed
One feed of everything the product knows changed — configuration diffs, status transitions, acknowledgements, provisioning outcomes, cost anomalies, budget thresholds and the markers customers' systems post — read at request time from each source's own table, never from a second store. Ordered by the event's own time, never the ingest's: a late report lands in its place.
A member sees the events on the accounts their teams reach and on
their teams' own scopes; an administrator everything. Each source
contributes at most one page of rows to a page; truncated names the
kinds that had more, and a narrower window or fewer kinds shows the
rest. Pages are keyed by (at, kind, id), so a cursor yields the same
page on every read, late arrivals included.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
from | query | string (date-time) | Inclusive lower bound on at. |
to | query | string (date-time) | Exclusive upper bound on at. The window defaults to the last 7
days and may not exceed 90 days; a wider request is a 400. |
kind | query | array of ChangeKind | Repeatable. Only these kinds; omitted, every kind. |
accountId | query | array of string | Repeatable. Any of the given accounts. |
resourceKey | query | string | One resource, with exactly one accountId. Sources without a resource drop out. |
resourceType | query | string | Events on resources of this type; sources without a resource drop out. |
appId | query | string (uuid) | A declared app's resources (by its rules) and the markers naming it; sources without a resource drop out. |
teamId | query | string (uuid) | One team's accounts and scopes — narrowed within the viewer's own reach, never widened. |
cursor | query | string | Opaque cursor from the previous page's nextCursor; omit for the first page. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ChangeFeedPage | A page of the feed, newest first. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /changes/markers
Post an external marker
A deploy, a release, a runbook step — what a customer's own system says happened, at the time it says. The product reads nothing from that system; it pushes. The marker carries a time, a label, a URL back, a scope (an account, a declared app, resource keys, or none for a tenant-wide marker) and an idempotency key.
Replay safety. The idempotency key is per credential: the same key with the same marker returns 200 with the stored marker and writes nothing; the same key with a different marker is 409.
Reach. A credential bound to accounts or apps may mark only those; a marker outside them, or one naming no account under a credential bound to accounts, is 403.
Request body application/json
| Field | Type | Description |
|---|---|---|
accountId | string | The account the marker is about; omitted, the marker is the tenant's. |
appId | string (uuid) | The declared app the marker is about. |
atrequired | string (date-time) | When it happened, by the poster's clock. |
idempotencyKeyrequired | string | |
labelrequired | string | |
resourceKeys | array of string | The resources it touched, as keys under accountId. |
url | string | A link back to the poster's own record. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ChangeMarker | The same marker again, as stored the first time. |
201 | ChangeMarker | The marker, stored. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
409 | ErrorResponse | conflict — this idempotency key names a different marker. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
performance
Estate's Performance section (p7_74): the CloudWatch latency, errors and
saturation of the resources the inventory lists, kept 14 days. Needs
the licence's performance capability.
GET /performance/pod
A pod's own series, read from CloudWatch now
A Kubernetes pod's own series and its containers', read from CloudWatch
when someone opens it rather than by the hourly job (p7_86, the owner's
decision of 2026-09-14): Container Insights with enhanced observability
names each pod and container, and reading every pod every hour would be
the customer's largest CloudWatch bill. The answer is kept for the
window's step, so a page reloaded within it reads nothing more; a pod
has no history here before the first look. Up to eight containers are
read; containersCut says how many more there are.
Each read asks CloudWatch for up to 31 billed metrics, so the route has
ceilings (P786-R1-F01): reads of one pod and window at once share one
CloudWatch read; at most four distinct reads run at once in a process,
and past that the answer is 429 rate_limited with a Retry-After,
not a wait; and each address may ask 30 times a minute
(x-rate-limit). A pod's restarts are its workload's: CloudWatch
publishes them only per workload, and the series says so.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountIdrequired | query | string | |
regionrequired | query | string | |
resourceIdrequired | query | string | The pod's inventory id, cluster:namespace:name. |
windowrequired | query | PerformanceWindow | 1h at a five-minute step, 24h at fifteen minutes, 7d at an
hour, ending at the current step. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | PerformancePodWindow | The pod and its own series over the window. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — the tenant has not bought Estate; or
capability_not_entitled — its licence does not carry
performance (x-capability). |
404 | ErrorResponse | not_found |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
502 | ErrorResponse | upstream_error — CloudWatch did not answer for the pod: the
account's reader role refused the read, or the service failed. |
GET /performance/resource
One resource's latency, errors and saturation over a window
The CloudWatch series the Performance job stored for one resource,
named by its natural key as /resources/detail names it. A step longer
than the stored five minutes combines the points by the signal's own
statistic: an average is averaged, a sum summed, a maximum's maximum
taken. A kind the type publishes no metric for is listed in
absentKinds rather than borrowed from another metric; a type the
catalogue does not measure has no series and all three kinds absent.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountIdrequired | query | string | |
regionrequired | query | string | |
systemrequired | query | string | |
servicerequired | query | string | |
resourceTyperequired | query | string | |
resourceIdrequired | query | string | |
windowrequired | query | PerformanceWindow | 1h at a five-minute step, 24h at fifteen minutes, 7d at an
hour, ending at the current step. |
compare | query | string enum | previous: each series also carries the window before this one, at the same step (p7_85). |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | PerformanceResourceWindow | The resource and its series over the window. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — the tenant has not bought Estate; or
capability_not_entitled — its licence does not carry
performance (x-capability). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /performance/sections/{section}
A section's resources and their series over a window
Every live resource, across the tenant's accounts, whose type the
catalogue places in the section, a page at a time in key order, each
with its series as /performance/resource gives them. A section whose
types the catalogue has not seeded yet is an empty page, not an error.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
sectionrequired | path | PerformanceSection | |
windowrequired | query | PerformanceWindow | 1h at a five-minute step, 24h at fifteen minutes, 7d at an
hour, ending at the current step. |
limit | query | integer | Resources per page. |
compare | query | string enum | previous: each series also carries the window before this one, at the same step (p7_85). |
cursor | query | string | The previous page's nextCursor: the page is the resources after
it in key order, over the window the first page described. The
cursor carries that window's end, so every page of one listing has
the same from and to however long the reader took to ask. A
cursor that does not decode, belongs to another window, or names a
window this API did not give out is a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | PerformanceSectionPage | A page of the section's resources and their series. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — the tenant has not bought Estate; or
capability_not_entitled — its licence does not carry
performance (x-capability). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /performance/topology
How a resource's parts hang together, for a drawing
An EKS cluster → its managed node groups, then its Karpenter node
pools, then the nodes in neither → its nodes → their pods, and the pods
on no node the inventory holds apart; a load balancer → its target
groups → their targets (p7_87). Each part carries its status and its
load: the newest stored point of its first saturation series since
loadSince, from the inventory and what the Performance job stored —
no CloudWatch read. Up to 500 nodes and 5,000 pods a cluster and 500
targets a balancer; a part's cut is how many of its children were
left out. A resource has a topology when hasTopology says so.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountIdrequired | query | string | |
regionrequired | query | string | |
systemrequired | query | string | |
servicerequired | query | string | |
resourceTyperequired | query | string | |
resourceIdrequired | query | string |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | PerformanceTopology | The resource and its parts. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — the tenant has not bought Estate; or
capability_not_entitled — its licence does not carry
performance (x-capability). |
404 | ErrorResponse | No live resource by this key, or its type has no topology. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
accounts
Connected cloud accounts.
GET /accounts
Connected accounts
Every cloud account registered for this tenant, enabled or not, so the UI can show a disabled account and offer to enable it.
Readable by any role — knowing which accounts exist is not privileged,
while changing them is, which is why the mutating operations live under
/admin. externalId is never included; see Account.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | AccountList | Every account registered for this tenant. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
admin
Operator endpoints. Require the admin role.
POST /admin/accounts/discover
Enumerate accounts through AWS Organizations
Registers every account it finds with enabled = false. Collecting from
an account nobody has approved would be both surprising and expensive,
so enabling is always a separate, deliberate act.
Assumes the reader role of the account marked isManagement and calls
organizations:ListAccounts through it. With no account so marked this
is a 400 that says so — there is nothing to assume.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | DiscoveryResult | What discovery found, and what changed as a result. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
502 | ErrorResponse | AWS Organizations could not be reached or refused the call. |
GET /admin/accounts/{accountId}
One account
The admin view of a single account, for the edit form. Identical in
shape to an entry from /accounts — externalId is withheld here too,
because an admin session is not a reason to hand out half of the
confused-deputy defence.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | Account | The account, without externalId. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PUT /admin/accounts/{accountId}
Enable, disable, or re-scope an account
A partial update: only the fields present are changed.
Enabling is the operation that costs money and makes API calls into a customer's account, so it is deliberately separate from discovery, and it is where the subscription's account cap is enforced.
Request body application/json
| Field | Type | Description |
|---|---|---|
enabled | boolean | |
environment | string enum | One of production, staging, sdlc, shared, unknown. |
isManagement | boolean | Mark (or unmark) this account as the organisation's management
account. Marking a second one is a 409: there is at most one. |
name | string | |
regions | array of string | |
roleArn | string | Correct the reader role's ARN when it differs from the convention
discovery assumed (role/panorama-reader). The external id cannot be
set here: it is per tenant, generated once by
panorama admin add-management-account, and inherited by every
account discovery registers. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | Account | The updated account. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | account_limit_reached — enabling this account would exceed the
tenant's subscription cap. The message names the cap and the current
count, because "upgrade" is only actionable if you know by how much. invalid_request with a 409 — marking this account as the management
account when another already is; unmark that one first. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /admin/accounts/{accountId}/verify
Assume the role and report exactly what is missing
Attempts the cross-account role assumption and the calls a collection needs, then reports precisely which permissions are absent. "Access denied" without saying what to grant turns onboarding into guesswork.
When the account has a Cost & Usage Report configured and the tenant
holds the cost module, the report role is checked the same way
(p7_22): its assumption, s3:ListBucket on the report prefix and
s3:GetObject on the newest manifest, each named with the exact
ARN to grant on — or, with no role entered yet, the check that says
which template to deploy.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountIdrequired | path | string | The account's id: a 12-digit AWS account id, or a Google Cloud project id (p7_82) — six to thirty lower-case letters, digits and hyphens, a letter first, so the two can never be mistaken for each other. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | AccountVerification | The verification ran. ok reports the outcome — a failed check is
not an HTTP error, it is the answer to the question asked. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /admin/audit
The tenant's audit log
Every change the tenant's audit log records, newest first (p7_88): who, what, when, and the before and after as recorded — a secret never is. Administrators only, read under the tenant's row-level security.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
actor | query | string (uuid) | Only this account's actions. |
action | query | string | Only actions that begin with this, as auth. or auth.identity_provider.update. |
subjectType | query | string | |
subjectId | query | string | |
from | query | string (date-time) | Entries at or after this instant. |
to | query | string (date-time) | Entries before this instant. |
limit | query | integer | |
cursor | query | string | The previous page's nextCursor; the page is the entries older than its last. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | AuditLogPage | A page of the log. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /admin/collector-runs
Recent runs for one account
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountIdrequired | query | string | |
limit | query | integer |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CollectorRunList | The most recent runs, newest first. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /admin/collector-runs/matrix
Account × module freshness grid
Answers "is the data I am looking at even fresh?". Without it a silently broken collector is invisible, which is the failure mode that makes an inventory worse than none.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CollectorMatrix | One entry per account, region and module, plus a per-account roll-up in which the worst cell wins. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /admin/overview
The Admin area's overview
One read for the Admin area's landing page (p7_88): the licence as
GET /entitlements answers it, the users, the accounts and their
collection health as the collector matrix rolls it up, single
sign-on's state, and the latest audit entries.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | AdminOverview | The overview. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
entitlements
What the tenant's subscription unlocks.
GET /entitlements
What this tenant's subscription unlocks
Entitlement is not authorization. Roles say what a user may do; entitlements say what a tenant has bought. They are separate mechanisms with separate error codes, because "upgrade your plan" and "ask your admin" are different problems.
A tenant with no entitlement row has nothing unlocked until a licence
is installed (p5_1). The one exception is a deployment run with
ENTITLEMENTS_UNRESTRICTED, the development posture, where a missing
row means every module and no cap; state says which.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | Entitlements | The tenant's modules and account cap. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
ingest
Collector-facing. Bearer token, machine only.
POST /collection-runs
Register a run and the buckets it may reconcile
A run may only reconcile buckets it registered, and only after a complete enumeration. Registering claims the next generation for each bucket, which fences any older run out.
Replay-safe: the runId is the idempotency key. An identical
re-registration returns the original result and does not bump
generations again — doing so would supersede the run's own registration
and it could never write. A different bucket set for the same runId is
run_registration_conflict.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
X-Collection-Lease | header | string (uuid) | The claim this request is doing the work of, as returned by the dequeue
that took the job. Required with a control-plane worker credential, and forbidden with a
per-tenant ingest token. The two credentials answer "which tenant"
differently: a per-tenant token names one, so a lease beside it would be
a second and contradictory answer; the shared fleet's names none, so
without a lease there is nothing this server can act on. Sending one
where it does not belong is 400; omitting it where it does is 401. It names a lease and not a tenant, and that is deliberate — no endpoint
here takes a tenant identifier. What the server does with it is read a
row the caller cannot write. The lease also binds the run: a run records the claim that registered
it, and only that claim may write to it afterwards. A worker holding one
live lease cannot finalize another run of the same tenant. |
Request body application/json
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
bucketsrequired | array of CollectionRunBucket | Must not be empty. A module that declares no buckets can neither
store nor finalize, which is a wiring bug worth failing loudly. Every bucket's region must equal the run's region, and the set
must contain no duplicates. Both are 400 invalid_request, and both
are rejected rather than tidied up because each is a claim on a
generation: a duplicate would bump one bucket's fence twice and
supersede the run's own registration, and a bucket in another region
would supersede whichever run legitimately owns that region — after
which finalize there would tombstone live resources. global is a region like any other here: a global bucket belongs to
a run whose own region is global. |
modulerequired | string | |
regionrequired | string | |
runIdrequired | string (uuid) | Generated by the collector before its first attempt, and reused on every retry. This is the idempotency key for registration. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CollectionRunRegistered | Registered, or re-registered identically. The response is the same either way, deliberately: a collector cannot tell whether its first attempt was lost, and must not need to. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — this tenant's subscription does not include
the module the run collects for, and the fourteen-day grace after
expiry has passed (x-entitlement-access: collect). |
409 | ErrorResponse | run_registration_conflict — this runId is already registered
with a different account, region, module or bucket set. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /collection-runs/{runId}/fail
Mark a run failed and close it
Must be callable after the module's own deadline has fired, so it runs on a separate bounded lifecycle context.
This closes the run against a late batch and a late finalize, and records
what happened. It does not release a fence — registration always
claims the next generation, so a later run supersedes this one whether or
not it was marked failed. A run left running stays open to reconciling
on an enumeration that never completed, and reports as in-progress, until
cleanup reaps it.
Idempotent: failing a terminal run is a no-op that succeeds.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
X-Collection-Lease | header | string (uuid) | The claim this request is doing the work of, as returned by the dequeue
that took the job. Required with a control-plane worker credential, and forbidden with a
per-tenant ingest token. The two credentials answer "which tenant"
differently: a per-tenant token names one, so a lease beside it would be
a second and contradictory answer; the shared fleet's names none, so
without a lease there is nothing this server can act on. Sending one
where it does not belong is 400; omitting it where it does is 401. It names a lease and not a tenant, and that is deliberate — no endpoint
here takes a tenant identifier. What the server does with it is read a
row the caller cannot write. The lease also binds the run: a run records the claim that registered
it, and only that claim may write to it afterwards. A worker holding one
live lease cannot finalize another run of the same tenant. |
runIdrequired | path | string (uuid) |
Request body application/json
| Field | Type | Description |
|---|---|---|
reasonrequired | string | Why the run failed, for the admin matrix. Recorded verbatim, so it must not contain credentials or raw provider payloads. Clients truncate to this length before sending. The server truncates rather than rejecting — uniquely among the body constraints here. This is the call that closes a run against a late batch and a late finalize, and refusing it because an error string ran long would leave the run open for the whole stale-run window over something cosmetic. |
resourcesAttemptedrequired | integer | Resources the enumeration observed before it failed, or 0 if it
never got that far. SET on the run, never added to. The bound is
the column's: resources_attempted is a PostgreSQL int, and a
larger value would fail inside the transaction as a 500 for what is
a malformed request. Out of range is 400 invalid_request. |
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | Recorded. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — this tenant's subscription does not include
the module the run collects for, and the fourteen-day grace after
expiry has passed (x-entitlement-access: collect). |
404 | ErrorResponse | run_not_found |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /collection-runs/{runId}/finalize
Reconcile: tombstone what this run did not observe
Only ever called after a complete enumeration. A run that returned an incomplete result stores its partials and never reaches here — it does not know what is missing, and deleting on that basis removes live resources.
Replay-safe: finalizing an already-finalized run returns the stored tombstone count. Finalizing twice must never tombstone twice.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
X-Collection-Lease | header | string (uuid) | The claim this request is doing the work of, as returned by the dequeue
that took the job. Required with a control-plane worker credential, and forbidden with a
per-tenant ingest token. The two credentials answer "which tenant"
differently: a per-tenant token names one, so a lease beside it would be
a second and contradictory answer; the shared fleet's names none, so
without a lease there is nothing this server can act on. Sending one
where it does not belong is 400; omitting it where it does is 401. It names a lease and not a tenant, and that is deliberate — no endpoint
here takes a tenant identifier. What the server does with it is read a
row the caller cannot write. The lease also binds the run: a run records the claim that registered
it, and only that claim may write to it afterwards. A worker holding one
live lease cannot finalize another run of the same tenant. |
runIdrequired | path | string (uuid) |
Request body application/json
| Field | Type | Description |
|---|---|---|
resourcesAttemptedrequired | integer | Resources the enumeration observed. SET on the run, never added
to, so a replayed finalize leaves it unchanged. The bound is the
column's — a PostgreSQL int. Out of range is
400 invalid_request. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | FinalizeResponse | Reconciled, or already finalized and reporting the stored result. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — this tenant's subscription does not include
the module the run collects for, and the fourteen-day grace after
expiry has passed (x-entitlement-access: collect). |
404 | ErrorResponse | run_not_found |
409 | ErrorResponse | run_not_running (the run failed) or run_superseded (a newer run
has claimed one of these buckets, so this one may no longer decide
what is missing). |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /ingest/snapshots/batch
Store a batch of observed resources
Status is derived here, once, and stored as a column — a transition that was never materialised cannot be detected later.
Every write is replayable. The Idempotency-Key header is required and
must be generated before the retry loop, not inside it: a lost
response is indistinguishable from a lost request, and a key minted per
attempt makes a retry look like new data.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
X-Collection-Lease | header | string (uuid) | The claim this request is doing the work of, as returned by the dequeue
that took the job. Required with a control-plane worker credential, and forbidden with a
per-tenant ingest token. The two credentials answer "which tenant"
differently: a per-tenant token names one, so a lease beside it would be
a second and contradictory answer; the shared fleet's names none, so
without a lease there is nothing this server can act on. Sending one
where it does not belong is 400; omitting it where it does is 401. It names a lease and not a tenant, and that is deliberate — no endpoint
here takes a tenant identifier. What the server does with it is read a
row the caller cannot write. The lease also binds the run: a run records the claim that registered
it, and only that claim may write to it afterwards. A worker holding one
live lease cannot finalize another run of the same tenant. |
Idempotency-Keyrequired | header | string | Stable for the lifetime of one chunk, across every retry of it.
Repeating a key with the same body returns the stored response and
writes nothing; repeating it with a different body is
idempotency_key_reuse. maxLength is enforced by the server, not only declared: a longer
key would otherwise reach the database and fail as an index-size
error inside the transaction, reporting a malformed request as a
500 — the class a collector retries. Over-length is
400 invalid_request. |
Request body application/json
| Field | Type | Description |
|---|---|---|
itemsrequired | array of SnapshotInput | No duplicate resource keys within a batch: a duplicate makes the upsert order-dependent, which makes transitions non-deterministic. |
runIdrequired | string (uuid) | The run these observations belong to. Every item must fall inside a bucket that run registered, or the batch is rejected. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | SnapshotBatchResponse | Stored, or replayed from the stored result. Identical either way. |
400 | ErrorResponse | invalid_batch, idempotency_key_required, or invalid_request
for an Idempotency-Key longer than maxLength. The message names
the offending index and field and never echoes the value — an error
body must not become a data-exfiltration channel. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — this tenant's subscription does not include
the module producing these snapshots. |
404 | ErrorResponse | run_not_found — no run with this ID in this tenant. Matches
POST /collection-runs/{runId}/finalize and /fail, and tasks 23
and 25, which require 404 here. |
409 | ErrorResponse | idempotency_key_reuse, run_not_running, run_superseded,
run_account_mismatch or run_bucket_mismatch — the last two
meaning the batch is for a bucket this run never registered. run_not_found is 404, not here: an unknown run is a missing
resource, and the collector's handling differs. A 409 says the run
lost a claim it once held; a 404 says it never had one. Never retry a 4xx. A 409 means the run lost its claim; retrying
identically fails identically and builds a hot loop. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
org
The tenant's teams, members, accounts and tag values (PLATFORM.md §4).
Platform-level: every module scopes by it, so none of them owns it.
Reads for any session; writes require the admin role.
GET /org/marker-credentials
Marker credentials
The credentials customers' deployment systems post change markers with (p8_10), revoked ones included as the record that they existed. Never the token: only its hash is stored.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | MarkerCredentialList | This tenant's marker credentials, newest first. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /org/marker-credentials
Issue a marker credential
Admin only. The credential is bound at issue to the accounts and apps
it may mark; empty lists mean any. The token value is returned
once, here, and never again: only sha256(token) is stored.
Request body application/json
| Field | Type | Description |
|---|---|---|
accountIds | array of string | |
appIds | array of string (uuid) | |
namerequired | string |
Responses
| Status | Body | Meaning |
|---|---|---|
201 | MarkerCredentialIssued | The credential and its token. Store the token now; it cannot be read back. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
DELETE /org/marker-credentials/{credentialId}
Revoke a marker credential
Idempotent. The row stays as the record; the token stops being honoured at once.
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | Revoked, or already was. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /org/notification-channels
The tenant's notification destinations, redacted
Every channel (p7_21): its kind, name, team scope, whether it is enabled, the last test delivery, and a redacted view of the target — the host, a hint of the path's end and a fingerprint. The target itself is never read back: a Slack incoming-webhook URL is a bearer secret, and a read that returned it would be a copy in every browser that opened the page. Any session may list; only an administrator writes.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | NotificationChannelList | The channels, by name. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /org/notification-channels
Add a notification destination
Creates a channel from a target this deployment will post to. The
target is checked before it is stored (p7_21): https only, no
credentials in the URL, a host that resolves, and every address it
resolves to public — loopback, link-local (the cloud metadata
endpoint), RFC 1918, unique-local, carrier-grade NAT and the reserved
ranges are refused, as is a host that resolves to one public and one
private address (a rebinding setup). The same rule is applied again
at send time, on the address actually dialled, so a name that changes
its answer later gains nothing. Redirects are never followed.
A channel with a team routes that team's events; without one it is
the tenant default. The same target for the same kind and team is
409 channel_exists. Audited as notify.channel.create with the
redacted view, never the target.
Request body application/json
| Field | Type | Description |
|---|---|---|
enabled | boolean | |
kindrequired | NotificationChannelKind | slack posts {"text": …} to an incoming webhook; webhook posts
the message as JSON with its kind, for a receiver that routes on it;
ai-reviewer (p8_23) hands every item to the operator's AI reviewer
queue as a small claim-check message — its target is the deployment's
queue, set by the platform, never given by the tenant. One of slack, webhook, ai-reviewer. |
namerequired | string | |
targetrequired | string | An https URL this deployment will post to. Never read back. |
teamId | string (uuid) | Scope the channel to a team; omit for the tenant default. |
Responses
| Status | Body | Meaning |
|---|---|---|
201 | NotificationChannel | The channel, redacted. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | channel_exists — the same target is already a channel of that kind for that team (or the tenant default). |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /org/notification-channels/{channelId}
One channel, redacted
Responses
| Status | Body | Meaning |
|---|---|---|
200 | NotificationChannel | The channel. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PATCH /org/notification-channels/{channelId}
Rename, rescope, enable or disable a channel, or rotate its target
A partial update. A present target is the rotation: it is checked
the way a new one is, stored in place of the old, and the last test
delivery is cleared with it — a green diagnostic must not outlive the
destination it described. enabled: false takes the channel out of
routing without losing it. Audited as notify.channel.update,
notify.channel.rotate, notify.channel.enable or
notify.channel.disable, with the redacted view.
It asks for the password (x-step-up, P772-R1-F01): enabled: false
stops the channel's deliveries, as deleting it would. Any other change
asks too; it is the same operation.
Request body application/json
| Field | Type | Description |
|---|---|---|
enabled | boolean | |
name | string | |
target | string | A new target — the rotation. Checked like a new one; the last test is cleared. |
teamId | string (uuid) | Move the channel to a team. |
tenantDefault | boolean | true takes the channel out of its team and makes it a tenant default. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | NotificationChannel | The channel as it now stands. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | channel_exists — the same target is already a channel of that kind for that team (or the tenant default). |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
DELETE /org/notification-channels/{channelId}
Remove a channel
Gone, not disabled: the row and its target are deleted. Audited as
notify.channel.delete. Prefer enabled: false for a destination
that may come back.
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | Removed. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /org/notification-channels/{channelId}/test
Send a test message and report what happened
Delivers one fixed message through the same sender the outbox uses
— the same client, the same destination policy, the same retries —
and reports the outcome (p7_21): delivered, refused (the
destination answered with an error, or with a redirect this platform
does not follow), unreachable (no answer: DNS, TLS, a timeout) or
blocked (the destination policy refused it before a byte was
sent). The HTTP status and an error class come with it; the
destination's own words, and its URL, never do. The outcome is kept
on the channel as its last test. Rate-limited per address: an
administrator's diagnostic, not an outbound scanner.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | NotificationChannelTest | What the test delivery did. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /org/ownership/overrides
The tenant's ownership overrides
Live first, then revoked, newest first within each; a team narrows the list.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
teamId | query | string (uuid) | |
includeRevoked | query | boolean |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | OwnershipOverrideList | The overrides. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /org/ownership/overrides
Set an explicit owner
An administrator anywhere; a lead only within their teams' accounts
and tag values, and only in favour of a team they lead (p8_2). One
live override per match; the team must be open. Audited as
org.ownership.override.
Request body application/json
| Field | Type | Description |
|---|---|---|
accountId | string | |
expiresAt | string (date-time) | |
kindrequired | string enum | One of resource, tag, account. |
reasonrequired | string | |
resourceKey | string | |
tagKey | string | |
tagValue | string | |
teamIdrequired | string (uuid) |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | OwnershipOverride | The override. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | The match already has a live override, or the team is archived. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
DELETE /org/ownership/overrides/{overrideId}
Revoke an ownership override
The row stays, marked revoked; audited as org.ownership.override_revoked. A lead revokes within the reach they may set.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
overrideIdrequired | path | string (uuid) |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | OwnershipOverride | The override, revoked. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /org/teams
The teams, live by default
Live teams by name, with how many members and accounts each has.
Archived teams are hidden unless includeArchived is set; they are
never offered by a picker.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
includeArchived | query | boolean |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | TeamList | The teams. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /org/teams
Create a team
Name and slug are each unique per tenant; a collision on either is
409 team_exists. The slug is derived from the name when omitted.
Request body application/json
| Field | Type | Description |
|---|---|---|
namerequired | string | |
slug | string | Derived from the name when omitted. |
Responses
| Status | Body | Meaning |
|---|---|---|
201 | Team | Created. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
409 | ErrorResponse | team_exists — the name or the slug is already a team's. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /org/teams/{teamId}
One team, with its members, accounts and tag values
Everything the org settings page shows for one team. Each account
carries attributedSpend: what the rollups currently attribute to
this team through the account (attribution team_account), by
currency — the number that moves to the untagged bucket if the
account is unassigned, shown before the confirm.
A team the caller's tenant does not hold is 404, whether it does
not exist or belongs to someone else; the two are not distinguished.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | TeamDetail | The team. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PATCH /org/teams/{teamId}
Rename or archive a team
A partial update. archived: true closes the team: its memberships,
accounts and tag values stay, its history stays readable, and it
leaves every picker. archived: false reopens it. Renaming into an
existing name is 409 team_exists.
It asks for the password (x-step-up, P772-R1-F01): archiving takes
the team out of every picker, as a removal would. A rename is the same
operation and asks too, as a change to a user does.
Request body application/json
| Field | Type | Description |
|---|---|---|
archived | boolean | |
name | string |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | Team | The team as it now stands. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | team_exists — the name or the slug is already a team's. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PUT /org/teams/{teamId}/accounts/{accountId}
Assign an AWS account to the team
An account belongs to at most one team (0101's unique index — the
reason cost's account-level fallback is deterministic). An account
another team owns is 409 account_assigned, and the message names
the team; move it by unassigning there first, so the consequence is
seen where it lands. Assigning re-attributes the account's untagged
history to this team on the next rebuild, which the response reports
as enqueued.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | TeamAccountChange | The assignment, and what it changes. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | account_assigned — another team owns the account, named in the
message. team_archived — the team is closed. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
DELETE /org/teams/{teamId}/accounts/{accountId}
Unassign an AWS account from the team
The operation with a consequence (p3_18 §3): the account-level
fallback stops applying, and every dollar the rollups attribute to
this team through the account moves to the untagged bucket on the
next rebuild. 200, not 204, because the response says what that
is — spendMoving, by currency, over usageFrom..usageTo.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | TeamAccountChange | What moved. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PUT /org/teams/{teamId}/directory-group
Bind the team to a group of the identity provider
The team's membership then follows the provider's groups claim at each sign-in (p6_6): a person carrying the group joins it, one no longer carrying it leaves it. A binding decides membership and nothing else — the platform role stays whatever it is. One live team per group; an archived team takes no binding.
Request body application/json
| Field | Type | Description |
|---|---|---|
grouprequired | string | The group exactly as the provider names it in the groups claim — an id or a name, whichever the provider emits. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | Team | The team, bound. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | That group is bound to another live team of the tenant. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
DELETE /org/teams/{teamId}/directory-group
Unbind the team from its group
The memberships stay as they are and the team is hand-kept again. Idempotent.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | Team | The team, hand-kept. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PUT /org/teams/{teamId}/members/{userId}
Add a user to the team, or set their team role
Idempotent: a user already on the team has their role set. The team
role (member / lead) is scope within the tenant and confers no
platform privilege — that is the user's platform role, and the two
axes are never blended (PLATFORM.md §4). An archived team takes no
new members: 409 team_archived.
Request body application/json
| Field | Type | Description |
|---|---|---|
teamRolerequired | TeamRole | A user's role within one team. Deliberately disjoint from the
platform roles so the two axes cannot be compared by accident. One of member, lead. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | TeamMember | The membership. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | team_archived — the team is closed and takes no new assignment. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
DELETE /org/teams/{teamId}/members/{userId}
Remove a user from the team
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | Removed. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /org/teams/{teamId}/tag-values
Map a tag value onto the team
Matched case-insensitively, and a value maps to at most one team:
one another team holds is 409 tag_value_mapped, naming it. Adding
a value re-attributes everything tagged with it on the next rebuild.
Request body application/json
| Field | Type | Description |
|---|---|---|
tagValuerequired | string |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | TeamTagValueChange | The mapping, and whether a rebuild was enqueued. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | tag_value_mapped — another team holds the value, named in the
message. team_archived — the team is closed. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
DELETE /org/teams/{teamId}/tag-values/{tagValue}
Unmap a tag value from the team
Everything tagged with the value moves to the untagged bucket — or to whichever lower-priority rule then matches — on the next rebuild.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | TeamTagValueChange | The mapping that was removed, and whether a rebuild was enqueued. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /org/users
The tenant's users, for the membership picker and the directory
Every user in the tenant with their platform role and the teams they are on. Admin only: a member list is a directory, and a directory is not a viewer's to read.
Deactivated users are left out unless includeDisabled is set: the
picker must not offer them, and the directory that manages them must
show them (p7_20).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
includeDisabled | query | boolean | Also list users whose access has been deactivated. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | OrgUserList | The users, by email. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /org/users
Add a user to the tenant
Creates a user with a platform role and a generated temporary password
(p7_20). The password is in the response once and is never stored
in plaintext or logged: hand it to the person, who changes it with
POST /auth/password. There is no mail transport in the platform, so
an invitation would be a token the administrator has to carry across
anyway; a password with the same lifetime is the smaller thing.
The email must be new in the tenant (409 user_exists). The change is
audited as org.user.create.
Request body application/json
| Field | Type | Description |
|---|---|---|
emailrequired | string | |
rolerequired | string enum | One of viewer, engineer, admin. |
Responses
| Status | Body | Meaning |
|---|---|---|
201 | OrgUserCreated | The user, and their temporary password, shown once. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
409 | ErrorResponse | user_exists — a user with that email is already in the tenant. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PATCH /org/users/{userId}
Change a user's role, or deactivate and reactivate them
A partial update (p7_20). role moves the platform role; disabled: true deactivates the account — every session they hold ends there
and then, and a login is refused with the same answer as a wrong
password — and disabled: false reactivates it. A role change ends
the user's sessions too: the next login carries the new role, and no
open tab keeps the old one.
The last enabled administrator cannot be demoted or deactivated
(409 last_admin); two administrators changing each other at once
are serialised on the row, so one of them is refused. A user in
another tenant is 404, never a silent success. Audited as
org.user.set_role, org.user.disable or org.user.enable.
Request body application/json
| Field | Type | Description |
|---|---|---|
breakGlass | boolean | An administrator who keeps a password when the tenant enforces single sign-on (p5_5). Only an administrator can be one. |
disabled | boolean | true deactivates the account; false reactivates it. |
role | string enum | One of viewer, engineer, admin. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | OrgUser | The user as they now stand. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | last_admin — the change would leave the tenant with no enabled
administrator, and nothing inside the product could repair that. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /org/users/{userId}/password-resets
Issue a single-use password reset for a user
Returns a reset token once (p7_20). It is valid for 24 hours and
for exactly one redemption at POST /auth/password/reset; only its
SHA-256 hash is stored, so a lost token is replaced by issuing
another. Issuing a new reset does not revoke the user's sessions —
redeeming it does. Audited as org.user.reset_password, with the
expiry and never the token.
Responses
| Status | Body | Meaning |
|---|---|---|
201 | PasswordResetIssued | The reset token, shown once, and when it expires. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
DELETE /org/users/{userId}/sessions
End every session a user holds
Every open session of the user ends now (p7_20); the account, its
role and its password are untouched, and the next login works. For
a lost laptop, or a session an administrator wants gone without
deactivating the person. Audited as org.user.revoke_sessions.
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | The sessions are gone. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
apps
Applications as tags define them (platform task p6_5), and as teams
declare them (p6_8, platform/docs/declared-apps.md): a group per
value of each attribution tag key, joining the inventory's health,
Spend's month-to-date amount and Provisioning's count. Platform-level
and read-only; the modules it reads from keep their own scoping.
GET /apps
Applications, as the attribution tags define them
One row per application, where an application is one value of one of
the tenant's attribution tag keys — the keys Spend already attributes
by (PUT /cost/settings), so nothing new is configured. Each row
carries the inventory's health counts for the tag value, and — only
when the tenant holds the module — Spend's month-to-date amortized
amount for it (per currency) and how many of its live resources
Provisioning created.
Scoping follows each module's own rule: health is tenant-wide, as the
inventory is; spend and the provisioned count cover the accounts a
team manages — every team's for an admin, the viewer's own teams' for
a member — as Spend and Provisioning do. An account no team manages
is outside both, for everyone. A group is present when at least one
live resource carries the value.
A tenant with no attribution rules has no groups and tagKeys says
so.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | AppGroups | The groups, the keys they came from, and the period spend covers. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /apps/declared
The apps teams have declared
Every live declared app (p6_8, platform/docs/declared-apps.md): a
name a team owns, defined by tag matches plus resources added and
excluded, with the same columns as the tag-defined groups — the
inventory's health for its live members, month-to-date spend at one
grain (see DeclaredAppMoney), and how many of its members
Provisioning created — each column only with its module. Membership
is evaluated against the live inventory when read; a resource
counts once however many matches it hits.
Scoping follows each module's rule, as GET /apps does: health is
tenant-wide; spend and the provisioned count cover the accounts the
viewer's teams manage. canCreate says whether the viewer may
declare an app, ownableTeams which open teams they may give one to,
and each app's canEdit whether the viewer may change it. Each
answers on the platform role floor and the team rule together, so the
portal never guesses — neither about the action nor about the teams it
may offer.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | DeclaredAppList | The declared apps, by name, and the period spend covers. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /apps/declared
Declare an app
Creates an app from its definition. The caller must be a lead of the
owning team or an admin (403 forbidden otherwise); the team must
exist and be open; the name must be free among live apps
(409 app_exists). A definition needs at least one tag match or one
added resource; explicit resources are accountId plus a resource
key (region/system/service/type/id). Audited as
apps.declared.create with the definition.
Request body application/json
| Field | Type | Description |
|---|---|---|
description | string | |
exclude | array of ResourceRef | Resources kept out although a match hits them. |
include | array of ResourceRef | Resources added by hand. |
matchesrequired | array of TagMatch | |
namerequired | string | |
teamIdrequired | string (uuid) |
Responses
| Status | Body | Meaning |
|---|---|---|
201 | DeclaredApp | The app, with the list's columns. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | app_exists: a live app already has this name; team_archived: the owning team is closed. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /apps/declared/{appId}
One declared app in full
The app with the list's columns, its live members (each saying whether a tag match or an explicit addition put it there), and — with Spend — a six-month trend, each month resolved to its own grain by the same rule. An archived app is readable and says so.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | DeclaredAppDetail | The app, its members and its trend. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PUT /apps/declared/{appId}
Replace an app's definition
The whole definition, replaced. The caller must be allowed on the
app's current team and on the new one, if it moves. An archived app
is 409 app_archived. Audited as apps.declared.update with the
definition before and after.
Request body application/json
| Field | Type | Description |
|---|---|---|
description | string | |
exclude | array of ResourceRef | Resources kept out although a match hits them. |
include | array of ResourceRef | Resources added by hand. |
matchesrequired | array of TagMatch | |
namerequired | string | |
teamIdrequired | string (uuid) |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | DeclaredApp | The app as it now is. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | app_exists, app_archived or team_archived. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /apps/declared/{appId}/archive
Archive an app
Hides the app from the list and makes it read-only; its definition
and history stay readable. Never a delete. Audited as
apps.declared.archive.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | DeclaredApp | The app, archived. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | app_archived: already archived. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
provisioning-catalog
Terraform template catalog and its immutable pinned versions. Behind
RequireModule("provisioning").
GET /provisioning/templates
List catalog templates
The catalog an engineer picks from. Archived templates are excluded unless asked for: an archived template may still have live resources provisioned from it, so it is hidden, never deleted.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
category | query | array of string | Repeatable. Any of the given categories. |
includeArchived | query | boolean | Include templates with archivedAt set. Default false. |
page | query | integer | 1-based. Normalised server-side rather than rejected: < 1 becomes 1,
and a page past the end is clamped to totalPages. Deliberately no minimum: a schema constraint would make the generated
server reject page=0 before the handler could normalise it, which
contradicts the normalisation this very description promises. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | TemplatePage | A page of templates. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /provisioning/templates
Register a template
Admin only — the catalog is curated, not crowd-sourced (D6). Creating a template creates no version and therefore nothing requestable: a template with no resolved version cannot be requested at all.
A git template's URL is checked before it is stored: scheme allowlist,
no credentials embedded in the URL, and the host must resolve outside
private and reserved ranges. That last one is SSRF defence and it is not
optional (PROVISIONING_SPEC.md §8.2).
Request body application/json
| Field | Type | Description |
|---|---|---|
category | string | |
description | string | |
gitRef | string | Default ref for new versions. Only for git. |
gitUrl | string | Required for git, rejected for inline. |
namerequired | string | |
slugrequired | string | |
sourceTyperequired | TemplateSourceType | Where a template's code comes from, and therefore what its source
identity is: a commit SHA for git, a content sha256 for inline.
Either way it is pinned at publish and never updated. One of git, inline. |
Responses
| Status | Body | Meaning |
|---|---|---|
201 | Template | The registered template. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
409 | ErrorResponse | invalid_request is used for a malformed slug; this is the narrower
case of a well-formed slug that is already taken. slug is unique
per tenant and immutable, because it is what a request pins to and
what a URL names. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /provisioning/templates/{templateId}
One template
Responses
| Status | Body | Meaning |
|---|---|---|
200 | Template | The template. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PATCH /provisioning/templates/{templateId}
Rename, recategorise, or archive a template
Admin only. A partial update: only the fields present are changed.
slug and sourceType are absent from the request body deliberately.
Both are pinned by every request and every provisioned resource that
came from this template, and changing either would rewrite history.
It asks for the password (x-step-up, P772-R1-F01): archiving takes
the template out of the catalogue. Any other change asks too; it is the
same operation.
Request body application/json
| Field | Type | Description |
|---|---|---|
archived | boolean | True archives, false restores. Archiving hides the template and closes it to new versions; it never touches what was provisioned from it. |
category | string | |
description | string | |
gitRef | string | |
name | string |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | Template | The updated template. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /provisioning/templates/{templateId}/versions
Published versions of a template
Newest first. A version with resolveState other than resolved is
listed — the catalog UI shows a failed resolution with the agent's own
error, which is the only way an admin can act on it — but it cannot be
requested.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
page | query | integer | 1-based. Normalised server-side rather than rejected: < 1 becomes 1,
and a page past the end is clamped to totalPages. Deliberately no minimum: a schema constraint would make the generated
server reject page=0 before the handler could normalise it, which
contradicts the normalisation this very description promises. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | TemplateVersionPage | A page of versions. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /provisioning/templates/{templateId}/versions
Publish the next version
Admin only, and the moment a version's source identity is fixed.
For an inline source the publish is synchronous and complete: the
exact bytes are stored, their sha256 is computed, and resolveState is
resolved on the way out.
For a git source it is asynchronous and needs a live agent (D7):
the platform holds no git credential and never reaches the customer's
network. The publishing transaction therefore also enqueues a
provisioning request with action: resolve
(PROVISIONING_SPEC.md §6.6), which an agent claims through the
ordinary claim and reports on the ordinary terminal status — there
is no resolve endpoint, and no second claim/fence/replay mechanism to
keep in step with the first. Until an agent reports, the version is
pending and unrequestable: a 201 here is not a promise that the
version will ever become usable.
The request targets the executor the platform chooses (p7_15): the
first enabled account with a live agent, else the first with any
registered agent, recorded as resolveAccountId. With no such
account the version is published pending with no request and no
resolveAccountId — waiting — and the next agent registration
enqueues it; the catalog says so.
Request body application/json
| Field | Type | Description |
|---|---|---|
gitRef | string | For git templates. Defaults to the template's gitRef. This is
what an agent resolves; it is never what runs. |
inlineSource | string | For inline templates: the exact bytes, capped at 256 KiB, UTF-8,
no NUL. Their sha256 is the version's source identity, so an inline
version is resolved the instant it is published — "approved code
equals executed code" holds without a clone. |
variablesSchemarequired | VariablesSchema | Stored on the version and immutable with it. The portal generates the
request form from this; the agent uses it to write
generated.auto.tfvars as proper HCL through an encoder, never by
string concatenation, because hand-rolled escaping is how tfvars
injection happens. |
Responses
| Status | Body | Meaning |
|---|---|---|
201 | TemplateVersion | The new version. resolved for inline, pending for git. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | request_state_conflict — the template is archived, so it takes no
new versions. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /provisioning/templates/{templateId}/versions/{version}
One version, with its variables schema
The variables schema served here is what the portal generates the
request form from. It is never reimplemented client-side: a client-side
check is a convenience, and the Go validator at submit is the authority
(PROVISIONING_SPEC.md §8.1).
Responses
| Status | Body | Meaning |
|---|---|---|
200 | TemplateVersion | The version. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /provisioning/templates/{templateId}/versions/{version}/resolve
Re-offer a git resolution to an agent
Admin only. A failed resolution is retryable: the usual causes are a
ref that did not exist yet, a repository the agent could not reach, and
an agent that was not running at all. A pending one may be requeued
too (p7_15): one waiting with no executor, or one whose request is
still queued for an account whose agent has gone — that request is
cancelled, and the executor is chosen again by the same rule as at
publish. The two request rows are the record of the move.
This enqueues a new request with action: resolve (§6.6). It does
not reopen the old one: attempts, fencing and replay behave here exactly
as they do for a create, because it is the same mechanism. A
resolution an agent is running is left to finish.
A resolved version's source identity is write-once — re-resolving it
would silently change what an existing approval approved — and is
refused.
Responses
| Status | Body | Meaning |
|---|---|---|
202 | TemplateVersion | Requeued. resolveState is back to pending; resolveAccountId
names the account whose agent will claim it, or is absent when
none is eligible yet. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | resolution_conflict — the version is resolved (its source
identity is written once and never updated), or an agent is
running its resolution now. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
provisioning-requests
Provision and destroy requests, and the single approval decision each one
gets. Behind RequireModule("provisioning").
GET /provisioning/requests
List provision and destroy requests
Newest first. Scoped by team through team_accounts by the shared
filter builder, never by a predicate hand-copied into this query.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
state | query | array of ProvisionRequestState | Repeatable. Any of the given request states. |
accountId | query | array of string | Repeatable. Any of the given accounts. |
templateId | query | string (uuid) | |
awaitingMyDecision | query | boolean | Only requests in pending_approval that this user may decide — so
never their own, since self-approval is refused (D8). |
page | query | integer | 1-based. Normalised server-side rather than rejected: < 1 becomes 1,
and a page past the end is clamped to totalPages. Deliberately no minimum: a schema constraint would make the generated
server reject page=0 before the handler could normalise it, which
contradicts the normalisation this very description promises. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ProvisionRequestPage | A page of requests. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /provisioning/requests
Request a create or a destroy
The request pins (templateId, templateVersion) at submit, and that
pin never moves. Everything downstream — the approval, the claim, the
apply — refers to the one source identity the version already carries,
which is what makes "the approval approved this code" true rather than
hopeful.
Variables are validated server-side against the version's schema before the request exists. The response to invalid input is a per-variable error, not a single "invalid request": a generated form that cannot say which field is wrong is a worse form than no form.
There is no in-place update in v1 (D5). Changing a resource is a destroy followed by a create, and each goes through approval.
Request body application/json
| Field | Type | Description |
|---|---|---|
actionrequired | ProvisionAction | create or destroy. resolve is refused here with
invalid_request: a resolution request is created by the
transaction that publishes a git-sourced version (§6.6), never by a
person, and one submitted by hand would pin a version to a SHA
nobody asked for. |
namerequired | string | The requester's name for the thing. |
resourceId | string (uuid) | Required for destroy, rejected for create. |
targetAccountIdrequired | string | Must exist in this tenant's accounts registry, be enabled, and be
owned by a team the requester belongs to. |
teamId | string (uuid) | The owning team, which decides who may approve. Optional when the requester belongs to exactly one team that owns the account; required when they belong to several, because guessing would decide the approver. |
templateIdrequired | string (uuid) | |
templateVersionrequired | integer | Explicit, never "latest". A request that pinned a moving target would be approved against one version and applied from another. |
variables | object | Validated against the pinned version's schema before the request
exists — both the value types, which this schema constrains, and the
names, which it cannot: a key the version does not declare is
rejected by the validator. Values marked sensitive are stored
unmasked (the agent needs them) and masked everywhere they are read
back. |
Responses
| Status | Body | Meaning |
|---|---|---|
201 | ProvisionRequest | Created in pending_approval, with expiresAt already set. The
approvers for the target account's owning team are notified through
the platform outbox. |
400 | ProvisionRequestValidationError | invalid_request. details names every variable that failed and
why, so the form can mark the fields rather than the page. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | version_unresolved — the pinned version has no source identity
yet, or its resolution failed, so there is nothing an agent could
be told to execute — or account_not_managed, when the target
account is not in this tenant's registry, is disabled, or is not
owned by a team this user belongs to. request_state_conflict
covers a destroy its resource cannot take now: already destroyed,
or another request on it in flight, its create's retry included
(p7_65). |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /provisioning/requests/{requestId}
One request, with everything an approver needs
The approver's view, which D1 had to earn: approval fires before any
agent claims the request, so there is no Terraform plan to show. What
replaces it is PROVISIONING_SPEC.md §11 — the pinned version and its
source identity, every variable with sensitive values masked, the
target account and its environment, the requester and their team, and
this template's recent outcomes in this account.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ProvisionRequestDetail | The request. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /provisioning/requests/{requestId}/cancel
Cancel a queued or running request
From queued this is immediate and terminal: nothing has run, so the
request goes straight to cancelled.
From running it is a request to stop, not a stop. The request
moves to cancelling and the agent learns about it on its next
heartbeat — cancellation rides the heartbeat response rather than
getting a polled endpoint of its own, because the agent is already
calling it on a known interval and one fewer endpoint is one fewer thing
a third-party implementer can get wrong. The request reaches cancelled
only when the agent reports it there.
Infrastructure already created before the stop is never
auto-destroyed (D10). If the apply had begun, expect
partialApply = true and a state backend location to act on.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ProvisionRequest | cancelled if nothing had been claimed, cancelling if an agent is
running it. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | request_state_conflict — the request is terminal, or still
awaiting approval (withdraw it instead). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /provisioning/requests/{requestId}/decision
Approve or reject
A request is decided exactly once, ever: approvals is keyed
(tenant_id, request_id). Approving writes the decision and sets the
request to queued in one transaction — approved is deliberately
not a state, because it could only ever be observed as a limbo where a
request is blessed but not runnable.
Who may decide: role engineer or admin and membership of a team
that owns the target account through team_accounts. A tenant admin
may always decide, which is what unblocks a one-person team without
anyone having a self-approval switch to leave on.
Request body application/json
| Field | Type | Description |
|---|---|---|
comment | string | Recorded permanently and shown to the requester. Optional on approve; a rejection without one is technically allowed and practically unkind. |
decisionrequired | string enum | One of approved, rejected. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ProvisionRequest | Decided. The request is queued on approve and rejected on
reject, and the decision, its actor, its comment and its timestamp
are permanent — they outlive log retention. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | self_approval_refused — the actor is the requester. Refused for
everyone including admins, and enforced by
approvals.actor_id <> provision_requests.requested_by rather than
by a UI that hides the button. Otherwise forbidden (not an approver for this account's team) or
module_not_entitled. |
404 | ErrorResponse | not_found |
409 | ErrorResponse | request_state_conflict — the request is not in pending_approval.
It has already been decided, withdrawn, or expired; the message
names the state it is actually in. A decision at or after
expiresAt expires the request then and there, in the same
transaction, and is refused with this code (p7_14): the deadline
does not wait for the nightly sweep. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /provisioning/requests/{requestId}/retry
Put a failed request back on the queue
Retry is an explicit operator action and never automatic (D10). It
returns the request to queued and bumps attempt, which is the fence:
every agent write carrying the old attempt is refused task_superseded
from that moment, so the previous task cannot resurrect and write over
the new one.
The pinned version does not move. A retry re-runs the code that was approved, not the code that is current.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ProvisionRequest | Queued again, with attempt incremented. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | request_state_conflict — only a failed request may be retried,
and not a create whose resource has a destroy requested, running
or done (p7_65) — or version_unresolved, if the pinned version's
resolution has since been invalidated. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /provisioning/requests/{requestId}/withdraw
Withdraw a request awaiting approval
The requester's own escape hatch, and only theirs. Terminal: a withdrawn request is not resubmitted, it is replaced by a new one.
Only from pending_approval. Once approved the request is queued and
an agent may already have claimed it, so stopping it is a cancel, which
is a different operation with a different shape.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ProvisionRequest | Withdrawn. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | request_state_conflict — not in pending_approval. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
provisioning-tasks
What an agent actually did: task state, and the streamed logs. Behind
RequireModule("provisioning").
GET /provisioning/requests/{requestId}/tasks
Every attempt at one request
One task per attempt, newest first. A request that was reaped twice and
then succeeded has three tasks, and the two abandoned ones are part of
the audit record rather than noise to hide.
Paged like every other list on the platform. maxAttempts bounds this
at three today, but the bound is a tenant setting and a list operation
whose size depends on a setting is a list that needs a page.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
page | query | integer | 1-based. Normalised server-side rather than rejected: < 1 becomes 1,
and a page past the end is clamped to totalPages. Deliberately no minimum: a schema constraint would make the generated
server reject page=0 before the handler could normalise it, which
contradicts the normalisation this very description promises. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ProvisionTaskPage | A page of tasks, newest first. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /provisioning/tasks/{taskId}
One task and its permanent summary
The summary outlives the logs. Full logs are kept 30 days; what the task did, whether it worked, how long each stage took, and which agent and OpenTofu version ran it are kept forever. An audit that expires is not an audit.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ProvisionTaskDetail | The task, with its summary once it is terminal. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /provisioning/tasks/{taskId}/logs
Streamed task logs
Cursor-paginated on the agent-assigned seq, oldest first, so the
portal can tail a running apply by re-requesting from nextCursor. Not
page numbers: lines arrive continuously at the head, and an offset into
a growing table silently skips or repeats rows.
Lines are scrubbed by the agent, before they are sent
(PROVISIONING_SPEC.md §7.5), including exact-value redaction of every
variable the schema marks sensitive. The platform stores what it is
given; it does not get a second chance.
Visible to the requester, the owning team and tenant admins. Retention is 30 days, after which this returns an empty page for a task whose summary is still there — which is the documented behaviour and not a bug.
A tailer polls with nextCursor, which is always returned and
always positions after the last line the caller has seen. It is a
watermark, not a "there is more" flag: an empty page at the tail comes
back with the same watermark, and the next poll returns only lines
appended since. An earlier draft of this contract omitted nextCursor
at the tail and told clients to repeat their input cursor, which for the
first page — where there is no input cursor — replays the page just
consumed, forever.
A tailer stops on completion, and on nothing else (p7_7). The agent
reports the terminal status before it drains its last log batches, so
the task is terminal while the lines that explain a failure are still
arriving; completion stays pending until the line at the agent's
watermark has been paged out, and turns incomplete or unverified
after a bounded grace so no reader waits forever for a line that will
never come.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
cursor | query | string | Opaque keyset cursor from the previous page's nextCursor. Omit for
the first page, which starts at the lowest retained seq. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ProvisionTaskLogPage | A page of log lines, oldest first. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
provisioning-drift
Provisioned resources, and the two drift directions computed against the
Estate inventory. Behind RequireModule("provisioning").
GET /provisioning/drift/missing
Drift: provisioned but missing
Resources this tenant provisioned that the Estate inventory no
longer sees. Computed live, by joining provisioned_resources against
inventory rows carrying panorama:request_id — no new tables, no
materialised view, no staleness window (D12).
expectedCount is the number of tagged resources the agent found in
state immediately after a successful apply. Without it,
"provisioned-but-missing" has nothing to be missing from.
Rows with provenanceTagged = false are excluded rather than
reported as fully missing: the stamp never verifiably landed, so their
absence from inventory says nothing. They carry a visible "provenance
unverified" badge on the resource instead — a stamp that silently failed
to land is worse than no stamp, so it is never silently missing.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountId | query | array of string | Repeatable. Any of the given accounts. |
page | query | integer | 1-based. Normalised server-side rather than rejected: < 1 becomes 1,
and a page past the end is clamped to totalPages. Deliberately no minimum: a schema constraint would make the generated
server reject page=0 before the handler could normalise it, which
contradicts the normalisation this very description promises. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | DriftMissingPage | A page of provisioned-but-missing findings. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /provisioning/drift/unprovisioned
Drift: existing but unprovisioned
Live inventory in accounts under management that carries no
panorama:request_id tag — infrastructure somebody made by hand.
Scoped to the (service, resourceType) pairs this tenant's provisioning
has actually produced before, which makes the filter self-calibrating:
it learns what provisioning creates by watching it, rather than from a
hand-maintained type list that would go stale. An unfiltered version of
this query is noise, and noise is how a drift page gets ignored.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountId | query | array of string | Repeatable. Any of the given accounts. |
service | query | array of string | Repeatable. |
page | query | integer | 1-based. Normalised server-side rather than rejected: < 1 becomes 1,
and a page past the end is clamped to totalPages. Deliberately no minimum: a schema constraint would make the generated
server reject page=0 before the handler could normalise it, which
contradicts the normalisation this very description promises. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | DriftUnprovisionedPage | A page of existing-but-unprovisioned findings. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /provisioning/resources
What provisioning has created
The provenance record. Every row here answers "who asked for this, from
which template version, approved by whom" for the resources carrying its
panorama:request_id tag.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountId | query | array of string | Repeatable. Any of the given accounts. |
templateId | query | string (uuid) | |
createRequestId | query | string (uuid) | The create request whose resource this is: the one row that create left, or none. The request page finds what to destroy with it, where a page of the account's resources could miss the row (p7_65, review round 2). |
status | query | array of ProvisionedResourceStatus | Repeatable. |
page | query | integer | 1-based. Normalised server-side rather than rejected: < 1 becomes 1,
and a page past the end is clamped to totalPages. Deliberately no minimum: a schema constraint would make the generated
server reject page=0 before the handler could normalise it, which
contradicts the normalisation this very description promises. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ProvisionedResourcePage | A page of provisioned resources. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
provisioning-admin
Agent registration tokens. Require the admin role, and behind
RequireModule("provisioning").
GET /provisioning/agents
Registered agents
One agent per AWS account (D4). lastSeenAt, agentVersion and
tofuVersion are what the last handshake reported, so an account whose
agent has stopped calling is visible here as a stale timestamp rather
than as requests that mysteriously never start.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
includeRevoked | query | boolean | |
page | query | integer | 1-based. Normalised server-side rather than rejected: < 1 becomes 1,
and a page past the end is clamped to totalPages. Deliberately no minimum: a schema constraint would make the generated
server reject page=0 before the handler could normalise it, which
contradicts the normalisation this very description promises. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | ProvisioningAgentPage | A page of the agent tokens registered for this tenant. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /provisioning/agents
Issue an agent token
Admin only. The token is bound to exactly one (tenant, account) at
issue, which is what makes a claim account-scoped structurally instead
of by a filter a handler could forget.
The token value is returned once, here, and never again: only
sha256(token) is stored. This is the one operator response in the
provisioning API that carries a secret, and it is deliberately not
reachable from any /provisioning/agent/* operation.
Request body application/json
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
namerequired | string |
Responses
| Status | Body | Meaning |
|---|---|---|
201 | ProvisioningAgentTokenIssued | The agent and its token. Store the token now; it cannot be read back. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
409 | ErrorResponse | account_not_managed — the account is not in this tenant's registry
or is disabled. An agent for an account nobody has enabled would
poll forever for work that can never be created. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
DELETE /provisioning/agents/{agentId}
Revoke an agent token
Admin only, and effective on the agent's next call: revokedAt is
stamped and every subsequent request from that token is 401. A task the
revoked agent still holds is not finished by it — its writes are refused
and the scheduler reaps the task after heartbeatTimeoutSeconds, which
returns the request to queued.
Idempotent: revoking an already-revoked token succeeds and does not move
revokedAt. The row is kept, never deleted, because it is what a task's
agentId refers to.
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | Revoked. Repeating this is not an error. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("provisioning") refused this
before the handler ran, because the tenant's subscription does not
include the provisioning module — or forbidden, when the module is
entitled and the user's role or team scope does not permit the
operation. Two codes because they are two mechanisms and two remedies: "upgrade
your plan" and "ask your admin" are different problems, and the portal
renders a locked-module upsell for the first and an access message for
the second. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
provisioning-agent
The agent protocol. A product contract with third-party operators: it runs inside customers' AWS accounts, on their upgrade schedule, and a breaking change cannot be shipped by redeploying.
POST /provisioning/agent/claim
Long-poll for work
Outbound-only, long-polled. No customer is ever asked for an inbound firewall rule, which is why this is a poll and not a webhook.
Replay safety. The agent generates claimId before its first
attempt and reuses it on every retry of that attempt. Repeating a
claimId returns the same work item, never a second one: a lost
response must not cost the agent a claim it will then never heartbeat.
A claimId that was used for a different work item is claim_conflict.
Fencing. A claim stamps the request's current attempt onto
the task it creates, selecting the request FOR UPDATE SKIP LOCKED. It
does not change it. The increment belongs to every event that
invalidates a task — reap, cancel, retry — and happens in the same
transaction as the invalidation (§6.3), so a task is fenced at the
instant it is lost rather than at the convenience of whatever agent
polls next. Every later write from this agent carries the stamped
attempt, and every write whose attempt is not the request's current one
is refused task_superseded. Two instances of one agent — a restart
overlapping its predecessor, a duplicated deployment — is the case this
defends, and it does happen.
Scope. The claim is account-scoped by the token, structurally. There is no account parameter to get wrong.
One shape, three actions. A claimed task carries an action.
create and destroy are an apply. resolve is a git ref to turn into
a commit SHA with the customer's own git credentials, which the platform
does not have and will never ask for (D7); it is reported on the
terminal status like any other outcome, which is why there is no
resolve endpoint (§6.6).
Request body application/json
| Field | Type | Description |
|---|---|---|
agentVersionrequired | string | |
claimIdrequired | string (uuid) | Generated by the agent before its first attempt and reused on every retry of that attempt. This is the idempotency key for claiming: repeating it returns the same work item, never a second one. Minting it inside the retry loop is the mistake that turns one lost response into a task nobody heartbeats. |
tofuVersionrequired | string | |
waitSeconds | integer | How long the agent is willing to hold the poll open. Clamped
server-side to claimPollTimeoutSeconds; it is a hint, not a
demand. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | AgentTask | Work to do. Repeating the same claimId returns this same item, and
the response is identical either way — the agent cannot tell whether
its first attempt was lost, and must not need to. |
204 | — | Nothing to do. The long poll expired without work appearing; the agent polls again. Not an error, and not a reason to back off. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no agent token, an unknown token, or a revoked token.
Fails closed (§3.7): there is no degraded mode and no anonymous claim. |
403 | ErrorResponse | account_not_managed — the token is valid, but the account it is bound
to is disabled or no longer in the tenant's accounts registry — or
module_not_entitled, when the tenant's subscription no longer includes
provisioning. Distinct from 401 on purpose: the agent's own credential is fine, so an
operator should look at the account, not reissue a token. The agent
keeps polling and logs the reason; nothing is silently dropped. module_not_entitled is answered by register and claim only
(x-entitlement-access: start). A task the agent already holds
settles whatever the licence says since: its heartbeat, logs and
status keep answering (settle), so an apply that crossed the expiry
reports its outcome rather than leaving cloud and platform state
disagreeing. |
409 | ErrorResponse | claim_conflict — this claimId was already used for a different
work item. The agent minted a key inside its retry loop instead of
before it, or reused one across attempts. Not retryable: a new
claimId is required. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
426 | ErrorResponse | agent_version_unsupported — the agent is below
minAgentVersion and may not claim work. The message names the
required version. Deliberately not a silent no-work 204: an operator whose agents have
aged out sees requests that never start and nothing that says why.
The agent logs this and keeps polling; nothing is dropped, and the
moment it is upgraded it picks the queue up. |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /provisioning/agent/register
Version handshake and policy fetch
The first call an agent makes, and the only place it learns the operating parameters it must respect: how often to heartbeat, how long a claim may hang, how large a log batch may be, and how many attempts a request gets. Hard-coding any of those in the agent is how a fleet becomes impossible to tune.
Naturally idempotent — it stores the reported versions on the agent's token row and returns policy. Safe to call on every start, and on reconnect after a long outage.
Version policy is report-and-warn, and refuses only below a declared
minimum (D16). An agent below minAgentVersion still registers, so that
an operator can see it in the Agents page and know what to upgrade; it
is claim that refuses it, with 426.
Request body application/json
| Field | Type | Description |
|---|---|---|
agentVersionrequired | string | |
capabilities | array of string | Optional feature flags an agent advertises, for forward compatibility. An unknown capability is ignored, never rejected: the fleet is upgraded by customers on their own schedule, so a newer agent must always be able to talk to an older platform. |
tofuVersionrequired | string | The OpenTofu binary pinned in this agent's image. OpenTofu rather than Terraform (D13): MPL-2.0, so a commercial provisioning product raises no BUSL "competitive offering" question, and existing Terraform templates run unchanged. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | AgentRegistrationResult | Registered. accountId is echoed from the token, not accepted from
the request — an agent that finds it disagrees with its own
configuration is pointed at the wrong account and should stop. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no agent token, an unknown token, or a revoked token.
Fails closed (§3.7): there is no degraded mode and no anonymous claim. |
403 | ErrorResponse | account_not_managed — the token is valid, but the account it is bound
to is disabled or no longer in the tenant's accounts registry — or
module_not_entitled, when the tenant's subscription no longer includes
provisioning. Distinct from 401 on purpose: the agent's own credential is fine, so an
operator should look at the account, not reissue a token. The agent
keeps polling and logs the reason; nothing is silently dropped. module_not_entitled is answered by register and claim only
(x-entitlement-access: start). A task the agent already holds
settles whatever the licence says since: its heartbeat, logs and
status keep answering (settle), so an apply that crossed the expiry
reports its outcome rather than leaving cloud and platform state
disagreeing. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /provisioning/agent/tasks/{taskId}/heartbeat
Liveness, and the cancellation channel
Sent every heartbeatIntervalSeconds for each task the agent holds.
A task whose heartbeatAt falls behind heartbeatTimeoutSeconds is
reaped: in one transaction the task becomes abandoned, the request's
attempt is incremented — which is what fences the abandoned task,
immediately, with no window in which its writes still pass (§6.3) — and
the request returns to queued, unless it has used up maxAttempts, in
which case it fails with failureClass = timeout.
Cancellation rides this response. cancelRequested is how an agent
learns a human asked it to stop; there is no separate polled endpoint,
because the agent is already calling this on a known interval and one
fewer endpoint is one fewer thing a third-party implementer can get
wrong. On seeing it the agent stops at the next safe point and reports
cancelled, and it does not destroy what has already been created.
Naturally idempotent. It is also fenced: a heartbeat carrying a stale
attempt is task_superseded, which is precisely how a process that
has not noticed it lost its task finds out.
Request body application/json
| Field | Type | Description |
|---|---|---|
attemptrequired | integer | The fence. |
stage | string |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | AgentHeartbeatResult | Liveness recorded. Act on cancelRequested. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no agent token, an unknown token, or a revoked token.
Fails closed (§3.7): there is no degraded mode and no anonymous claim. |
403 | ErrorResponse | account_not_managed — the token is valid, but the account it is bound
to is disabled or no longer in the tenant's accounts registry — or
module_not_entitled, when the tenant's subscription no longer includes
provisioning. Distinct from 401 on purpose: the agent's own credential is fine, so an
operator should look at the account, not reissue a token. The agent
keeps polling and logs the reason; nothing is silently dropped. module_not_entitled is answered by register and claim only
(x-entitlement-access: start). A task the agent already holds
settles whatever the licence says since: its heartbeat, logs and
status keep answering (settle), so an apply that crossed the expiry
reports its outcome rather than leaving cloud and platform state
disagreeing. |
404 | ErrorResponse | not_found |
409 | ErrorResponse | task_superseded — this task's attempt is not the request's current
attempt, so the task was reaped, cancelled or retried and this process
has not noticed it lost it (§6.3). Every agent write is fenced this way, so a late write from a dead or
duplicated process changes nothing. The agent's correct response is to
abandon the task and go back to claiming, not to retry: retrying
identically fails identically. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /provisioning/agent/tasks/{taskId}/logs
Append a batch of log lines
Lines are scrubbed by the agent before they are sent — the platform
stores what it is given and gets no second chance. Scrubbing is exact
for every variable the version's schema marks sensitive, and pattern
based for AWS key IDs, assignment forms and PEM blocks
(PROVISIONING_SPEC.md §7.5). It is deliberately not the donor's bare
40-character base64 pattern, which ate hashes, ARNs and resource IDs.
Replay safety has two independent layers, and both are needed. The
Idempotency-Key header makes a repeated batch return the stored
acknowledgement — the same counts, unchanged — without writing. The
(taskId, seq) primary key makes a repeated line impossible even if it
arrives in a differently-shaped batch, which is what happens when an
agent restarts mid-flush and re-chunks its buffer.
seq is agent-assigned and monotonic per task, and it is the ordering
the portal renders. Gaps are permitted and mean lines were dropped by
the agent's own buffer under pressure; they are not an error and must
not stall the stream.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Keyrequired | header | string | Stable for the lifetime of one batch, across every retry of it.
Generate it before the retry loop: a key minted per attempt
makes a retry look like new data, which is the one mistake this
header exists to prevent. Repeating a key with the same batch returns the stored
acknowledgement and writes nothing; repeating it with a different
batch is idempotency_key_reuse. Over-length is
400 invalid_request, enforced by the server and not merely
declared — a longer key reaches the database as an index-size error
inside the transaction, which would report a malformed request as
the 500 an agent retries. |
Request body application/json
| Field | Type | Description |
|---|---|---|
attemptrequired | integer | The fence. |
linesrequired | array of AgentLogLine | At most maxLogBatchLines from the policy; 500 is the ceiling this
contract allows regardless. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | AgentLogBatchAccepted | Stored, or the stored acknowledgement replayed unchanged. |
400 | ErrorResponse | invalid_request — the body is not a batch, idempotency_key_required
when the header is missing, one line's message exceeds
maxLogLineBytes, or one line's createdAt is more than a day
behind the platform's clock or an hour ahead of it (p7_53) — a
clock that far off has no partition to land in. A line the
platform will not take as sent is a
record, not a size: an agent that has halved its batch down to that
one line replaces it with a note and moves on, rather than repeating
the refusal forever with every later line held behind it (p7_5).
The message names the offending line index and field and never
echoes the value: an error body must not become the exfiltration
channel that the scrubber exists to close. |
401 | ErrorResponse | unauthorized — no agent token, an unknown token, or a revoked token.
Fails closed (§3.7): there is no degraded mode and no anonymous claim. |
403 | ErrorResponse | account_not_managed — the token is valid, but the account it is bound
to is disabled or no longer in the tenant's accounts registry — or
module_not_entitled, when the tenant's subscription no longer includes
provisioning. Distinct from 401 on purpose: the agent's own credential is fine, so an
operator should look at the account, not reissue a token. The agent
keeps polling and logs the reason; nothing is silently dropped. module_not_entitled is answered by register and claim only
(x-entitlement-access: start). A task the agent already holds
settles whatever the licence says since: its heartbeat, logs and
status keep answering (settle), so an apply that crossed the expiry
reports its outcome rather than leaving cloud and platform state
disagreeing. |
404 | ErrorResponse | not_found |
409 | ErrorResponse | task_superseded (the fence, or a task that finished more than
fifteen minutes ago: its log is closed, p7_51) or
idempotency_key_reuse (this key was used for a different
batch). Neither is retryable. A task keeps at most 64 MiB of
message; the line that would cross it is kept as a note saying
so, and later lines are acknowledged without being stored. |
413 | ErrorResponse | invalid_request — more than maxLogBatchLines lines in one batch,
or a body over the route's byte limit. Split the batch and send
again; the seqs are unchanged, so the halves land exactly once. |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /provisioning/agent/tasks/{taskId}/status
Report a stage or a terminal state
The one write that moves a task, and through it the request.
Replay safety. The target state plus attempt is the key. Setting
the state the task already has returns 200 with the stored response,
byte for byte, and writes nothing — the agent cannot tell a replay from
a first attempt, and must not need to. Setting a different terminal
state after a terminal state is task_state_conflict — that is a bug in
the agent, not a retry, and silently accepting it would let a lost
response rewrite an outcome somebody has already been notified about.
Fencing. attempt must equal the request's current attempt or the
write is refused task_superseded (§6.3). The attempt an agent holds
goes stale when the task is reaped, cancelled or retried, and it goes
stale in that transaction — not at some later claim.
Resolutions report here too. A task with action: resolve finishes
by reporting succeeded with gitSha, and the handler writes
templateVersions.gitSha and resolveState: resolved in the same
transaction as the request's terminal state (§6.6). gitSha is
write-once: the same SHA again is a replay and returns the stored
response; a different SHA for an already-resolved version is
resolution_conflict and the stored SHA does not move. A failed
resolution with failureClass: configuration sets resolveState: failed and stores the agent's own scrubbed git error.
Partial applies. A failure that created something reports
partialApply = true with the stateBackend location. Nothing is ever
auto-destroyed (D10): infrastructure that exists and is not tracked is a
problem for a human, and quietly destroying it is a worse one.
Failure classes are six, and the pair that earns its keep is
configuration against infrastructure: "the template author must fix
this" and "your cloud account must be fixed" go to different people, and
collapsing them sends every failure to the wrong one.
Request body application/json
| Field | Type | Description |
|---|---|---|
agentVersionrequired | string | |
attemptrequired | integer | The fence, from the claimed task. A value that is not the request's
current attempt is task_superseded, and that is the answer, not an
error to retry past. |
createdCount | integer | |
destroyedCount | integer | |
failureClass | ProvisionFailureClass | Required when state is failed. Pick honestly: configuration
sends the report to the template's author, infrastructure sends it
to whoever owns the cloud account, and getting it wrong wastes the
time of somebody who cannot fix it.
restored is the platform's alone, and a report of it is 400. |
failureMessage | string | Already scrubbed when it is sent. The platform stores it verbatim and shows it to humans; it has no second chance to redact. |
finalLogSeq | integer (int64) | Terminal reports only. The log-completion watermark (p7_7): the
highest seq this agent has assigned for the task, which is the
last line it will ever send — -1 when it buffered none. Send it
on every terminal report, even though the lines behind it may
still be in your buffer: the reference agent reports first and
drains afterwards, and the platform accepts the drain after the
task is terminal. The platform stores it once, in the transaction that closes the
task, and the operator's log read
(getProvisioningTaskLogs) reports the log complete only when a
line at this seq has been paged out — never from "the task is
terminal and the page was short", which is how a viewer used to
miss the tail. If the line never arrives (a drain that failed, a
buffer that was lost), the read reports incomplete after a
bounded grace rather than waiting forever. Absent, the platform falls back to logLineCount - 1 when
logLineCount is positive (every reference agent has sent it), and
otherwise calls the log unverified once the grace passes. Sending
this field is what tells the difference between "all here" and
"nobody said". |
gitSha | string | resolve only, and required when it succeeds. The commit the
ref resolved to, from git ls-remote. SHA-1 or SHA-256, because git
repositories exist in both object formats and a 40-character-only
rule would lock out the newer ones. Write-once. The same SHA again is a replay and returns the stored
response; a different SHA for an already-resolved version is 409
resolution_conflict and the stored value does not move — it would
otherwise silently change what a pending approval is about to
approve. |
logLineCount | integer (int64) | Every line the agent buffered for this task, dropped ones included (Rule 3), so the portal can show a loss as a number. Kept in the permanent summary. |
partialApply | boolean | The apply failed after creating something. Report it truthfully even when it is embarrassing: the alternative is infrastructure nobody knows exists. Nothing is auto-destroyed on the strength of this. |
planDigest | string | sha256 of the plan output. Kept in the permanent summary. |
provenanceTaggedCount | integer | Resources carrying panorama:request_id that the agent counted in state
after a successful apply, by reading tofu show -json locally — no
extra cloud API call. It becomes expectedCount, the baseline the
drift diff measures against. Zero on a create that produced resources means the stamp did not
land: the row is stored provenanceTagged = false and excluded from
drift with a visible badge, never silently omitted. |
stage | string | |
stageDurationsMs | object | |
staterequired | ProvisionTaskState | One attempt at one request. abandoned is what the reaper writes when a
task's heartbeat goes stale — distinct from failed, because nobody
reported a failure: the process stopped talking, and what it did before
it stopped is unknown. One of claimed, running, succeeded, failed, cancelled, abandoned. |
stateBackend | string | Where this task's Terraform state lives, so an operator can act on a partial apply. A location, never contents and never a credential — the platform does not read state (D3). |
tofuVersionrequired | string |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | AgentTaskStatusAccepted | Recorded, or the stored response replayed unchanged. There is nothing in the body that distinguishes the two, deliberately. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no agent token, an unknown token, or a revoked token.
Fails closed (§3.7): there is no degraded mode and no anonymous claim. |
403 | ErrorResponse | account_not_managed — the token is valid, but the account it is bound
to is disabled or no longer in the tenant's accounts registry — or
module_not_entitled, when the tenant's subscription no longer includes
provisioning. Distinct from 401 on purpose: the agent's own credential is fine, so an
operator should look at the account, not reissue a token. The agent
keeps polling and logs the reason; nothing is silently dropped. module_not_entitled is answered by register and claim only
(x-entitlement-access: start). A task the agent already holds
settles whatever the licence says since: its heartbeat, logs and
status keep answering (settle), so an apply that crossed the expiry
reports its outcome rather than leaving cloud and platform state
disagreeing. |
404 | ErrorResponse | not_found |
409 | ErrorResponse | task_superseded (the fence rejected this attempt),
task_state_conflict (a different terminal state was already
recorded) or resolution_conflict (a different gitSha is already
recorded for the version this resolve task pins). Never repeat
the same request after any of them — the write will fail
identically forever, and repeating it builds a hot loop against a
platform that is behaving correctly. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
429 | ErrorResponse | Rate limited. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
ai
The AI reviewer (p8_23) — the reviews a channel opened and the reviewer's answers. Authenticated by an agent token and never by a session, and no schema reachable from any of these operations carries a cloud credential — a test walks this document to assert it, rather than trusting the sentence.
GET /ai/reviews
The AI reviews of a resource or a subject
The reviews an ai-reviewer channel opened (p8_23), newest first —
for a resource (its alerts' reviews) or for one subject. Each carries
the answer when it came, and says when it did not.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountId | query | string | |
resourceKey | query | string | region/system/service/type/id |
subjectKind | query | string enum | |
subjectId | query | string | |
limit | query | integer |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | AIReviewList | The reviews. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /ai/reviews/{reviewId}/answer
The reviewer's answer to one review
The AI reviewer (p8_24) answers the review its token names; the path's id must be that review's. Every finding's evidence must name a row this tenant can see — a resource by its account and key, a transition or a cost anomaly by id — or the whole answer is refused. The same answer twice is a no-op; a different answer to an answered review is a conflict. The answer lands on the subject and in the inbox of the tenant's administrators (the subject's owners once ownership resolution is wired), and every string keeps correlation apart from cause.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
reviewIdrequired | path | string (uuid) |
Request body application/json
| Field | Type | Description |
|---|---|---|
confidence | string enum | One of low, medium, high. |
findings | array of AIReviewFinding | |
modelrequired | string | The model that reviewed, as the reviewer names it. |
summaryrequired | string | |
verdictrequired | string enum | One of nothing_stands_out, findings, could_not_review. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | AIReview | The review, answered. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | forbidden — authenticated, but the user's role does not permit this.
Distinct from module_not_entitled, which is about the tenant's
subscription rather than the user. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | The review was already answered differently. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
cost
The Cost module (COST_SPEC.md): AWS spend, attributed to the teams that
caused it, joined to the inventory Estate owns. Behind
RequireModule("cost"). Every money-bearing response is an array of
{currency, amount} — never a scalar, never two currencies added
together (C7) — and amount is a decimal string, because a JSON
number is a float and money is not.
GET /cost/anomalies
Detected anomalies
Spend that left its baseline (§9): a relative deviation above the
tenant's sensitivity and an absolute delta above the floor, per
(scope, service), newest first. Each is also an event in the platform
outbox, delivered once per suppression window.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status | query | array of CostAnomalyStatus | Repeatable. Defaults to open. |
accountId | query | array of string | Repeatable. Any of the given accounts. |
teamId | query | array of string (uuid) | Repeatable. Narrows within the caller's team scope, never widens it; a team the caller may not see yields no rows, not an error. |
page | query | integer | 1-based. Normalised server-side rather than rejected: < 1 becomes 1,
and a page past the end is clamped to totalPages. Deliberately no minimum: a schema constraint would make the generated
server reject page=0 before the handler could normalise it, which
contradicts the normalisation this very description promises. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostAnomalyPage | A page of anomalies. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PATCH /cost/anomalies/{anomalyId}
Acknowledge or dismiss an anomaly
acknowledged means somebody is looking; dismissed means it was
expected. Either closes it. An anomaly cannot be reopened through this
endpoint — the detector opens a new one if the spend is still out of
line on the next window.
Request body application/json
| Field | Type | Description |
|---|---|---|
statusrequired | string enum | One of acknowledged, dismissed. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostAnomaly | The anomaly as it now stands. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /cost/breakdown
One level of grouping, with drill-down keys
Rows for the chosen grouping, largest first in the tenant's default
currency ordering, each with the key a client passes back as a filter
to drill one level further (a team's services, a service's accounts).
untagged appears as its own row under groupBy=team — visible,
never dropped (§7.1 step 3).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
from | query | string (date) | First day of the range, inclusive. Defaults to the first of the
current month. from after to is invalid_request. |
to | query | string (date) | Last day of the range, inclusive. Defaults to today. |
figure | query | CostFigure | Which money figure (C1). Amortized is what makes chargeback defensible — a reserved-instance purchase is a flat line rather than a spike on the team that did not make it — and is the default; unblended is what the invoice says. |
groupByrequired | query | string enum | |
accountId | query | array of string | Repeatable. Any of the given accounts. |
teamId | query | array of string (uuid) | Repeatable. Narrows within the caller's team scope, never widens it; a team the caller may not see yields no rows, not an error. |
service | query | array of string | Repeatable. |
limit | query | integer | Rows to return before the rest is summed into an other row.
Normalised: < 1 becomes 20, > 200 becomes 200. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostBreakdown | The breakdown. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /cost/forecast
The month-end forecast for a scope
The approved forecast (p3_21: p3_20's trailing median, its parameters approved by the owner on 2026-09-12), computed daily after the rollups. For a scope — the tenant, a team or an account — and a month, one forecast per currency, from the newest day computed. Amortized, per currency, unconverted, and a forecast: it never changes the actuals.
state is not_enough_history below 14 settled days. lowConfidence
flags a scope whose backtest at the nearest checkpoint has fewer than
three scored months, or a 90th-percentile error above 25%.
An administrator reads any scope. A member reads their own teams'
only: a team they cannot see is an empty list, and the tenant and
account scopes are 403 forbidden.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
scoperequired | query | CostForecastScope | |
teamId | query | string (uuid) | The team, with scope=team. |
accountId | query | string | The account, with scope=account. |
month | query | string | The month, YYYY-MM; the current month when absent. |
currency | query | string | One currency; every currency the scope spends in when absent. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostForecastList | The scope's forecasts for the month. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /cost/kubernetes
EKS node spend attributed to namespaces and workloads
Kubernetes cost attribution (p3_7): each cluster's node line items for the window, split across the workloads that ran on the nodes by their CPU and memory requests, with the idle share nobody requested as its own row. Computed daily from the inventory's nodes and pods and the nodes' line items; a read never calls a cluster. A cluster's rows sum to its nodes' spend.
Node spend here is per-resource evidence (§8), not a total of the account's spend: it is what the nodes' line items said. Scoped to the caller's teams' accounts.
A cluster is accountId + region + cluster — the inventory's
identity for an EKS cluster — so two clusters named alike in two
regions are two clusters here, apart from discovery to display
(p7_13). cluster and region narrow to a name and a region; both
together to one cluster.
The attribution uses the pods and nodes the collector last saw, applied to each day of the trailing window (the accepted current-placement approximation, COST_SPEC.md p3_7): a change of placement mid-window shows as a step at the next collection, not a pro-rated day. Daily placement history is a later improvement, and no figure here claims it.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
from | query | string (date) | First day of the range, inclusive. Defaults to the first of the
current month. from after to is invalid_request. |
to | query | string (date) | Last day of the range, inclusive. Defaults to today. |
figure | query | CostFigure | Which money figure (C1). Amortized is what makes chargeback defensible — a reserved-instance purchase is a flat line rather than a spike on the team that did not make it — and is the default; unblended is what the invoice says. |
accountId | query | array of string | Repeatable. Any of the given accounts. |
cluster | query | string | A cluster name; with region, one cluster. |
region | query | string | The cluster's region, as the inventory holds it. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostKubernetes | The attribution. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /cost/resources
Per-resource cost, joined to the inventory
The 14-day per-resource view (§4, C4) joined to the Estate
inventory on the platform resource key — names, types and status come
from the inventory, with zero cost-side discovery. windowDays is in
every response because the limitation is stated, not buried.
filter=orphaned is cost-without-resource: spend in the window on
a key the inventory has tombstoned or never held. filter=unattributed
is resource-without-cost: live inventory rows in managed accounts
with no per-resource spend in the window. These are the two views no
single-tool competitor can produce, and both say 14 days.
Each day's evidence is one source's — the Cost & Usage Report's
resource rows when the day has any, Cost Explorer's sample otherwise
(cost.AuthorityCTE, the same day-by-day rule the totals follow) —
so a resource both sources report on a day is counted once, never
twice (p7_10).
An account no source supplies per-resource rows for — Cost Explorer's
resource-level granularity switched off and no report carrying
resource IDs — has no rows here and is listed in unavailableAccounts
with the resource_granularity_unavailable reason; one that no
source has been pulled from yet is not_yet_ingested. Documented
states, never errors.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
figure | query | CostFigure | Which money figure (C1). Amortized is what makes chargeback defensible — a reserved-instance purchase is a flat line rather than a spike on the team that did not make it — and is the default; unblended is what the invoice says. |
filter | query | string enum | |
accountId | query | array of string | Repeatable. Any of the given accounts. |
service | query | array of string | Repeatable. |
query | query | string | Substring match on the resource id and name. |
page | query | integer | 1-based. Normalised server-side rather than rejected: < 1 becomes 1,
and a page past the end is clamped to totalPages. Deliberately no minimum: a schema constraint would make the generated
server reject page=0 before the handler could normalise it, which
contradicts the normalisation this very description promises. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostResourcePage | A page of resources with their spend. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /cost/summary
Totals for a period, with the previous period beside them
Spend for the range by currency, the same length of range immediately before it, and the change between them — per currency, because a delta across two currencies is not a number. Also the split across the three attribution tiers (§7.1), which always sums to the total: that is the reconciliation invariant, and it is asserted for this endpoint and every other aggregation.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
from | query | string (date) | First day of the range, inclusive. Defaults to the first of the
current month. from after to is invalid_request. |
to | query | string (date) | Last day of the range, inclusive. Defaults to today. |
figure | query | CostFigure | Which money figure (C1). Amortized is what makes chargeback defensible — a reserved-instance purchase is a flat line rather than a spike on the team that did not make it — and is the default; unblended is what the invoice says. |
accountId | query | array of string | Repeatable. Any of the given accounts. |
teamId | query | array of string (uuid) | Repeatable. Narrows within the caller's team scope, never widens it; a team the caller may not see yields no rows, not an error. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostSummary | The period's totals. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /cost/trend
A daily or monthly series, grouped
One series per group — team, service or account — each a list of
{date, amounts} points. A day with no spend is a point with an empty
amounts array rather than a missing point, so a chart's x-axis is
the range and not the data.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
from | query | string (date) | First day of the range, inclusive. Defaults to the first of the
current month. from after to is invalid_request. |
to | query | string (date) | Last day of the range, inclusive. Defaults to today. |
figure | query | CostFigure | Which money figure (C1). Amortized is what makes chargeback defensible — a reserved-instance purchase is a flat line rather than a spike on the team that did not make it — and is the default; unblended is what the invoice says. |
groupByrequired | query | string enum | |
granularity | query | string enum | |
accountId | query | array of string | Repeatable. Any of the given accounts. |
teamId | query | array of string (uuid) | Repeatable. Narrows within the caller's team scope, never widens it; a team the caller may not see yields no rows, not an error. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostTrend | The series. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
cost-settings
Attribution rules, team tag values, anomaly sensitivity, budgets.
Writes require the admin role; changing a rule or a tag value
re-attributes all history (C3), and the response says so.
GET /cost/budgets
Team budgets, with month-to-date progress
Every budget the caller's team scope allows, with the current period's
amortized spend in the budget's currency beside the amount. Spend in a
currency the budget is not denominated in is not converted (C7):
it is listed under otherCurrencies so the mismatch is visible.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
teamId | query | array of string (uuid) | Repeatable. Narrows within the caller's team scope, never widens it; a team the caller may not see yields no rows, not an error. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostBudgetList | The budgets. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
POST /cost/budgets
Create a team's monthly budget
One budget per team per period; a second is 409 budget_exists.
Thresholds are percentages of amount, and each fires once per period
(§9) through the platform outbox to the team's channel.
Request body application/json
| Field | Type | Description |
|---|---|---|
amountrequired | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
enabled | boolean | |
period | string enum | One of monthly. |
teamIdrequired | string (uuid) | |
thresholds | array of integer |
Responses
| Status | Body | Meaning |
|---|---|---|
201 | CostBudget | Created. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | budget_exists — the team already has a budget for this period. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /cost/budgets/{budgetId}
One budget with its progress
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostBudget | The budget. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PATCH /cost/budgets/{budgetId}
Change a budget's amount, currency, thresholds or enabled flag
Changing the currency is currency_mismatch while the current period
already holds notifications in the old one: a threshold that fired at
80% of 1,000 USD cannot be reinterpreted as 80% of 1,000 EUR.
It asks for the password (x-step-up, P772-R1-F01): enabled: false
stops the budget alerting, as deleting it would. Any other change asks
too; it is the same operation.
Request body application/json
| Field | Type | Description |
|---|---|---|
amount | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
enabled | boolean | |
thresholds | array of integer |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostBudget | The budget as it now stands. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
409 | ErrorResponse | currency_mismatch — see the description. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
DELETE /cost/budgets/{budgetId}
Remove a budget
Its notification history goes with it.
Responses
| Status | Body | Meaning |
|---|---|---|
204 | — | Removed. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
404 | ErrorResponse | not_found |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /cost/recommendations
Trusted Advisor's cost recommendations, per account
AWS's cost-optimisation checks (p3_13), one row per flagged resource,
largest estimated saving first. The savings are AWS's estimates
and are presented as such. Where a check names a resource the key
derivation can map, resourceKey links to the inventory.
Trusted Advisor needs Business or Enterprise support on the account;
an account without it is listed under unavailableAccounts with
support_plan_required — a documented state, never an error.
Scoped to the caller's teams' accounts.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
accountId | query | array of string | Repeatable. Any of the given accounts. |
page | query | integer | 1-based. Normalised server-side rather than rejected: < 1 becomes 1,
and a page past the end is clamped to totalPages. Deliberately no minimum: a schema constraint would make the generated
server reject page=0 before the handler could normalise it, which
contradicts the normalisation this very description promises. |
pageSize | query | integer | Normalised server-side, not rejected: < 1 becomes 100, > 500 becomes
500. No minimum or maximum, for the same reason as page — a
constraint here would turn a clamp into a 400. |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostRecommendationPage | The recommendations. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
GET /cost/settings
Attribution rules, tag values, sensitivity, and ingestion health
Everything the Settings page shows: the tag keys in priority order, the messy tag values mapped onto teams, anomaly sensitivity, the display default, per-account ingestion state — backfill progress, the resource-granularity flag, the last error, the pass A / pass B discrepancy — the count of per-resource identifiers the key derivation could not map (§5.4), and whether a rebuild is in flight.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostSettings | The settings. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
PUT /cost/settings
Replace the attribution rules, tag values and sensitivity
A whole-document replace, because rules are an ordered list and a
partial update of an ordering is ambiguous. Changing the rules or the
tag values enqueues a rebuild of every rollup month (§7.3, C3); the
response reports rebuildEnqueued. A second change while a rebuild is
already running is 409 attribution_rebuild_in_progress: let the
first finish, or its result would describe rules that no longer exist.
Request body application/json
| Field | Type | Description |
|---|---|---|
allocationRules | array of CostAllocationRule | The whole list, in order. Omitted, the rules are left as they are. Changing them re-attributes history like a rule change (§7.3): the allocation runs inside the rebuild. |
anomalyrequired | CostAnomalySettings | |
attributionRulesrequired | array of CostAttributionRule | |
curReports | array of CostCurReport | The whole set, like the rules: an account absent from the list has its report configuration removed. Omitted, the configuration is left as it is. Changing a report enqueues no rebuild — the next pull replaces the periods it reads, and the rebuild follows the data. |
defaultFigurerequired | CostFigure | One of amortized, unblended. |
teamTagValuesrequired | array of CostTeamTagValue |
Responses
| Status | Body | Meaning |
|---|---|---|
200 | CostSettings | The settings as they now stand. |
400 | ErrorResponse | invalid_request — a malformed parameter or body. The batch ingest
endpoint uses invalid_batch instead, because a collector branches on
it differently: a bad batch is a bug in the collector, while a bad
request is usually a bug in the caller's query string. |
401 | ErrorResponse | unauthorized — no credential, or one that is not valid. |
403 | ErrorResponse | module_not_entitled — RequireModule("cost") refused this before the
handler ran (task_58's first real consumer; the portal renders the
locked-module upsell) — or forbidden, when the module is entitled and
the user's role does not permit the operation. On an unsafe request, also origin_not_trusted: the browser boundary
refused the request's origin before anything else ran (p7_18). |
409 | ErrorResponse | attribution_rebuild_in_progress — a rebuild from the previous change is still running. |
413 | ErrorResponse | The request body exceeded the size the server will read. Declared
because it is real: a body is otherwise read until the client stops
sending, and one caller could exhaust the process. The cap is above any
legitimate request — 500 items at the additionalInfo limit is roughly
160 MB — so reaching it means something is wrong with the sender. |
415 | ErrorResponse | unsupported_media_type — the body of a browser-facing route is not
labelled application/json (a +json suffix and a charset parameter
are fine). Refused before the body is read: a form post, or a
"simple" cross-site request carrying JSON as text/plain to dodge
the preflight, is not something this API reads (p7_18). Bearer
routes are not held to it. |
500 | ErrorResponse | internal_error. The message is generic by design: internal error text
never reaches a response body. The real error is logged with the
request ID. |
Schemas
Every object the API reads or writes. A field marked required is always present.
AIReview
| Field | Type | Description |
|---|---|---|
accountId | string | |
answerSummary | string | |
answeredAt | string (date-time) | |
confidence | string enum | One of low, medium, high. |
expiresAtrequired | string (date-time) | When the request's token stops answering. |
findingsrequired | array of AIReviewFinding | |
idrequired | string (uuid) | |
model | string | |
requestedAtrequired | string (date-time) | |
resourceKey | string | |
subjectIdrequired | string | |
subjectKindrequired | string enum | One of alert, anomaly, test. |
summaryrequired | string | The one line the reviewer was handed. |
verdict | string enum | One of nothing_stands_out, findings, could_not_review. |
AIReviewAnswer
| Field | Type | Description |
|---|---|---|
confidence | string enum | One of low, medium, high. |
findings | array of AIReviewFinding | |
modelrequired | string | The model that reviewed, as the reviewer names it. |
summaryrequired | string | |
verdictrequired | string enum | One of nothing_stands_out, findings, could_not_review. |
AIReviewEvidence
A row a finding rests on; it must be one this tenant can see.
| Field | Type | Description |
|---|---|---|
idrequired | string | A resource's account/region/system/service/type/id; a transition's or an anomaly's id. |
kindrequired | string enum | One of resource, transition, anomaly. |
AIReviewFinding
| Field | Type | Description |
|---|---|---|
detail | string | |
evidence | array of AIReviewEvidence | |
titlerequired | string |
AIReviewList
| Field | Type | Description |
|---|---|---|
itemsrequired | array of AIReview |
Account
externalId is deliberately absent from every response. It is half of
the confused-deputy defence on the cross-account role, and an API that
hands it out has given away that half.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
createdAtrequired | string (date-time) | |
enabledrequired | boolean | New accounts default to false. Discovery registers everything it finds; collecting from an unapproved account is both surprising and expensive. |
environmentrequired | string enum | One of production, staging, sdlc, shared, unknown. |
googleServiceAccount | string | A Google Cloud project's reader service account, which the platform impersonates; absent for an AWS account. |
isManagementrequired | boolean | Whether this is the organisation's management account — the one discovery assumes a role in to enumerate the rest. At most one per tenant. Registered like any other account and then marked, so discovery needs nothing the registry does not already hold. |
namerequired | string | |
regionsrequired | array of string | |
roleArnrequired | string | |
systemrequired | string enum | The account's cloud (p7_82). A Google Cloud project is registered
as an account, accountId its project id; its roleArn is empty. One of aws, gcp. |
AccountList
| Field | Type | Description |
|---|---|---|
itemsrequired | array of Account |
AccountUpdate
Only the fields present are changed.
| Field | Type | Description |
|---|---|---|
enabled | boolean | |
environment | string enum | One of production, staging, sdlc, shared, unknown. |
isManagement | boolean | Mark (or unmark) this account as the organisation's management
account. Marking a second one is a 409: there is at most one. |
name | string | |
regions | array of string | |
roleArn | string | Correct the reader role's ARN when it differs from the convention
discovery assumed (role/panorama-reader). The external id cannot be
set here: it is per tenant, generated once by
panorama admin add-management-account, and inherited by every
account discovery registers. |
AccountVerification
| Field | Type | Description |
|---|---|---|
assumedRole | boolean | Whether the role assumption itself succeeded. |
checksrequired | array of AccountVerificationCheck | |
okrequired | boolean | True only if every check passed. |
AccountVerificationCheck
| Field | Type | Description |
|---|---|---|
detail | string | What to grant, when the check failed. "Access denied" without saying what is missing turns onboarding into guesswork. |
okrequired | boolean | |
permissionrequired | string |
AcknowledgeRequest
A resource's alert to acknowledge, and why. The note is required.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
noterequired | string | |
regionrequired | string | |
resourceIdrequired | string | |
resourceTyperequired | string | |
servicerequired | string | |
systemrequired | string |
Acknowledgement
An active acknowledgement, with the resource it covers.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
acknowledgedAtrequired | string (date-time) | |
acknowledgedByrequired | ActorRef | Who did something, for display and for audit. Not User: that carries
the tenant and the role of the session, and an audit record naming a
role that has since changed would be a lie about the past. |
noterequired | string | |
regionrequired | string | |
resourceIdrequired | string | |
resourceNamerequired | string | |
resourceTyperequired | string | |
servicerequired | string | |
statusrequired | Status | The health of something that currently exists. Derived once at ingest
and stored, never recomputed at read time. deleted is absent, and that is the point: a deleted resource is
tombstoned, not given a status, so a live resource carrying
status: deleted is an impossible value. Keeping one enum for both made
that impossible value contract-valid everywhere. unknown (p7_83) is a resource the inventory knows exists, or was
recently active, whose health it cannot observe — found only from its
metrics or its name. It is neither healthy nor a problem: never green,
not on the Alerts page, and counted as not checked on the public page. One of failed, degraded, operational, unknown. |
systemrequired | string | |
transitionIdrequired | integer (int64) |
AcknowledgementList
| Field | Type | Description |
|---|---|---|
itemsrequired | array of Acknowledgement |
AcknowledgementTarget
A resource, by its natural key.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
regionrequired | string | |
resourceIdrequired | string | |
resourceTyperequired | string | |
servicerequired | string | |
systemrequired | string |
ActorRef
Who did something, for display and for audit. Not User: that carries
the tenant and the role of the session, and an audit record naming a
role that has since changed would be a lie about the past.
| Field | Type | Description |
|---|---|---|
emailrequired | string | |
idrequired | string (uuid) | |
name | string | The name the person gave themselves (p7_89), when they gave one. |
AdminOverview
| Field | Type | Description |
|---|---|---|
accountsrequired | object | |
collectionrequired | object | The enabled accounts by their collectors' worst state, as the collector matrix rolls it up. |
licencerequired | Entitlements | |
recentrequired | array of AuditEntry | The latest audit entries, newest first. |
ssorequired | object | |
usersrequired | object |
AgentClaimRequest
| Field | Type | Description |
|---|---|---|
agentVersionrequired | string | |
claimIdrequired | string (uuid) | Generated by the agent before its first attempt and reused on every retry of that attempt. This is the idempotency key for claiming: repeating it returns the same work item, never a second one. Minting it inside the retry loop is the mistake that turns one lost response into a task nobody heartbeats. |
tofuVersionrequired | string | |
waitSeconds | integer | How long the agent is willing to hold the poll open. Clamped
server-side to claimPollTimeoutSeconds; it is a hint, not a
demand. |
AgentHeartbeat
| Field | Type | Description |
|---|---|---|
attemptrequired | integer | The fence. |
stage | string |
AgentHeartbeatResult
| Field | Type | Description |
|---|---|---|
cancelRequestedrequired | boolean | A human asked this task to stop. Stop at the next safe point and
report cancelled; do not destroy what has already been
created — that decision belongs to an operator looking at a partial
apply, not to an agent reacting to a boolean. This rides the heartbeat rather than getting its own polled endpoint
because the agent is already calling this on a known interval, and
one fewer endpoint is one fewer thing a third-party implementer can
get wrong. |
heartbeatIntervalSecondsrequired | integer | Repeated here so the platform can retune a running fleet without waiting for every agent to re-register. |
AgentLogBatch
| Field | Type | Description |
|---|---|---|
attemptrequired | integer | The fence. |
linesrequired | array of AgentLogLine | At most maxLogBatchLines from the policy; 500 is the ceiling this
contract allows regardless. |
AgentLogBatchAccepted
The acknowledgement stored against this batch's Idempotency-Key.
Repeating the key returns this same body, unchanged and without
writing — including stored, which keeps reporting what the batch wrote
the first time rather than the nothing it wrote this time. There is no
replayed flag for the same reason AgentTaskStatusAccepted has none.
| Field | Type | Description |
|---|---|---|
duplicatesrequired | integer | Lines whose seq was already stored. Not an error: it is what a
re-chunked retry looks like, and the count is here so an agent can
notice it is retrying more than it thinks. |
highestSeqrequired | integer (int64) | The highest seq stored for this task as of this batch. |
storedrequired | integer | Lines this batch wrote. A stored value, so a replay reports the original number. |
AgentLogLine
| Field | Type | Description |
|---|---|---|
createdAtrequired | string (date-time) | When the agent wrote the line. Within a day behind the platform's clock and an hour ahead of it, or the batch is refused with 400 (p7_53). |
levelrequired | string enum | One of debug, info, warn, error. |
messagerequired | string | Already scrubbed. Exact-value redaction of every sensitive
variable, plus AWS key IDs, assignment forms and PEM blocks
(§7.5). Deliberately not the donor's bare 40-character base64
pattern: it ate hashes, ARNs and resource IDs, and a log that
redacts the resource ID is a log nobody can debug from. |
seqrequired | integer (int64) | Agent-assigned, monotonic per task, and half of the replay defence:
(taskId, seq) is a primary key, so a line that arrives twice in
two differently-shaped batches still lands once. That happens
whenever an agent restarts mid-flush and re-chunks its buffer. |
stagerequired | string |
AgentPolicy
The operating parameters an agent must respect. Served rather than hard-coded: a fleet whose intervals are compiled in cannot be tuned without a customer-scheduled upgrade.
| Field | Type | Description |
|---|---|---|
claimPollTimeoutSecondsrequired | integer | The longest a claim will hang before returning 204. The agent should use a client timeout comfortably above this, or it will abandon every idle poll and look like a reconnect storm. |
heartbeatIntervalSecondsrequired | integer | How often to heartbeat each held task. Default 30. |
heartbeatTimeoutSecondsrequired | integer | After this much silence the scheduler reaps the task. Published so an agent can tell how much slack it has before a slow apply's heartbeat gap costs it the task. Default 300. |
maxAttemptsrequired | integer | After this many attempts a request fails rather than returning to
queued. Default 3. |
maxLogBatchLinesrequired | integer | |
maxLogLineBytesrequired | integer | |
minAgentVersionrequired | string | Below this, claim returns 426 and this agent does no work. The
only hard refusal in the version policy. |
AgentRegistration
The version handshake. Note what is not here: no tenant, no account, no credential of any kind. Both identities come from the token (§3.6), so there is nothing in this body for a compromised agent to claim to be.
| Field | Type | Description |
|---|---|---|
agentVersionrequired | string | |
capabilities | array of string | Optional feature flags an agent advertises, for forward compatibility. An unknown capability is ignored, never rejected: the fleet is upgraded by customers on their own schedule, so a newer agent must always be able to talk to an older platform. |
tofuVersionrequired | string | The OpenTofu binary pinned in this agent's image. OpenTofu rather than Terraform (D13): MPL-2.0, so a commercial provisioning product raises no BUSL "competitive offering" question, and existing Terraform templates run unchanged. |
AgentRegistrationResult
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | Echoed from the token, never accepted from the request. An agent that finds this disagrees with its own configuration is pointed at the wrong account and should stop rather than proceed. |
agentIdrequired | string (uuid) | |
policyrequired | AgentPolicy | The operating parameters an agent must respect. Served rather than hard-coded: a fleet whose intervals are compiled in cannot be tuned without a customer-scheduled upgrade. |
versionStatusrequired | AgentVersionStatus | Report and warn; refuse only below the declared minimum (D16).
outdated still claims work — an operator upgrades a fleet over weeks,
and a platform that stops the fleet the day it ships a release is a
platform nobody upgrades. One of supported, outdated, unsupported. |
AgentTask
One claimed task: everything the agent needs to do exactly the approved work, and nothing else.
What is not here is the design. No cloud credential: the agent runs
with ambient credentials — instance profile, IRSA, task role — and the
platform has none to send. No git credential: a create or destroy
against a git source arrives already resolved to a commit SHA, and the
resolve that produced it used the customer's own credentials, on the
customer's own network. No state backend configuration: state is
customer-owned (D3) and the agent configures its own, then reports where
it put it. No template body for a git source: the agent clones it
itself, at the pinned SHA.
One shape, not a tagged union. action says what to do, and it is
the same action the request carries — a resolution is a request
(§6.6), so it needs no work-item kind of its own. An earlier draft
multiplexed two kinds through a kind discriminator and invented a
surrogate versionId for the second; both are gone, because the second
kind was a mechanism the data model did not have.
Fields that are conditional on action are marked below. OpenAPI 3.0
cannot express the condition, so it is stated rather than encoded: an
agent must read action first.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | The target account, which is also the account the token is bound to. Sent for logging and for an agent's own sanity check, not as something to act on: a claim is account-scoped structurally and this can never be an account this agent may not touch. |
actionrequired | ProvisionAction | What a request asks for, and PROVISIONING_SPEC.md §4.2's action
CHECK constraint exactly. create and destroy are the lifecycle (D5). There is no in-place
update in v1: changing a resource is a destroy and a create, and each
gets its own approval. A lifecycle with three verbs and one of them
partial is worse than one with two that are complete. resolve is not a lifecycle verb. It is git ref resolution modelled as
a request (§6.6) so that it inherits the claim, the fence, the replay
rules, the heartbeat, the reap and the tenant scoping wholesale instead
of growing a second copy of each. It is created only by publishing a
git-sourced version, never by POST /provisioning/requests, and it is
the one action that skips pending_approval: approval governs
infrastructure, and resolving a ref creates none. One of create, destroy, resolve. |
attemptrequired | integer | The fence, stamped from the request at claim time and not
incremented by it. Send it back on every subsequent write for
this task — status, logs and heartbeat — and expect
task_superseded the moment the task is reaped, cancelled or
retried, which is the moment it stops being current rather than the
moment somebody else claims. |
claimIdrequired | string (uuid) | Echoed from the claim, so a replayed claim is recognisable as one — and so that an agent which lost a response can prove the task it is holding is the one it asked for rather than a second one. |
claimedAtrequired | string (date-time) | |
createRequestId | string (uuid) | destroy only. The request whose apply created what this
destroys. It is the value of the panorama:request_id stamp on the live
resources, and provenanceTags on a destroy carries it rather than
this request's own id, so a destroy never proposes re-tagging what
it is about to remove. An agent that lays state out by request id
derives the create's location from it. |
deadlineSeconds | integer | The budget for the whole task. Exceeding it is
failureClass: timeout, reported by the agent — the platform's own
reaper is the backstop for an agent that cannot report at all. |
gitRef | string | resolve only, and the thing to resolve: a branch, a tag or any
other ref. Absent for create and destroy, which are pinned to a
SHA precisely so that a moving ref cannot decide what runs. |
gitSha | string | create and destroy only. The pinned commit, and the source
identity the agent must execute. Checking out anything else — a
branch head, a newer tag — is a protocol violation: it would run code
no approver ever saw. |
gitUrl | string | For git. create/destroy: clone --depth 1 and check out
gitSha. resolve: git ls-remote this, and nothing more. |
inlineSha256 | string | |
inlineSource | string | For inline: the exact bytes to write into the workspace. Verify
inlineSha256 over them before running anything. |
namerequired | string | |
provenanceTags | object | create and destroy. The tags to stamp onto everything the
apply creates, written into a generated ccc_provenance_override.tf
before init — an override merged into each provider "aws"
configuration the template declares, its own default_tags kept
and these merged last. Always includes panorama:request_id. Absent for
resolve, which creates nothing to stamp. This is the whole identity mechanism (D11): one writer, so there
is no key derived independently by two parties to disagree — the
failure PLATFORM.md §3 calls its highest-consequence line is removed
by construction rather than defended against. |
requestIdrequired | string (uuid) | |
resourceId | string (uuid) | destroy only. The provisionedResources row being torn down. |
sourceTyperequired | TemplateSourceType | Where a template's code comes from, and therefore what its source
identity is: a commit SHA for git, a content sha256 for inline.
Either way it is pinned at publish and never updated. One of git, inline. |
stateBackend | string | destroy only. Where the create's state lives, exactly as the
agent that ran the create reported it (D3: state is customer-owned;
the platform stores the address and never the state). The
destroying agent configures that backend and runs destroy against
it — destroying against any other state is how a destroy removes
the wrong thing or nothing. Absent when the creating agent never
reported one, in which case the agent must fail the task as
configuration rather than guess. |
taskIdrequired | string (uuid) | |
templateIdrequired | string (uuid) | |
templateSlugrequired | string | |
templateVersionrequired | integer | With templateId, the whole identity of the version:
template_versions is keyed (tenant_id, template_id, version) and
has no surrogate id. An earlier draft of this contract invented one
for a resolve endpoint that no longer exists. |
variables | object | create and destroy. The validated variable values,
unmasked, including the ones marked sensitive — the template
needs them and this is the only response that carries them. Write
them to generated.auto.tfvars through an HCL encoder, never by
string concatenation. Empty for resolve, which runs no template. The keys are exactly the names variablesSchema declares. The
server validated the request against that schema before the request
existed, so nothing else can be here — and an agent should check,
because a key it cannot find in the schema means it is talking to
something that is not this platform. That is the closed key space,
and it is closed by a sibling field rather than by this schema
because OpenAPI 3.0 has no way to say it: propertyNames is a 3.1
keyword, the pinned redocly rejects the document outright when it
appears, and neither kin-openapi nor either generator enforces it.
Declaring it would be a rule that reads as a constraint and enforces
nothing. A credential-shaped key is not, and cannot be, refused here, and
that is a decision rather than an oversight. §8.1's own worked
example declares db_password, aws_db_instance names its variable
password, and a name rule strong enough to refuse
aws_secret_access_key refuses both — which would make this module
unable to provision the database template §4.1 uses as its example.
A name rule would not stop the value either: the same secret in a
variable called pw passes any blocklist. What §3.1 forbids is a platform-defined field that could carry a
cloud credential, and that is what the scan of this document
enforces. These values are the customer's own template inputs,
travelling to the customer's own account, typed by §8.1's five types
and no others. Where one is genuinely secret the schema marks it
sensitive, and it is then masked in the UI, masked in
variablesRedacted, and exact-value redacted from every stored log
line (§7.5) — which is a defence that acts on the value, where the
risk actually is. |
variablesSchema | VariablesSchema | create and destroy. The pinned version's schema. The agent
needs it twice: for the HCL type of each value, and for the set of
sensitive names whose exact values must be redacted from every log
line it sends. Absent for resolve. |
AgentTaskStatusAccepted
The stored result of this task's status. A repeat of a report already recorded returns this same body, byte for byte, without writing: there is deliberately no field saying "you are replaying", because a response that differs on a replay is not a replay, and an agent that can see the difference will eventually branch on it.
| Field | Type | Description |
|---|---|---|
requestStaterequired | ProvisionRequestState | Where the request landed. Worth reading: a terminal task on a
request that went back to queued means this attempt was already
superseded. |
taskIdrequired | string (uuid) | |
taskStaterequired | ProvisionTaskState | One attempt at one request. abandoned is what the reaper writes when a
task's heartbeat goes stale — distinct from failed, because nobody
reported a failure: the process stopped talking, and what it did before
it stopped is unknown. One of claimed, running, succeeded, failed, cancelled, abandoned. |
AgentTaskStatusReport
Report a stage transition or a terminal outcome. The counts and the digest are only meaningful on a terminal report and are ignored otherwise.
This is also where a resolve task reports its answer, in gitSha
(§6.6). A resolution has no endpoint of its own, so it has no replay
rule of its own either: the target state plus attempt is the key here
as it is for every other task.
| Field | Type | Description |
|---|---|---|
agentVersionrequired | string | |
attemptrequired | integer | The fence, from the claimed task. A value that is not the request's
current attempt is task_superseded, and that is the answer, not an
error to retry past. |
createdCount | integer | |
destroyedCount | integer | |
failureClass | ProvisionFailureClass | Required when state is failed. Pick honestly: configuration
sends the report to the template's author, infrastructure sends it
to whoever owns the cloud account, and getting it wrong wastes the
time of somebody who cannot fix it.
restored is the platform's alone, and a report of it is 400. |
failureMessage | string | Already scrubbed when it is sent. The platform stores it verbatim and shows it to humans; it has no second chance to redact. |
finalLogSeq | integer (int64) | Terminal reports only. The log-completion watermark (p7_7): the
highest seq this agent has assigned for the task, which is the
last line it will ever send — -1 when it buffered none. Send it
on every terminal report, even though the lines behind it may
still be in your buffer: the reference agent reports first and
drains afterwards, and the platform accepts the drain after the
task is terminal. The platform stores it once, in the transaction that closes the
task, and the operator's log read
(getProvisioningTaskLogs) reports the log complete only when a
line at this seq has been paged out — never from "the task is
terminal and the page was short", which is how a viewer used to
miss the tail. If the line never arrives (a drain that failed, a
buffer that was lost), the read reports incomplete after a
bounded grace rather than waiting forever. Absent, the platform falls back to logLineCount - 1 when
logLineCount is positive (every reference agent has sent it), and
otherwise calls the log unverified once the grace passes. Sending
this field is what tells the difference between "all here" and
"nobody said". |
gitSha | string | resolve only, and required when it succeeds. The commit the
ref resolved to, from git ls-remote. SHA-1 or SHA-256, because git
repositories exist in both object formats and a 40-character-only
rule would lock out the newer ones. Write-once. The same SHA again is a replay and returns the stored
response; a different SHA for an already-resolved version is 409
resolution_conflict and the stored value does not move — it would
otherwise silently change what a pending approval is about to
approve. |
logLineCount | integer (int64) | Every line the agent buffered for this task, dropped ones included (Rule 3), so the portal can show a loss as a number. Kept in the permanent summary. |
partialApply | boolean | The apply failed after creating something. Report it truthfully even when it is embarrassing: the alternative is infrastructure nobody knows exists. Nothing is auto-destroyed on the strength of this. |
planDigest | string | sha256 of the plan output. Kept in the permanent summary. |
provenanceTaggedCount | integer | Resources carrying panorama:request_id that the agent counted in state
after a successful apply, by reading tofu show -json locally — no
extra cloud API call. It becomes expectedCount, the baseline the
drift diff measures against. Zero on a create that produced resources means the stamp did not
land: the row is stored provenanceTagged = false and excluded from
drift with a visible badge, never silently omitted. |
stage | string | |
stageDurationsMs | object | |
staterequired | ProvisionTaskState | One attempt at one request. abandoned is what the reaper writes when a
task's heartbeat goes stale — distinct from failed, because nobody
reported a failure: the process stopped talking, and what it did before
it stopped is unknown. One of claimed, running, succeeded, failed, cancelled, abandoned. |
stateBackend | string | Where this task's Terraform state lives, so an operator can act on a partial apply. A location, never contents and never a credential — the platform does not read state (D3). |
tofuVersionrequired | string |
AgentVersionStatus
Report and warn; refuse only below the declared minimum (D16).
outdated still claims work — an operator upgrades a fleet over weeks,
and a platform that stops the fleet the day it ships a release is a
platform nobody upgrades.
Type: string enum
AppGroup
One application: one value of one attribution tag key.
| Field | Type | Description |
|---|---|---|
healthrequired | StatusCounts | Computed over the full filtered set, not the current page. |
provisioned | integer | How many of the group's live resources Provisioning created. Absent when the tenant does not hold Provisioning. |
spend | array of Money | Month-to-date amortized spend for the tag value, one entry per currency, from each day's authoritative source — the Cost & Usage Report where it is ingested, Cost Explorer otherwise; the same attribution the cost views make (p7_10). Absent when the tenant does not hold Spend; empty when it does and no source reported any. |
tagKeyrequired | string | |
tagValuerequired | string |
AppGroups
| Field | Type | Description |
|---|---|---|
groupsrequired | array of AppGroup | |
periodrequired | object | The month-to-date range spend covers, inclusive. |
tagKeysrequired | array of string | The attribution tag keys the groups came from, in priority order; empty when none are configured. |
AuditActor
The account that acted; absent for the platform's own changes and the CLI's.
| Field | Type | Description |
|---|---|---|
displayName | string | The name the person gave themselves (p7_89), while the account exists and they gave one. |
email | string | The account's email, while the account exists. |
idrequired | string (uuid) |
AuditEntry
| Field | Type | Description |
|---|---|---|
actionrequired | string | Dotted, as auth.identity_provider.update or notify.channel.create. |
actor | AuditActor | The account that acted; absent for the platform's own changes and the CLI's. |
after | object | The subject after the change, as the audit recorded it. |
atrequired | string (date-time) | |
before | object | The subject before the change, as the audit recorded it. |
idrequired | integer (int64) | |
subjectIdrequired | string | |
subjectTyperequired | string |
AuditLogPage
| Field | Type | Description |
|---|---|---|
entriesrequired | array of AuditEntry | |
nextCursor | string | Present while older entries follow; pass it back as cursor. |
ChangeEvent
One row of the change feed. id is <kind>:<source id> — stable
across reads and pages. at is the event's own time.
| Field | Type | Description |
|---|---|---|
accountId | string | The account, where the source has one. |
actor | ActorRef | Who did something, for display and for audit. Not User: that carries
the tenant and the role of the session, and an audit record naming a
role that has since changed would be a lie about the past. |
appId | string (uuid) | The declared app a marker names. |
atrequired | string (date-time) | |
detailrequired | object | The source's own fields, by kind. |
evidencerequired | ChangeEvidence | Where the row's source can be read in full. |
idrequired | string | |
kindrequired | ChangeKind | One source's kind of event in the change feed (p8_10). One of config_change, status_transition, acknowledgement, provisioning_apply, provisioning_destroy, provisioning_partial, cost_anomaly, budget_threshold, external_marker. |
resourceKey | string | The resource key (region/system/service/type/id), where the source has one. |
summaryrequired | string | What happened, in one line. Never why. |
teamId | string (uuid) | The team, for a team-scoped event. |
ChangeEvidence
Where the row's source can be read in full.
| Field | Type | Description |
|---|---|---|
apirequired | string | The API read behind the row. |
hrefrequired | string | A portal path, or a marker's own URL. |
ChangeFeedPage
| Field | Type | Description |
|---|---|---|
itemsrequired | array of ChangeEvent | |
nextCursor | string | Present when another page follows; opaque. |
truncatedrequired | array of ChangeKind | The kinds whose source had more rows in this window than one page carries; their contribution was capped. |
ChangeKind
One source's kind of event in the change feed (p8_10).
Type: string enum
ChangeMarker
| Field | Type | Description |
|---|---|---|
accountId | string | |
appId | string (uuid) | |
atrequired | string (date-time) | |
createdAtrequired | string (date-time) | |
idrequired | string (uuid) | |
idempotencyKeyrequired | string | |
labelrequired | string | |
resourceKeysrequired | array of string | |
urlrequired | string |
ChangeMarkerCreate
| Field | Type | Description |
|---|---|---|
accountId | string | The account the marker is about; omitted, the marker is the tenant's. |
appId | string (uuid) | The declared app the marker is about. |
atrequired | string (date-time) | When it happened, by the poster's clock. |
idempotencyKeyrequired | string | |
labelrequired | string | |
resourceKeys | array of string | The resources it touched, as keys under accountId. |
url | string | A link back to the poster's own record. |
CollectionRunBucket
The unit of reconciliation: (account, region, system, service, resourceType). The account comes from the run, so it is not repeated
here.
| Field | Type | Description |
|---|---|---|
regionrequired | string | |
resourceTyperequired | string | |
servicerequired | string | |
systemrequired | string |
CollectionRunRegistered
| Field | Type | Description |
|---|---|---|
bucketsrequired | array of RegisteredBucket | Each registered bucket with the generation this run claimed. |
runIdrequired | string (uuid) |
CollectionRunRegistration
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
bucketsrequired | array of CollectionRunBucket | Must not be empty. A module that declares no buckets can neither
store nor finalize, which is a wiring bug worth failing loudly. Every bucket's region must equal the run's region, and the set
must contain no duplicates. Both are 400 invalid_request, and both
are rejected rather than tidied up because each is a claim on a
generation: a duplicate would bump one bucket's fence twice and
supersede the run's own registration, and a bucket in another region
would supersede whichever run legitimately owns that region — after
which finalize there would tombstone live resources. global is a region like any other here: a global bucket belongs to
a run whose own region is global. |
modulerequired | string | |
regionrequired | string | |
runIdrequired | string (uuid) | Generated by the collector before its first attempt, and reused on every retry. This is the idempotency key for registration. |
CollectorMatrix
| Field | Type | Description |
|---|---|---|
accountsrequired | array of CollectorMatrixAccount | Per-account roll-up, worst status wins. |
entriesrequired | array of MatrixEntry |
CollectorMatrixAccount
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
overallStatusrequired | string enum | One of ok, error, slow, no-data. |
CollectorRun
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
durationMs | integer (int64) | |
errorMessage | string | |
finishedAt | string (date-time) | |
modulerequired | string | |
regionrequired | string | |
resourcesAttempted | integer (int64) | |
resourcesStored | integer (int64) | |
runIdrequired | string (uuid) | |
startedAtrequired | string (date-time) | |
staterequired | string enum | One of running, finalized, failed. |
tombstoned | integer (int64) |
CollectorRunList
| Field | Type | Description |
|---|---|---|
itemsrequired | array of CollectorRun |
CostAllocationRule
A shared-cost allocation rule (p3_8): the services whose UNCLAIMED spend — the untagged tier, after attribution — is split across teams, and how. Tried in list order; the first rule naming a service takes its rows. What a tag claimed is never allocated.
| Field | Type | Description |
|---|---|---|
enabledrequired | boolean | |
fixedShares | object | For fixed: team id → share. Any positive numbers; they are normalised. |
id | string (uuid) | |
methodrequired | string enum | proportional — in proportion to each team's direct spend in the
month, per currency (a currency with no direct spend stays
untagged); even — equally across every live team; fixed —
by fixedShares, normalised. One of proportional, even, fixed. |
namerequired | string | |
servicesrequired | array of string | Cost Explorer SERVICE names, as the breakdown spells them. |
CostAnomaly
| Field | Type | Description |
|---|---|---|
baselinerequired | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
detectedAtrequired | string (date-time) | |
deviationPctrequired | number | |
idrequired | string (uuid) | |
observedrequired | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
resolvedAt | string (date-time) | |
resolvedBy | ActorRef | Who did something, for display and for audit. Not User: that carries
the tenant and the role of the session, and an audit record naming a
role that has since changed would be a lie about the past. |
scopeIdrequired | string | An account id, or a team id. |
scopeLabel | string | The account's or team's name, for display. |
scopeTyperequired | string enum | One of account, team. |
servicerequired | string | Empty for a scope-wide anomaly. |
severityrequired | string enum | One of low, medium, high. |
statusrequired | CostAnomalyStatus | One of open, acknowledged, dismissed. |
windowEndrequired | string (date) | |
windowStartrequired | string (date) |
CostAnomalyPage
| Field | Type | Description |
|---|---|---|
itemsrequired | array of CostAnomaly | |
pagerequired | integer | |
pageSizerequired | integer | |
totalrequired | integer (int64) | |
totalPagesrequired | integer |
CostAnomalySettings
| Field | Type | Description |
|---|---|---|
minDeltarequired | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
sensitivityPctrequired | number | The relative deviation from baseline that counts. |
suppressionHoursrequired | integer |
CostAnomalyStatus
Type: string enum
CostAnomalyUpdate
| Field | Type | Description |
|---|---|---|
statusrequired | string enum | One of acknowledged, dismissed. |
CostAttributionAmounts
| Field | Type | Description |
|---|---|---|
amountsrequired | array of Money | |
attributionrequired | CostAttributionTier | Which step of the attribution chain (§7.1) attributed a row: a tag
value mapped to a team, the account's owning team, a shared-cost
allocation rule (p3_8 — spend nobody claimed, split across teams by
a rule the tenant set), or nothing — untagged, visible and never
dropped. The four tiers sum to the total. One of tag, team_account, allocated, untagged. |
CostAttributionRule
| Field | Type | Description |
|---|---|---|
priorityrequired | integer | |
tagKeyrequired | string |
CostAttributionTier
Which step of the attribution chain (§7.1) attributed a row: a tag
value mapped to a team, the account's owning team, a shared-cost
allocation rule (p3_8 — spend nobody claimed, split across teams by
a rule the tenant set), or nothing — untagged, visible and never
dropped. The four tiers sum to the total.
Type: string enum
CostBreakdown
| Field | Type | Description |
|---|---|---|
figurerequired | CostFigure | One of amortized, unblended. |
groupByrequired | string enum | One of team, service, account. |
periodrequired | CostPeriod | |
rebuildInProgressrequired | boolean | |
rebuiltAt | string (date-time) | When the rollups these figures come from were last built. |
rowsrequired | array of CostBreakdownRow | |
totalsrequired | array of Money | The sum of every row including other, per currency. |
CostBreakdownRow
| Field | Type | Description |
|---|---|---|
allocatedAmounts | array of Money | The part of amounts that arrived by a shared-cost allocation
rule (p3_8), per currency. Present whenever any did, so a team
can always see what was direct and what was allocated; the two
are never merged silently. |
amountsrequired | array of Money | The row's whole figure, per currency, allocation included. |
keyrequired | string | Pass it back as the matching filter to drill down. other and untagged are not drillable. |
labelrequired | string | |
sharePct | number | This row's share of the total in the row's leading currency, when the total has exactly one currency. Absent otherwise — a share of two currencies is not a number. |
CostBudget
| Field | Type | Description |
|---|---|---|
amountrequired | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
createdAtrequired | string (date-time) | |
enabledrequired | boolean | |
idrequired | string (uuid) | |
periodrequired | string enum | One of monthly. |
progress | CostBudgetProgress | The current period against the budget, in the budget's currency. |
teamIdrequired | string (uuid) | |
teamNamerequired | string | |
thresholdsrequired | array of integer | Percentages of amount, each firing once per period. |
updatedAtrequired | string (date-time) |
CostBudgetCreate
| Field | Type | Description |
|---|---|---|
amountrequired | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
enabled | boolean | |
period | string enum | One of monthly. |
teamIdrequired | string (uuid) | |
thresholds | array of integer |
CostBudgetList
| Field | Type | Description |
|---|---|---|
itemsrequired | array of CostBudget |
CostBudgetProgress
The current period against the budget, in the budget's currency.
| Field | Type | Description |
|---|---|---|
otherCurrencies | array of Money | The team's spend this period in currencies other than the budget's — not converted (C7), listed so the mismatch is seen. |
pctrequired | number | spent / amount × 100. |
periodStartrequired | string (date) | |
spentrequired | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
thresholdsCrossedrequired | array of integer | The thresholds that have already fired this period. |
CostBudgetUpdate
| Field | Type | Description |
|---|---|---|
amount | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
enabled | boolean | |
thresholds | array of integer |
CostCurReport
Where one account's Cost & Usage Report lives (p3_5), and the role
that reads it (p7_22). The report is read under reportRoleArn — a
role the account deploys from cost-report-role.yaml, scoped to
this bucket and prefix — never under the inventory reader role,
which reads no object content. Nothing here is a credential.
lastAssemblyId and lastPeriodStart are the most recent
generation ingested, read-only.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
bucketrequired | string | |
bucketRegionrequired | string | S3 does not follow a request to the wrong region; the client is built for this one. |
curOnly | boolean | Skip this account's daily Cost Explorer pulls — the aggregate, tag and per-resource passes, each billed per request — while this report covers them (p7_50). A preference, not a promise: a pull is skipped only while every day before today that it would write already has this report's own rows of its kind (money rows for the aggregate pull, resource rows for the per-resource one), which the cost module takes over Cost Explorer's anyway; otherwise it runs as before and the job logs the first day without them. Omitted on an update, it is false — the list is the whole set. |
enabledrequired | boolean | |
lastAssemblyId | string | |
lastPeriodStart | string (date) | |
prefixrequired | string | |
reportNamerequired | string | |
reportRoleArn | string | The ARN of the report role in this account. Blank until the
customer has deployed it: a pull is then refused with a reason
that names the template, and POST /admin/accounts/{accountId}/verify
reports it as the missing check. Must be an IAM role ARN when set. |
CostFigure
Type: string enum
CostForecast
| Field | Type | Description |
|---|---|---|
actualToDaterequired | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
asOfrequired | string (date) | |
backtestrequired | CostForecastBacktest | The scope's own backtest, at the checkpoint nearest the forecast's day. |
computedAtrequired | string (date-time) | |
daysRemainingrequired | integer | |
high | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
low | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
lowConfidencerequired | boolean | |
methodrequired | string | |
projection | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
settledThroughrequired | string (date) | The last day counted; the two days after it are still arriving. |
staterequired | string enum | ready with a projection; not_enough_history below 14 settled days, with none. One of ready, not_enough_history. |
CostForecastBacktest
The scope's own backtest, at the checkpoint nearest the forecast's day.
| Field | Type | Description |
|---|---|---|
checkpointrequired | integer | The day of the month the backtest was read at, 7, 14 or 21. |
p90ApePct | number (float) | The 90th-percentile absolute percentage error there; absent with no scored month. At most 1,000,000,000: a month whose credits all but cancel its spend gives an error without bound, and it is shown at that ceiling, the forecast already low-confidence. |
scoredMonthsrequired | integer |
CostForecastList
| Field | Type | Description |
|---|---|---|
forecastsrequired | array of CostForecast | One per currency, from the newest day computed; empty when none has been computed. |
monthrequired | string | |
scoperequired | CostForecastScope | What a forecast is for (p3_21). One of tenant, team, account. |
scopeIdrequired | string | The team's or the account's id; empty for the tenant. |
CostForecastScope
What a forecast is for (p3_21).
Type: string enum
CostIngestionState
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
backfillComplete | boolean | |
backfillCursor | string (date) | |
backfillFrom | string (date) | |
lastCompleteDate | string (date) | |
lastError | string | |
lastRunAt | string (date-time) | |
passDiscrepancy | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
resourceGranularityAvailable | boolean | Absent until the first resource pull has asked. |
sourcerequired | string enum | One of ce_aggregate, ce_tag, ce_resource, cur, trusted_advisor. |
supportPlanAvailable | boolean | For trusted_advisor: whether the account's support plan includes it. Absent until the first pull. |
CostInventoryState
What the inventory says about the key the spend is against: a live
row, a tombstoned one (spend continuing after deletion — the
cost-without-resource finding), or nothing at all (missing).
Type: string enum
CostKubernetes
| Field | Type | Description |
|---|---|---|
clustersrequired | array of CostKubernetesCluster | |
figurerequired | CostFigure | One of amortized, unblended. |
periodrequired | CostPeriod | |
rebuildInProgressrequired | boolean | |
rebuiltAt | string (date-time) | When the rollups these figures come from were last built. |
rowsrequired | array of CostKubernetesRow | Largest first, across clusters; filter by cluster for one. |
CostKubernetesCluster
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
clusterrequired | string | |
idleSpendrequired | array of Money | The part no pod requested, per currency. |
nodeCountrequired | integer | Nodes in the inventory on the last day computed. |
nodeSpendrequired | array of Money | The cluster's node line items over the window, per currency: what the rows sum to. |
regionrequired | string | The cluster's region: with accountId and cluster, its
identity. Empty for rows stored before the region was kept
whose cluster the inventory no longer holds — history of a
cluster that is gone, not attributed to a region. |
CostKubernetesRow
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
clusterrequired | string | |
namespacerequired | string | Empty for the idle row. |
regionrequired | string | The cluster's region; with accountId and cluster, which cluster this row belongs to. |
sharePct | number | This row's share of the cluster's node spend, in the row's leading currency, when the cluster has one currency. |
spendrequired | array of Money | |
workloadKindrequired | string | Deployment, StatefulSet, DaemonSet, CronJob, Job, Pod — or idle. |
workloadNamerequired | string |
CostPeriod
| Field | Type | Description |
|---|---|---|
fromrequired | string (date) | |
torequired | string (date) |
CostReadMeta
Carried by every read. rebuildInProgress is C3 said out loud: a
rule change is re-attributing history and yesterday's number may
move. figure echoes what was asked for.
| Field | Type | Description |
|---|---|---|
figurerequired | CostFigure | One of amortized, unblended. |
rebuildInProgressrequired | boolean | |
rebuiltAt | string (date-time) | When the rollups these figures come from were last built. |
CostRecommendation
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
checkIdrequired | string | |
checkNamerequired | string | |
estimatedMonthlySavings | Money | One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7). amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product. |
firstSeenAt | string (date-time) | |
metadatarequired | object | The check's columns, by the names Trusted Advisor gives them. |
observedAtrequired | string (date-time) | |
region | string | |
resourceIdrequired | string | Trusted Advisor's identifier for the flagged resource. |
resourceKey | string | The inventory key, when the check names a resource the derivation can map. |
statusrequired | string enum | One of ok, warning, error. |
CostRecommendationPage
| Field | Type | Description |
|---|---|---|
itemsrequired | array of CostRecommendation | |
pagerequired | integer | |
pageSizerequired | integer | |
totalrequired | integer (int64) | |
totalPagesrequired | integer | |
unavailableAccountsrequired | array of CostRecommendationUnavailableAccount | Accounts with no recommendations and why: no Trusted Advisor on the support plan, or not pulled yet. |
CostResource
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
amountsrequired | array of Money | Spend in the window, per currency. |
inventoryStaterequired | CostInventoryState | What the inventory says about the key the spend is against: a live
row, a tombstoned one (spend continuing after deletion — the
cost-without-resource finding), or nothing at all (missing). One of live, tombstoned, missing. |
lastSeenAt | string (date-time) | The inventory's last sighting, when it has a row. |
mapped | boolean | Whether the identifier the source reported mapped to a platform key (§5.4). Unmapped rows are attribution gaps in disguise and are counted in settings. |
region | string | |
resourceId | string | |
resourceKeyrequired | string | The platform resource key (PLATFORM.md §3), or the raw identifier the source reported when it could not be mapped. |
resourceName | string | From the inventory. Absent when the inventory has no row. |
resourceType | string | |
servicerequired | string | |
status | string | The inventory's status word, when it has a row. |
system | string |
CostResourcePage
| Field | Type | Description |
|---|---|---|
figurerequired | CostFigure | One of amortized, unblended. |
filterrequired | string enum | One of all, orphaned, unattributed. |
itemsrequired | array of CostResource | |
pagerequired | integer | |
pageSizerequired | integer | |
rebuildInProgressrequired | boolean | |
rebuiltAt | string (date-time) | When the rollups these figures come from were last built. |
totalrequired | integer (int64) | |
totalPagesrequired | integer | |
unavailableAccountsrequired | array of CostResourceUnavailableAccount | Accounts with no usable per-resource evidence and why.
resource_granularity_unavailable: no source the account is
pulled from carries resource-level rows — resource
granularity is not enabled in Cost Explorer preferences, and
any Cost & Usage Report ingested has no resource IDs. A
report with resource IDs is enough on its own (p7_10).
not_yet_ingested: no source has been pulled yet. Documented
states, never errors. |
windowrequired | CostPeriod | |
windowDaysrequired | integer | Always 14 in v1 (§4): Cost Explorer's per-resource window. Rendered beside every per-resource figure, because claiming more than the data supports is how the donors lost trust. |
CostSeries
| Field | Type | Description |
|---|---|---|
keyrequired | string | The group's identity: a team id, a service name, an account id —
or untagged for the unattributed remainder under groupBy=team. |
labelrequired | string | |
pointsrequired | array of CostTrendPoint |
CostSettings
| Field | Type | Description |
|---|---|---|
allocationRulesrequired | array of CostAllocationRule | Shared-cost allocation rules in the order they are tried (p3_8). |
anomalyrequired | CostAnomalySettings | |
attributionRulesrequired | array of CostAttributionRule | |
curReportsrequired | array of CostCurReport | Per account: where its Cost & Usage Report is, when one is configured. |
defaultFigurerequired | CostFigure | One of amortized, unblended. |
ingestionrequired | array of CostIngestionState | |
rebuildEnqueued | boolean | On the response to a PUT that changed the rules or the tag values. |
rebuildInProgressrequired | boolean | |
teamTagValuesrequired | array of CostTeamTagValue | |
unmappedIdentifiersrequired | integer | Per-resource rows whose identifier the key derivation could not map (§5.4), in the current window. |
CostSettingsUpdate
| Field | Type | Description |
|---|---|---|
allocationRules | array of CostAllocationRule | The whole list, in order. Omitted, the rules are left as they are. Changing them re-attributes history like a rule change (§7.3): the allocation runs inside the rebuild. |
anomalyrequired | CostAnomalySettings | |
attributionRulesrequired | array of CostAttributionRule | |
curReports | array of CostCurReport | The whole set, like the rules: an account absent from the list has its report configuration removed. Omitted, the configuration is left as it is. Changing a report enqueues no rebuild — the next pull replaces the periods it reads, and the rebuild follows the data. |
defaultFigurerequired | CostFigure | One of amortized, unblended. |
teamTagValuesrequired | array of CostTeamTagValue |
CostSummary
| Field | Type | Description |
|---|---|---|
byAttributionrequired | array of CostAttributionAmounts | The three tiers, each per currency. Their sum is totals, to
the cent — the reconciliation invariant (§7.2). |
deltarequired | array of MoneyDelta | |
figurerequired | CostFigure | One of amortized, unblended. |
periodrequired | CostPeriod | |
previousPeriodrequired | CostPeriod | |
previousTotalsrequired | array of Money | |
rebuildInProgressrequired | boolean | |
rebuiltAt | string (date-time) | When the rollups these figures come from were last built. |
totalsrequired | array of Money | Per currency. A single-currency tenant sees one element. |
CostTeamTagValue
| Field | Type | Description |
|---|---|---|
tagValuerequired | string | Matched case-insensitively. |
teamIdrequired | string (uuid) |
CostTrend
| Field | Type | Description |
|---|---|---|
figurerequired | CostFigure | One of amortized, unblended. |
granularityrequired | string enum | One of daily, monthly. |
groupByrequired | string enum | One of team, service, account. |
periodrequired | CostPeriod | |
rebuildInProgressrequired | boolean | |
rebuiltAt | string (date-time) | When the rollups these figures come from were last built. |
seriesrequired | array of CostSeries |
CostTrendPoint
| Field | Type | Description |
|---|---|---|
amountsrequired | array of Money | Per currency; empty on a day with no spend. |
daterequired | string (date) | The day, or the first of the month for monthly granularity. |
DeclaredApp
| Field | Type | Description |
|---|---|---|
archivedAt | string (date-time) | |
canEditrequired | boolean | Whether the viewer may change it: their platform role clears the
engineer floor, they are a lead of the owning team or an admin,
and it is not archived. The same two axes as canCreate. |
createdAtrequired | string (date-time) | |
description | string | |
excluderequired | array of ResourceRef | |
healthrequired | StatusCounts | Computed over the full filtered set, not the current page. |
idrequired | string (uuid) | |
includerequired | array of ResourceRef | |
matchesrequired | array of TagMatch | |
money | DeclaredAppMoney | An app's money for the period, at one grain, named
(platform/docs/declared-apps.md §2). resource: the members' own
rows from the Cost & Usage Report, exact, when every account-day of
the period with money has the report as its evidence source. tag
otherwise: the matched tags' attributed amounts, the tag-defined
page's figure for each, summed — and overlapResources says how
many members hit more than one match (their money may be counted
twice) while explicitOutsideTagGrain counts the added resources
that carry no matched tag and the excluded ones that do (the tag
grain cannot see either). Never both grains in one period. |
namerequired | string | |
provisioned | integer | How many of the app's live members Provisioning created, over the viewer's accounts. Absent without the module. |
slugrequired | string | |
teamrequired | TeamRef | A team named where only its identity matters — an app's owner, or a
team a picker offers. Team is the org model's full row, with the
counts the settings page shows; this is the two fields everything
else needs. |
updatedAtrequired | string (date-time) |
DeclaredAppDefinition
What a lead declares. At least one tag match or one added resource. A resource that is both added and excluded is refused.
| Field | Type | Description |
|---|---|---|
description | string | |
exclude | array of ResourceRef | Resources kept out although a match hits them. |
include | array of ResourceRef | Resources added by hand. |
matchesrequired | array of TagMatch | |
namerequired | string | |
teamIdrequired | string (uuid) |
DeclaredAppDetail
| Field | Type | Description |
|---|---|---|
apprequired | DeclaredApp | |
membersrequired | array of DeclaredAppMember | |
ownableTeamsrequired | array of TeamRef | The open teams this viewer may give this app to, by the same rule
as the list's — so the editor opened here can move an app between
teams, and offers only the moves PUT accepts. Empty when the
viewer may not write at all, including a platform viewer who leads
a team; app.canEdit is the check for this app as it stands. |
trend | array of DeclaredAppTrendMonth | Six calendar months ending with the current one, each at its own grain. Absent without Spend. |
DeclaredAppList
| Field | Type | Description |
|---|---|---|
appsrequired | array of DeclaredApp | |
canCreaterequired | boolean | Whether the viewer may declare an app. Both axes, never one: the
platform role must clear the engineer floor every write route
carries, and they must be an admin or the lead of an open
team. The two are disjoint (PLATFORM.md §4) — a platform viewer
can lead a team, and answering on the team rule alone would offer
a write the route refuses with 403. Exactly ownableTeams being non-empty: a lead whose only team has
been closed may not declare either, because every team the write
would accept is gone. |
ownableTeamsrequired | array of TeamRef | The open teams this viewer may give an app to — every one for an
admin, the ones they lead otherwise — by name, and empty for a
platform viewer whatever their team roles. The editor offers these
and nothing else, so nobody is shown a team the write would
refuse: not a lead offered another team, not a closed team
(409 team_archived), and not a reader offered any. Empty exactly when canCreate is false. |
periodrequired | object | The month-to-date range spend covers, inclusive. |
DeclaredAppMember
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
lastSeenAtrequired | string (date-time) | |
namerequired | string | |
regionrequired | string | |
resourceIdrequired | string | |
resourceTyperequired | string | |
servicerequired | string | |
statusrequired | Status | The health of something that currently exists. Derived once at ingest
and stored, never recomputed at read time. deleted is absent, and that is the point: a deleted resource is
tombstoned, not given a status, so a live resource carrying
status: deleted is an impossible value. Keeping one enum for both made
that impossible value contract-valid everywhere. unknown (p7_83) is a resource the inventory knows exists, or was
recently active, whose health it cannot observe — found only from its
metrics or its name. It is neither healthy nor a problem: never green,
not on the Alerts page, and counted as not checked on the public page. One of failed, degraded, operational, unknown. |
systemrequired | string | |
viarequired | string enum | Why it is in — a tag match, or an explicit addition. One of tag, added. |
DeclaredAppMoney
An app's money for the period, at one grain, named
(platform/docs/declared-apps.md §2). resource: the members' own
rows from the Cost & Usage Report, exact, when every account-day of
the period with money has the report as its evidence source. tag
otherwise: the matched tags' attributed amounts, the tag-defined
page's figure for each, summed — and overlapResources says how
many members hit more than one match (their money may be counted
twice) while explicitOutsideTagGrain counts the added resources
that carry no matched tag and the excluded ones that do (the tag
grain cannot see either). Never both grains in one period.
| Field | Type | Description |
|---|---|---|
amountsrequired | array of Money | |
explicitOutsideTagGrainrequired | integer | |
grainrequired | string enum | One of resource, tag. |
overlapResourcesrequired | integer | |
sourcerequired | string enum | One of cur, ce, legacy, mixed, none. |
DeclaredAppTrendMonth
| Field | Type | Description |
|---|---|---|
moneyrequired | DeclaredAppMoney | An app's money for the period, at one grain, named
(platform/docs/declared-apps.md §2). resource: the members' own
rows from the Cost & Usage Report, exact, when every account-day of
the period with money has the report as its evidence source. tag
otherwise: the matched tags' attributed amounts, the tag-defined
page's figure for each, summed — and overlapResources says how
many members hit more than one match (their money may be counted
twice) while explicitOutsideTagGrain counts the added resources
that carry no matched tag and the excluded ones that do (the tag
grain cannot see either). Never both grains in one period. |
monthrequired | string (date) | The first day of the month. |
DiffEntry
One changed path, older to newer. Paths follow the descriptor's field
keys: status, state, tags.<key>, additionalInfo.<key>, nested
by ., a list element by [i]. A set member's entry sits under the
set's own path with the member as old (removed) or new (added).
A redacted entry is a field the descriptor marks sensitive: its
values are not carried, here or anywhere else.
| Field | Type | Description |
|---|---|---|
kindrequired | string enum | One of added, removed, changed. |
new | object | |
old | object | |
pathrequired | string | |
redacted | boolean |
DiscoveryResult
updated and unchanged are separate on purpose. Discovery re-runs
routinely, and "did anything actually change?" is the question an
operator has — collapsing them into one "already known" count destroys
the only interesting part of a repeat run.
| Field | Type | Description |
|---|---|---|
accounts | array of Account | |
createdrequired | integer | Newly registered, all with enabled = false. |
foundrequired | integer | Accounts returned by Organizations. |
unchangedrequired | integer | Already known and identical. |
updatedrequired | integer | Already known, and some field differed. |
DriftMissing
Provisioned, tagged, and no longer in the inventory. Severity is the gap, not a computed score: "4 of 7 gone" is what an operator acts on.
| Field | Type | Description |
|---|---|---|
expectedCountrequired | integer | |
gaprequired | integer | expectedCount - observedCount, always positive here. |
lastObservedAt | string (date-time) | |
observedCountrequired | integer | Live inventory rows in that account whose tags carry
panorama:request_id = createRequestId and which are not tombstoned. |
resourcerequired | ProvisionedResource | What a create produced, and the anchor of the provenance badge on the
inventory side. A create that stopped part-way has one too, partial,
so what it made can be destroyed from the platform (p7_65). |
DriftMissingPage
| Field | Type | Description |
|---|---|---|
itemsrequired | array of DriftMissing | |
pagerequired | integer | |
pageSizerequired | integer | |
totalrequired | integer (int64) | |
totalPagesrequired | integer |
DriftUnprovisioned
A live inventory resource of a type this tenant's provisioning has
produced before, carrying no panorama:request_id. Somebody made it by hand.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
firstSeenAtrequired | string (date-time) | |
lastSeenAtrequired | string (date-time) | |
regionrequired | string | |
resourceIdrequired | string | |
resourceName | string | |
resourceTyperequired | string | |
servicerequired | string | |
systemrequired | string |
DriftUnprovisionedPage
| Field | Type | Description |
|---|---|---|
itemsrequired | array of DriftUnprovisioned | |
pagerequired | integer | |
pageSizerequired | integer | |
totalrequired | integer (int64) | |
totalPagesrequired | integer |
Entitlements
| Field | Type | Description |
|---|---|---|
capabilitiesrequired | array of string | Features the licence carries beside its modules: sso
(Enterprise) and performance (Professional and Enterprise, p7_74);
later scim. Locked with the modules at expiry. |
enabledAccountCountrequired | integer | |
licensee | string | Who the licence was issued to, when the row came from a licence. |
maxAccounts | integer | Absent means unlimited. |
modulesrequired | array of string | Unlocked modules, now. A tenant with no row has none — unless the
deployment runs with ENTITLEMENTS_UNRESTRICTED, the development
exception, in which case it has every module this build serves
and state says unrestricted. |
staterequired | string enum | The one word the portal turns into a banner. unrestricted: no
row, development exception. none: no row, nothing unlocked.
licensed: valid. expiring: within thirty days of validUntil
— a banner, not a lock. lapsed: past validUntil; every module
is locked and collection continues for fourteen days. paused:
past that grace; collection has stopped. Nothing is ever deleted
because of a lapse. One of unrestricted, none, licensed, expiring, lapsed, paused. |
tier | string | The licence's tier id (essentials, professional, enterprise),
when the row came from a licence. An id, never a name: the name is
the brand's. |
validUntil | string (date-time) | Checked at read time, not by a scheduled job: an expired row behaves as unentitled the moment it is read. |
ErrorResponse
| Field | Type | Description |
|---|---|---|
errorrequired | object |
FailRunRequest
resourcesAttempted is here as well as on finalize, and that is the
whole point of it. A run that finalizes successfully has
resources_stored equal to what it sent by construction — ingest is
atomic per batch — so the counters can only ever differ on a run that
did NOT finish. Reporting the count only at finalize would leave it zero
for exactly the failures it exists to describe.
What it cannot detect: a worker that dies without calling either
endpoint. That run stays running until cleanup reaps it, with
resources_attempted at zero; it is identified by its state and the
reaper's error_message, not by the counters.
| Field | Type | Description |
|---|---|---|
reasonrequired | string | Why the run failed, for the admin matrix. Recorded verbatim, so it must not contain credentials or raw provider payloads. Clients truncate to this length before sending. The server truncates rather than rejecting — uniquely among the body constraints here. This is the call that closes a run against a late batch and a late finalize, and refusing it because an error string ran long would leave the run open for the whole stale-run window over something cosmetic. |
resourcesAttemptedrequired | integer | Resources the enumeration observed before it failed, or 0 if it
never got that far. SET on the run, never added to. The bound is
the column's: resources_attempted is a PostgreSQL int, and a
larger value would fail inside the transaction as a 500 for what is
a malformed request. Out of range is 400 invalid_request. |
FieldDescriptor
| Field | Type | Description |
|---|---|---|
badgeMap | object | Raw value to badge tone, for format: badge. |
columns | array of FieldDescriptor | Column descriptors, for format: table. |
formatrequired | FieldFormat | The renderer implements exactly these and falls back to json for
anything it does not recognise, so a backend that adds a format cannot
break the UI. One of text, number, bytes, datetime, relativeTime, boolean, badge, list, table, json, link. |
keyrequired | string | A path into the resource, including additionalInfo. prefixes. |
labelrequired | string | |
unit | string |
FieldFormat
The renderer implements exactly these and falls back to json for
anything it does not recognise, so a backend that adds a format cannot
break the UI.
Type: string enum
FieldGroup
| Field | Type | Description |
|---|---|---|
fieldsrequired | array of FieldDescriptor | |
titlerequired | string |
FinalizeRequest
Added 2026-08-19 (task_21 review). collection_runs.resources_attempted
previously had no writer at all, so the admin matrix would have reported
zero forever.
The count belongs here because only the collector knows it: how many
resources its enumeration actually saw, before anything was chunked or
sent. Divergence from resources_stored then means what is worth
knowing — chunks that never landed, because a batch was rejected or the
run died mid-send.
It is deliberately not an increment. Finalize is replay-safe, and a counter that grew on every retry would report a run collecting more each time its response was lost.
Reported to /fail as well, because a run that finalizes successfully
has resources_stored equal to what it sent by construction: the
counters can only differ on a run that did not finish.
| Field | Type | Description |
|---|---|---|
resourcesAttemptedrequired | integer | Resources the enumeration observed. SET on the run, never added
to, so a replayed finalize leaves it unchanged. The bound is the
column's — a PostgreSQL int. Out of range is
400 invalid_request. |
FinalizeResponse
| Field | Type | Description |
|---|---|---|
alreadyFinalized | boolean | True when this call replayed an earlier finalize. |
runIdrequired | string (uuid) | |
tombstonedrequired | integer (int64) | Resources this run did not observe and therefore logically deleted. On a replayed finalize this is the stored count, not a fresh one. |
HealthStatus
| Field | Type | Description |
|---|---|---|
statusrequired | string |
IdentityProvider
| Field | Type | Description |
|---|---|---|
boundAccountsrequired | integer | Accounts bound to a provider's subject. While any is bound to another issuer, saving this one is refused (P55-R2-F03). |
clientIdrequired | string | |
clientSecretSetrequired | boolean | The secret is stored and never returned. |
defaultRolerequired | string enum | One of viewer, engineer, admin. |
emailDomainsrequired | array of string | |
enforcedrequired | boolean | |
groupsClaimrequired | string | The ID-token claim the person's groups are read from at sign-in (p6_6); groups unless named. |
issuerrequired | string | |
redirectUrirequired | string | The callback address to register at the provider, for this host. |
updatedAtrequired | string (date-time) |
IdentityProviderInput
| Field | Type | Description |
|---|---|---|
clientIdrequired | string | |
clientSecret | string | Required the first time; absent keeps the stored one. |
defaultRolerequired | string enum | One of viewer, engineer, admin. |
emailDomainsrequired | array of string | |
enforcedrequired | boolean | |
groupsClaim | string | The claim the groups are read from; empty or absent keeps groups. |
issuerrequired | string |
InboxEntry
| Field | Type | Description |
|---|---|---|
createdAtrequired | string (date-time) | |
headerrequired | string | |
idrequired | integer (int64) | |
kindrequired | string | The notification's kind, as a webhook receives it — estate.status_transition, cost.anomaly, provisioning.task_failed. |
linesrequired | array of string | |
readAt | string (date-time) | When the person marked it read; absent while unread. |
InboxPage
| Field | Type | Description |
|---|---|---|
itemsrequired | array of InboxEntry | |
morerequired | boolean | Whether older entries follow this page. |
unreadrequired | integer | How many of the person's entries are unread, across every page. |
InboxRead
| Field | Type | Description |
|---|---|---|
all | boolean | Every entry of the person's, whatever ids says. |
ids | array of integer (int64) |
InboxUnread
| Field | Type | Description |
|---|---|---|
unreadrequired | integer |
LoginRequest
| Field | Type | Description |
|---|---|---|
emailrequired | string | The longest address RFC 5321 permits. Deliberately NOT format: email. Syntax beyond "not blank" is not
validated — an address that an over-strict pattern rejects is a
customer who cannot log in — and declaring a format the server does
not enforce is a contract that lies to whoever generates a client
from it. |
passwordrequired | string (password) | Bounded because verifying it is an argon2 derivation over whatever was sent, and this endpoint is unauthenticated. |
tenantSlug | string | Optional. Needed only when the request host does not identify a
tenant, because an email address alone does not identify a user. A DNS label, because it is a subdomain: acme is
acme.cloudpanorama.example. Matched case-insensitively. |
MarkerCredential
A marker credential (p8_10), everything but the token.
| Field | Type | Description |
|---|---|---|
accountIdsrequired | array of string | The accounts it may mark; empty is any. |
appIdsrequired | array of string (uuid) | The declared apps it may mark; empty is any. |
createdAtrequired | string (date-time) | |
idrequired | string (uuid) | |
lastUsedAt | string (date-time) | |
namerequired | string | |
revokedAt | string (date-time) |
MarkerCredentialCreate
| Field | Type | Description |
|---|---|---|
accountIds | array of string | |
appIds | array of string (uuid) | |
namerequired | string |
MarkerCredentialIssued
| Field | Type | Description |
|---|---|---|
credentialrequired | MarkerCredential | A marker credential (p8_10), everything but the token. |
tokenrequired | string (password) | Returned once and never again: only sha256(token) is stored. |
MarkerCredentialList
| Field | Type | Description |
|---|---|---|
itemsrequired | array of MarkerCredential |
MatrixEntry
One cell per (account, region, module) — the unit the scheduler actually runs. A cell per (account, module) would let one healthy region hide a failed or never-collected one, which is precisely the silent gap this grid exists to expose.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
durationMs | integer (int64) | |
errorMessage | string | |
lastRunAt | string (date-time) | |
modulerequired | string | |
regionrequired | string | |
resourcesAttempted | integer (int64) | |
resourcesStored | integer (int64) | |
statusrequired | string enum | slow is duration greater than three times the trailing ten-run
average. no-data is no run within three cadence intervals — the
state a silently dead collector produces. One of ok, error, slow, no-data. |
Money
One amount in one currency. The only way money appears in this
document. TestAllMoneyIsCurrencyQualified walks every response
schema and fails on any amount-shaped property that is not inside
this object or an array of it, because "a scalar total" is the one
contract mistake that cannot be retrofitted once a client has summed
two currencies (C7).
amount is a decimal as a string, never a JSON number: numeric(20,10)
does not survive a float, and a total that reconciles to the cent is
the product.
| Field | Type | Description |
|---|---|---|
amountrequired | string | |
currencyrequired | string |
MoneyDelta
The change between two periods, per currency. pct is absent when the
previous period had no spend in this currency — a change from zero is
not a percentage.
| Field | Type | Description |
|---|---|---|
amountrequired | string | Current minus previous. |
currencyrequired | string | |
pct | number | Percentage change, when the previous period is non-zero. |
NeighbourEdge
| Field | Type | Description |
|---|---|---|
categoryrequired | string enum | Impact (p8_13) reads only the operational edges. One of structural, operational. |
confidencerequired | string enum | One of observed, declared, inferred. |
evidencerequired | object | What the edge rests on: the collector field, the tag, the request, the app's rule, the resolver's candidate. |
fromrequired | string | |
kindrequired | NeighbourEdgeKind | One kind of relationship (p8_12). One of contains, runs_on, fronts, guards, provisioned_by, belongs_to, owned_by. |
torequired | string |
NeighbourEdgeKind
One kind of relationship (p8_12).
Type: string enum
NeighbourNode
One thing in the graph. id is stable across reads: a resource's
account and key, or a request's, template version's, app's or
team's id.
| Field | Type | Description |
|---|---|---|
depthrequired | integer | Edges from the root: 0 the root, 1 a neighbour, 2 a neighbour's neighbour. |
detailrequired | object | The node's own fields: a request's state, a template version's source identity, an app's or team's slug. |
idrequired | string | |
kindrequired | string enum | One of resource, request, template_version, app, team. |
labelrequired | string | |
resource | object | The resource behind a resource node. |
NotReadyStatus
| Field | Type | Description |
|---|---|---|
reasonrequired | string | Names the cause without naming a credential, host or database role. |
statusrequired | string |
NotificationChannel
A destination, redacted (p7_21). The target is never here; targetHost,
targetHint and targetFingerprint are what an administrator uses
to recognise a channel and to see that a rotation changed it.
| Field | Type | Description |
|---|---|---|
createdAtrequired | string (date-time) | |
enabledrequired | boolean | |
idrequired | string (uuid) | |
kindrequired | NotificationChannelKind | slack posts {"text": …} to an incoming webhook; webhook posts
the message as JSON with its kind, for a receiver that routes on it;
ai-reviewer (p8_23) hands every item to the operator's AI reviewer
queue as a small claim-check message — its target is the deployment's
queue, set by the platform, never given by the tenant. One of slack, webhook, ai-reviewer. |
lastTest | NotificationChannelTest | The most recent test delivery, if any since the last rotation. |
namerequired | string | |
targetFingerprintrequired | string | sha256 of the target, hex. Two channels with the same fingerprint point at the same place. |
targetHintrequired | string | The last few characters of the destination's path, for recognition; empty for a row that predates the surface. |
targetHostrequired | string | The destination's host. |
teamId | string (uuid) | The team whose events this channel receives; absent for the tenant default. |
teamName | string | |
updatedAtrequired | string (date-time) |
NotificationChannelCreate
| Field | Type | Description |
|---|---|---|
enabled | boolean | |
kindrequired | NotificationChannelKind | slack posts {"text": …} to an incoming webhook; webhook posts
the message as JSON with its kind, for a receiver that routes on it;
ai-reviewer (p8_23) hands every item to the operator's AI reviewer
queue as a small claim-check message — its target is the deployment's
queue, set by the platform, never given by the tenant. One of slack, webhook, ai-reviewer. |
namerequired | string | |
targetrequired | string | An https URL this deployment will post to. Never read back. |
teamId | string (uuid) | Scope the channel to a team; omit for the tenant default. |
NotificationChannelKind
slack posts {"text": …} to an incoming webhook; webhook posts
the message as JSON with its kind, for a receiver that routes on it;
ai-reviewer (p8_23) hands every item to the operator's AI reviewer
queue as a small claim-check message — its target is the deployment's
queue, set by the platform, never given by the tenant.
Type: string enum
NotificationChannelList
| Field | Type | Description |
|---|---|---|
itemsrequired | array of NotificationChannel |
NotificationChannelTest
| Field | Type | Description |
|---|---|---|
atrequired | string (date-time) | |
errorClass | string enum | Why it did not deliver, without the destination's words or its URL. One of dns, tls, timeout, network, redirect, private_address, invalid_target, refused. |
httpStatus | integer | The destination's status code, when it answered. |
outcomerequired | string enum | One of delivered, refused, unreachable, blocked. |
NotificationChannelUpdate
| Field | Type | Description |
|---|---|---|
enabled | boolean | |
name | string | |
target | string | A new target — the rotation. Checked like a new one; the last test is cleared. |
teamId | string (uuid) | Move the channel to a team. |
tenantDefault | boolean | true takes the channel out of its team and makes it a tenant default. |
NotificationChoices
What reaches the person's own inbox (p7_89).
| Field | Type | Description |
|---|---|---|
alertsrequired | boolean | Status alerts on the accounts the person may see — their teams', or every account for an administrator. An account no team owns reaches the administrators alone. |
anomaliesrequired | boolean | Cost anomalies on the person's teams and their teams' accounts; every one, for an administrator. |
budgetsrequired | boolean | Budget thresholds of the person's teams; every team's, for an administrator. |
myRequestsrequired | boolean | Their own provisioning requests — approvals, successes and failures. |
OrgUser
| Field | Type | Description |
|---|---|---|
breakGlass | boolean | An administrator who keeps a password under enforced single sign-on (p5_5). |
createdAtrequired | string (date-time) | |
disabledAt | string (date-time) | When the account was deactivated; absent while it is active. A deactivated user cannot log in and holds no session (p7_20). |
displayName | string | The name the person gave themselves (p7_89); absent while they gave none. |
emailrequired | string | |
idrequired | string (uuid) | |
rolerequired | string enum | The platform role. Says nothing about teams. One of viewer, engineer, admin. |
teamsrequired | array of TeamMembership |
OrgUserCreate
| Field | Type | Description |
|---|---|---|
emailrequired | string | |
rolerequired | string enum | One of viewer, engineer, admin. |
OrgUserCreated
| Field | Type | Description |
|---|---|---|
temporaryPasswordrequired | string | Shown once. Not stored in plaintext, not logged, not shown again: a lost one is replaced with a password reset. |
userrequired | OrgUser |
OrgUserList
| Field | Type | Description |
|---|---|---|
itemsrequired | array of OrgUser |
OrgUserUpdate
| Field | Type | Description |
|---|---|---|
breakGlass | boolean | An administrator who keeps a password when the tenant enforces single sign-on (p5_5). Only an administrator can be one. |
disabled | boolean | true deactivates the account; false reactivates it. |
role | string enum | One of viewer, engineer, admin. |
OwnSession
| Field | Type | Description |
|---|---|---|
address | string | The address the session began from, when it was recorded. |
createdAtrequired | string (date-time) | |
currentrequired | boolean | Whether this is the session making the request. |
expiresAtrequired | string (date-time) | |
idrequired | string | |
lastSeenAtrequired | string (date-time) | |
userAgent | string | The browser the session began in, when it was recorded. |
OwnSessionList
| Field | Type | Description |
|---|---|---|
itemsrequired | array of OwnSession |
OwnTeam
| Field | Type | Description |
|---|---|---|
idrequired | string (uuid) | |
namerequired | string | |
rolerequired | string enum | The person's role in the team. One of member, lead. |
slugrequired | string |
OwnTeamList
| Field | Type | Description |
|---|---|---|
itemsrequired | array of OwnTeam |
Ownership
Who owns the resource (p8_2), from the one resolver, derived when the
row is read. state is the one-word answer; team, confidence,
source and evidence are the winning tier's; reason says why
there is no owner although a tier answered — a conflict between two
matches of equal reach, or an archived team; tried lists the tiers
that said nothing; candidates is every tier's answer, best first —
the evidence one click away, and the discrepancy when a lower tier
disagrees. Ownership explains and proposes: it never widens what a
member sees, and never touches attribution.
| Field | Type | Description |
|---|---|---|
candidatesrequired | array of OwnershipCandidate | |
confidencerequired | string enum | One of explicit, derived, inferred, unknown. |
evidence | object | |
reason | string | |
sourcerequired | string enum | One of override, provenance, tag, account, relationship, none. |
staterequired | string enum | One of owned, unowned, conflict. |
team | TeamRef | A team named where only its identity matters — an app's owner, or a
team a picker offers. Team is the org model's full row, with the
counts the settings page shows; this is the two fields everything
else needs. |
triedrequired | array of string enum |
OwnershipCandidate
| Field | Type | Description |
|---|---|---|
archived | boolean | |
confidencerequired | string enum | One of explicit, derived, inferred. |
evidence | object | |
rankrequired | integer | |
sourcerequired | string enum | One of override, provenance, tag, account, relationship. |
teamrequired | TeamRef | A team named where only its identity matters — an app's owner, or a
team a picker offers. Team is the org model's full row, with the
counts the settings page shows; this is the two fields everything
else needs. |
OwnershipOverride
An explicit owner (p8_2) for one resource, for every resource carrying a tag value, or for every resource of an account; with who set it, why and until when. A revoked override keeps its row, marked.
| Field | Type | Description |
|---|---|---|
accountId | string | |
createdAtrequired | string (date-time) | |
createdByrequired | string (uuid) | |
expired | boolean | Past its expiry: listed, but no longer applying. |
expiresAt | string (date-time) | |
idrequired | string (uuid) | |
kindrequired | string enum | One of resource, tag, account. |
reasonrequired | string | |
resourceKey | string | region/system/service/type/id, for a resource override. |
revokedAt | string (date-time) | |
revokedBy | string (uuid) | |
tagKey | string | |
tagValue | string | |
teamrequired | TeamRef | A team named where only its identity matters — an app's owner, or a
team a picker offers. Team is the org model's full row, with the
counts the settings page shows; this is the two fields everything
else needs. |
OwnershipOverrideInput
| Field | Type | Description |
|---|---|---|
accountId | string | |
expiresAt | string (date-time) | |
kindrequired | string enum | One of resource, tag, account. |
reasonrequired | string | |
resourceKey | string | |
tagKey | string | |
tagValue | string | |
teamIdrequired | string (uuid) |
OwnershipOverrideList
| Field | Type | Description |
|---|---|---|
itemsrequired | array of OwnershipOverride |
PageInfo
| Field | Type | Description |
|---|---|---|
pagerequired | integer | |
pageSizerequired | integer | |
totalrequired | integer (int64) | |
totalPagesrequired | integer |
PasswordChange
| Field | Type | Description |
|---|---|---|
currentPasswordrequired | string | |
newPasswordrequired | string |
PasswordResetIssued
| Field | Type | Description |
|---|---|---|
expiresAtrequired | string (date-time) | |
tokenrequired | string | Shown once; only its hash is stored. |
PasswordResetRedeem
| Field | Type | Description |
|---|---|---|
newPasswordrequired | string | |
tokenrequired | string |
PerformanceAbsentReasons
Why a kind in absentKinds is missing, where the catalogue knows more
than that the type publishes no such metric (p7_76): a CloudFront
distribution's origin latency is an additional metric the account
turns on and pays for. A kind without a reason here has none to give.
| Field | Type | Description |
|---|---|---|
errors | string | |
latency | string | |
saturation | string |
PerformanceKind
What a series stands for.
Type: string enum
PerformanceKubernetesPlacement
Compute only (p7_75): where the resource sits in Kubernetes. An EKS
cluster names itself; a node its cluster, its managed node group or
Karpenter node pool, and its EC2 instance; a pod its cluster,
namespace, node and workload. An EC2 instance has one when the
owner's rule places it in Kubernetes — rule says which part — and
none when it is EC2 outside Kubernetes.
| Field | Type | Description |
|---|---|---|
clusterrequired | string | The cluster's name; empty for an instance only a Karpenter node pool tag places in Kubernetes. |
instanceId | string | A node's EC2 instance. |
namespace | string | A pod's namespace. |
node | string | A pod's node. |
nodeGroup | string | The managed node group, when one is known. |
nodePool | string | The Karpenter node pool, when one is known. |
rolerequired | string enum | What the resource is to the cluster: the cluster itself, a node, a
pod, or a machine the rule places in it (an EC2 instance). The
portal groups by it, and names no service. One of cluster, node, pod, machine. |
rule | string enum | An EC2 instance's: which part of the rule placed it — its Auto
Scaling group is a node group's, or it carries eks:cluster-name,
kubernetes.io/cluster/<name> or karpenter.sh/nodepool. One of nodeGroupAutoScaling, eksClusterTag, kubernetesClusterTag, karpenterTag. |
workload | string | A pod's workload. Container Insights reports the pods of one workload together, so a pod's series are its workload's. |
PerformanceLoad
A part's newest stored value of its first saturation series.
| Field | Type | Description |
|---|---|---|
atrequired | string (date-time) | |
metricrequired | string | |
signalrequired | string | |
unitrequired | string | |
valuerequired | number (double) |
PerformancePodWindow
A pod's own series over a window, read from CloudWatch on demand (p7_86).
| Field | Type | Description |
|---|---|---|
containersCutrequired | integer | The pod's containers past the first eight, whose series the read left out. |
fromrequired | string (date-time) | |
readAtrequired | string (date-time) | When CloudWatch was read for these series — now, or within the step before, whose answer is kept. |
resourcerequired | PerformanceResourceSeries | |
stepSecondsrequired | integer | |
torequired | string (date-time) | |
windowrequired | PerformanceWindow | One of 1h, 24h, 7d. |
PerformancePoint
| Field | Type | Description |
|---|---|---|
atrequired | string (date-time) | The start of the step the value covers. |
max | number (double) | The highest stored five-minute value in the step, where the step combines more than one (p7_85). |
min | number (double) | The lowest stored five-minute value in the step, where the step combines more than one (p7_85). |
valuerequired | number (double) |
PerformanceResourceSeries
| Field | Type | Description |
|---|---|---|
absentKindsrequired | array of PerformanceKind | The kinds this resource's type publishes no metric for; the view says so rather than showing another metric. |
absentReasons | PerformanceAbsentReasons | Why a kind in absentKinds is missing, where the catalogue knows more
than that the type publishes no such metric (p7_76): a CloudFront
distribution's origin latency is an additional metric the account
turns on and pays for. A kind without a reason here has none to give. |
accountIdrequired | string | |
hasTopology | boolean | Set when /performance/topology draws this resource's parts
(p7_87): an EKS cluster's, a load balancer's. The portal links to
the drawing by it, naming no service. |
kubernetes | PerformanceKubernetesPlacement | Compute only (p7_75): where the resource sits in Kubernetes. An EKS
cluster names itself; a node its cluster, its managed node group or
Karpenter node pool, and its EC2 instance; a pod its cluster,
namespace, node and workload. An EC2 instance has one when the
owner's rule places it in Kubernetes — rule says which part — and
none when it is EC2 outside Kubernetes. |
regionrequired | string | |
resourceIdrequired | string | |
resourceNamerequired | string | |
resourceTyperequired | string | |
section | PerformanceSection | Estate's Performance sections; the catalogue places each resource type in one. One of compute, networking, databases, messaging, serverless, storage. |
seriesrequired | array of PerformanceSeries | |
servicerequired | string | |
systemrequired | string | |
typeLabel | string | The type's plural label, from its render descriptor (the same as
/resource-types gives), which the portal groups a section by
(p7_76). An RDS instance of Aurora, DocumentDB or Neptune carries
its engine's label instead, so the Databases section shows them
apart (p7_77). |
PerformanceResourceWindow
| Field | Type | Description |
|---|---|---|
fromrequired | string (date-time) | |
resourcerequired | PerformanceResourceSeries | |
stepSecondsrequired | integer | |
torequired | string (date-time) | |
windowrequired | PerformanceWindow | One of 1h, 24h, 7d. |
PerformanceSection
Estate's Performance sections; the catalogue places each resource type in one.
Type: string enum
PerformanceSectionPage
| Field | Type | Description |
|---|---|---|
fromrequired | string (date-time) | |
nextCursor | string | Present when more resources follow; pass it back as cursor. |
resourcesrequired | array of PerformanceResourceSeries | |
sectionrequired | PerformanceSection | Estate's Performance sections; the catalogue places each resource type in one. One of compute, networking, databases, messaging, serverless, storage. |
stepSecondsrequired | integer | |
torequired | string (date-time) | |
windowrequired | PerformanceWindow | One of 1h, 24h, 7d. |
PerformanceSeries
| Field | Type | Description |
|---|---|---|
detail | string | Which of the resource's series of one metric this is, or what the metric counts where its name alone would mislead (p7_76): a regional NAT gateway's zone; "client errors, throttling included" for API Gateway's 4xx. |
kindrequired | PerformanceKind | What a series stands for. One of latency, errors, saturation. |
latestAt | string (date-time) | The newest stored point's time, in the window or before it; absent when the series has no point stored at all. How old the series is: a budget that reaches an estate less often than once a run shows here. |
metricrequired | string | |
namespacerequired | string | |
pointsrequired | array of PerformancePoint | One per step that has a stored point, oldest first. A step with none is left out, not zero. |
previous | array of PerformancePoint | With compare=previous (p7_85): the series over the window before
this one, at the same step, its points at that window's times;
empty when nothing was stored then. |
signalrequired | string | The catalogue's name for the series, stable across releases (cpu, read_latency). |
statisticrequired | string | |
summary | PerformanceSeriesSummary | The series' stored five-minute values over the window (p7_85): their mean, lowest, highest and 95th percentile, and how many there are. Computed from what the Performance job stored, so it costs no CloudWatch read; a spike shorter than five minutes counts as its five-minute value. |
unitrequired | string | CloudWatch's unit for the metric (Percent, Seconds, Count). |
PerformanceSeriesSummary
The series' stored five-minute values over the window (p7_85): their mean, lowest, highest and 95th percentile, and how many there are. Computed from what the Performance job stored, so it costs no CloudWatch read; a spike shorter than five minutes counts as its five-minute value.
| Field | Type | Description |
|---|---|---|
countrequired | integer | The five-minute values the summary is of. |
maxrequired | number (double) | |
meanrequired | number (double) | |
minrequired | number (double) | |
p95required | number (double) |
PerformanceTopology
A resource's parts, for a drawing (p7_87).
| Field | Type | Description |
|---|---|---|
loadSincerequired | string (date-time) | The oldest a stored point may be and still be a part's load; a part with none since has no load. |
rootrequired | PerformanceTopologyPart |
PerformanceTopologyPart
| Field | Type | Description |
|---|---|---|
childrenrequired | array of PerformanceTopologyPart | |
cutrequired | integer | How many of its children were left out past the caps. |
kindrequired | string enum | What the part is: the cluster; a managed node group, a Karpenter
node pool, or the nodes in neither (ungrouped); a node; the pods
on no node the inventory holds (unscheduled); a pod; the load
balancer; a target group; a target. One of cluster, nodeGroup, nodePool, ungrouped, node, unscheduled, pod, loadBalancer, targetGroup, target. |
labelrequired | string | Its name — a resource's, a group's, or a target's id and port; empty for ungrouped and unscheduled. |
load | PerformanceLoad | A part's newest stored value of its first saturation series. |
podsOnNodesLeftOut | integer | A cluster's pods on the nodes its cap left out (cut): counted,
not drawn, and never among the pods on no node the inventory holds
(P787-R1-F01). |
reason | string | Why a target is in its state, as the balancer gave it; or what could not be read of a balancer or a group. |
resource | PerformanceTopologyResource | The inventory resource a part is, for a link to its detail or its Performance. |
state | string | A target's health state, as the balancer gave it (healthy, unhealthy, draining, …); its status follows it. |
status | Status | The health of something that currently exists. Derived once at ingest
and stored, never recomputed at read time. deleted is absent, and that is the point: a deleted resource is
tombstoned, not given a status, so a live resource carrying
status: deleted is an impossible value. Keeping one enum for both made
that impossible value contract-valid everywhere. unknown (p7_83) is a resource the inventory knows exists, or was
recently active, whose health it cannot observe — found only from its
metrics or its name. It is neither healthy nor a problem: never green,
not on the Alerts page, and counted as not checked on the public page. One of failed, degraded, operational, unknown. |
PerformanceTopologyResource
The inventory resource a part is, for a link to its detail or its Performance.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
regionrequired | string | |
resourceIdrequired | string | |
resourceTyperequired | string | |
servicerequired | string | |
systemrequired | string |
PerformanceWindow
Type: string enum
Preferences
The person's portal preferences (p7_89), applied by the portal; an absent field is the browser's own.
| Field | Type | Description |
|---|---|---|
dateFormat | string enum | iso 2026-09-15, dmy 15.09.2026, mdy 09/15/2026. One of iso, dmy, mdy. |
numberFormat | string enum | commaDot 1,234.5, dotComma 1.234,5, spaceComma 1 234,5, plain 1234.5. One of commaDot, dotComma, spaceComma, plain. |
theme | string enum | One of system, light, dark. |
timeZone | string | An IANA time zone, such as Europe/Berlin. |
ProfileUpdate
| Field | Type | Description |
|---|---|---|
displayName | string | |
preferences | Preferences | The person's portal preferences (p7_89), applied by the portal; an absent field is the browser's own. |
ProvisionAction
What a request asks for, and PROVISIONING_SPEC.md §4.2's action
CHECK constraint exactly.
create and destroy are the lifecycle (D5). There is no in-place
update in v1: changing a resource is a destroy and a create, and each
gets its own approval. A lifecycle with three verbs and one of them
partial is worse than one with two that are complete.
resolve is not a lifecycle verb. It is git ref resolution modelled as
a request (§6.6) so that it inherits the claim, the fence, the replay
rules, the heartbeat, the reap and the tenant scoping wholesale instead
of growing a second copy of each. It is created only by publishing a
git-sourced version, never by POST /provisioning/requests, and it is
the one action that skips pending_approval: approval governs
infrastructure, and resolving a ref creates none.
Type: string enum
ProvisionApproval
A request is decided exactly once, ever — approvals is keyed
(tenant_id, request_id). Actor, decision, comment and timestamp are
permanent and outlive log retention.
| Field | Type | Description |
|---|---|---|
actorrequired | ActorRef | Who did something, for display and for audit. Not User: that carries
the tenant and the role of the session, and an audit record naming a
role that has since changed would be a lie about the past. |
comment | string | |
decidedAtrequired | string (date-time) | |
decisionrequired | string enum | One of approved, rejected. |
ProvisionDecision
| Field | Type | Description |
|---|---|---|
comment | string | Recorded permanently and shown to the requester. Optional on approve; a rejection without one is technically allowed and practically unkind. |
decisionrequired | string enum | One of approved, rejected. |
ProvisionFailureClass
PROVISIONING_SPEC.md §7.4 — six classes, and PLATFORM.md §8 was
amended on 2026-09-01 to match.
These are what an agent reports. A request can also fail restored,
which only the platform writes (ProvisionRequestFailureClass).
| Class | Meaning | Retried by the agent |
|---|---|---|
retryable |
Network, throttling, transient provider error | Yes, bounded, with jitter |
permanent |
The operation cannot succeed as specified | No |
configuration |
Bad template, bad variables, missing provider config | No — the message points at the template |
infrastructure |
The cloud refused: quota, outage, IAM denial | No — the message points at the account |
timeout |
A stage exceeded its limit | No |
cancelled |
A human asked it to stop | No |
The pair that earns its keep is configuration against
infrastructure. One means the template author must fix something, the
other means the cloud account must be; collapsing them into "permanent"
routes every failure to the wrong person, which is what the four-class
version of this list did.
Type: string enum
ProvisionOutcomeSummary
One past request for the same template in the same account, for the approver's "has this worked here before" question.
| Field | Type | Description | ||||
|---|---|---|---|---|---|---|
failureClass | ProvisionRequestFailureClass | Why a request failed, as the platform records it: the six classes an
agent reports (ProvisionFailureClass), and a seventh that only the
platform writes (PROVISIONING_SPEC.md §7.4, amended 2026-09-12).
restored, or anything outside
ProvisionFailureClass, is refused with 400. One of retryable, permanent, configuration, infrastructure, timeout, cancelled, restored. | ||||
requestIdrequired | string (uuid) | |||||
requestedAtrequired | string (date-time) | |||||
requestedByrequired | ActorRef | Who did something, for display and for audit. Not User: that carries
the tenant and the role of the session, and an audit record naming a
role that has since changed would be a lie about the past. | ||||
staterequired | ProvisionRequestState | PROVISIONING_SPEC.md §5, exactly. approved is deliberately not a state. Approving writes the
approvals row and sets queued in one transaction; a separate
approved state could only ever be observed as a limbo where a request
is blessed but not runnable, and every reader would have to treat the
two identically. cancelling is the only non-terminal state a human can ask for and not
immediately get: the agent learns about it on its next heartbeat and
moves the request to cancelled itself. One of pending_approval, rejected, expired, withdrawn, queued, running, cancelling, cancelled, succeeded, failed. | ||||
templateVersionrequired | integer | |||||
terminalAt | string (date-time) |
ProvisionRequest
| Field | Type | Description | ||||
|---|---|---|---|---|---|---|
actionrequired | ProvisionAction | What a request asks for, and PROVISIONING_SPEC.md §4.2's action
CHECK constraint exactly. create and destroy are the lifecycle (D5). There is no in-place
update in v1: changing a resource is a destroy and a create, and each
gets its own approval. A lifecycle with three verbs and one of them
partial is worse than one with two that are complete. resolve is not a lifecycle verb. It is git ref resolution modelled as
a request (§6.6) so that it inherits the claim, the fence, the replay
rules, the heartbeat, the reap and the tenant scoping wholesale instead
of growing a second copy of each. It is created only by publishing a
git-sourced version, never by POST /provisioning/requests, and it is
the one action that skips pending_approval: approval governs
infrastructure, and resolving a ref creates none. One of create, destroy, resolve. | ||||
approval | ProvisionApproval | A request is decided exactly once, ever — approvals is keyed
(tenant_id, request_id). Actor, decision, comment and timestamp are
permanent and outlive log retention. | ||||
attemptrequired | integer | The fence (§6.3). A claim stamps this value onto the task it
creates and leaves it alone; what increments it is every event that
invalidates the current task — reap, cancel, retry — in the same
transaction as the invalidation. Every agent write carrying an older
value is refused task_superseded, which is what stops an
abandoned task's process from resurrecting, from the instant it is
abandoned rather than from the next claim. | ||||
expiresAtrequired | string (date-time) | The pending_approval deadline (D9). An unactioned request becomes
expired — terminal — after which the pinned version can safely
move on. A reminder fires at half the window. | ||||
failureClass | ProvisionRequestFailureClass | Why a request failed, as the platform records it: the six classes an
agent reports (ProvisionFailureClass), and a seventh that only the
platform writes (PROVISIONING_SPEC.md §7.4, amended 2026-09-12).
restored, or anything outside
ProvisionFailureClass, is refused with 400. One of retryable, permanent, configuration, infrastructure, timeout, cancelled, restored. | ||||
failureMessage | string | Scrubbed by the agent before it was ever sent. | ||||
idrequired | string (uuid) | |||||
namerequired | string | The requester's name for the thing. | ||||
partialApply | boolean | A failure left infrastructure behind. Surfaced loudly and never auto-destroyed: the operator gets the state backend location and both next actions, and makes the call themselves. | ||||
requestedAtrequired | string (date-time) | |||||
requestedByrequired | ActorRef | Who did something, for display and for audit. Not User: that carries
the tenant and the role of the session, and an audit record naming a
role that has since changed would be a lie about the past. | ||||
resourceId | string (uuid) | The provisionedResources row this destroys. Present for
destroy and absent for create, both enforced. | ||||
sourceIdentity | string | The pinned version's gitSha or inlineSha256, denormalised onto
the request because it is what the approver is actually approving
and it must still read correctly after the template is archived. | ||||
staterequired | ProvisionRequestState | PROVISIONING_SPEC.md §5, exactly. approved is deliberately not a state. Approving writes the
approvals row and sets queued in one transaction; a separate
approved state could only ever be observed as a limbo where a request
is blessed but not runnable, and every reader would have to treat the
two identically. cancelling is the only non-terminal state a human can ask for and not
immediately get: the agent learns about it on its next heartbeat and
moves the request to cancelled itself. One of pending_approval, rejected, expired, withdrawn, queued, running, cancelling, cancelled, succeeded, failed. | ||||
stateBackend | string | Where the agent put this request's Terraform state, as the agent reported it. The platform never reads it — state is customer-owned (D3) — and stores the location only so an operator can find it after a partial apply. | ||||
targetAccountIdrequired | string | |||||
teamId | string (uuid) | The owning team, which decides who may approve. Present for every
action except resolve, which has no owning team because it creates
no infrastructure and skips approval (§6.6). | ||||
teamName | string | |||||
templateIdrequired | string (uuid) | |||||
templateName | string | |||||
templateSlugrequired | string | |||||
templateVersionrequired | integer | |||||
terminalAt | string (date-time) | |||||
variablesRedactedrequired | object | The submitted variables with every sensitive value masked. The
unmasked values are never returned by any read endpoint — they
leave the platform exactly once, to the agent that claimed the task,
and that is a different response type. Keys are exactly the variable names the pinned version's schema
declares; nothing else can reach this map, because the request was
validated against that schema before it existed. |
ProvisionRequestCreate
| Field | Type | Description |
|---|---|---|
actionrequired | ProvisionAction | create or destroy. resolve is refused here with
invalid_request: a resolution request is created by the
transaction that publishes a git-sourced version (§6.6), never by a
person, and one submitted by hand would pin a version to a SHA
nobody asked for. |
namerequired | string | The requester's name for the thing. |
resourceId | string (uuid) | Required for destroy, rejected for create. |
targetAccountIdrequired | string | Must exist in this tenant's accounts registry, be enabled, and be
owned by a team the requester belongs to. |
teamId | string (uuid) | The owning team, which decides who may approve. Optional when the requester belongs to exactly one team that owns the account; required when they belong to several, because guessing would decide the approver. |
templateIdrequired | string (uuid) | |
templateVersionrequired | integer | Explicit, never "latest". A request that pinned a moving target would be approved against one version and applied from another. |
variables | object | Validated against the pinned version's schema before the request
exists — both the value types, which this schema constrains, and the
names, which it cannot: a key the version does not declare is
rejected by the validator. Values marked sensitive are stored
unmasked (the agent needs them) and masked everywhere they are read
back. |
ProvisionRequestDetail
| Field | Type | Description | ||||
|---|---|---|---|---|---|---|
actionrequired | ProvisionAction | What a request asks for, and PROVISIONING_SPEC.md §4.2's action
CHECK constraint exactly. create and destroy are the lifecycle (D5). There is no in-place
update in v1: changing a resource is a destroy and a create, and each
gets its own approval. A lifecycle with three verbs and one of them
partial is worse than one with two that are complete. resolve is not a lifecycle verb. It is git ref resolution modelled as
a request (§6.6) so that it inherits the claim, the fence, the replay
rules, the heartbeat, the reap and the tenant scoping wholesale instead
of growing a second copy of each. It is created only by publishing a
git-sourced version, never by POST /provisioning/requests, and it is
the one action that skips pending_approval: approval governs
infrastructure, and resolving a ref creates none. One of create, destroy, resolve. | ||||
approval | ProvisionApproval | A request is decided exactly once, ever — approvals is keyed
(tenant_id, request_id). Actor, decision, comment and timestamp are
permanent and outlive log retention. | ||||
attemptrequired | integer | The fence (§6.3). A claim stamps this value onto the task it
creates and leaves it alone; what increments it is every event that
invalidates the current task — reap, cancel, retry — in the same
transaction as the invalidation. Every agent write carrying an older
value is refused task_superseded, which is what stops an
abandoned task's process from resurrecting, from the instant it is
abandoned rather than from the next claim. | ||||
destroyTarget | ProvisionedResource | destroy only. What will be removed: the resource, its
account, the number of tagged resources the create left behind
(expectedCount), and the request that created it. "Destroy
web-01" and "destroy 34 resources" are different decisions, and
an approval screen that shows the template name alone cannot
tell them apart (p4_12). | ||||
expiresAtrequired | string (date-time) | The pending_approval deadline (D9). An unactioned request becomes
expired — terminal — after which the pinned version can safely
move on. A reminder fires at half the window. | ||||
failureClass | ProvisionRequestFailureClass | Why a request failed, as the platform records it: the six classes an
agent reports (ProvisionFailureClass), and a seventh that only the
platform writes (PROVISIONING_SPEC.md §7.4, amended 2026-09-12).
restored, or anything outside
ProvisionFailureClass, is refused with 400. One of retryable, permanent, configuration, infrastructure, timeout, cancelled, restored. | ||||
failureMessage | string | Scrubbed by the agent before it was ever sent. | ||||
idrequired | string (uuid) | |||||
namerequired | string | The requester's name for the thing. | ||||
partialApply | boolean | A failure left infrastructure behind. Surfaced loudly and never auto-destroyed: the operator gets the state backend location and both next actions, and makes the call themselves. | ||||
previousApprovedVersion | integer | The last version of this template that was approved in this account, so the portal can show a diff against it. Absent when there is none, which is itself worth seeing. | ||||
recentOutcomes | array of ProvisionOutcomeSummary | Newest first. | ||||
requestedAtrequired | string (date-time) | |||||
requestedByrequired | ActorRef | Who did something, for display and for audit. Not User: that carries
the tenant and the role of the session, and an audit record naming a
role that has since changed would be a lie about the past. | ||||
resourceId | string (uuid) | The provisionedResources row this destroys. Present for
destroy and absent for create, both enforced. | ||||
sourceIdentity | string | The pinned version's gitSha or inlineSha256, denormalised onto
the request because it is what the approver is actually approving
and it must still read correctly after the template is archived. | ||||
staterequired | ProvisionRequestState | PROVISIONING_SPEC.md §5, exactly. approved is deliberately not a state. Approving writes the
approvals row and sets queued in one transaction; a separate
approved state could only ever be observed as a limbo where a request
is blessed but not runnable, and every reader would have to treat the
two identically. cancelling is the only non-terminal state a human can ask for and not
immediately get: the agent learns about it on its next heartbeat and
moves the request to cancelled itself. One of pending_approval, rejected, expired, withdrawn, queued, running, cancelling, cancelled, succeeded, failed. | ||||
stateBackend | string | Where the agent put this request's Terraform state, as the agent reported it. The platform never reads it — state is customer-owned (D3) — and stores the location only so an operator can find it after a partial apply. | ||||
targetAccountrequired | Account | Including its environment tag. "Production" is the single most decision-changing fact on an approval screen. | ||||
targetAccountIdrequired | string | |||||
teamId | string (uuid) | The owning team, which decides who may approve. Present for every
action except resolve, which has no owning team because it creates
no infrastructure and skips approval (§6.6). | ||||
teamName | string | |||||
templateIdrequired | string (uuid) | |||||
templateName | string | |||||
templateSlugrequired | string | |||||
templateVersionrequired | integer | |||||
terminalAt | string (date-time) | |||||
variablesRedactedrequired | object | The submitted variables with every sensitive value masked. The
unmasked values are never returned by any read endpoint — they
leave the platform exactly once, to the agent that claimed the task,
and that is a different response type. Keys are exactly the variable names the pinned version's schema
declares; nothing else can reach this map, because the request was
validated against that schema before it existed. | ||||
versionrequired | TemplateVersion | The pinned version, including its source identity. inlineSource
is present here: for an inline template this is the code being
approved, and an approver who cannot read it is rubber-stamping. |
ProvisionRequestFailureClass
Why a request failed, as the platform records it: the six classes an
agent reports (ProvisionFailureClass), and a seventh that only the
platform writes (PROVISIONING_SPEC.md §7.4, amended 2026-09-12).
| Class | Meaning |
|---|---|
restored |
The platform's database was restored from a backup while this request was queued, running or cancelling. What it did after the backup is unknown, and failureMessage names the backup and where the request's state is. It runs again only when an operator retries it (p7_62). |
An agent that reports restored, or anything outside
ProvisionFailureClass, is refused with 400.
Type: string enum
ProvisionRequestPage
| Field | Type | Description |
|---|---|---|
itemsrequired | array of ProvisionRequest | |
pagerequired | integer | |
pageSizerequired | integer | |
totalrequired | integer (int64) | |
totalPagesrequired | integer |
ProvisionRequestState
PROVISIONING_SPEC.md §5, exactly.
approved is deliberately not a state. Approving writes the
approvals row and sets queued in one transaction; a separate
approved state could only ever be observed as a limbo where a request
is blessed but not runnable, and every reader would have to treat the
two identically.
cancelling is the only non-terminal state a human can ask for and not
immediately get: the agent learns about it on its next heartbeat and
moves the request to cancelled itself.
Type: string enum
ProvisionRequestValidationError
| Field | Type | Description |
|---|---|---|
details | array of object | One entry per variable that failed. A generated form that cannot say which field is wrong is worse than no form, so this is part of the contract rather than a courtesy. |
errorrequired | object |
ProvisionTask
One attempt at one request, by one agent.
| Field | Type | Description |
|---|---|---|
agentIdrequired | string (uuid) | |
attemptrequired | integer | |
claimedAtrequired | string (date-time) | |
finishedAt | string (date-time) | |
heartbeatAtrequired | string (date-time) | Falling behind heartbeatTimeoutSeconds is what makes the scheduler
reap this task and return its request to queued. |
idrequired | string (uuid) | |
requestIdrequired | string (uuid) | |
stagerequired | string | Free text from the agent — init, plan, apply and so on — not
an enum, because a third-party agent may legitimately have stages
this platform has never heard of, and refusing them would make the
protocol brittle for no gain. Rendered as a label, never branched on. |
staterequired | ProvisionTaskState | One attempt at one request. abandoned is what the reaper writes when a
task's heartbeat goes stale — distinct from failed, because nobody
reported a failure: the process stopped talking, and what it did before
it stopped is unknown. One of claimed, running, succeeded, failed, cancelled, abandoned. |
ProvisionTaskDetail
| Field | Type | Description |
|---|---|---|
agentIdrequired | string (uuid) | |
attemptrequired | integer | |
claimedAtrequired | string (date-time) | |
finishedAt | string (date-time) | |
heartbeatAtrequired | string (date-time) | Falling behind heartbeatTimeoutSeconds is what makes the scheduler
reap this task and return its request to queued. |
idrequired | string (uuid) | |
requestIdrequired | string (uuid) | |
stagerequired | string | Free text from the agent — init, plan, apply and so on — not
an enum, because a third-party agent may legitimately have stages
this platform has never heard of, and refusing them would make the
protocol brittle for no gain. Rendered as a label, never branched on. |
staterequired | ProvisionTaskState | One attempt at one request. abandoned is what the reaper writes when a
task's heartbeat goes stale — distinct from failed, because nobody
reported a failure: the process stopped talking, and what it did before
it stopped is unknown. One of claimed, running, succeeded, failed, cancelled, abandoned. |
summary | ProvisionTaskSummary | Written when the task becomes terminal. |
ProvisionTaskLogLine
One stored log line, as the portal reads it.
Deliberately not AgentLogLine, for the same reason ResourceSnapshot
is not SnapshotInput: the write shape carries the bounds the server
enforces on the way in — a batch that violates them is rejected whole —
and the read shape carries what was stored. Merging them would put a
maxLength on a response, which is a promise about history that a
change to the limit would break retroactively.
| Field | Type | Description |
|---|---|---|
createdAtrequired | string (date-time) | |
levelrequired | string enum | One of debug, info, warn, error. |
messagerequired | string | Already scrubbed by the agent when it was sent. |
seqrequired | integer (int64) | Agent-assigned and monotonic per task. Gaps mean the agent's own buffer dropped lines under pressure; they are not an error. |
stagerequired | string |
ProvisionTaskLogPage
Cursor-paginated, so deliberately no page, total or totalPages: a
running apply appends continuously, and a page number has no stable
meaning against a table that is growing at the tail.
| Field | Type | Description |
|---|---|---|
completionrequired | string enum | Whether anything more will come after nextCursor (p7_7, F11).
This — not the task's state, and not a short page — is the
tailer's stop condition. The agent reports its terminal status
before it drains the last log batches, so a task is terminal
while its most useful lines are still in flight; a viewer that
stopped at "terminal plus a short page" lost them until a reload.
pending, complete, incomplete, unverified. |
itemsrequired | array of ProvisionTaskLogLine | |
nextCursorrequired | string | Always present, and always positioned after the last line in
items. Pass it as cursor on the next request and you get only
what has been appended since. It is a watermark, not a "there is more" flag. An empty page returns
the watermark it was given, so a tailer that polls with it makes
progress and never re-reads; a tailer that polls with its own
previous input cursor — which an earlier draft of this contract told
it to do when nextCursor was absent — re-reads the page it just
consumed, and on the first page, where there is no input cursor,
re-reads the log from the beginning forever. "Is there more?" is answered by completion, not by this field
and not by the task's state alone: a page can be empty because
the apply is between log lines, or because the agent is still
draining after its terminal report, and a client that stops on an
empty page — or on a terminal state — stops before the tail. |
ProvisionTaskPage
| Field | Type | Description |
|---|---|---|
itemsrequired | array of ProvisionTask | |
pagerequired | integer | |
pageSizerequired | integer | |
totalrequired | integer (int64) | |
totalPagesrequired | integer |
ProvisionTaskState
One attempt at one request. abandoned is what the reaper writes when a
task's heartbeat goes stale — distinct from failed, because nobody
reported a failure: the process stopped talking, and what it did before
it stopped is unknown.
Type: string enum
ProvisionTaskSummary
Permanent. A task's full logs are dropped after 30 days; this survives, because "what did it do, did it work, how long did it take, and which agent and OpenTofu version ran it" is the audit record. An audit that expires is not an audit.
| Field | Type | Description | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
agentVersionrequired | string | ||||||||||||||||||||||
createdAtrequired | string (date-time) | ||||||||||||||||||||||
createdCount | integer | ||||||||||||||||||||||
destroyedCount | integer | ||||||||||||||||||||||
failureClass | ProvisionFailureClass | PROVISIONING_SPEC.md §7.4 — six classes, and PLATFORM.md §8 was
amended on 2026-09-01 to match. These are what an agent reports. A request can also fail restored,
which only the platform writes (ProvisionRequestFailureClass).
configuration against
infrastructure. One means the template author must fix something, the
other means the cloud account must be; collapsing them into "permanent"
routes every failure to the wrong person, which is what the four-class
version of this list did. One of retryable, permanent, configuration, infrastructure, timeout, cancelled. | |||||||||||||||||||||
failureMessage | string | ||||||||||||||||||||||
logLineCount | integer (int64) | ||||||||||||||||||||||
outcomerequired | ProvisionTaskState | One attempt at one request. abandoned is what the reaper writes when a
task's heartbeat goes stale — distinct from failed, because nobody
reported a failure: the process stopped talking, and what it did before
it stopped is unknown. One of claimed, running, succeeded, failed, cancelled, abandoned. | |||||||||||||||||||||
planDigest | string | sha256 of the plan output. plan still runs even though it no
longer gates approval (D1): it is a cheap failure detector before
anything is created, and its digest is how two applies of the same
version are compared after the logs are gone. | |||||||||||||||||||||
stageDurationsMs | object | Stage name to elapsed milliseconds. | |||||||||||||||||||||
tofuVersionrequired | string |
ProvisionedResource
What a create produced, and the anchor of the provenance badge on the
inventory side. A create that stopped part-way has one too, partial,
so what it made can be destroyed from the platform (p7_65).
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
createRequestIdrequired | string (uuid) | |
createdAtrequired | string (date-time) | |
destroyRequestId | string (uuid) | |
destroyedAt | string (date-time) | |
expectedCountrequired | integer | Tagged resources found in state immediately after the apply. This is the baseline the drift diff compares against; without it, "provisioned but missing" has nothing to be missing from. Untaggable resource types simply do not contribute — they lower this number, they do not make it wrong. |
idrequired | string (uuid) | |
namerequired | string | |
provenanceTaggedrequired | boolean | Whether the panorama:request_id stamp verifiably landed — the agent
read state after the apply and counted resources carrying it. False excludes this row from the drift diff and shows a "provenance
unverified" badge instead. A stamp that silently failed to land is
worse than no stamp at all, so it is never silently missing: the
usual cause is a template declaring its own default_tags, which
collides with the override. |
stateBackend | string | Where its Terraform state lives, as the agent reported it. |
statusrequired | ProvisionedResourceStatus | active: a create succeeded, and the drift diff watches it.
partial: a create or a destroy stopped part-way; what exists is in
the state at stateBackend, and a destroy is how it goes (p7_65). A
destroy that fails cleanly leaves the status as it was.
destroyed: a destroy succeeded; the row stays as the record. One of active, destroyed, partial. |
teamIdrequired | string (uuid) | |
templateIdrequired | string (uuid) | |
templateSlug | string | |
templateVersionrequired | integer |
ProvisionedResourcePage
| Field | Type | Description |
|---|---|---|
itemsrequired | array of ProvisionedResource | |
pagerequired | integer | |
pageSizerequired | integer | |
totalrequired | integer (int64) | |
totalPagesrequired | integer |
ProvisionedResourceStatus
active: a create succeeded, and the drift diff watches it.
partial: a create or a destroy stopped part-way; what exists is in
the state at stateBackend, and a destroy is how it goes (p7_65). A
destroy that fails cleanly leaves the status as it was.
destroyed: a destroy succeeded; the row stays as the record.
Type: string enum
ProvisioningAgent
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | The one account this agent may ever claim work for. One agent per AWS account (D4). |
agentVersion | string | |
createdAtrequired | string (date-time) | |
createdByrequired | ActorRef | Who did something, for display and for audit. Not User: that carries
the tenant and the role of the session, and an audit record naming a
role that has since changed would be a lie about the past. |
idrequired | string (uuid) | |
lastSeenAt | string (date-time) | The last call this token made. A stale value on an account whose requests never start is the answer to why. |
namerequired | string | |
revokedAt | string (date-time) | |
tofuVersion | string | |
versionStatusrequired | AgentVersionStatus | Report and warn; refuse only below the declared minimum (D16).
outdated still claims work — an operator upgrades a fleet over weeks,
and a platform that stops the fleet the day it ships a release is a
platform nobody upgrades. One of supported, outdated, unsupported. |
ProvisioningAgentPage
| Field | Type | Description |
|---|---|---|
heartbeatTimeoutSecondsrequired | integer | The server's agent_heartbeat_timeout: an agent whose
lastSeenAt is older than this is stopped, and the portal
says so (p4_15 §4) using the same number the reaper uses,
rather than one of its own. |
itemsrequired | array of ProvisioningAgent | |
pagerequired | integer | |
pageSizerequired | integer | |
totalrequired | integer (int64) | |
totalPagesrequired | integer |
ProvisioningAgentTokenCreate
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
namerequired | string |
ProvisioningAgentTokenIssued
| Field | Type | Description |
|---|---|---|
agentrequired | ProvisioningAgent | |
tokenrequired | string (password) | Returned once and never again: only sha256(token) is stored.
Install it in the agent's environment as its platform credential —
and note that the agent must keep it out of OpenTofu's environment,
which is what the §7.6 allowlist is for. A malicious template that
could read this would have the tenant's provisioning API. |
PublicEnvironment
One of PublicStatus's objects, and closed like it (see PublicStatus); named so the server builds it as a type rather than retyping it (p7_52).
| Field | Type | Description |
|---|---|---|
namerequired | string | |
servicesDegradedrequired | integer | |
servicesFailedrequired | integer | |
servicesTotalrequired | integer | |
servicesUnknownrequired | integer | Services with nothing confirmed lately: every resource in
them is stale, or unknown (p7_83). Neither healthy nor
unhealthy, and not in servicesDegraded or servicesFailed
(p7_12). |
statusrequired | PublicHealth | An environment's health as the public page says it: Status plus
unknown, for an environment none of whose services has been
confirmed inside its freshness window (p7_12). Its own enum: a
resource's unknown (p7_83) is health the inventory cannot see,
which the public page counts as not confirmed, with the stale. One of failed, degraded, operational, unknown. |
unhealthySince | string (date-time) | When the environment stopped being healthy: the start of the oldest still-open incident among its unhealthy resources, each resource's incident beginning at its first non-operational transition after it was last operational (or deleted). Derived from the permanent per-resource history, so a neighbour recovering never moves it and it is not bounded by the timeline's window. Absent when the environment is healthy, and absent when no history establishes a start — the page then says the environment is unhealthy without saying since when. |
PublicHealth
An environment's health as the public page says it: Status plus
unknown, for an environment none of whose services has been
confirmed inside its freshness window (p7_12). Its own enum: a
resource's unknown (p7_83) is health the inventory cannot see,
which the public page counts as not confirmed, with the stale.
Type: string enum
PublicStatus
The public page's own type, not an internal type with fields omitted — a shared type is one careless serialisation tag away from leaking the whole inventory.
Never contains resource names, resource IDs, account IDs, regions, IP
addresses, ARNs or additionalInfo.
Every object here is closed, including the nested ones. Without
additionalProperties: false the allowlist is a comment: a response
that added resourceId would still validate, and this is the one
endpoint where that mistake is unauthenticated and public.
| Field | Type | Description |
|---|---|---|
demorequired | boolean | True when this status page describes a fabricated estate
(panorama admin seed-demo). The page says so on every load. |
environmentsrequired | array of PublicEnvironment | |
timelinerequired | array of PublicTimelineEvent | Aggregated. "3 instances became degraded", never a list of which. |
PublicTenant
The slug the status page's own URL carries, and the demonstration mark it shows — and nothing else: the tenant's registered name is not public (p7_92, review round 1).
| Field | Type | Description |
|---|---|---|
demorequired | boolean | A demonstration tenant, as the status page marks it. |
tenantSlugrequired | string |
PublicTimelineEvent
One of PublicStatus's objects, and closed like it (see PublicStatus); named so the server builds it as a type rather than retyping it (p7_52).
| Field | Type | Description |
|---|---|---|
descriptionrequired | string | |
environmentrequired | string | |
occurredAtrequired | string (date-time) | |
servicerequired | string | |
statusrequired | Status | The health of something that currently exists. Derived once at ingest
and stored, never recomputed at read time. deleted is absent, and that is the point: a deleted resource is
tombstoned, not given a status, so a live resource carrying
status: deleted is an impossible value. Keeping one enum for both made
that impossible value contract-valid everywhere. unknown (p7_83) is a resource the inventory knows exists, or was
recently active, whose health it cannot observe — found only from its
metrics or its name. It is neither healthy nor a problem: never green,
not on the Alerts page, and counted as not checked on the public page. One of failed, degraded, operational, unknown. |
RegisteredBucket
A registered bucket and the generation this run claimed for it.
Written out in full rather than composed from CollectionRunBucket with
allOf. That composition was unsatisfiable and every successful
registration violated it: additionalProperties is evaluated per
subschema, so the closed base rejected the generation the sibling
subschema added. Closing the request schema and extending it in a
response are incompatible aims, and the request's closedness is the one
that catches a real mistake — a collector sending a field that will be
silently dropped.
The four identity fields are therefore duplicated on purpose. The
response-validation test in internal/api/handlers is what keeps the
copy honest.
| Field | Type | Description |
|---|---|---|
generationrequired | integer (int64) | A run behind the current fence is superseded and may neither write nor finalize. |
regionrequired | string | |
resourceTyperequired | string | |
servicerequired | string | |
systemrequired | string |
Resource
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
additionalInfo | object | Provider-specific detail. How each key is displayed comes from
/resource-types, not from the frontend. |
collectedAt | string (date-time) | The bucket's last successful collection — when the resource was last confirmed. Absent when the bucket has never completed one. |
firstSeenAtrequired | string (date-time) | |
lastRunId | string (uuid) | The run that last observed this resource. |
lastSeenAtrequired | string (date-time) | |
ownership | Ownership | Who owns the resource (p8_2), from the one resolver, derived when the
row is read. state is the one-word answer; team, confidence,
source and evidence are the winning tier's; reason says why
there is no owner although a tier answered — a conflict between two
matches of equal reach, or an archived team; tried lists the tiers
that said nothing; candidates is every tier's answer, best first —
the evidence one click away, and the discrepancy when a lower tier
disagrees. Ownership explains and proposes: it never widens what a
member sees, and never touches attribution. |
regionrequired | string | global for global resources. |
resourceIdrequired | string | |
resourceNamerequired | string | |
resourceTyperequired | string | |
servicerequired | string | |
stalerequired | boolean | True when the resource's bucket has no successful collection
inside its freshness window — the module's cadence, plus the
scheduler's maximum jitter, plus the configured headroom
(FRESHNESS_HEADROOM). What is shown is what was last observed;
the collector has not confirmed it since. Distinct from deletion,
which tombstones the row and removes it from every list (p7_12). |
staterequired | string | The raw provider state the status was derived from. |
statusrequired | Status | The health of something that currently exists. Derived once at ingest
and stored, never recomputed at read time. deleted is absent, and that is the point: a deleted resource is
tombstoned, not given a status, so a live resource carrying
status: deleted is an impossible value. Keeping one enum for both made
that impossible value contract-valid everywhere. unknown (p7_83) is a resource the inventory knows exists, or was
recently active, whose health it cannot observe — found only from its
metrics or its name. It is neither healthy nor a problem: never green,
not on the Alerts page, and counted as not checked on the public page. One of failed, degraded, operational, unknown. |
systemrequired | string | |
tags | object | The provider's own tags, stored exactly as the provider returned them — keys included. AWS is case-sensitive about keys and customers are inconsistent, and inventory is a faithful record of what is actually there; case-folding belongs to whatever is matching against it (cost attribution, COST_SPEC.md §7.1), not here. |
ResourceDetail
| Field | Type | Description |
|---|---|---|
history | SnapshotHistory | How much of the resource's past is retained, so a missing
lastChange is explained rather than shown as an empty diff.
sole_observation: one observation is retained. older_not_retained:
the retained observations differ in nothing, and whatever changed
last did so before retention. |
lastChange | SnapshotDiff | What changed from one observation to a later one (p8_9): the one
canonical diff, computed by the same function whether it was stored
beside the snapshot at ingest or recomputed for two observations
picked on request. from is always the older observation, whatever
order a request named them in; selectionReversed says when the
request had them the other way round. |
resourcerequired | Resource | |
snapshotsrequired | array of ResourceSnapshot | The resource's most recently recorded states, newest first. A state is recorded when its status, state, details or tags change, and once a day while nothing does (p7_49) — not once per collection. |
transitionsrequired | array of Transition | Status changes for this resource, newest first. |
ResourceNeighbours
| Field | Type | Description |
|---|---|---|
edgesrequired | array of NeighbourEdge | |
elidedrequired | integer | Neighbours the viewer's team scope does not admit: counted, never rendered. |
lookedForrequired | array of NeighbourEdgeKind | Every kind the read tried, found or not. |
nodesrequired | array of NeighbourNode | |
rootrequired | NeighbourNode | One thing in the graph. id is stable across reads: a resource's
account and key, or a request's, template version's, app's or
team's id. |
truncatedrequired | boolean | A cap was hit. |
truncatedCountsrequired | object | How many edges of each kind a cap left out. |
unavailablerequired | array of object | The kinds that could not be answered for the time asked, each with its reason. |
ResourcePage
| Field | Type | Description |
|---|---|---|
itemsrequired | array of Resource | |
pagerequired | integer | |
pageSizerequired | integer | |
statusCounts | StatusCounts | Absent here. /resources/search returns ResourceSearchPage,
where it is required. |
totalrequired | integer (int64) | |
totalPagesrequired | integer |
ResourceRef
One resource: its account and its key, region/system/service/type/id.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
keyrequired | string |
ResourceSearchPage
A separate schema so statusCounts is REQUIRED rather than optional.
Search always returns it, and a generated client that has to null-check
a field the server always sends will eventually stop checking — which is
exactly when it will be absent.
| Field | Type | Description |
|---|---|---|
nextCursor | string | Reads the next page as cursor. Present in the default order,
by resource name, when there is a next page; absent otherwise.
Whether there is one is read from the rows, one past the page,
not from total: collection adds and removes resources while a
list is paged, and the count moves with it. In that order
totalPages follows the walk — at least page + 1 while
nextCursor is present, and page on the page where it ends,
an empty one included. |
statusCountsrequired | StatusCounts | Computed over the full filtered set, not the current page. |
ResourceSnapshot
One stored observation, as the API returns it. status and state are
present because the server derived them; see SnapshotInput for what a
collector actually sends.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
additionalInfo | object | |
id | integer (int64) | The observation's handle for /resources/snapshots/diff (p8_9).
Present on every observation the server returns. |
observedAtrequired | string (date-time) | |
regionrequired | string | |
resourceIdrequired | string | |
resourceName | string | |
resourceTyperequired | string | |
schemaVersion | integer | The render descriptor version the observation was written under; 0 for a type no collector describes. Two observations under different versions diff as one schema change. |
servicerequired | string | |
staterequired | string | The raw provider state the status was derived from. |
statusrequired | Status | The health of something that currently exists. Derived once at ingest
and stored, never recomputed at read time. deleted is absent, and that is the point: a deleted resource is
tombstoned, not given a status, so a live resource carrying
status: deleted is an impossible value. Keeping one enum for both made
that impossible value contract-valid everywhere. unknown (p7_83) is a resource the inventory knows exists, or was
recently active, whose health it cannot observe — found only from its
metrics or its name. It is neither healthy nor a problem: never green,
not on the Alerts page, and counted as not checked on the public page. One of failed, degraded, operational, unknown. |
systemrequired | string | |
tags | object | The tags as they were at this observation. A tag that changed is a fact about the past, which is what this table is for. |
ResourceTypeDescriptor
| Field | Type | Description |
|---|---|---|
groupsrequired | array of FieldGroup | |
icon | string | |
labelrequired | string | |
listColumnsrequired | array of string | Field keys to show as columns in the list view. |
pluralLabelrequired | string | |
resourceTyperequired | string | |
servicerequired | string | |
systemrequired | string |
ResourceTypeList
| Field | Type | Description |
|---|---|---|
resourceTypesrequired | array of ResourceTypeDescriptor |
SessionsEnded
| Field | Type | Description |
|---|---|---|
endedrequired | integer |
SignOnIdentitiesReleased
| Field | Type | Description |
|---|---|---|
releasedrequired | integer | The accounts whose provider identity was cleared. |
SingleSignOnOptions
| Field | Type | Description |
|---|---|---|
enabledrequired | boolean | |
enforcedrequired | boolean | Passwords work only for break-glass administrators. |
SnapshotBatchRequest
| Field | Type | Description |
|---|---|---|
itemsrequired | array of SnapshotInput | No duplicate resource keys within a batch: a duplicate makes the upsert order-dependent, which makes transitions non-deterministic. |
runIdrequired | string (uuid) | The run these observations belong to. Every item must fall inside a bucket that run registered, or the batch is rejected. |
SnapshotBatchResponse
| Field | Type | Description |
|---|---|---|
replayed | boolean | True when this key had already been processed, so nothing was written and these are the stored counts. |
storedrequired | integer | Snapshots written. |
transitionsrequired | integer | Status changes detected and recorded. |
SnapshotDiff
What changed from one observation to a later one (p8_9): the one
canonical diff, computed by the same function whether it was stored
beside the snapshot at ingest or recomputed for two observations
picked on request. from is always the older observation, whatever
order a request named them in; selectionReversed says when the
request had them the other way round.
| Field | Type | Description |
|---|---|---|
entriesrequired | array of DiffEntry | |
fromrequired | SnapshotRef | One end of a diff — an observation by its id and time. |
schemaChanged | object | The two observations were written under different descriptor
versions, so additionalInfo was not compared field by field. |
selectionReversed | boolean | |
torequired | SnapshotRef | One end of a diff — an observation by its id and time. |
SnapshotHistory
How much of the resource's past is retained, so a missing
lastChange is explained rather than shown as an empty diff.
sole_observation: one observation is retained. older_not_retained:
the retained observations differ in nothing, and whatever changed
last did so before retention.
| Field | Type | Description |
|---|---|---|
note | string enum | One of sole_observation, older_not_retained. |
oldestRetainedAt | string (date-time) | |
retainedrequired | integer | The number of observations retained. |
SnapshotInput
One observation, as a collector reports it.
Deliberately NOT the same schema as ResourceSnapshot. A collector
reports what it saw; it does not report health. status and state are
derived server-side at ingest by the deriver registered for the resource
type, so that status is computed once, in one place, and stored — which
is what makes a transition detectable at all. A collector that could
submit its own status could also submit a different one each run, and no
two modules would agree.
Identity
accountId, region, system, service, resourceType and
resourceId together are the resource's primary key. Each must be
non-empty and must carry no leading or trailing whitespace.
Violations are rejected with 400 invalid_batch, never trimmed, because
i-123 and i-123 are two different resources for one real thing.
The duplicate row is not the lasting damage — the next successful
finalize that does not observe it tombstones it, and retention purges
that tombstone about thirty days later. The transitions are. Creating it
writes a first observation, tombstoning it writes a Deleted, and
transitions are kept forever: a moment of stray whitespace leaves
permanent history for a resource that never appeared and never went
away, next to a live one whose real history has a gap where those events
should be. The Deleted also notifies, so somebody is told a resource
vanished that never existed. While both rows exist the inventory and its
status counts double-count the resource.
Trimming server-side would be worse than rejecting, not better: it would store a value the collector never sent, so the id in the inventory would no longer be the id the provider reports. Whitespace inside a value is untouched, because some providers use it.
maxLength on resourceId counts characters, as JSON Schema defines it,
not bytes.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
additionalInfo | object | Provider-specific detail, at most 256 KB when marshalled. This is also the deriver's input: status and state are computed from it. |
observedAt | string (date-time) | Optional; the server uses its own clock when absent. When present it must be within ±24 hours of now — a clock-skewed collector writing far-future observations would suppress every later one. |
regionrequired | string | |
resourceIdrequired | string | |
resourceName | string | |
resourceTyperequired | string | |
servicerequired | string | |
systemrequired | string | |
tags | object | The provider's own tags, at most 64 of them, keys at most 128
characters and values at most 256. Stored exactly as returned. Replaced wholesale on every observation, never merged. A tag
removed in the provider has to disappear here, and merging would
make removal unobservable — which breaks "resources tagged env=prod
that should not be", and would leave a stale panorama:request_id on a
resource re-created outside provisioning. |
SnapshotPage
Cursor-paginated as TimelinePage is, newest first; nextCursor
absent means the last page.
| Field | Type | Description |
|---|---|---|
itemsrequired | array of ResourceSnapshot | |
nextCursor | string |
SnapshotRef
One end of a diff — an observation by its id and time.
| Field | Type | Description |
|---|---|---|
idrequired | integer (int64) | |
observedAtrequired | string (date-time) |
Status
The health of something that currently exists. Derived once at ingest and stored, never recomputed at read time.
deleted is absent, and that is the point: a deleted resource is
tombstoned, not given a status, so a live resource carrying
status: deleted is an impossible value. Keeping one enum for both made
that impossible value contract-valid everywhere.
unknown (p7_83) is a resource the inventory knows exists, or was
recently active, whose health it cannot observe — found only from its
metrics or its name. It is neither healthy nor a problem: never green,
not on the Alerts page, and counted as not checked on the public page.
Type: string enum
StatusCounts
Computed over the full filtered set, not the current page.
| Field | Type | Description |
|---|---|---|
degradedrequired | integer (int64) | |
failedrequired | integer (int64) | |
operationalrequired | integer (int64) | |
stalerequired | integer (int64) | How many of the counted resources are stale — in a bucket with no successful collection inside its freshness window. They are counted under their last known status as well; this is the part of each count the collector has not confirmed lately (p7_12). |
unknownrequired | integer (int64) | Resources whose health the inventory cannot observe (p7_83); neither healthy nor a problem. |
StepUpAtProvider
| Field | Type | Description |
|---|---|---|
authorizationUrlrequired | string | The provider's address the browser goes to. |
StepUpAtProviderRequest
| Field | Type | Description |
|---|---|---|
returnTo | string | A path on this site to come back to after the provider; anything else is the portal's root. |
StepUpRequest
| Field | Type | Description |
|---|---|---|
passwordrequired | string | The current user's password. |
TagMatch
One tag match: a resource carrying key=value is in the app.
| Field | Type | Description |
|---|---|---|
keyrequired | string | |
valuerequired | string |
Team
| Field | Type | Description |
|---|---|---|
accountCountrequired | integer | |
archivedAt | string (date-time) | Present when the team is closed. Its history is still readable. |
createdAtrequired | string (date-time) | |
directoryGroup | string | The provider's group the team follows, as the groups claim names it. Present when source is directory. |
idrequired | string (uuid) | |
lastSyncedAt | string (date-time) | The last sign-in that considered this team's binding. |
memberCountrequired | integer | |
namerequired | string | |
slugrequired | string | |
sourcerequired | string enum | Who keeps the membership: administrators (manual), or the identity
provider's group at each sign-in (directory, p6_6). One of manual, directory. |
tagValueCountrequired | integer |
TeamAccount
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
attributedSpendrequired | array of Money | What the rollups attribute to this team through this account —
the account-level fallback, attribution team_account — by
currency, over all history. This is the number that moves to
the untagged bucket if the account is unassigned. |
namerequired | string | |
usageFrom | string (date) | |
usageTo | string (date) |
TeamAccountChange
What an assignment or an unassignment changes. On an unassignment
spendMoving is what leaves this team for the untagged bucket; on
an assignment it is what the account's untagged history brings in.
Either way the rebuild is enqueued and rebuildEnqueued says so —
false only when the enqueue itself failed, in which case the change
stands and the next settings save will rebuild.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
assignedrequired | boolean | |
rebuildEnqueuedrequired | boolean | |
spendMovingrequired | array of Money | |
teamIdrequired | string (uuid) | |
usageFrom | string (date) | |
usageTo | string (date) |
TeamCreate
| Field | Type | Description |
|---|---|---|
namerequired | string | |
slug | string | Derived from the name when omitted. |
TeamDetail
| Field | Type | Description |
|---|---|---|
accountsrequired | array of TeamAccount | |
membersrequired | array of TeamMember | |
tagValuesrequired | array of string | |
teamrequired | Team |
TeamDirectoryGroup
| Field | Type | Description |
|---|---|---|
grouprequired | string | The group exactly as the provider names it in the groups claim — an id or a name, whichever the provider emits. |
TeamList
| Field | Type | Description |
|---|---|---|
itemsrequired | array of Team |
TeamMember
| Field | Type | Description |
|---|---|---|
emailrequired | string | |
platformRolerequired | string enum | One of viewer, engineer, admin. |
teamRolerequired | TeamRole | A user's role within one team. Deliberately disjoint from the
platform roles so the two axes cannot be compared by accident. One of member, lead. |
userIdrequired | string (uuid) |
TeamMemberSet
| Field | Type | Description |
|---|---|---|
teamRolerequired | TeamRole | A user's role within one team. Deliberately disjoint from the
platform roles so the two axes cannot be compared by accident. One of member, lead. |
TeamMembership
A team seen from a user: which, and the user's role in it.
| Field | Type | Description |
|---|---|---|
teamIdrequired | string (uuid) | |
teamNamerequired | string | |
teamRolerequired | TeamRole | A user's role within one team. Deliberately disjoint from the
platform roles so the two axes cannot be compared by accident. One of member, lead. |
TeamRef
A team named where only its identity matters — an app's owner, or a
team a picker offers. Team is the org model's full row, with the
counts the settings page shows; this is the two fields everything
else needs.
| Field | Type | Description |
|---|---|---|
idrequired | string (uuid) | |
namerequired | string |
TeamRole
A user's role within one team. Deliberately disjoint from the platform roles so the two axes cannot be compared by accident.
Type: string enum
TeamTagValueChange
| Field | Type | Description |
|---|---|---|
mappedrequired | boolean | |
rebuildEnqueuedrequired | boolean | |
tagValuerequired | string | |
teamIdrequired | string (uuid) |
TeamTagValueSet
| Field | Type | Description |
|---|---|---|
tagValuerequired | string |
TeamUpdate
| Field | Type | Description |
|---|---|---|
archived | boolean | |
name | string |
Template
| Field | Type | Description |
|---|---|---|
archivedAt | string (date-time) | Set means hidden from the catalog and closed to new versions. Archiving never deletes: resources provisioned from this template are still live and still need their provenance. |
categoryrequired | string | |
createdAtrequired | string (date-time) | |
createdByrequired | ActorRef | Who did something, for display and for audit. Not User: that carries
the tenant and the role of the session, and an audit record naming a
role that has since changed would be a lie about the past. |
descriptionrequired | string | |
gitRef | string | The default ref new versions are published from. |
gitUrl | string | Present for git sources. Validated on the way in: scheme
allowlist, no embedded credentials, and the host must resolve
outside private and reserved CIDR ranges. That last check is SSRF
defence — the platform would otherwise dial arbitrary hosts on a
caller's behalf. |
idrequired | string (uuid) | |
latestVersion | integer | The highest published version, resolved or not. Absent when the template has none, which also means it cannot be requested. |
namerequired | string | |
slugrequired | string | Stable, human-facing and immutable. It is what a URL names and what an audit record refers to years later, so it cannot be renamed. |
sourceTyperequired | TemplateSourceType | Where a template's code comes from, and therefore what its source
identity is: a commit SHA for git, a content sha256 for inline.
Either way it is pinned at publish and never updated. One of git, inline. |
TemplateCreate
| Field | Type | Description |
|---|---|---|
category | string | |
description | string | |
gitRef | string | Default ref for new versions. Only for git. |
gitUrl | string | Required for git, rejected for inline. |
namerequired | string | |
slugrequired | string | |
sourceTyperequired | TemplateSourceType | Where a template's code comes from, and therefore what its source
identity is: a commit SHA for git, a content sha256 for inline.
Either way it is pinned at publish and never updated. One of git, inline. |
TemplatePage
| Field | Type | Description |
|---|---|---|
itemsrequired | array of Template | |
pagerequired | integer | |
pageSizerequired | integer | |
totalrequired | integer (int64) | |
totalPagesrequired | integer |
TemplateSourceType
Where a template's code comes from, and therefore what its source
identity is: a commit SHA for git, a content sha256 for inline.
Either way it is pinned at publish and never updated.
Type: string enum
TemplateUpdate
Only the fields present are changed. slug and sourceType are absent
on purpose — both are pinned by every request ever made against this
template.
| Field | Type | Description |
|---|---|---|
archived | boolean | True archives, false restores. Archiving hides the template and closes it to new versions; it never touches what was provisioned from it. |
category | string | |
description | string | |
gitRef | string | |
name | string |
TemplateVersion
Immutable once published. Its source identity — gitSha for git,
inlineSha256 for inline — is written once and never updated, and a
request pins (templateId, version). That pair is what makes "the
approval approved this code" a statement of fact rather than a hope, and
it is also the version's whole identity: there is no surrogate id, here
or anywhere else, because template_versions has no column for one
(PROVISIONING_SPEC.md §4.1).
| Field | Type | Description |
|---|---|---|
gitRef | string | The ref that was asked for. What ran is gitSha. |
gitSha | string | The pinned commit. Absent until an agent resolves it, which is the
only reason a git version can sit pending. |
gitUrl | string | |
inlineSha256 | string | sha256 of inlineSource, computed at publish. |
inlineSource | string | The exact bytes, for inline sources. Present on the single-version
read, which is what the admin editor loads; list responses omit it,
because a catalog page does not need every template's source. |
needsSchemarequired | boolean | Imported from the selfservice donor without a variables schema
(p4_10). Listed in the catalog, refused at submit with
409 version_needs_schema; an admin publishes the next version
with a schema. Versions are immutable, so this one stays marked. |
publishedAtrequired | string (date-time) | |
publishedByrequired | ActorRef | Who did something, for display and for audit. Not User: that carries
the tenant and the role of the session, and an audit record naming a
role that has since changed would be a lie about the past. |
resolveAccountId | string | The account whose agent resolves this version, chosen at enqueue
among the enabled accounts with a registered agent — a live one
first (p7_15). Absent while pending and no account has an
agent: the version is waiting, and the next registration enqueues
it. Absent for inline versions, which resolve at publish. |
resolveError | string | The agent's own message when resolution failed. Shown in the catalog because it is the only thing that makes the failure actionable — a ref that does not exist and a repository the agent cannot reach need different fixes. |
resolveStaterequired | TemplateVersionResolveState | Whether a version has a source identity yet. inline versions are
resolved the moment they are published; git versions are pending
until an agent reports a commit SHA (D7), and a version that is not
resolved cannot be requested. failed is retryable and visible. One of pending, resolved, failed. |
resolvedAt | string (date-time) | |
sourceTyperequired | TemplateSourceType | Where a template's code comes from, and therefore what its source
identity is: a commit SHA for git, a content sha256 for inline.
Either way it is pinned at publish and never updated. One of git, inline. |
templateIdrequired | string (uuid) | |
variablesSchemarequired | VariablesSchema | Stored on the version and immutable with it. The portal generates the
request form from this; the agent uses it to write
generated.auto.tfvars as proper HCL through an encoder, never by
string concatenation, because hand-rolled escaping is how tfvars
injection happens. |
versionrequired | integer | |
warnings | array of string | Present on the publish response only, and not stored: what the
platform could tell about the source without running it. Today one
warning exists — an inline source that declares the AWS provider's
own default_tags, which will collide with the provenance stamp the
agent writes (PROVISIONING_SPEC.md §9). The apply still runs; the
post-apply verification is what catches the collision for real and
marks the resource provenance-unverified (p4_14). A git source
cannot be inspected at publish, because its bytes arrive on the
agent. |
TemplateVersionPage
| Field | Type | Description |
|---|---|---|
itemsrequired | array of TemplateVersion | |
pagerequired | integer | |
pageSizerequired | integer | |
totalrequired | integer (int64) | |
totalPagesrequired | integer |
TemplateVersionPublish
Exactly one of gitRef and inlineSource applies, decided by the
template's own sourceType rather than by a field here — a version
cannot be a different kind of thing from its template.
| Field | Type | Description |
|---|---|---|
gitRef | string | For git templates. Defaults to the template's gitRef. This is
what an agent resolves; it is never what runs. |
inlineSource | string | For inline templates: the exact bytes, capped at 256 KiB, UTF-8,
no NUL. Their sha256 is the version's source identity, so an inline
version is resolved the instant it is published — "approved code
equals executed code" holds without a clone. |
variablesSchemarequired | VariablesSchema | Stored on the version and immutable with it. The portal generates the
request form from this; the agent uses it to write
generated.auto.tfvars as proper HCL through an encoder, never by
string concatenation, because hand-rolled escaping is how tfvars
injection happens. |
TemplateVersionResolveState
Whether a version has a source identity yet. inline versions are
resolved the moment they are published; git versions are pending
until an agent reports a commit SHA (D7), and a version that is not
resolved cannot be requested. failed is retryable and visible.
Type: string enum
TimelinePage
Cursor-paginated, so there is deliberately no page, total or
totalPages. A total would need a second full count of a
forever-growing table on every request, and a page number has no stable
meaning when rows arrive continuously at the head.
| Field | Type | Description |
|---|---|---|
itemsrequired | array of Transition | |
nextCursor | string | Pass as cursor for the next page. Absent means this is the last
page — which is the only reliable end-of-results signal here, since
a short page can also mean rows were filtered. |
Transition
One status change, kept forever. Also the notification outbox — the same row the notifier drains.
| Field | Type | Description |
|---|---|---|
accountIdrequired | string | |
acknowledgement | TransitionAcknowledgement | The latest acknowledgement of a transition (p7_84), reversed or not: who acknowledged it, when and why, and who reversed it, when anyone did. An email is the user's as it is now, empty once the user is gone. Shown to an administrator, and to a member only on their teams' accounts (the owner, 2026-09-14). |
fromState | string | |
fromStatus | TransitionStatus | Absent for the first ever observation of a resource. Those rows are written pre-suppressed: onboarding 300 accounts must not emit 40,000 notifications. |
idrequired | integer (int64) | |
occurredAtrequired | string (date-time) | |
regionrequired | string | |
resourceIdrequired | string | |
resourceName | string | |
resourceTyperequired | string | |
runId | string (uuid) | |
servicerequired | string | |
systemrequired | string | |
toState | string | |
toStatusrequired | TransitionStatus | A status a transition can move to or from. Extends Status with
deleted, which only ever appears as a destination — the transition
recording that a resource was tombstoned. One of failed, degraded, operational, unknown, deleted. |
TransitionAcknowledgement
The latest acknowledgement of a transition (p7_84), reversed or not: who acknowledged it, when and why, and who reversed it, when anyone did. An email is the user's as it is now, empty once the user is gone. Shown to an administrator, and to a member only on their teams' accounts (the owner, 2026-09-14).
| Field | Type | Description |
|---|---|---|
acknowledgedAtrequired | string (date-time) | |
acknowledgedByrequired | ActorRef | Who did something, for display and for audit. Not User: that carries
the tenant and the role of the session, and an audit record naming a
role that has since changed would be a lie about the past. |
noterequired | string | |
reversedAt | string (date-time) | |
reversedBy | ActorRef | Who did something, for display and for audit. Not User: that carries
the tenant and the role of the session, and an audit record naming a
role that has since changed would be a lie about the past. |
TransitionStatus
A status a transition can move to or from. Extends Status with
deleted, which only ever appears as a destination — the transition
recording that a resource was tombstoned.
Type: string enum
User
| Field | Type | Description |
|---|---|---|
demorequired | boolean | True when the tenant's whole estate was fabricated by
panorama admin seed-demo. The portal shows a persistent "Demo data"
banner while it is set, so fabricated infrastructure is never
mistaken for real. |
displayName | string | The name the person gave themselves (p7_89); absent when they gave none. |
emailrequired | string | The address as it was given when the account was created. Not
format: email for the same reason as on the way in: nothing
validates the syntax, so nothing should claim it. |
idrequired | string (uuid) | |
preferences | Preferences | The person's portal preferences (p7_89), applied by the portal; an absent field is the browser's own. |
rolerequired | string enum | What this user may do. Separate from entitlements, which are what
the tenant has bought. One of viewer, engineer, admin. |
tenantNamerequired | string | |
tenantSlugrequired | string |
VariableDefinition
One input a template declares. Deliberately small: a variables schema
grows into a type system if allowed, and this is the entire vocabulary
until a customer asks for more (PROVISIONING_SPEC.md §8.1).
One validator, two consumers. The Go validator runs at submit and is authoritative; the portal generates the form from this same schema served over the API and never reimplements a rule. A client-side check is a convenience; the server decides.
| Field | Type | Description |
|---|---|---|
default | VariableValue | Used when the value is omitted, and validated against this definition itself — a default that its own rules reject is a template bug that would otherwise surface as a failed apply. |
description | string | Form rendering only. |
enum | array of VariableValue | Closed set, for string and number. Renders as a select. Typed as
a variable value rather than left free-form; that the two scalar
types are the only ones for which a closed set means anything is the
validator's rule, as every other rule here is. |
group | string | Form rendering only. |
max | number | Inclusive, for number. |
maxItems | integer | Inclusive, for list(string). |
maxLength | integer | Inclusive, for string. |
min | number | Inclusive, for number. |
minItems | integer | Inclusive, for list(string). |
minLength | integer | Inclusive, for string. |
namerequired | string | |
pattern | string | RE2, for string. Anchored by the validator, not by the author:
an unanchored pattern accepts anything that merely contains a match,
which is the difference between a constraint and a suggestion. |
required | boolean | Absent at submit and no default → rejected. |
sensitive | boolean | Masked in the UI and in variablesRedacted, and exact-value
redacted from stored logs by the agent. The exact-value rule is
the strongest of the five scrubbing rules precisely because it does
not guess: the agent knows the string it must not print. |
typerequired | string enum | Nothing else. Anything richer is a type system. One of string, number, boolean, list(string), map(string). |
VariableValue
One variable value, in one of the five types PROVISIONING_SPEC.md §8.1
allows and in no other. That list is the entire vocabulary until a
customer asks for more, and a value outside it is a template the
validator will reject anyway.
Written as a oneOf even though AgentTask deliberately avoids one for
its work item: a union at a leaf costs a third-party generator nothing
it does not already pay — a generator that cannot express it falls back
to "any", which is precisely what an unconstrained schema gives it —
while a oneOf at the top of a work item makes the whole response
awkward to read. The value here is that the map carrying these is no
longer free-form: an arbitrary nested blob does not validate, so there
is nowhere for one to hide.
One of: string, number (double), boolean, array of string, object
VariablesSchema
Stored on the version and immutable with it. The portal generates the
request form from this; the agent uses it to write
generated.auto.tfvars as proper HCL through an encoder, never by
string concatenation, because hand-rolled escaping is how tfvars
injection happens.
| Field | Type | Description |
|---|---|---|
variablesrequired | array of VariableDefinition |