CloudPanorama

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

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

publicoperation getHealthz

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

StatusBodyMeaning
200HealthStatusThe process is alive.
429ErrorResponseRate limited.

GET /metrics

publicoperation getMetrics

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

StatusBodyMeaning
200stringMetrics in the Prometheus text exposition format.
404METRICS_LISTEN has moved the metrics to a port of their own; the API listener does not serve them.
429ErrorResponseRate limited.

GET /readyz

publicoperation getReadyz

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

StatusBodyMeaning
200HealthStatusReady.
429ErrorResponseRate limited.
503NotReadyStatusNot 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

publicoperation getDocs

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

StatusBodyMeaning
200stringThe documentation page.
429ErrorResponseRate limited.

GET /openapi.json

publicoperation getOpenapiSpec

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

StatusBodyMeaning
200objectThe 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.
429ErrorResponseRate limited.

public

Unauthenticated. Deliberately minimal.

GET /public/tenant

public60 requests a minute per addressoperation getPublicTenant

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

StatusBodyMeaning
200PublicTenantThe tenant whose status page this host shows.
404ErrorResponsenot_found
429ErrorResponseRate limited.
500ErrorResponseinternal_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

public60 requests a minute per addressoperation getPublicStatus

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

NameInTypeDescription
tenantSlugrequiredpathstringThe only place a tenant is named in a request. Everywhere else the tenant comes from the credential; here there is none.

Responses

StatusBodyMeaning
200PublicStatusCurrent status summary and recent aggregated timeline.
404ErrorResponsenot_found
429ErrorResponseRate limited.
500ErrorResponseinternal_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

credential sessionCookierole adminoperation getIdentityProvider

The tenant's identity provider

The registration, without its client secret (p5_5).

Responses

StatusBodyMeaning
200IdentityProviderThe registration.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookierole adminbody at most 16384 bytesoperation saveIdentityProvider

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

FieldTypeDescription
clientIdrequiredstring
clientSecretstringRequired the first time; absent keeps the stored one.
defaultRolerequiredstring enum One of viewer, engineer, admin.
emailDomainsrequiredarray of string
enforcedrequiredboolean
groupsClaimstringThe claim the groups are read from; empty or absent keeps groups.
issuerrequiredstring

Responses

StatusBodyMeaning
200IdentityProviderThe registration.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
409ErrorResponserequest_state_conflict — accounts are bound to another provider's issuer (P55-R2-F03): POST /admin/sso/release first.
500ErrorResponseinternal_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

credential sessionCookierole adminoperation deleteIdentityProvider

Remove the tenant's identity provider

Passwords work again for everyone, and sign-ins in flight fail (p5_5).

Responses

StatusBodyMeaning
204Removed.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookierole adminoperation releaseSignOnIdentities

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

StatusBodyMeaning
200SignOnIdentitiesReleasedHow many accounts were released.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookieoperation listOwnInbox

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

NameInTypeDescription
beforequeryinteger (int64)Only entries older than this id.
limitqueryintegerAt most this many entries; 100 when absent.

Responses

StatusBodyMeaning
200InboxPageA page of the person's inbox.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
500ErrorResponseinternal_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

credential sessionCookieoperation markInboxRead

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

FieldTypeDescription
allbooleanEvery entry of the person's, whatever ids says.
idsarray of integer (int64)

Responses

StatusBodyMeaning
200InboxUnreadHow many of the person's entries stay unread.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseorigin_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.
500ErrorResponseinternal_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

publicbody at most 8192 bytesoperation 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

FieldTypeDescription
emailrequiredstringThe 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.
passwordrequiredstring (password)Bounded because verifying it is an argon2 derivation over whatever was sent, and this endpoint is unauthenticated.
tenantSlugstringOptional. 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

StatusBodyMeaning
200UserLogged in. Sets an HttpOnly; Secure; SameSite=Lax session cookie.
400ErrorResponseinvalid_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.
401ErrorResponseWrong credentials, unknown tenant, or a disabled user — deliberately indistinguishable, so this endpoint cannot be used to enumerate tenants or accounts.
403ErrorResponseorigin_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.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
429ErrorResponseRate limited.
500ErrorResponseinternal_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

publicoperation 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

StatusBodyMeaning
204Logged out. Repeating this is not an error.
403ErrorResponseorigin_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.
429ErrorResponseRate limited.
500ErrorResponseinternal_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

credential sessionCookie300 requests a minute per addressoperation getCurrentUser

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

StatusBodyMeaning
200UserThe authenticated user, their role and their tenant.
401ErrorResponseunauthorized — no credential, or one that is not valid.
429ErrorResponseRate limited.
500ErrorResponseinternal_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

credential sessionCookiebody at most 8192 bytesoperation updateOwnProfile

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

FieldTypeDescription
displayNamestring
preferencesPreferencesThe person's portal preferences (p7_89), applied by the portal; an absent field is the browser's own.

Responses

StatusBodyMeaning
200UserThe person, as GET /auth/me answers.
400ErrorResponseinvalid_request — a field breaks its rule; the message names the field and the rule.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseorigin_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.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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

credential sessionCookieoperation getOwnNotificationChoices

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

StatusBodyMeaning
200NotificationChoicesThe person's choices.
401ErrorResponseunauthorized — no credential, or one that is not valid.
500ErrorResponseinternal_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

credential sessionCookieoperation setOwnNotificationChoices

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

FieldTypeDescription
alertsrequiredbooleanStatus 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.
anomaliesrequiredbooleanCost anomalies on the person's teams and their teams' accounts; every one, for an administrator.
budgetsrequiredbooleanBudget thresholds of the person's teams; every team's, for an administrator.
myRequestsrequiredbooleanTheir own provisioning requests — approvals, successes and failures.

Responses

StatusBodyMeaning
200NotificationChoicesThe person's choices, as stored.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseorigin_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.
500ErrorResponseinternal_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

credential sessionCookieoperation listOwnTeams

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

StatusBodyMeaning
200OwnTeamListThe person's teams.
401ErrorResponseunauthorized — no credential, or one that is not valid.
500ErrorResponseinternal_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

credential sessionCookiebody at most 8192 bytesoperation changePassword

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

FieldTypeDescription
currentPasswordrequiredstring
newPasswordrequiredstring

Responses

StatusBodyMeaning
204Changed. A fresh session cookie is set.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseorigin_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.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
429ErrorResponseRate limited.
500ErrorResponseinternal_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

publicbody at most 8192 bytesoperation redeemPasswordReset

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

FieldTypeDescription
newPasswordrequiredstring
tokenrequiredstring

Responses

StatusBodyMeaning
204The password is set. Log in with it.
400ErrorResponseinvalid_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.
403ErrorResponseorigin_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.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
429ErrorResponseRate limited.
500ErrorResponseinternal_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

credential sessionCookieoperation listOwnSessions

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

StatusBodyMeaning
200OwnSessionListThe person's live sessions.
401ErrorResponseunauthorized — no credential, or one that is not valid.
500ErrorResponseinternal_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

credential sessionCookieoperation endOtherSessions

Sign out everywhere else

Ends every session of the person's but the one making this request (p7_89).

Responses

StatusBodyMeaning
200SessionsEndedHow many sessions ended.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseorigin_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.
500ErrorResponseinternal_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}

credential sessionCookieoperation endOwnSession

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

NameInTypeDescription
sessionIdrequiredpathstringA session's id, as GET /auth/sessions gives it.

Responses

StatusBodyMeaning
204The session ended.
400ErrorResponseinvalid_request — the session named is the one making the request.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseorigin_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.
404ErrorResponsenot_found
500ErrorResponseinternal_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

publicoperation finishSingleSignOn

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

NameInTypeDescription
statequerystring
codequerystring
errorquerystringThe provider's error, when the person cancelled or was refused.

Responses

StatusBodyMeaning
302To the return path with the session cookie, or to the login page.

GET /auth/sso/options

publicoperation getSingleSignOnOptions

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

NameInTypeDescription
tenantSlugquerystring

Responses

StatusBodyMeaning
200SingleSignOnOptionsThe tenant's single sign-on options.
500ErrorResponseinternal_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

publicoperation startSingleSignOn

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

NameInTypeDescription
tenantSlugquerystring
returnToquerystringA path on this site to return to; anything else is the portal's root.

Responses

StatusBodyMeaning
302To the identity provider, or back to the login page.

POST /auth/step-up

credential sessionCookiebody at most 8192 bytesoperation stepUp

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

FieldTypeDescription
passwordrequiredstringThe current user's password.

Responses

StatusBodyMeaning
204Confirmed. For 10 minutes this session may take destructive actions.
400ErrorResponseinvalid_request — the password is wrong, or longer than a password can be.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseorigin_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.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
429ErrorResponseRate limited.
500ErrorResponseinternal_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

credential sessionCookiebody at most 8192 bytesoperation stepUpAtProvider

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

FieldTypeDescription
returnTostringA path on this site to come back to after the provider; anything else is the portal's root.

Responses

StatusBodyMeaning
200StepUpAtProviderWhere the browser goes to confirm.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseorigin_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.
404ErrorResponsenot_found — the tenant has no identity provider.
409ErrorResponserequest_state_conflict — this account has never signed in through the tenant's provider; it confirms with its password.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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

credential sessionCookiemodule estateoperation listResourceTypes

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

NameInTypeDescription
If-None-MatchheaderstringA previously returned ETag.

Responses

StatusBodyMeaning
200ResourceTypeListDescriptors for every registered resource type.
304The client's If-None-Match still matches. No body.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookiemodule estateoperation listResources

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

NameInTypeDescription
accountIdqueryarray of stringRepeatable. Any of the given accounts.
servicequeryarray of stringRepeatable.
resourceTypequeryarray of stringRepeatable.
regionqueryarray of stringRepeatable.
systemqueryarray of string enumThe 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.
statusqueryarray of TransitionStatusRepeatable. On /timeline this filters on the destination status, where deleted is meaningful; on resource endpoints it filters live health, where it is not.
stalequerybooleantrue 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).
ownershipquerystring enumNarrows 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.
ownerTeamIdquerystring (uuid)Narrows to resources the resolver gives to this team.
tagqueryarray of stringRepeatable, 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.
pagequeryinteger1-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.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200ResourcePageA page of resources.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookiemodule estateoperation listAcknowledgements

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

NameInTypeDescription
accountIdqueryarray of stringRepeatable. Any of the given accounts.
servicequeryarray of stringRepeatable.
statusqueryarray of TransitionStatusRepeatable. On /timeline this filters on the destination status, where deleted is meaningful; on resource endpoints it filters live health, where it is not.

Responses

StatusBodyMeaning
200AcknowledgementListThe active acknowledgements.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookiemodule estatebody at most 8192 bytesoperation acknowledgeAlert

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

FieldTypeDescription
accountIdrequiredstring
noterequiredstring
regionrequiredstring
resourceIdrequiredstring
resourceTyperequiredstring
servicerequiredstring
systemrequiredstring

Responses

StatusBodyMeaning
201AcknowledgementThe acknowledgement.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponseThe resource is not failed or degraded, or its alert is acknowledged already.
500ErrorResponseinternal_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

credential sessionCookiemodule estatebody at most 4096 bytesoperation reverseAcknowledgement

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

FieldTypeDescription
accountIdrequiredstring
regionrequiredstring
resourceIdrequiredstring
resourceTyperequiredstring
servicerequiredstring
systemrequiredstring

Responses

StatusBodyMeaning
204Reversed.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
409ErrorResponseThe alert is not acknowledged.
500ErrorResponseinternal_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

credential sessionCookiemodule estateoperation getResourceDetail

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

NameInTypeDescription
accountIdrequiredquerystring
regionrequiredquerystring
systemrequiredquerystring
servicerequiredquerystring
resourceTyperequiredquerystring
resourceIdrequiredquerystring

Responses

StatusBodyMeaning
200ResourceDetailThe resource, with its recent snapshots and transitions.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookiemodule estateoperation getResourceNeighbours

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

NameInTypeDescription
accountIdrequiredquerystring
regionrequiredquerystring
systemrequiredquerystring
servicerequiredquerystring
resourceTyperequiredquerystring
resourceIdrequiredquerystring
depthqueryintegerOne or two; anything else reads as one.
kindqueryarray of NeighbourEdgeKindRepeatable. Only these kinds; omitted, every kind.
atquerystring (date-time)The graph as of this time; omitted, now.

Responses

StatusBodyMeaning
200ResourceNeighboursThe graph around the resource.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookiemodule estateoperation searchResources

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

NameInTypeDescription
accountIdqueryarray of stringRepeatable. Any of the given accounts.
servicequeryarray of stringRepeatable.
resourceTypequeryarray of stringRepeatable.
regionqueryarray of stringRepeatable.
systemqueryarray of string enumThe 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.
statusqueryarray of TransitionStatusRepeatable. On /timeline this filters on the destination status, where deleted is meaningful; on resource endpoints it filters live health, where it is not.
stalequerybooleantrue 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).
ownershipquerystring enumNarrows 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.
ownerTeamIdquerystring (uuid)Narrows to resources the resolver gives to this team.
acknowledgedquerybooleanfalse 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.
tagqueryarray of stringRepeatable, 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.
queryquerystringFree-text match against resource name, resource ID or account ID, case-insensitively.
sortByquerystring enumA closed set, not a column name. Anything interpolated into SQL goes through an allowlist.
sortDirectionquerystring enum
pagequeryinteger1-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.
pageSizequeryintegerNormalised 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.
cursorquerystringThe 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

StatusBodyMeaning
200ResourceSearchPageA page of matching resources, with counts over the whole match.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookiemodule estateoperation listResourceSnapshots

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

NameInTypeDescription
accountIdrequiredquerystring
regionrequiredquerystring
systemrequiredquerystring
servicerequiredquerystring
resourceTyperequiredquerystring
resourceIdrequiredquerystring
cursorquerystringThe previous page's nextCursor.
pageSizequeryinteger

Responses

StatusBodyMeaning
200SnapshotPageOne page of observations, newest first.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookiemodule estateoperation getSnapshotDiff

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

NameInTypeDescription
accountIdrequiredquerystring
regionrequiredquerystring
systemrequiredquerystring
servicerequiredquerystring
resourceTyperequiredquerystring
resourceIdrequiredquerystring
fromrequiredqueryinteger (int64)
torequiredqueryinteger (int64)

Responses

StatusBodyMeaning
200SnapshotDiffThe diff, older to newer.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookiemodule estateoperation getTimeline

Status transitions

Status is derived once at ingest and stored, so every change is materialised. This reads that history; it never recomputes status.

Parameters

NameInTypeDescription
fromquerystring (date-time)Inclusive lower bound on occurredAt.
toquerystring (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.
accountIdqueryarray of stringRepeatable. Any of the given accounts.
servicequeryarray of stringRepeatable.
statusqueryarray of TransitionStatusRepeatable. On /timeline this filters on the destination status, where deleted is meaningful; on resource endpoints it filters live health, where it is not.
cursorquerystringOpaque 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.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200TimelinePageA page of transitions, newest first.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookiemodule estateoperation getChanges

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

NameInTypeDescription
fromquerystring (date-time)Inclusive lower bound on at.
toquerystring (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.
kindqueryarray of ChangeKindRepeatable. Only these kinds; omitted, every kind.
accountIdqueryarray of stringRepeatable. Any of the given accounts.
resourceKeyquerystringOne resource, with exactly one accountId. Sources without a resource drop out.
resourceTypequerystringEvents on resources of this type; sources without a resource drop out.
appIdquerystring (uuid)A declared app's resources (by its rules) and the markers naming it; sources without a resource drop out.
teamIdquerystring (uuid)One team's accounts and scopes — narrowed within the viewer's own reach, never widened.
cursorquerystringOpaque cursor from the previous page's nextCursor; omit for the first page.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200ChangeFeedPageA page of the feed, newest first.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential bearerMarkermodule estate600 requests a minute per addressoperation postChangeMarker

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

FieldTypeDescription
accountIdstringThe account the marker is about; omitted, the marker is the tenant's.
appIdstring (uuid)The declared app the marker is about.
atrequiredstring (date-time)When it happened, by the poster's clock.
idempotencyKeyrequiredstring
labelrequiredstring
resourceKeysarray of stringThe resources it touched, as keys under accountId.
urlstringA link back to the poster's own record.

Responses

StatusBodyMeaning
200ChangeMarkerThe same marker again, as stored the first time.
201ChangeMarkerThe marker, stored.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
409ErrorResponseconflict — this idempotency key names a different marker.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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

credential sessionCookiemodule estate30 requests a minute per addressoperation getPerformancePod

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

NameInTypeDescription
accountIdrequiredquerystring
regionrequiredquerystring
resourceIdrequiredquerystringThe pod's inventory id, cluster:namespace:name.
windowrequiredqueryPerformanceWindow1h at a five-minute step, 24h at fifteen minutes, 7d at an hour, ending at the current step.

Responses

StatusBodyMeaning
200PerformancePodWindowThe pod and its own series over the window.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitled — the tenant has not bought Estate; or capability_not_entitled — its licence does not carry performance (x-capability).
404ErrorResponsenot_found
429ErrorResponseRate limited.
500ErrorResponseinternal_error. The message is generic by design: internal error text never reaches a response body. The real error is logged with the request ID.
502ErrorResponseupstream_error — CloudWatch did not answer for the pod: the account's reader role refused the read, or the service failed.

GET /performance/resource

credential sessionCookiemodule estateoperation getPerformanceResource

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

NameInTypeDescription
accountIdrequiredquerystring
regionrequiredquerystring
systemrequiredquerystring
servicerequiredquerystring
resourceTyperequiredquerystring
resourceIdrequiredquerystring
windowrequiredqueryPerformanceWindow1h at a five-minute step, 24h at fifteen minutes, 7d at an hour, ending at the current step.
comparequerystring enumprevious: each series also carries the window before this one, at the same step (p7_85).

Responses

StatusBodyMeaning
200PerformanceResourceWindowThe resource and its series over the window.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitled — the tenant has not bought Estate; or capability_not_entitled — its licence does not carry performance (x-capability).
404ErrorResponsenot_found
500ErrorResponseinternal_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}

credential sessionCookiemodule estateoperation getPerformanceSection

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

NameInTypeDescription
sectionrequiredpathPerformanceSection
windowrequiredqueryPerformanceWindow1h at a five-minute step, 24h at fifteen minutes, 7d at an hour, ending at the current step.
limitqueryintegerResources per page.
comparequerystring enumprevious: each series also carries the window before this one, at the same step (p7_85).
cursorquerystringThe 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

StatusBodyMeaning
200PerformanceSectionPageA page of the section's resources and their series.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitled — the tenant has not bought Estate; or capability_not_entitled — its licence does not carry performance (x-capability).
500ErrorResponseinternal_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

credential sessionCookiemodule estateoperation getPerformanceTopology

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

NameInTypeDescription
accountIdrequiredquerystring
regionrequiredquerystring
systemrequiredquerystring
servicerequiredquerystring
resourceTyperequiredquerystring
resourceIdrequiredquerystring

Responses

StatusBodyMeaning
200PerformanceTopologyThe resource and its parts.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitled — the tenant has not bought Estate; or capability_not_entitled — its licence does not carry performance (x-capability).
404ErrorResponseNo live resource by this key, or its type has no topology.
500ErrorResponseinternal_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

credential sessionCookieoperation listAccounts

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

StatusBodyMeaning
200AccountListEvery account registered for this tenant.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookierole adminoperation discoverAccounts

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

StatusBodyMeaning
200DiscoveryResultWhat discovery found, and what changed as a result.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_error. The message is generic by design: internal error text never reaches a response body. The real error is logged with the request ID.
502ErrorResponseAWS Organizations could not be reached or refused the call.

GET /admin/accounts/{accountId}

credential sessionCookierole adminoperation getAccount

One account

The admin view of a single account, for the edit form. Identical in shape to an entry from /accountsexternalId is withheld here too, because an admin session is not a reason to hand out half of the confused-deputy defence.

Responses

StatusBodyMeaning
200AccountThe account, without externalId.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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}

credential sessionCookierole adminoperation updateAccount

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

FieldTypeDescription
enabledboolean
environmentstring enum One of production, staging, sdlc, shared, unknown.
isManagementbooleanMark (or unmark) this account as the organisation's management account. Marking a second one is a 409: there is at most one.
namestring
regionsarray of string
roleArnstringCorrect 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

StatusBodyMeaning
200AccountThe updated account.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponseaccount_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.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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

credential sessionCookierole adminoperation verifyAccount

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

NameInTypeDescription
accountIdrequiredpathstringThe 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

StatusBodyMeaning
200AccountVerificationThe verification ran. ok reports the outcome — a failed check is not an HTTP error, it is the answer to the question asked.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookierole adminoperation listAuditLog

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

NameInTypeDescription
actorquerystring (uuid)Only this account's actions.
actionquerystringOnly actions that begin with this, as auth. or auth.identity_provider.update.
subjectTypequerystring
subjectIdquerystring
fromquerystring (date-time)Entries at or after this instant.
toquerystring (date-time)Entries before this instant.
limitqueryinteger
cursorquerystringThe previous page's nextCursor; the page is the entries older than its last.

Responses

StatusBodyMeaning
200AuditLogPageA page of the log.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookiemodule estaterole adminoperation listCollectorRuns

Recent runs for one account

Parameters

NameInTypeDescription
accountIdrequiredquerystring
limitqueryinteger

Responses

StatusBodyMeaning
200CollectorRunListThe most recent runs, newest first.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookiemodule estaterole adminoperation getCollectorRunMatrix

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

StatusBodyMeaning
200CollectorMatrixOne entry per account, region and module, plus a per-account roll-up in which the worst cell wins.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookierole adminoperation getAdminOverview

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

StatusBodyMeaning
200AdminOverviewThe overview.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookieoperation getEntitlements

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

StatusBodyMeaning
200EntitlementsThe tenant's modules and account cap.
401ErrorResponseunauthorized — no credential, or one that is not valid.
500ErrorResponseinternal_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

credential bearerIngestmodule estateoperation registerCollectionRun

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

NameInTypeDescription
X-Collection-Leaseheaderstring (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

FieldTypeDescription
accountIdrequiredstring
bucketsrequiredarray of CollectionRunBucketMust 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.
modulerequiredstring
regionrequiredstring
runIdrequiredstring (uuid)Generated by the collector before its first attempt, and reused on every retry. This is the idempotency key for registration.

Responses

StatusBodyMeaning
200CollectionRunRegisteredRegistered, 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.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_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).
409ErrorResponserun_registration_conflict — this runId is already registered with a different account, region, module or bucket set.
413ErrorResponseThe 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.
500ErrorResponseinternal_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

credential bearerIngestmodule estateoperation failCollectionRun

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

NameInTypeDescription
X-Collection-Leaseheaderstring (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.
runIdrequiredpathstring (uuid)

Request body application/json

FieldTypeDescription
reasonrequiredstringWhy 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.
resourcesAttemptedrequiredintegerResources 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

StatusBodyMeaning
204Recorded.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_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).
404ErrorResponserun_not_found
413ErrorResponseThe 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.
500ErrorResponseinternal_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

credential bearerIngestmodule estateoperation finalizeCollectionRun

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

NameInTypeDescription
X-Collection-Leaseheaderstring (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.
runIdrequiredpathstring (uuid)

Request body application/json

FieldTypeDescription
resourcesAttemptedrequiredintegerResources 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

StatusBodyMeaning
200FinalizeResponseReconciled, or already finalized and reporting the stored result.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_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).
404ErrorResponserun_not_found
409ErrorResponserun_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).
413ErrorResponseThe 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.
500ErrorResponseinternal_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

credential bearerIngestmodule estatebody at most 2.01326592e+08 bytesoperation ingestSnapshotBatch

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

NameInTypeDescription
X-Collection-Leaseheaderstring (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-KeyrequiredheaderstringStable 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

FieldTypeDescription
itemsrequiredarray of SnapshotInputNo duplicate resource keys within a batch: a duplicate makes the upsert order-dependent, which makes transitions non-deterministic.
runIdrequiredstring (uuid)The run these observations belong to. Every item must fall inside a bucket that run registered, or the batch is rejected.

Responses

StatusBodyMeaning
200SnapshotBatchResponseStored, or replayed from the stored result. Identical either way.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitled — this tenant's subscription does not include the module producing these snapshots.
404ErrorResponserun_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.
409ErrorResponseidempotency_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.
413ErrorResponseThe 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.
500ErrorResponseinternal_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

credential sessionCookierole adminoperation listMarkerCredentials

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

StatusBodyMeaning
200MarkerCredentialListThis tenant's marker credentials, newest first.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookierole adminoperation issueMarkerCredential

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

FieldTypeDescription
accountIdsarray of string
appIdsarray of string (uuid)
namerequiredstring

Responses

StatusBodyMeaning
201MarkerCredentialIssuedThe credential and its token. Store the token now; it cannot be read back.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookierole adminoperation revokeMarkerCredential

Revoke a marker credential

Idempotent. The row stays as the record; the token stops being honoured at once.

Responses

StatusBodyMeaning
204Revoked, or already was.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookieoperation listNotificationChannels

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

StatusBodyMeaning
200NotificationChannelListThe channels, by name.
401ErrorResponseunauthorized — no credential, or one that is not valid.
500ErrorResponseinternal_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

credential sessionCookierole adminbody at most 8192 bytesoperation createNotificationChannel

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

FieldTypeDescription
enabledboolean
kindrequiredNotificationChannelKindslack 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.
namerequiredstring
targetrequiredstringAn https URL this deployment will post to. Never read back.
teamIdstring (uuid)Scope the channel to a team; omit for the tenant default.

Responses

StatusBodyMeaning
201NotificationChannelThe channel, redacted.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponsechannel_exists — the same target is already a channel of that kind for that team (or the tenant default).
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookieoperation getNotificationChannel

One channel, redacted

Responses

StatusBodyMeaning
200NotificationChannelThe channel.
401ErrorResponseunauthorized — no credential, or one that is not valid.
404ErrorResponsenot_found
500ErrorResponseinternal_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}

credential sessionCookierole adminbody at most 8192 bytesoperation updateNotificationChannel

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

FieldTypeDescription
enabledboolean
namestring
targetstringA new target — the rotation. Checked like a new one; the last test is cleared.
teamIdstring (uuid)Move the channel to a team.
tenantDefaultbooleantrue takes the channel out of its team and makes it a tenant default.

Responses

StatusBodyMeaning
200NotificationChannelThe channel as it now stands.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponsechannel_exists — the same target is already a channel of that kind for that team (or the tenant default).
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookierole adminoperation deleteNotificationChannel

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

StatusBodyMeaning
204Removed.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookierole admin30 requests a minute per addressoperation testNotificationChannel

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

StatusBodyMeaning
200NotificationChannelTestWhat the test delivery did.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
429ErrorResponseRate limited.
500ErrorResponseinternal_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

credential sessionCookieoperation listOwnershipOverrides

The tenant's ownership overrides

Live first, then revoked, newest first within each; a team narrows the list.

Parameters

NameInTypeDescription
teamIdquerystring (uuid)
includeRevokedqueryboolean

Responses

StatusBodyMeaning
200OwnershipOverrideListThe overrides.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
500ErrorResponseinternal_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

credential sessionCookierole engineerbody at most 8192 bytesoperation createOwnershipOverride

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

FieldTypeDescription
accountIdstring
expiresAtstring (date-time)
kindrequiredstring enum One of resource, tag, account.
reasonrequiredstring
resourceKeystring
tagKeystring
tagValuestring
teamIdrequiredstring (uuid)

Responses

StatusBodyMeaning
200OwnershipOverrideThe override.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponseThe match already has a live override, or the team is archived.
500ErrorResponseinternal_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}

credential sessionCookierole engineeroperation revokeOwnershipOverride

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

NameInTypeDescription
overrideIdrequiredpathstring (uuid)

Responses

StatusBodyMeaning
200OwnershipOverrideThe override, revoked.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookieoperation listTeams

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

NameInTypeDescription
includeArchivedqueryboolean

Responses

StatusBodyMeaning
200TeamListThe teams.
401ErrorResponseunauthorized — no credential, or one that is not valid.
500ErrorResponseinternal_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

credential sessionCookierole adminoperation createTeam

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

FieldTypeDescription
namerequiredstring
slugstringDerived from the name when omitted.

Responses

StatusBodyMeaning
201TeamCreated.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
409ErrorResponseteam_exists — the name or the slug is already a team's.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookieoperation getTeam

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

StatusBodyMeaning
200TeamDetailThe team.
401ErrorResponseunauthorized — no credential, or one that is not valid.
404ErrorResponsenot_found
500ErrorResponseinternal_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}

credential sessionCookierole adminoperation updateTeam

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

FieldTypeDescription
archivedboolean
namestring

Responses

StatusBodyMeaning
200TeamThe team as it now stands.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponseteam_exists — the name or the slug is already a team's.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookierole adminoperation assignTeamAccount

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

StatusBodyMeaning
200TeamAccountChangeThe assignment, and what it changes.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponseaccount_assigned — another team owns the account, named in the message. team_archived — the team is closed.
500ErrorResponseinternal_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}

credential sessionCookierole adminoperation unassignTeamAccount

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

StatusBodyMeaning
200TeamAccountChangeWhat moved.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookierole adminoperation bindTeamDirectoryGroup

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

FieldTypeDescription
grouprequiredstringThe group exactly as the provider names it in the groups claim — an id or a name, whichever the provider emits.

Responses

StatusBodyMeaning
200TeamThe team, bound.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponseThat group is bound to another live team of the tenant.
500ErrorResponseinternal_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

credential sessionCookierole adminoperation unbindTeamDirectoryGroup

Unbind the team from its group

The memberships stay as they are and the team is hand-kept again. Idempotent.

Responses

StatusBodyMeaning
200TeamThe team, hand-kept.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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}

credential sessionCookierole adminoperation setTeamMember

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

FieldTypeDescription
teamRolerequiredTeamRoleA 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

StatusBodyMeaning
200TeamMemberThe membership.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponseteam_archived — the team is closed and takes no new assignment.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookierole adminoperation removeTeamMember

Remove a user from the team

Responses

StatusBodyMeaning
204Removed.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookierole adminoperation addTeamTagValue

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

FieldTypeDescription
tagValuerequiredstring

Responses

StatusBodyMeaning
200TeamTagValueChangeThe mapping, and whether a rebuild was enqueued.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponsetag_value_mapped — another team holds the value, named in the message. team_archived — the team is closed.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookierole adminoperation removeTeamTagValue

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

StatusBodyMeaning
200TeamTagValueChangeThe mapping that was removed, and whether a rebuild was enqueued.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookierole adminoperation listOrgUsers

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

NameInTypeDescription
includeDisabledquerybooleanAlso list users whose access has been deactivated.

Responses

StatusBodyMeaning
200OrgUserListThe users, by email.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookierole adminbody at most 8192 bytesoperation createOrgUser

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

FieldTypeDescription
emailrequiredstring
rolerequiredstring enum One of viewer, engineer, admin.

Responses

StatusBodyMeaning
201OrgUserCreatedThe user, and their temporary password, shown once.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
409ErrorResponseuser_exists — a user with that email is already in the tenant.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookierole adminbody at most 8192 bytesoperation updateOrgUser

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

FieldTypeDescription
breakGlassbooleanAn administrator who keeps a password when the tenant enforces single sign-on (p5_5). Only an administrator can be one.
disabledbooleantrue deactivates the account; false reactivates it.
rolestring enum One of viewer, engineer, admin.

Responses

StatusBodyMeaning
200OrgUserThe user as they now stand.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponselast_admin — the change would leave the tenant with no enabled administrator, and nothing inside the product could repair that.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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

credential sessionCookierole adminoperation issuePasswordReset

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

StatusBodyMeaning
201PasswordResetIssuedThe reset token, shown once, and when it expires.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookierole adminoperation revokeOrgUserSessions

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

StatusBodyMeaning
204The sessions are gone.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookiemodule estateoperation listApps

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

StatusBodyMeaning
200AppGroupsThe groups, the keys they came from, and the period spend covers.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookiemodule estateoperation listDeclaredApps

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

StatusBodyMeaning
200DeclaredAppListThe declared apps, by name, and the period spend covers.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
500ErrorResponseinternal_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

credential sessionCookiemodule estaterole engineerbody at most 65536 bytesoperation createDeclaredApp

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

FieldTypeDescription
descriptionstring
excludearray of ResourceRefResources kept out although a match hits them.
includearray of ResourceRefResources added by hand.
matchesrequiredarray of TagMatch
namerequiredstring
teamIdrequiredstring (uuid)

Responses

StatusBodyMeaning
201DeclaredAppThe app, with the list's columns.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponseapp_exists: a live app already has this name; team_archived: the owning team is closed.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookiemodule estateoperation getDeclaredApp

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

StatusBodyMeaning
200DeclaredAppDetailThe app, its members and its trend.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
500ErrorResponseinternal_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}

credential sessionCookiemodule estaterole engineerbody at most 65536 bytesoperation replaceDeclaredApp

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

FieldTypeDescription
descriptionstring
excludearray of ResourceRefResources kept out although a match hits them.
includearray of ResourceRefResources added by hand.
matchesrequiredarray of TagMatch
namerequiredstring
teamIdrequiredstring (uuid)

Responses

StatusBodyMeaning
200DeclaredAppThe app as it now is.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponseapp_exists, app_archived or team_archived.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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

credential sessionCookiemodule estaterole engineeroperation archiveDeclaredApp

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

StatusBodyMeaning
200DeclaredAppThe app, archived.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponseapp_archived: already archived.
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningoperation listProvisioningTemplates

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

NameInTypeDescription
categoryqueryarray of stringRepeatable. Any of the given categories.
includeArchivedquerybooleanInclude templates with archivedAt set. Default false.
pagequeryinteger1-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.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200TemplatePageA page of templates.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningrole adminbody at most 4.194304e+06 bytesoperation createProvisioningTemplate

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

FieldTypeDescription
categorystring
descriptionstring
gitRefstringDefault ref for new versions. Only for git.
gitUrlstringRequired for git, rejected for inline.
namerequiredstring
slugrequiredstring
sourceTyperequiredTemplateSourceTypeWhere 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

StatusBodyMeaning
201TemplateThe registered template.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
409ErrorResponseinvalid_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.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookiemodule provisioningoperation getProvisioningTemplate

One template

Responses

StatusBodyMeaning
200TemplateThe template.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
500ErrorResponseinternal_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}

credential sessionCookiemodule provisioningrole adminbody at most 4.194304e+06 bytesoperation updateProvisioningTemplate

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

FieldTypeDescription
archivedbooleanTrue archives, false restores. Archiving hides the template and closes it to new versions; it never touches what was provisioned from it.
categorystring
descriptionstring
gitRefstring
namestring

Responses

StatusBodyMeaning
200TemplateThe updated template.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningoperation listProvisioningTemplateVersions

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

NameInTypeDescription
pagequeryinteger1-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.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200TemplateVersionPageA page of versions.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningrole adminbody at most 4.194304e+06 bytesoperation publishProvisioningTemplateVersion

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

FieldTypeDescription
gitRefstringFor git templates. Defaults to the template's gitRef. This is what an agent resolves; it is never what runs.
inlineSourcestringFor 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.
variablesSchemarequiredVariablesSchemaStored 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

StatusBodyMeaning
201TemplateVersionThe new version. resolved for inline, pending for git.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
409ErrorResponserequest_state_conflict — the template is archived, so it takes no new versions.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookiemodule provisioningoperation getProvisioningTemplateVersion

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

StatusBodyMeaning
200TemplateVersionThe version.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningrole adminoperation requeueProvisioningVersionResolve

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

StatusBodyMeaning
202TemplateVersionRequeued. resolveState is back to pending; resolveAccountId names the account whose agent will claim it, or is absent when none is eligible yet.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
409ErrorResponseresolution_conflict — the version is resolved (its source identity is written once and never updated), or an agent is running its resolution now.
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningoperation listProvisioningRequests

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

NameInTypeDescription
statequeryarray of ProvisionRequestStateRepeatable. Any of the given request states.
accountIdqueryarray of stringRepeatable. Any of the given accounts.
templateIdquerystring (uuid)
awaitingMyDecisionquerybooleanOnly requests in pending_approval that this user may decide — so never their own, since self-approval is refused (D8).
pagequeryinteger1-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.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200ProvisionRequestPageA page of requests.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningrole engineeroperation createProvisioningRequest

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

FieldTypeDescription
actionrequiredProvisionActioncreate 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.
namerequiredstringThe requester's name for the thing.
resourceIdstring (uuid)Required for destroy, rejected for create.
targetAccountIdrequiredstringMust exist in this tenant's accounts registry, be enabled, and be owned by a team the requester belongs to.
teamIdstring (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.
templateIdrequiredstring (uuid)
templateVersionrequiredintegerExplicit, never "latest". A request that pinned a moving target would be approved against one version and applied from another.
variablesobjectValidated 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

StatusBodyMeaning
201ProvisionRequestCreated in pending_approval, with expiresAt already set. The approvers for the target account's owning team are notified through the platform outbox.
400ProvisionRequestValidationErrorinvalid_request. details names every variable that failed and why, so the form can mark the fields rather than the page.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
409ErrorResponseversion_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).
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookiemodule provisioningoperation getProvisioningRequest

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

StatusBodyMeaning
200ProvisionRequestDetailThe request.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningrole engineeroperation cancelProvisioningRequest

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

StatusBodyMeaning
200ProvisionRequestcancelled if nothing had been claimed, cancelling if an agent is running it.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
409ErrorResponserequest_state_conflict — the request is terminal, or still awaiting approval (withdraw it instead).
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningrole engineeroperation decideProvisioningRequest

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

FieldTypeDescription
commentstringRecorded permanently and shown to the requester. Optional on approve; a rejection without one is technically allowed and practically unkind.
decisionrequiredstring enum One of approved, rejected.

Responses

StatusBodyMeaning
200ProvisionRequestDecided. 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.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseself_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.
404ErrorResponsenot_found
409ErrorResponserequest_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.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningrole engineeroperation retryProvisioningRequest

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

StatusBodyMeaning
200ProvisionRequestQueued again, with attempt incremented.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
409ErrorResponserequest_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.
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningrole engineeroperation withdrawProvisioningRequest

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

StatusBodyMeaning
200ProvisionRequestWithdrawn.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
409ErrorResponserequest_state_conflict — not in pending_approval.
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningoperation listProvisioningTasks

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

NameInTypeDescription
pagequeryinteger1-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.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200ProvisionTaskPageA page of tasks, newest first.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
500ErrorResponseinternal_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}

credential sessionCookiemodule provisioningoperation getProvisioningTask

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

StatusBodyMeaning
200ProvisionTaskDetailThe task, with its summary once it is terminal.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningoperation getProvisioningTaskLogs

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

NameInTypeDescription
cursorquerystringOpaque keyset cursor from the previous page's nextCursor. Omit for the first page, which starts at the lowest retained seq.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200ProvisionTaskLogPageA page of log lines, oldest first.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningoperation listProvisioningDriftMissing

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

NameInTypeDescription
accountIdqueryarray of stringRepeatable. Any of the given accounts.
pagequeryinteger1-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.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200DriftMissingPageA page of provisioned-but-missing findings.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningoperation listProvisioningDriftUnprovisioned

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

NameInTypeDescription
accountIdqueryarray of stringRepeatable. Any of the given accounts.
servicequeryarray of stringRepeatable.
pagequeryinteger1-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.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200DriftUnprovisionedPageA page of existing-but-unprovisioned findings.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningoperation listProvisionedResources

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

NameInTypeDescription
accountIdqueryarray of stringRepeatable. Any of the given accounts.
templateIdquerystring (uuid)
createRequestIdquerystring (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).
statusqueryarray of ProvisionedResourceStatusRepeatable.
pagequeryinteger1-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.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200ProvisionedResourcePageA page of provisioned resources.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningoperation listProvisioningAgents

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

NameInTypeDescription
includeRevokedqueryboolean
pagequeryinteger1-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.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200ProvisioningAgentPageA page of the agent tokens registered for this tenant.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule provisioningrole adminoperation issueProvisioningAgentToken

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

FieldTypeDescription
accountIdrequiredstring
namerequiredstring

Responses

StatusBodyMeaning
201ProvisioningAgentTokenIssuedThe agent and its token. Store the token now; it cannot be read back.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
409ErrorResponseaccount_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.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookiemodule provisioningrole adminoperation revokeProvisioningAgentToken

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

StatusBodyMeaning
204Revoked. Repeating this is not an error.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential bearerAgentmodule provisioning1200 requests a minute per addressoperation claimProvisioningWork

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

FieldTypeDescription
agentVersionrequiredstring
claimIdrequiredstring (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.
tofuVersionrequiredstring
waitSecondsintegerHow long the agent is willing to hold the poll open. Clamped server-side to claimPollTimeoutSeconds; it is a hint, not a demand.

Responses

StatusBodyMeaning
200AgentTaskWork 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.
204Nothing to do. The long poll expired without work appearing; the agent polls again. Not an error, and not a reason to back off.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no agent token, an unknown token, or a revoked token. Fails closed (§3.7): there is no degraded mode and no anonymous claim.
403ErrorResponseaccount_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.
409ErrorResponseclaim_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.
413ErrorResponseThe 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.
426ErrorResponseagent_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.
429ErrorResponseRate limited.
500ErrorResponseinternal_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

credential bearerAgentmodule provisioning1200 requests a minute per addressoperation registerProvisioningAgent

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

FieldTypeDescription
agentVersionrequiredstring
capabilitiesarray of stringOptional 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.
tofuVersionrequiredstringThe 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

StatusBodyMeaning
200AgentRegistrationResultRegistered. 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.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no agent token, an unknown token, or a revoked token. Fails closed (§3.7): there is no degraded mode and no anonymous claim.
403ErrorResponseaccount_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.
413ErrorResponseThe 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.
429ErrorResponseRate limited.
500ErrorResponseinternal_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

credential bearerAgentmodule provisioning1200 requests a minute per addressoperation heartbeatProvisioningTask

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

FieldTypeDescription
attemptrequiredintegerThe fence.
stagestring

Responses

StatusBodyMeaning
200AgentHeartbeatResultLiveness recorded. Act on cancelRequested.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no agent token, an unknown token, or a revoked token. Fails closed (§3.7): there is no degraded mode and no anonymous claim.
403ErrorResponseaccount_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.
404ErrorResponsenot_found
409ErrorResponsetask_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.
413ErrorResponseThe 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.
429ErrorResponseRate limited.
500ErrorResponseinternal_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

credential bearerAgentmodule provisioningbody at most 8.388608e+06 bytesoperation appendProvisioningTaskLogs

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

NameInTypeDescription
Idempotency-KeyrequiredheaderstringStable 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

FieldTypeDescription
attemptrequiredintegerThe fence.
linesrequiredarray of AgentLogLineAt most maxLogBatchLines from the policy; 500 is the ceiling this contract allows regardless.

Responses

StatusBodyMeaning
200AgentLogBatchAcceptedStored, or the stored acknowledgement replayed unchanged.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no agent token, an unknown token, or a revoked token. Fails closed (§3.7): there is no degraded mode and no anonymous claim.
403ErrorResponseaccount_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.
404ErrorResponsenot_found
409ErrorResponsetask_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.
413ErrorResponseinvalid_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.
429ErrorResponseRate limited.
500ErrorResponseinternal_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

credential bearerAgentmodule provisioningoperation reportProvisioningTaskStatus

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

FieldTypeDescription
agentVersionrequiredstring
attemptrequiredintegerThe 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.
createdCountinteger
destroyedCountinteger
failureClassProvisionFailureClassRequired 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.
failureMessagestringAlready scrubbed when it is sent. The platform stores it verbatim and shows it to humans; it has no second chance to redact.
finalLogSeqinteger (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".
gitShastringresolve 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.
logLineCountinteger (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.
partialApplybooleanThe 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.
planDigeststringsha256 of the plan output. Kept in the permanent summary.
provenanceTaggedCountintegerResources 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.
stagestring
stageDurationsMsobject
staterequiredProvisionTaskStateOne 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.
stateBackendstringWhere 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).
tofuVersionrequiredstring

Responses

StatusBodyMeaning
200AgentTaskStatusAcceptedRecorded, or the stored response replayed unchanged. There is nothing in the body that distinguishes the two, deliberately.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no agent token, an unknown token, or a revoked token. Fails closed (§3.7): there is no degraded mode and no anonymous claim.
403ErrorResponseaccount_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.
404ErrorResponsenot_found
409ErrorResponsetask_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.
413ErrorResponseThe 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.
429ErrorResponseRate limited.
500ErrorResponseinternal_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

credential sessionCookieoperation listAIReviews

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

NameInTypeDescription
accountIdquerystring
resourceKeyquerystringregion/system/service/type/id
subjectKindquerystring enum
subjectIdquerystring
limitqueryinteger

Responses

StatusBodyMeaning
200AIReviewListThe reviews.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
500ErrorResponseinternal_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

credential bearerReviewbody at most 65536 bytesoperation answerAIReview

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

NameInTypeDescription
reviewIdrequiredpathstring (uuid)

Request body application/json

FieldTypeDescription
confidencestring enum One of low, medium, high.
findingsarray of AIReviewFinding
modelrequiredstringThe model that reviewed, as the reviewer names it.
summaryrequiredstring
verdictrequiredstring enum One of nothing_stands_out, findings, could_not_review.

Responses

StatusBodyMeaning
200AIReviewThe review, answered.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponseforbidden — 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).
404ErrorResponsenot_found
409ErrorResponseThe review was already answered differently.
500ErrorResponseinternal_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

credential sessionCookiemodule costoperation listCostAnomalies

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

NameInTypeDescription
statusqueryarray of CostAnomalyStatusRepeatable. Defaults to open.
accountIdqueryarray of stringRepeatable. Any of the given accounts.
teamIdqueryarray 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.
pagequeryinteger1-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.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200CostAnomalyPageA page of anomalies.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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}

credential sessionCookiemodule costrole engineeroperation updateCostAnomaly

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

FieldTypeDescription
statusrequiredstring enum One of acknowledged, dismissed.

Responses

StatusBodyMeaning
200CostAnomalyThe anomaly as it now stands.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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

credential sessionCookiemodule costoperation getCostBreakdown

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

NameInTypeDescription
fromquerystring (date)First day of the range, inclusive. Defaults to the first of the current month. from after to is invalid_request.
toquerystring (date)Last day of the range, inclusive. Defaults to today.
figurequeryCostFigureWhich 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.
groupByrequiredquerystring enum
accountIdqueryarray of stringRepeatable. Any of the given accounts.
teamIdqueryarray 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.
servicequeryarray of stringRepeatable.
limitqueryintegerRows to return before the rest is summed into an other row. Normalised: < 1 becomes 20, > 200 becomes 200.

Responses

StatusBodyMeaning
200CostBreakdownThe breakdown.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule costoperation getCostForecast

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

NameInTypeDescription
scoperequiredqueryCostForecastScope
teamIdquerystring (uuid)The team, with scope=team.
accountIdquerystringThe account, with scope=account.
monthquerystringThe month, YYYY-MM; the current month when absent.
currencyquerystringOne currency; every currency the scope spends in when absent.

Responses

StatusBodyMeaning
200CostForecastListThe scope's forecasts for the month.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule costoperation getCostKubernetes

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

NameInTypeDescription
fromquerystring (date)First day of the range, inclusive. Defaults to the first of the current month. from after to is invalid_request.
toquerystring (date)Last day of the range, inclusive. Defaults to today.
figurequeryCostFigureWhich 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.
accountIdqueryarray of stringRepeatable. Any of the given accounts.
clusterquerystringA cluster name; with region, one cluster.
regionquerystringThe cluster's region, as the inventory holds it.

Responses

StatusBodyMeaning
200CostKubernetesThe attribution.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule costoperation listCostResources

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

NameInTypeDescription
figurequeryCostFigureWhich 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.
filterquerystring enum
accountIdqueryarray of stringRepeatable. Any of the given accounts.
servicequeryarray of stringRepeatable.
queryquerystringSubstring match on the resource id and name.
pagequeryinteger1-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.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200CostResourcePageA page of resources with their spend.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule costoperation getCostSummary

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

NameInTypeDescription
fromquerystring (date)First day of the range, inclusive. Defaults to the first of the current month. from after to is invalid_request.
toquerystring (date)Last day of the range, inclusive. Defaults to today.
figurequeryCostFigureWhich 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.
accountIdqueryarray of stringRepeatable. Any of the given accounts.
teamIdqueryarray 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

StatusBodyMeaning
200CostSummaryThe period's totals.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule costoperation getCostTrend

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

NameInTypeDescription
fromquerystring (date)First day of the range, inclusive. Defaults to the first of the current month. from after to is invalid_request.
toquerystring (date)Last day of the range, inclusive. Defaults to today.
figurequeryCostFigureWhich 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.
groupByrequiredquerystring enum
granularityquerystring enum
accountIdqueryarray of stringRepeatable. Any of the given accounts.
teamIdqueryarray 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

StatusBodyMeaning
200CostTrendThe series.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule costoperation listCostBudgets

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

NameInTypeDescription
teamIdqueryarray 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

StatusBodyMeaning
200CostBudgetListThe budgets.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule costrole adminoperation createCostBudget

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

FieldTypeDescription
amountrequiredMoneyOne 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.
enabledboolean
periodstring enum One of monthly.
teamIdrequiredstring (uuid)
thresholdsarray of integer

Responses

StatusBodyMeaning
201CostBudgetCreated.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
409ErrorResponsebudget_exists — the team already has a budget for this period.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookiemodule costoperation getCostBudget

One budget with its progress

Responses

StatusBodyMeaning
200CostBudgetThe budget.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
500ErrorResponseinternal_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}

credential sessionCookiemodule costrole adminoperation updateCostBudget

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

FieldTypeDescription
amountMoneyOne 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.
enabledboolean
thresholdsarray of integer

Responses

StatusBodyMeaning
200CostBudgetThe budget as it now stands.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
409ErrorResponsecurrency_mismatch — see the description.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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}

credential sessionCookiemodule costrole adminoperation deleteCostBudget

Remove a budget

Its notification history goes with it.

Responses

StatusBodyMeaning
204Removed.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
404ErrorResponsenot_found
500ErrorResponseinternal_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

credential sessionCookiemodule costoperation listCostRecommendations

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

NameInTypeDescription
accountIdqueryarray of stringRepeatable. Any of the given accounts.
pagequeryinteger1-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.
pageSizequeryintegerNormalised 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

StatusBodyMeaning
200CostRecommendationPageThe recommendations.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule costoperation getCostSettings

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

StatusBodyMeaning
200CostSettingsThe settings.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
500ErrorResponseinternal_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

credential sessionCookiemodule costrole adminoperation updateCostSettings

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

FieldTypeDescription
allocationRulesarray of CostAllocationRuleThe 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.
anomalyrequiredCostAnomalySettings
attributionRulesrequiredarray of CostAttributionRule
curReportsarray of CostCurReportThe 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.
defaultFigurerequiredCostFigure One of amortized, unblended.
teamTagValuesrequiredarray of CostTeamTagValue

Responses

StatusBodyMeaning
200CostSettingsThe settings as they now stand.
400ErrorResponseinvalid_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.
401ErrorResponseunauthorized — no credential, or one that is not valid.
403ErrorResponsemodule_not_entitledRequireModule("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).
409ErrorResponseattribution_rebuild_in_progress — a rebuild from the previous change is still running.
413ErrorResponseThe 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.
415ErrorResponseunsupported_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.
500ErrorResponseinternal_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

FieldTypeDescription
accountIdstring
answerSummarystring
answeredAtstring (date-time)
confidencestring enum One of low, medium, high.
expiresAtrequiredstring (date-time)When the request's token stops answering.
findingsrequiredarray of AIReviewFinding
idrequiredstring (uuid)
modelstring
requestedAtrequiredstring (date-time)
resourceKeystring
subjectIdrequiredstring
subjectKindrequiredstring enum One of alert, anomaly, test.
summaryrequiredstringThe one line the reviewer was handed.
verdictstring enum One of nothing_stands_out, findings, could_not_review.

AIReviewAnswer

FieldTypeDescription
confidencestring enum One of low, medium, high.
findingsarray of AIReviewFinding
modelrequiredstringThe model that reviewed, as the reviewer names it.
summaryrequiredstring
verdictrequiredstring 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.

FieldTypeDescription
idrequiredstringA resource's account/region/system/service/type/id; a transition's or an anomaly's id.
kindrequiredstring enum One of resource, transition, anomaly.

AIReviewFinding

FieldTypeDescription
detailstring
evidencearray of AIReviewEvidence
titlerequiredstring

AIReviewList

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

FieldTypeDescription
accountIdrequiredstring
createdAtrequiredstring (date-time)
enabledrequiredbooleanNew accounts default to false. Discovery registers everything it finds; collecting from an unapproved account is both surprising and expensive.
environmentrequiredstring enum One of production, staging, sdlc, shared, unknown.
googleServiceAccountstringA Google Cloud project's reader service account, which the platform impersonates; absent for an AWS account.
isManagementrequiredbooleanWhether 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.
namerequiredstring
regionsrequiredarray of string
roleArnrequiredstring
systemrequiredstring enumThe 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

FieldTypeDescription
itemsrequiredarray of Account

AccountUpdate

Only the fields present are changed.

FieldTypeDescription
enabledboolean
environmentstring enum One of production, staging, sdlc, shared, unknown.
isManagementbooleanMark (or unmark) this account as the organisation's management account. Marking a second one is a 409: there is at most one.
namestring
regionsarray of string
roleArnstringCorrect 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

FieldTypeDescription
assumedRolebooleanWhether the role assumption itself succeeded.
checksrequiredarray of AccountVerificationCheck
okrequiredbooleanTrue only if every check passed.

AccountVerificationCheck

FieldTypeDescription
detailstringWhat to grant, when the check failed. "Access denied" without saying what is missing turns onboarding into guesswork.
okrequiredboolean
permissionrequiredstring

AcknowledgeRequest

A resource's alert to acknowledge, and why. The note is required.

FieldTypeDescription
accountIdrequiredstring
noterequiredstring
regionrequiredstring
resourceIdrequiredstring
resourceTyperequiredstring
servicerequiredstring
systemrequiredstring

Acknowledgement

An active acknowledgement, with the resource it covers.

FieldTypeDescription
accountIdrequiredstring
acknowledgedAtrequiredstring (date-time)
acknowledgedByrequiredActorRefWho 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.
noterequiredstring
regionrequiredstring
resourceIdrequiredstring
resourceNamerequiredstring
resourceTyperequiredstring
servicerequiredstring
statusrequiredStatusThe 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.
systemrequiredstring
transitionIdrequiredinteger (int64)

AcknowledgementList

FieldTypeDescription
itemsrequiredarray of Acknowledgement

AcknowledgementTarget

A resource, by its natural key.

FieldTypeDescription
accountIdrequiredstring
regionrequiredstring
resourceIdrequiredstring
resourceTyperequiredstring
servicerequiredstring
systemrequiredstring

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.

FieldTypeDescription
emailrequiredstring
idrequiredstring (uuid)
namestringThe name the person gave themselves (p7_89), when they gave one.

AdminOverview

FieldTypeDescription
accountsrequiredobject
collectionrequiredobjectThe enabled accounts by their collectors' worst state, as the collector matrix rolls it up.
licencerequiredEntitlements
recentrequiredarray of AuditEntryThe latest audit entries, newest first.
ssorequiredobject
usersrequiredobject

AgentClaimRequest

FieldTypeDescription
agentVersionrequiredstring
claimIdrequiredstring (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.
tofuVersionrequiredstring
waitSecondsintegerHow long the agent is willing to hold the poll open. Clamped server-side to claimPollTimeoutSeconds; it is a hint, not a demand.

AgentHeartbeat

FieldTypeDescription
attemptrequiredintegerThe fence.
stagestring

AgentHeartbeatResult

FieldTypeDescription
cancelRequestedrequiredbooleanA 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.
heartbeatIntervalSecondsrequiredintegerRepeated here so the platform can retune a running fleet without waiting for every agent to re-register.

AgentLogBatch

FieldTypeDescription
attemptrequiredintegerThe fence.
linesrequiredarray of AgentLogLineAt 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.

FieldTypeDescription
duplicatesrequiredintegerLines 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.
highestSeqrequiredinteger (int64)The highest seq stored for this task as of this batch.
storedrequiredintegerLines this batch wrote. A stored value, so a replay reports the original number.

AgentLogLine

FieldTypeDescription
createdAtrequiredstring (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).
levelrequiredstring enum One of debug, info, warn, error.
messagerequiredstringAlready 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.
seqrequiredinteger (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.
stagerequiredstring

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.

FieldTypeDescription
claimPollTimeoutSecondsrequiredintegerThe 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.
heartbeatIntervalSecondsrequiredintegerHow often to heartbeat each held task. Default 30.
heartbeatTimeoutSecondsrequiredintegerAfter 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.
maxAttemptsrequiredintegerAfter this many attempts a request fails rather than returning to queued. Default 3.
maxLogBatchLinesrequiredinteger
maxLogLineBytesrequiredinteger
minAgentVersionrequiredstringBelow 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.

FieldTypeDescription
agentVersionrequiredstring
capabilitiesarray of stringOptional 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.
tofuVersionrequiredstringThe 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

FieldTypeDescription
accountIdrequiredstringEchoed 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.
agentIdrequiredstring (uuid)
policyrequiredAgentPolicyThe 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.
versionStatusrequiredAgentVersionStatusReport 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.

FieldTypeDescription
accountIdrequiredstringThe 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.
actionrequiredProvisionActionWhat 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.
attemptrequiredintegerThe 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.
claimIdrequiredstring (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.
claimedAtrequiredstring (date-time)
createRequestIdstring (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.
deadlineSecondsintegerThe 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.
gitRefstringresolve 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.
gitShastringcreate 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.
gitUrlstringFor git. create/destroy: clone --depth 1 and check out gitSha. resolve: git ls-remote this, and nothing more.
inlineSha256string
inlineSourcestringFor inline: the exact bytes to write into the workspace. Verify inlineSha256 over them before running anything.
namerequiredstring
provenanceTagsobjectcreate 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.
requestIdrequiredstring (uuid)
resourceIdstring (uuid)destroy only. The provisionedResources row being torn down.
sourceTyperequiredTemplateSourceTypeWhere 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.
stateBackendstringdestroy 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.
taskIdrequiredstring (uuid)
templateIdrequiredstring (uuid)
templateSlugrequiredstring
templateVersionrequiredintegerWith 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.
variablesobjectcreate 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.
variablesSchemaVariablesSchemacreate 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.

FieldTypeDescription
requestStaterequiredProvisionRequestStateWhere the request landed. Worth reading: a terminal task on a request that went back to queued means this attempt was already superseded.
taskIdrequiredstring (uuid)
taskStaterequiredProvisionTaskStateOne 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.

FieldTypeDescription
agentVersionrequiredstring
attemptrequiredintegerThe 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.
createdCountinteger
destroyedCountinteger
failureClassProvisionFailureClassRequired 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.
failureMessagestringAlready scrubbed when it is sent. The platform stores it verbatim and shows it to humans; it has no second chance to redact.
finalLogSeqinteger (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".
gitShastringresolve 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.
logLineCountinteger (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.
partialApplybooleanThe 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.
planDigeststringsha256 of the plan output. Kept in the permanent summary.
provenanceTaggedCountintegerResources 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.
stagestring
stageDurationsMsobject
staterequiredProvisionTaskStateOne 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.
stateBackendstringWhere 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).
tofuVersionrequiredstring

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.

FieldTypeDescription
healthrequiredStatusCountsComputed over the full filtered set, not the current page.
provisionedintegerHow many of the group's live resources Provisioning created. Absent when the tenant does not hold Provisioning.
spendarray of MoneyMonth-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.
tagKeyrequiredstring
tagValuerequiredstring

AppGroups

FieldTypeDescription
groupsrequiredarray of AppGroup
periodrequiredobjectThe month-to-date range spend covers, inclusive.
tagKeysrequiredarray of stringThe 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.

FieldTypeDescription
displayNamestringThe name the person gave themselves (p7_89), while the account exists and they gave one.
emailstringThe account's email, while the account exists.
idrequiredstring (uuid)

AuditEntry

FieldTypeDescription
actionrequiredstringDotted, as auth.identity_provider.update or notify.channel.create.
actorAuditActorThe account that acted; absent for the platform's own changes and the CLI's.
afterobjectThe subject after the change, as the audit recorded it.
atrequiredstring (date-time)
beforeobjectThe subject before the change, as the audit recorded it.
idrequiredinteger (int64)
subjectIdrequiredstring
subjectTyperequiredstring

AuditLogPage

FieldTypeDescription
entriesrequiredarray of AuditEntry
nextCursorstringPresent 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.

FieldTypeDescription
accountIdstringThe account, where the source has one.
actorActorRefWho 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.
appIdstring (uuid)The declared app a marker names.
atrequiredstring (date-time)
detailrequiredobjectThe source's own fields, by kind.
evidencerequiredChangeEvidenceWhere the row's source can be read in full.
idrequiredstring
kindrequiredChangeKindOne 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.
resourceKeystringThe resource key (region/system/service/type/id), where the source has one.
summaryrequiredstringWhat happened, in one line. Never why.
teamIdstring (uuid)The team, for a team-scoped event.

ChangeEvidence

Where the row's source can be read in full.

FieldTypeDescription
apirequiredstringThe API read behind the row.
hrefrequiredstringA portal path, or a marker's own URL.

ChangeFeedPage

FieldTypeDescription
itemsrequiredarray of ChangeEvent
nextCursorstringPresent when another page follows; opaque.
truncatedrequiredarray of ChangeKindThe 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

FieldTypeDescription
accountIdstring
appIdstring (uuid)
atrequiredstring (date-time)
createdAtrequiredstring (date-time)
idrequiredstring (uuid)
idempotencyKeyrequiredstring
labelrequiredstring
resourceKeysrequiredarray of string
urlrequiredstring

ChangeMarkerCreate

FieldTypeDescription
accountIdstringThe account the marker is about; omitted, the marker is the tenant's.
appIdstring (uuid)The declared app the marker is about.
atrequiredstring (date-time)When it happened, by the poster's clock.
idempotencyKeyrequiredstring
labelrequiredstring
resourceKeysarray of stringThe resources it touched, as keys under accountId.
urlstringA 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.

FieldTypeDescription
regionrequiredstring
resourceTyperequiredstring
servicerequiredstring
systemrequiredstring

CollectionRunRegistered

FieldTypeDescription
bucketsrequiredarray of RegisteredBucketEach registered bucket with the generation this run claimed.
runIdrequiredstring (uuid)

CollectionRunRegistration

FieldTypeDescription
accountIdrequiredstring
bucketsrequiredarray of CollectionRunBucketMust 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.
modulerequiredstring
regionrequiredstring
runIdrequiredstring (uuid)Generated by the collector before its first attempt, and reused on every retry. This is the idempotency key for registration.

CollectorMatrix

FieldTypeDescription
accountsrequiredarray of CollectorMatrixAccountPer-account roll-up, worst status wins.
entriesrequiredarray of MatrixEntry

CollectorMatrixAccount

FieldTypeDescription
accountIdrequiredstring
overallStatusrequiredstring enum One of ok, error, slow, no-data.

CollectorRun

FieldTypeDescription
accountIdrequiredstring
durationMsinteger (int64)
errorMessagestring
finishedAtstring (date-time)
modulerequiredstring
regionrequiredstring
resourcesAttemptedinteger (int64)
resourcesStoredinteger (int64)
runIdrequiredstring (uuid)
startedAtrequiredstring (date-time)
staterequiredstring enum One of running, finalized, failed.
tombstonedinteger (int64)

CollectorRunList

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

FieldTypeDescription
enabledrequiredboolean
fixedSharesobjectFor fixed: team id → share. Any positive numbers; they are normalised.
idstring (uuid)
methodrequiredstring enumproportional — 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.
namerequiredstring
servicesrequiredarray of stringCost Explorer SERVICE names, as the breakdown spells them.

CostAnomaly

FieldTypeDescription
baselinerequiredMoneyOne 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.
detectedAtrequiredstring (date-time)
deviationPctrequirednumber
idrequiredstring (uuid)
observedrequiredMoneyOne 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.
resolvedAtstring (date-time)
resolvedByActorRefWho 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.
scopeIdrequiredstringAn account id, or a team id.
scopeLabelstringThe account's or team's name, for display.
scopeTyperequiredstring enum One of account, team.
servicerequiredstringEmpty for a scope-wide anomaly.
severityrequiredstring enum One of low, medium, high.
statusrequiredCostAnomalyStatus One of open, acknowledged, dismissed.
windowEndrequiredstring (date)
windowStartrequiredstring (date)

CostAnomalyPage

FieldTypeDescription
itemsrequiredarray of CostAnomaly
pagerequiredinteger
pageSizerequiredinteger
totalrequiredinteger (int64)
totalPagesrequiredinteger

CostAnomalySettings

FieldTypeDescription
minDeltarequiredMoneyOne 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.
sensitivityPctrequirednumberThe relative deviation from baseline that counts.
suppressionHoursrequiredinteger

CostAnomalyStatus

Type: string enum

CostAnomalyUpdate

FieldTypeDescription
statusrequiredstring enum One of acknowledged, dismissed.

CostAttributionAmounts

FieldTypeDescription
amountsrequiredarray of Money
attributionrequiredCostAttributionTierWhich 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

FieldTypeDescription
priorityrequiredinteger
tagKeyrequiredstring

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

FieldTypeDescription
figurerequiredCostFigure One of amortized, unblended.
groupByrequiredstring enum One of team, service, account.
periodrequiredCostPeriod
rebuildInProgressrequiredboolean
rebuiltAtstring (date-time)When the rollups these figures come from were last built.
rowsrequiredarray of CostBreakdownRow
totalsrequiredarray of MoneyThe sum of every row including other, per currency.

CostBreakdownRow

FieldTypeDescription
allocatedAmountsarray of MoneyThe 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.
amountsrequiredarray of MoneyThe row's whole figure, per currency, allocation included.
keyrequiredstringPass it back as the matching filter to drill down. other and untagged are not drillable.
labelrequiredstring
sharePctnumberThis 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

FieldTypeDescription
amountrequiredMoneyOne 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.
createdAtrequiredstring (date-time)
enabledrequiredboolean
idrequiredstring (uuid)
periodrequiredstring enum One of monthly.
progressCostBudgetProgressThe current period against the budget, in the budget's currency.
teamIdrequiredstring (uuid)
teamNamerequiredstring
thresholdsrequiredarray of integerPercentages of amount, each firing once per period.
updatedAtrequiredstring (date-time)

CostBudgetCreate

FieldTypeDescription
amountrequiredMoneyOne 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.
enabledboolean
periodstring enum One of monthly.
teamIdrequiredstring (uuid)
thresholdsarray of integer

CostBudgetList

FieldTypeDescription
itemsrequiredarray of CostBudget

CostBudgetProgress

The current period against the budget, in the budget's currency.

FieldTypeDescription
otherCurrenciesarray of MoneyThe team's spend this period in currencies other than the budget's — not converted (C7), listed so the mismatch is seen.
pctrequirednumberspent / amount × 100.
periodStartrequiredstring (date)
spentrequiredMoneyOne 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.
thresholdsCrossedrequiredarray of integerThe thresholds that have already fired this period.

CostBudgetUpdate

FieldTypeDescription
amountMoneyOne 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.
enabledboolean
thresholdsarray 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.

FieldTypeDescription
accountIdrequiredstring
bucketrequiredstring
bucketRegionrequiredstringS3 does not follow a request to the wrong region; the client is built for this one.
curOnlybooleanSkip 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.
enabledrequiredboolean
lastAssemblyIdstring
lastPeriodStartstring (date)
prefixrequiredstring
reportNamerequiredstring
reportRoleArnstringThe 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

FieldTypeDescription
actualToDaterequiredMoneyOne 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.
asOfrequiredstring (date)
backtestrequiredCostForecastBacktestThe scope's own backtest, at the checkpoint nearest the forecast's day.
computedAtrequiredstring (date-time)
daysRemainingrequiredinteger
highMoneyOne 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.
lowMoneyOne 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.
lowConfidencerequiredboolean
methodrequiredstring
projectionMoneyOne 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.
settledThroughrequiredstring (date)The last day counted; the two days after it are still arriving.
staterequiredstring enumready 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.

FieldTypeDescription
checkpointrequiredintegerThe day of the month the backtest was read at, 7, 14 or 21.
p90ApePctnumber (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.
scoredMonthsrequiredinteger

CostForecastList

FieldTypeDescription
forecastsrequiredarray of CostForecastOne per currency, from the newest day computed; empty when none has been computed.
monthrequiredstring
scoperequiredCostForecastScopeWhat a forecast is for (p3_21). One of tenant, team, account.
scopeIdrequiredstringThe team's or the account's id; empty for the tenant.

CostForecastScope

What a forecast is for (p3_21).

Type: string enum

CostIngestionState

FieldTypeDescription
accountIdrequiredstring
backfillCompleteboolean
backfillCursorstring (date)
backfillFromstring (date)
lastCompleteDatestring (date)
lastErrorstring
lastRunAtstring (date-time)
passDiscrepancyMoneyOne 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.
resourceGranularityAvailablebooleanAbsent until the first resource pull has asked.
sourcerequiredstring enum One of ce_aggregate, ce_tag, ce_resource, cur, trusted_advisor.
supportPlanAvailablebooleanFor 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

FieldTypeDescription
clustersrequiredarray of CostKubernetesCluster
figurerequiredCostFigure One of amortized, unblended.
periodrequiredCostPeriod
rebuildInProgressrequiredboolean
rebuiltAtstring (date-time)When the rollups these figures come from were last built.
rowsrequiredarray of CostKubernetesRowLargest first, across clusters; filter by cluster for one.

CostKubernetesCluster

FieldTypeDescription
accountIdrequiredstring
clusterrequiredstring
idleSpendrequiredarray of MoneyThe part no pod requested, per currency.
nodeCountrequiredintegerNodes in the inventory on the last day computed.
nodeSpendrequiredarray of MoneyThe cluster's node line items over the window, per currency: what the rows sum to.
regionrequiredstringThe 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

FieldTypeDescription
accountIdrequiredstring
clusterrequiredstring
namespacerequiredstringEmpty for the idle row.
regionrequiredstringThe cluster's region; with accountId and cluster, which cluster this row belongs to.
sharePctnumberThis row's share of the cluster's node spend, in the row's leading currency, when the cluster has one currency.
spendrequiredarray of Money
workloadKindrequiredstringDeployment, StatefulSet, DaemonSet, CronJob, Job, Pod — or idle.
workloadNamerequiredstring

CostPeriod

FieldTypeDescription
fromrequiredstring (date)
torequiredstring (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.

FieldTypeDescription
figurerequiredCostFigure One of amortized, unblended.
rebuildInProgressrequiredboolean
rebuiltAtstring (date-time)When the rollups these figures come from were last built.

CostRecommendation

FieldTypeDescription
accountIdrequiredstring
checkIdrequiredstring
checkNamerequiredstring
estimatedMonthlySavingsMoneyOne 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.
firstSeenAtstring (date-time)
metadatarequiredobjectThe check's columns, by the names Trusted Advisor gives them.
observedAtrequiredstring (date-time)
regionstring
resourceIdrequiredstringTrusted Advisor's identifier for the flagged resource.
resourceKeystringThe inventory key, when the check names a resource the derivation can map.
statusrequiredstring enum One of ok, warning, error.

CostRecommendationPage

FieldTypeDescription
itemsrequiredarray of CostRecommendation
pagerequiredinteger
pageSizerequiredinteger
totalrequiredinteger (int64)
totalPagesrequiredinteger
unavailableAccountsrequiredarray of CostRecommendationUnavailableAccountAccounts with no recommendations and why: no Trusted Advisor on the support plan, or not pulled yet.

CostRecommendationUnavailableAccount

FieldTypeDescription
accountIdrequiredstring
reasonrequiredstring enum One of support_plan_required, not_yet_ingested.

CostResource

FieldTypeDescription
accountIdrequiredstring
amountsrequiredarray of MoneySpend in the window, per currency.
inventoryStaterequiredCostInventoryStateWhat 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.
lastSeenAtstring (date-time)The inventory's last sighting, when it has a row.
mappedbooleanWhether the identifier the source reported mapped to a platform key (§5.4). Unmapped rows are attribution gaps in disguise and are counted in settings.
regionstring
resourceIdstring
resourceKeyrequiredstringThe platform resource key (PLATFORM.md §3), or the raw identifier the source reported when it could not be mapped.
resourceNamestringFrom the inventory. Absent when the inventory has no row.
resourceTypestring
servicerequiredstring
statusstringThe inventory's status word, when it has a row.
systemstring

CostResourcePage

FieldTypeDescription
figurerequiredCostFigure One of amortized, unblended.
filterrequiredstring enum One of all, orphaned, unattributed.
itemsrequiredarray of CostResource
pagerequiredinteger
pageSizerequiredinteger
rebuildInProgressrequiredboolean
rebuiltAtstring (date-time)When the rollups these figures come from were last built.
totalrequiredinteger (int64)
totalPagesrequiredinteger
unavailableAccountsrequiredarray of CostResourceUnavailableAccountAccounts 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.
windowrequiredCostPeriod
windowDaysrequiredintegerAlways 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.

CostResourceUnavailableAccount

FieldTypeDescription
accountIdrequiredstring
reasonrequiredstring enum One of resource_granularity_unavailable, not_yet_ingested.

CostSeries

FieldTypeDescription
keyrequiredstringThe group's identity: a team id, a service name, an account id — or untagged for the unattributed remainder under groupBy=team.
labelrequiredstring
pointsrequiredarray of CostTrendPoint

CostSettings

FieldTypeDescription
allocationRulesrequiredarray of CostAllocationRuleShared-cost allocation rules in the order they are tried (p3_8).
anomalyrequiredCostAnomalySettings
attributionRulesrequiredarray of CostAttributionRule
curReportsrequiredarray of CostCurReportPer account: where its Cost & Usage Report is, when one is configured.
defaultFigurerequiredCostFigure One of amortized, unblended.
ingestionrequiredarray of CostIngestionState
rebuildEnqueuedbooleanOn the response to a PUT that changed the rules or the tag values.
rebuildInProgressrequiredboolean
teamTagValuesrequiredarray of CostTeamTagValue
unmappedIdentifiersrequiredintegerPer-resource rows whose identifier the key derivation could not map (§5.4), in the current window.

CostSettingsUpdate

FieldTypeDescription
allocationRulesarray of CostAllocationRuleThe 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.
anomalyrequiredCostAnomalySettings
attributionRulesrequiredarray of CostAttributionRule
curReportsarray of CostCurReportThe 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.
defaultFigurerequiredCostFigure One of amortized, unblended.
teamTagValuesrequiredarray of CostTeamTagValue

CostSummary

FieldTypeDescription
byAttributionrequiredarray of CostAttributionAmountsThe three tiers, each per currency. Their sum is totals, to the cent — the reconciliation invariant (§7.2).
deltarequiredarray of MoneyDelta
figurerequiredCostFigure One of amortized, unblended.
periodrequiredCostPeriod
previousPeriodrequiredCostPeriod
previousTotalsrequiredarray of Money
rebuildInProgressrequiredboolean
rebuiltAtstring (date-time)When the rollups these figures come from were last built.
totalsrequiredarray of MoneyPer currency. A single-currency tenant sees one element.

CostTeamTagValue

FieldTypeDescription
tagValuerequiredstringMatched case-insensitively.
teamIdrequiredstring (uuid)

CostTrend

FieldTypeDescription
figurerequiredCostFigure One of amortized, unblended.
granularityrequiredstring enum One of daily, monthly.
groupByrequiredstring enum One of team, service, account.
periodrequiredCostPeriod
rebuildInProgressrequiredboolean
rebuiltAtstring (date-time)When the rollups these figures come from were last built.
seriesrequiredarray of CostSeries

CostTrendPoint

FieldTypeDescription
amountsrequiredarray of MoneyPer currency; empty on a day with no spend.
daterequiredstring (date)The day, or the first of the month for monthly granularity.

DeclaredApp

FieldTypeDescription
archivedAtstring (date-time)
canEditrequiredbooleanWhether 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.
createdAtrequiredstring (date-time)
descriptionstring
excluderequiredarray of ResourceRef
healthrequiredStatusCountsComputed over the full filtered set, not the current page.
idrequiredstring (uuid)
includerequiredarray of ResourceRef
matchesrequiredarray of TagMatch
moneyDeclaredAppMoneyAn 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.
namerequiredstring
provisionedintegerHow many of the app's live members Provisioning created, over the viewer's accounts. Absent without the module.
slugrequiredstring
teamrequiredTeamRefA 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.
updatedAtrequiredstring (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.

FieldTypeDescription
descriptionstring
excludearray of ResourceRefResources kept out although a match hits them.
includearray of ResourceRefResources added by hand.
matchesrequiredarray of TagMatch
namerequiredstring
teamIdrequiredstring (uuid)

DeclaredAppDetail

FieldTypeDescription
apprequiredDeclaredApp
membersrequiredarray of DeclaredAppMember
ownableTeamsrequiredarray of TeamRefThe 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.
trendarray of DeclaredAppTrendMonthSix calendar months ending with the current one, each at its own grain. Absent without Spend.

DeclaredAppList

FieldTypeDescription
appsrequiredarray of DeclaredApp
canCreaterequiredbooleanWhether 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.
ownableTeamsrequiredarray of TeamRefThe 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.
periodrequiredobjectThe month-to-date range spend covers, inclusive.

DeclaredAppMember

FieldTypeDescription
accountIdrequiredstring
lastSeenAtrequiredstring (date-time)
namerequiredstring
regionrequiredstring
resourceIdrequiredstring
resourceTyperequiredstring
servicerequiredstring
statusrequiredStatusThe 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.
systemrequiredstring
viarequiredstring enumWhy 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.

FieldTypeDescription
amountsrequiredarray of Money
explicitOutsideTagGrainrequiredinteger
grainrequiredstring enum One of resource, tag.
overlapResourcesrequiredinteger
sourcerequiredstring enum One of cur, ce, legacy, mixed, none.

DeclaredAppTrendMonth

FieldTypeDescription
moneyrequiredDeclaredAppMoneyAn 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.
monthrequiredstring (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.

FieldTypeDescription
kindrequiredstring enum One of added, removed, changed.
newobject
oldobject
pathrequiredstring
redactedboolean

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.

FieldTypeDescription
accountsarray of Account
createdrequiredintegerNewly registered, all with enabled = false.
foundrequiredintegerAccounts returned by Organizations.
unchangedrequiredintegerAlready known and identical.
updatedrequiredintegerAlready 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.

FieldTypeDescription
expectedCountrequiredinteger
gaprequiredintegerexpectedCount - observedCount, always positive here.
lastObservedAtstring (date-time)
observedCountrequiredintegerLive inventory rows in that account whose tags carry panorama:request_id = createRequestId and which are not tombstoned.
resourcerequiredProvisionedResourceWhat 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

FieldTypeDescription
itemsrequiredarray of DriftMissing
pagerequiredinteger
pageSizerequiredinteger
totalrequiredinteger (int64)
totalPagesrequiredinteger

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.

FieldTypeDescription
accountIdrequiredstring
firstSeenAtrequiredstring (date-time)
lastSeenAtrequiredstring (date-time)
regionrequiredstring
resourceIdrequiredstring
resourceNamestring
resourceTyperequiredstring
servicerequiredstring
systemrequiredstring

DriftUnprovisionedPage

FieldTypeDescription
itemsrequiredarray of DriftUnprovisioned
pagerequiredinteger
pageSizerequiredinteger
totalrequiredinteger (int64)
totalPagesrequiredinteger

Entitlements

FieldTypeDescription
capabilitiesrequiredarray of stringFeatures the licence carries beside its modules: sso (Enterprise) and performance (Professional and Enterprise, p7_74); later scim. Locked with the modules at expiry.
enabledAccountCountrequiredinteger
licenseestringWho the licence was issued to, when the row came from a licence.
maxAccountsintegerAbsent means unlimited.
modulesrequiredarray of stringUnlocked 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.
staterequiredstring enumThe 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.
tierstringThe 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.
validUntilstring (date-time)Checked at read time, not by a scheduled job: an expired row behaves as unentitled the moment it is read.

ErrorResponse

FieldTypeDescription
errorrequiredobject

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.

FieldTypeDescription
reasonrequiredstringWhy 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.
resourcesAttemptedrequiredintegerResources 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

FieldTypeDescription
badgeMapobjectRaw value to badge tone, for format: badge.
columnsarray of FieldDescriptorColumn descriptors, for format: table.
formatrequiredFieldFormatThe 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.
keyrequiredstringA path into the resource, including additionalInfo. prefixes.
labelrequiredstring
unitstring

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

FieldTypeDescription
fieldsrequiredarray of FieldDescriptor
titlerequiredstring

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.

FieldTypeDescription
resourcesAttemptedrequiredintegerResources 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

FieldTypeDescription
alreadyFinalizedbooleanTrue when this call replayed an earlier finalize.
runIdrequiredstring (uuid)
tombstonedrequiredinteger (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

FieldTypeDescription
statusrequiredstring

IdentityProvider

FieldTypeDescription
boundAccountsrequiredintegerAccounts bound to a provider's subject. While any is bound to another issuer, saving this one is refused (P55-R2-F03).
clientIdrequiredstring
clientSecretSetrequiredbooleanThe secret is stored and never returned.
defaultRolerequiredstring enum One of viewer, engineer, admin.
emailDomainsrequiredarray of string
enforcedrequiredboolean
groupsClaimrequiredstringThe ID-token claim the person's groups are read from at sign-in (p6_6); groups unless named.
issuerrequiredstring
redirectUrirequiredstringThe callback address to register at the provider, for this host.
updatedAtrequiredstring (date-time)

IdentityProviderInput

FieldTypeDescription
clientIdrequiredstring
clientSecretstringRequired the first time; absent keeps the stored one.
defaultRolerequiredstring enum One of viewer, engineer, admin.
emailDomainsrequiredarray of string
enforcedrequiredboolean
groupsClaimstringThe claim the groups are read from; empty or absent keeps groups.
issuerrequiredstring

InboxEntry

FieldTypeDescription
createdAtrequiredstring (date-time)
headerrequiredstring
idrequiredinteger (int64)
kindrequiredstringThe notification's kind, as a webhook receives it — estate.status_transition, cost.anomaly, provisioning.task_failed.
linesrequiredarray of string
readAtstring (date-time)When the person marked it read; absent while unread.

InboxPage

FieldTypeDescription
itemsrequiredarray of InboxEntry
morerequiredbooleanWhether older entries follow this page.
unreadrequiredintegerHow many of the person's entries are unread, across every page.

InboxRead

FieldTypeDescription
allbooleanEvery entry of the person's, whatever ids says.
idsarray of integer (int64)

InboxUnread

FieldTypeDescription
unreadrequiredinteger

LoginRequest

FieldTypeDescription
emailrequiredstringThe 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.
passwordrequiredstring (password)Bounded because verifying it is an argon2 derivation over whatever was sent, and this endpoint is unauthenticated.
tenantSlugstringOptional. 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.

FieldTypeDescription
accountIdsrequiredarray of stringThe accounts it may mark; empty is any.
appIdsrequiredarray of string (uuid)The declared apps it may mark; empty is any.
createdAtrequiredstring (date-time)
idrequiredstring (uuid)
lastUsedAtstring (date-time)
namerequiredstring
revokedAtstring (date-time)

MarkerCredentialCreate

FieldTypeDescription
accountIdsarray of string
appIdsarray of string (uuid)
namerequiredstring

MarkerCredentialIssued

FieldTypeDescription
credentialrequiredMarkerCredentialA marker credential (p8_10), everything but the token.
tokenrequiredstring (password)Returned once and never again: only sha256(token) is stored.

MarkerCredentialList

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

FieldTypeDescription
accountIdrequiredstring
durationMsinteger (int64)
errorMessagestring
lastRunAtstring (date-time)
modulerequiredstring
regionrequiredstring
resourcesAttemptedinteger (int64)
resourcesStoredinteger (int64)
statusrequiredstring enumslow 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.

FieldTypeDescription
amountrequiredstring
currencyrequiredstring

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.

FieldTypeDescription
amountrequiredstringCurrent minus previous.
currencyrequiredstring
pctnumberPercentage change, when the previous period is non-zero.

NeighbourEdge

FieldTypeDescription
categoryrequiredstring enumImpact (p8_13) reads only the operational edges. One of structural, operational.
confidencerequiredstring enum One of observed, declared, inferred.
evidencerequiredobjectWhat the edge rests on: the collector field, the tag, the request, the app's rule, the resolver's candidate.
fromrequiredstring
kindrequiredNeighbourEdgeKindOne kind of relationship (p8_12). One of contains, runs_on, fronts, guards, provisioned_by, belongs_to, owned_by.
torequiredstring

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.

FieldTypeDescription
depthrequiredintegerEdges from the root: 0 the root, 1 a neighbour, 2 a neighbour's neighbour.
detailrequiredobjectThe node's own fields: a request's state, a template version's source identity, an app's or team's slug.
idrequiredstring
kindrequiredstring enum One of resource, request, template_version, app, team.
labelrequiredstring
resourceobjectThe resource behind a resource node.

NotReadyStatus

FieldTypeDescription
reasonrequiredstringNames the cause without naming a credential, host or database role.
statusrequiredstring

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.

FieldTypeDescription
createdAtrequiredstring (date-time)
enabledrequiredboolean
idrequiredstring (uuid)
kindrequiredNotificationChannelKindslack 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.
lastTestNotificationChannelTestThe most recent test delivery, if any since the last rotation.
namerequiredstring
targetFingerprintrequiredstringsha256 of the target, hex. Two channels with the same fingerprint point at the same place.
targetHintrequiredstringThe last few characters of the destination's path, for recognition; empty for a row that predates the surface.
targetHostrequiredstringThe destination's host.
teamIdstring (uuid)The team whose events this channel receives; absent for the tenant default.
teamNamestring
updatedAtrequiredstring (date-time)

NotificationChannelCreate

FieldTypeDescription
enabledboolean
kindrequiredNotificationChannelKindslack 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.
namerequiredstring
targetrequiredstringAn https URL this deployment will post to. Never read back.
teamIdstring (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

FieldTypeDescription
itemsrequiredarray of NotificationChannel

NotificationChannelTest

FieldTypeDescription
atrequiredstring (date-time)
errorClassstring enumWhy it did not deliver, without the destination's words or its URL. One of dns, tls, timeout, network, redirect, private_address, invalid_target, refused.
httpStatusintegerThe destination's status code, when it answered.
outcomerequiredstring enum One of delivered, refused, unreachable, blocked.

NotificationChannelUpdate

FieldTypeDescription
enabledboolean
namestring
targetstringA new target — the rotation. Checked like a new one; the last test is cleared.
teamIdstring (uuid)Move the channel to a team.
tenantDefaultbooleantrue takes the channel out of its team and makes it a tenant default.

NotificationChoices

What reaches the person's own inbox (p7_89).

FieldTypeDescription
alertsrequiredbooleanStatus 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.
anomaliesrequiredbooleanCost anomalies on the person's teams and their teams' accounts; every one, for an administrator.
budgetsrequiredbooleanBudget thresholds of the person's teams; every team's, for an administrator.
myRequestsrequiredbooleanTheir own provisioning requests — approvals, successes and failures.

OrgUser

FieldTypeDescription
breakGlassbooleanAn administrator who keeps a password under enforced single sign-on (p5_5).
createdAtrequiredstring (date-time)
disabledAtstring (date-time)When the account was deactivated; absent while it is active. A deactivated user cannot log in and holds no session (p7_20).
displayNamestringThe name the person gave themselves (p7_89); absent while they gave none.
emailrequiredstring
idrequiredstring (uuid)
rolerequiredstring enumThe platform role. Says nothing about teams. One of viewer, engineer, admin.
teamsrequiredarray of TeamMembership

OrgUserCreate

FieldTypeDescription
emailrequiredstring
rolerequiredstring enum One of viewer, engineer, admin.

OrgUserCreated

FieldTypeDescription
temporaryPasswordrequiredstringShown once. Not stored in plaintext, not logged, not shown again: a lost one is replaced with a password reset.
userrequiredOrgUser

OrgUserList

FieldTypeDescription
itemsrequiredarray of OrgUser

OrgUserUpdate

FieldTypeDescription
breakGlassbooleanAn administrator who keeps a password when the tenant enforces single sign-on (p5_5). Only an administrator can be one.
disabledbooleantrue deactivates the account; false reactivates it.
rolestring enum One of viewer, engineer, admin.

OwnSession

FieldTypeDescription
addressstringThe address the session began from, when it was recorded.
createdAtrequiredstring (date-time)
currentrequiredbooleanWhether this is the session making the request.
expiresAtrequiredstring (date-time)
idrequiredstring
lastSeenAtrequiredstring (date-time)
userAgentstringThe browser the session began in, when it was recorded.

OwnSessionList

FieldTypeDescription
itemsrequiredarray of OwnSession

OwnTeam

FieldTypeDescription
idrequiredstring (uuid)
namerequiredstring
rolerequiredstring enumThe person's role in the team. One of member, lead.
slugrequiredstring

OwnTeamList

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

FieldTypeDescription
candidatesrequiredarray of OwnershipCandidate
confidencerequiredstring enum One of explicit, derived, inferred, unknown.
evidenceobject
reasonstring
sourcerequiredstring enum One of override, provenance, tag, account, relationship, none.
staterequiredstring enum One of owned, unowned, conflict.
teamTeamRefA 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.
triedrequiredarray of string enum

OwnershipCandidate

FieldTypeDescription
archivedboolean
confidencerequiredstring enum One of explicit, derived, inferred.
evidenceobject
rankrequiredinteger
sourcerequiredstring enum One of override, provenance, tag, account, relationship.
teamrequiredTeamRefA 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.

FieldTypeDescription
accountIdstring
createdAtrequiredstring (date-time)
createdByrequiredstring (uuid)
expiredbooleanPast its expiry: listed, but no longer applying.
expiresAtstring (date-time)
idrequiredstring (uuid)
kindrequiredstring enum One of resource, tag, account.
reasonrequiredstring
resourceKeystringregion/system/service/type/id, for a resource override.
revokedAtstring (date-time)
revokedBystring (uuid)
tagKeystring
tagValuestring
teamrequiredTeamRefA 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

FieldTypeDescription
accountIdstring
expiresAtstring (date-time)
kindrequiredstring enum One of resource, tag, account.
reasonrequiredstring
resourceKeystring
tagKeystring
tagValuestring
teamIdrequiredstring (uuid)

OwnershipOverrideList

FieldTypeDescription
itemsrequiredarray of OwnershipOverride

PageInfo

FieldTypeDescription
pagerequiredinteger
pageSizerequiredinteger
totalrequiredinteger (int64)
totalPagesrequiredinteger

PasswordChange

FieldTypeDescription
currentPasswordrequiredstring
newPasswordrequiredstring

PasswordResetIssued

FieldTypeDescription
expiresAtrequiredstring (date-time)
tokenrequiredstringShown once; only its hash is stored.

PasswordResetRedeem

FieldTypeDescription
newPasswordrequiredstring
tokenrequiredstring

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.

FieldTypeDescription
errorsstring
latencystring
saturationstring

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.

FieldTypeDescription
clusterrequiredstringThe cluster's name; empty for an instance only a Karpenter node pool tag places in Kubernetes.
instanceIdstringA node's EC2 instance.
namespacestringA pod's namespace.
nodestringA pod's node.
nodeGroupstringThe managed node group, when one is known.
nodePoolstringThe Karpenter node pool, when one is known.
rolerequiredstring enumWhat 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.
rulestring enumAn 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.
workloadstringA 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.

FieldTypeDescription
atrequiredstring (date-time)
metricrequiredstring
signalrequiredstring
unitrequiredstring
valuerequirednumber (double)

PerformancePodWindow

A pod's own series over a window, read from CloudWatch on demand (p7_86).

FieldTypeDescription
containersCutrequiredintegerThe pod's containers past the first eight, whose series the read left out.
fromrequiredstring (date-time)
readAtrequiredstring (date-time)When CloudWatch was read for these series — now, or within the step before, whose answer is kept.
resourcerequiredPerformanceResourceSeries
stepSecondsrequiredinteger
torequiredstring (date-time)
windowrequiredPerformanceWindow One of 1h, 24h, 7d.

PerformancePoint

FieldTypeDescription
atrequiredstring (date-time)The start of the step the value covers.
maxnumber (double)The highest stored five-minute value in the step, where the step combines more than one (p7_85).
minnumber (double)The lowest stored five-minute value in the step, where the step combines more than one (p7_85).
valuerequirednumber (double)

PerformanceResourceSeries

FieldTypeDescription
absentKindsrequiredarray of PerformanceKindThe kinds this resource's type publishes no metric for; the view says so rather than showing another metric.
absentReasonsPerformanceAbsentReasonsWhy 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.
accountIdrequiredstring
hasTopologybooleanSet 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.
kubernetesPerformanceKubernetesPlacementCompute 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.
regionrequiredstring
resourceIdrequiredstring
resourceNamerequiredstring
resourceTyperequiredstring
sectionPerformanceSectionEstate's Performance sections; the catalogue places each resource type in one. One of compute, networking, databases, messaging, serverless, storage.
seriesrequiredarray of PerformanceSeries
servicerequiredstring
systemrequiredstring
typeLabelstringThe 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

FieldTypeDescription
fromrequiredstring (date-time)
resourcerequiredPerformanceResourceSeries
stepSecondsrequiredinteger
torequiredstring (date-time)
windowrequiredPerformanceWindow One of 1h, 24h, 7d.

PerformanceSection

Estate's Performance sections; the catalogue places each resource type in one.

Type: string enum

PerformanceSectionPage

FieldTypeDescription
fromrequiredstring (date-time)
nextCursorstringPresent when more resources follow; pass it back as cursor.
resourcesrequiredarray of PerformanceResourceSeries
sectionrequiredPerformanceSectionEstate's Performance sections; the catalogue places each resource type in one. One of compute, networking, databases, messaging, serverless, storage.
stepSecondsrequiredinteger
torequiredstring (date-time)
windowrequiredPerformanceWindow One of 1h, 24h, 7d.

PerformanceSeries

FieldTypeDescription
detailstringWhich 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.
kindrequiredPerformanceKindWhat a series stands for. One of latency, errors, saturation.
latestAtstring (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.
metricrequiredstring
namespacerequiredstring
pointsrequiredarray of PerformancePointOne per step that has a stored point, oldest first. A step with none is left out, not zero.
previousarray of PerformancePointWith 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.
signalrequiredstringThe catalogue's name for the series, stable across releases (cpu, read_latency).
statisticrequiredstring
summaryPerformanceSeriesSummaryThe 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.
unitrequiredstringCloudWatch'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.

FieldTypeDescription
countrequiredintegerThe five-minute values the summary is of.
maxrequirednumber (double)
meanrequirednumber (double)
minrequirednumber (double)
p95requirednumber (double)

PerformanceTopology

A resource's parts, for a drawing (p7_87).

FieldTypeDescription
loadSincerequiredstring (date-time)The oldest a stored point may be and still be a part's load; a part with none since has no load.
rootrequiredPerformanceTopologyPart

PerformanceTopologyPart

FieldTypeDescription
childrenrequiredarray of PerformanceTopologyPart
cutrequiredintegerHow many of its children were left out past the caps.
kindrequiredstring enumWhat 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.
labelrequiredstringIts name — a resource's, a group's, or a target's id and port; empty for ungrouped and unscheduled.
loadPerformanceLoadA part's newest stored value of its first saturation series.
podsOnNodesLeftOutintegerA 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).
reasonstringWhy a target is in its state, as the balancer gave it; or what could not be read of a balancer or a group.
resourcePerformanceTopologyResourceThe inventory resource a part is, for a link to its detail or its Performance.
statestringA target's health state, as the balancer gave it (healthy, unhealthy, draining, …); its status follows it.
statusStatusThe 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.

FieldTypeDescription
accountIdrequiredstring
regionrequiredstring
resourceIdrequiredstring
resourceTyperequiredstring
servicerequiredstring
systemrequiredstring

PerformanceWindow

Type: string enum

Preferences

The person's portal preferences (p7_89), applied by the portal; an absent field is the browser's own.

FieldTypeDescription
dateFormatstring enumiso 2026-09-15, dmy 15.09.2026, mdy 09/15/2026. One of iso, dmy, mdy.
numberFormatstring enumcommaDot 1,234.5, dotComma 1.234,5, spaceComma 1 234,5, plain 1234.5. One of commaDot, dotComma, spaceComma, plain.
themestring enum One of system, light, dark.
timeZonestringAn IANA time zone, such as Europe/Berlin.

ProfileUpdate

FieldTypeDescription
displayNamestring
preferencesPreferencesThe 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.

FieldTypeDescription
actorrequiredActorRefWho 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.
commentstring
decidedAtrequiredstring (date-time)
decisionrequiredstring enum One of approved, rejected.

ProvisionDecision

FieldTypeDescription
commentstringRecorded permanently and shown to the requester. Optional on approve; a rejection without one is technically allowed and practically unkind.
decisionrequiredstring 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.

FieldTypeDescription
failureClassProvisionRequestFailureClassWhy 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. One of retryable, permanent, configuration, infrastructure, timeout, cancelled, restored.
requestIdrequiredstring (uuid)
requestedAtrequiredstring (date-time)
requestedByrequiredActorRefWho 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.
staterequiredProvisionRequestStatePROVISIONING_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.
templateVersionrequiredinteger
terminalAtstring (date-time)

ProvisionRequest

FieldTypeDescription
actionrequiredProvisionActionWhat 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.
approvalProvisionApprovalA request is decided exactly once, ever — approvals is keyed (tenant_id, request_id). Actor, decision, comment and timestamp are permanent and outlive log retention.
attemptrequiredintegerThe 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.
expiresAtrequiredstring (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.
failureClassProvisionRequestFailureClassWhy 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. One of retryable, permanent, configuration, infrastructure, timeout, cancelled, restored.
failureMessagestringScrubbed by the agent before it was ever sent.
idrequiredstring (uuid)
namerequiredstringThe requester's name for the thing.
partialApplybooleanA 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.
requestedAtrequiredstring (date-time)
requestedByrequiredActorRefWho 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.
resourceIdstring (uuid)The provisionedResources row this destroys. Present for destroy and absent for create, both enforced.
sourceIdentitystringThe 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.
staterequiredProvisionRequestStatePROVISIONING_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.
stateBackendstringWhere 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.
targetAccountIdrequiredstring
teamIdstring (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).
teamNamestring
templateIdrequiredstring (uuid)
templateNamestring
templateSlugrequiredstring
templateVersionrequiredinteger
terminalAtstring (date-time)
variablesRedactedrequiredobjectThe 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

FieldTypeDescription
actionrequiredProvisionActioncreate 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.
namerequiredstringThe requester's name for the thing.
resourceIdstring (uuid)Required for destroy, rejected for create.
targetAccountIdrequiredstringMust exist in this tenant's accounts registry, be enabled, and be owned by a team the requester belongs to.
teamIdstring (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.
templateIdrequiredstring (uuid)
templateVersionrequiredintegerExplicit, never "latest". A request that pinned a moving target would be approved against one version and applied from another.
variablesobjectValidated 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

FieldTypeDescription
actionrequiredProvisionActionWhat 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.
approvalProvisionApprovalA request is decided exactly once, ever — approvals is keyed (tenant_id, request_id). Actor, decision, comment and timestamp are permanent and outlive log retention.
attemptrequiredintegerThe 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.
destroyTargetProvisionedResourcedestroy 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).
expiresAtrequiredstring (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.
failureClassProvisionRequestFailureClassWhy 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. One of retryable, permanent, configuration, infrastructure, timeout, cancelled, restored.
failureMessagestringScrubbed by the agent before it was ever sent.
idrequiredstring (uuid)
namerequiredstringThe requester's name for the thing.
partialApplybooleanA 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.
previousApprovedVersionintegerThe 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.
recentOutcomesarray of ProvisionOutcomeSummaryNewest first.
requestedAtrequiredstring (date-time)
requestedByrequiredActorRefWho 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.
resourceIdstring (uuid)The provisionedResources row this destroys. Present for destroy and absent for create, both enforced.
sourceIdentitystringThe 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.
staterequiredProvisionRequestStatePROVISIONING_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.
stateBackendstringWhere 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.
targetAccountrequiredAccountIncluding its environment tag. "Production" is the single most decision-changing fact on an approval screen.
targetAccountIdrequiredstring
teamIdstring (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).
teamNamestring
templateIdrequiredstring (uuid)
templateNamestring
templateSlugrequiredstring
templateVersionrequiredinteger
terminalAtstring (date-time)
variablesRedactedrequiredobjectThe 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.
versionrequiredTemplateVersionThe 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

FieldTypeDescription
itemsrequiredarray of ProvisionRequest
pagerequiredinteger
pageSizerequiredinteger
totalrequiredinteger (int64)
totalPagesrequiredinteger

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

FieldTypeDescription
detailsarray of objectOne 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.
errorrequiredobject

ProvisionTask

One attempt at one request, by one agent.

FieldTypeDescription
agentIdrequiredstring (uuid)
attemptrequiredinteger
claimedAtrequiredstring (date-time)
finishedAtstring (date-time)
heartbeatAtrequiredstring (date-time)Falling behind heartbeatTimeoutSeconds is what makes the scheduler reap this task and return its request to queued.
idrequiredstring (uuid)
requestIdrequiredstring (uuid)
stagerequiredstringFree 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.
staterequiredProvisionTaskStateOne 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

FieldTypeDescription
agentIdrequiredstring (uuid)
attemptrequiredinteger
claimedAtrequiredstring (date-time)
finishedAtstring (date-time)
heartbeatAtrequiredstring (date-time)Falling behind heartbeatTimeoutSeconds is what makes the scheduler reap this task and return its request to queued.
idrequiredstring (uuid)
requestIdrequiredstring (uuid)
stagerequiredstringFree 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.
staterequiredProvisionTaskStateOne 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.
summaryProvisionTaskSummaryWritten 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.

FieldTypeDescription
createdAtrequiredstring (date-time)
levelrequiredstring enum One of debug, info, warn, error.
messagerequiredstringAlready scrubbed by the agent when it was sent.
seqrequiredinteger (int64)Agent-assigned and monotonic per task. Gaps mean the agent's own buffer dropped lines under pressure; they are not an error.
stagerequiredstring

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.

FieldTypeDescription
completionrequiredstring enumWhether 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: keep polling. The task is running, or it is terminal and the line at its watermark has not been paged out yet.
  • complete: every line the agent will ever send is at or before nextCursor. The task is terminal and a line at its finalLogSeq has been paged out — or the agent said it sent none.
  • incomplete: the task is terminal, the agent named a watermark, and a grace of 60 seconds after the finish passed without a line at it: the tail was lost, or is no longer retained. Nothing more will come; say so rather than spinning.
  • unverified: the task is terminal, the grace passed, and the agent never said where its log ends (an agent that sends neither finalLogSeq nor logLineCount). Nothing more is expected, but nobody vouched for it.
Decided by the server on every page, so a reader that reconnects or starts over from no cursor reaches the same answer once it has paged to the watermark. One of pending, complete, incomplete, unverified.
itemsrequiredarray of ProvisionTaskLogLine
nextCursorrequiredstringAlways 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

FieldTypeDescription
itemsrequiredarray of ProvisionTask
pagerequiredinteger
pageSizerequiredinteger
totalrequiredinteger (int64)
totalPagesrequiredinteger

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.

FieldTypeDescription
agentVersionrequiredstring
createdAtrequiredstring (date-time)
createdCountinteger
destroyedCountinteger
failureClassProvisionFailureClassPROVISIONING_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. One of retryable, permanent, configuration, infrastructure, timeout, cancelled.
failureMessagestring
logLineCountinteger (int64)
outcomerequiredProvisionTaskStateOne 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.
planDigeststringsha256 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.
stageDurationsMsobjectStage name to elapsed milliseconds.
tofuVersionrequiredstring

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

FieldTypeDescription
accountIdrequiredstring
createRequestIdrequiredstring (uuid)
createdAtrequiredstring (date-time)
destroyRequestIdstring (uuid)
destroyedAtstring (date-time)
expectedCountrequiredintegerTagged 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.
idrequiredstring (uuid)
namerequiredstring
provenanceTaggedrequiredbooleanWhether 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.
stateBackendstringWhere its Terraform state lives, as the agent reported it.
statusrequiredProvisionedResourceStatusactive: 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.
teamIdrequiredstring (uuid)
templateIdrequiredstring (uuid)
templateSlugstring
templateVersionrequiredinteger

ProvisionedResourcePage

FieldTypeDescription
itemsrequiredarray of ProvisionedResource
pagerequiredinteger
pageSizerequiredinteger
totalrequiredinteger (int64)
totalPagesrequiredinteger

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

FieldTypeDescription
accountIdrequiredstringThe one account this agent may ever claim work for. One agent per AWS account (D4).
agentVersionstring
createdAtrequiredstring (date-time)
createdByrequiredActorRefWho 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.
idrequiredstring (uuid)
lastSeenAtstring (date-time)The last call this token made. A stale value on an account whose requests never start is the answer to why.
namerequiredstring
revokedAtstring (date-time)
tofuVersionstring
versionStatusrequiredAgentVersionStatusReport 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

FieldTypeDescription
heartbeatTimeoutSecondsrequiredintegerThe 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.
itemsrequiredarray of ProvisioningAgent
pagerequiredinteger
pageSizerequiredinteger
totalrequiredinteger (int64)
totalPagesrequiredinteger

ProvisioningAgentTokenCreate

FieldTypeDescription
accountIdrequiredstring
namerequiredstring

ProvisioningAgentTokenIssued

FieldTypeDescription
agentrequiredProvisioningAgent
tokenrequiredstring (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).

FieldTypeDescription
namerequiredstring
servicesDegradedrequiredinteger
servicesFailedrequiredinteger
servicesTotalrequiredinteger
servicesUnknownrequiredintegerServices 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).
statusrequiredPublicHealthAn 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.
unhealthySincestring (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.

FieldTypeDescription
demorequiredbooleanTrue when this status page describes a fabricated estate (panorama admin seed-demo). The page says so on every load.
environmentsrequiredarray of PublicEnvironment
timelinerequiredarray of PublicTimelineEventAggregated. "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).

FieldTypeDescription
demorequiredbooleanA demonstration tenant, as the status page marks it.
tenantSlugrequiredstring

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

FieldTypeDescription
descriptionrequiredstring
environmentrequiredstring
occurredAtrequiredstring (date-time)
servicerequiredstring
statusrequiredStatusThe 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.

FieldTypeDescription
generationrequiredinteger (int64)A run behind the current fence is superseded and may neither write nor finalize.
regionrequiredstring
resourceTyperequiredstring
servicerequiredstring
systemrequiredstring

Resource

FieldTypeDescription
accountIdrequiredstring
additionalInfoobjectProvider-specific detail. How each key is displayed comes from /resource-types, not from the frontend.
collectedAtstring (date-time)The bucket's last successful collection — when the resource was last confirmed. Absent when the bucket has never completed one.
firstSeenAtrequiredstring (date-time)
lastRunIdstring (uuid)The run that last observed this resource.
lastSeenAtrequiredstring (date-time)
ownershipOwnershipWho 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.
regionrequiredstringglobal for global resources.
resourceIdrequiredstring
resourceNamerequiredstring
resourceTyperequiredstring
servicerequiredstring
stalerequiredbooleanTrue 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).
staterequiredstringThe raw provider state the status was derived from.
statusrequiredStatusThe 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.
systemrequiredstring
tagsobjectThe 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

FieldTypeDescription
historySnapshotHistoryHow 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.
lastChangeSnapshotDiffWhat 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.
resourcerequiredResource
snapshotsrequiredarray of ResourceSnapshotThe 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.
transitionsrequiredarray of TransitionStatus changes for this resource, newest first.

ResourceNeighbours

FieldTypeDescription
edgesrequiredarray of NeighbourEdge
elidedrequiredintegerNeighbours the viewer's team scope does not admit: counted, never rendered.
lookedForrequiredarray of NeighbourEdgeKindEvery kind the read tried, found or not.
nodesrequiredarray of NeighbourNode
rootrequiredNeighbourNodeOne 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.
truncatedrequiredbooleanA cap was hit.
truncatedCountsrequiredobjectHow many edges of each kind a cap left out.
unavailablerequiredarray of objectThe kinds that could not be answered for the time asked, each with its reason.

ResourcePage

FieldTypeDescription
itemsrequiredarray of Resource
pagerequiredinteger
pageSizerequiredinteger
statusCountsStatusCountsAbsent here. /resources/search returns ResourceSearchPage, where it is required.
totalrequiredinteger (int64)
totalPagesrequiredinteger

ResourceRef

One resource: its account and its key, region/system/service/type/id.

FieldTypeDescription
accountIdrequiredstring
keyrequiredstring

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.

FieldTypeDescription
nextCursorstringReads 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.
statusCountsrequiredStatusCountsComputed 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.

FieldTypeDescription
accountIdrequiredstring
additionalInfoobject
idinteger (int64)The observation's handle for /resources/snapshots/diff (p8_9). Present on every observation the server returns.
observedAtrequiredstring (date-time)
regionrequiredstring
resourceIdrequiredstring
resourceNamestring
resourceTyperequiredstring
schemaVersionintegerThe 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.
servicerequiredstring
staterequiredstringThe raw provider state the status was derived from.
statusrequiredStatusThe 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.
systemrequiredstring
tagsobjectThe 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

FieldTypeDescription
groupsrequiredarray of FieldGroup
iconstring
labelrequiredstring
listColumnsrequiredarray of stringField keys to show as columns in the list view.
pluralLabelrequiredstring
resourceTyperequiredstring
servicerequiredstring
systemrequiredstring

ResourceTypeList

FieldTypeDescription
resourceTypesrequiredarray of ResourceTypeDescriptor

SessionsEnded

FieldTypeDescription
endedrequiredinteger

SignOnIdentitiesReleased

FieldTypeDescription
releasedrequiredintegerThe accounts whose provider identity was cleared.

SingleSignOnOptions

FieldTypeDescription
enabledrequiredboolean
enforcedrequiredbooleanPasswords work only for break-glass administrators.

SnapshotBatchRequest

FieldTypeDescription
itemsrequiredarray of SnapshotInputNo duplicate resource keys within a batch: a duplicate makes the upsert order-dependent, which makes transitions non-deterministic.
runIdrequiredstring (uuid)The run these observations belong to. Every item must fall inside a bucket that run registered, or the batch is rejected.

SnapshotBatchResponse

FieldTypeDescription
replayedbooleanTrue when this key had already been processed, so nothing was written and these are the stored counts.
storedrequiredintegerSnapshots written.
transitionsrequiredintegerStatus 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.

FieldTypeDescription
entriesrequiredarray of DiffEntry
fromrequiredSnapshotRefOne end of a diff — an observation by its id and time.
schemaChangedobjectThe two observations were written under different descriptor versions, so additionalInfo was not compared field by field.
selectionReversedboolean
torequiredSnapshotRefOne 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.

FieldTypeDescription
notestring enum One of sole_observation, older_not_retained.
oldestRetainedAtstring (date-time)
retainedrequiredintegerThe 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.

FieldTypeDescription
accountIdrequiredstring
additionalInfoobjectProvider-specific detail, at most 256 KB when marshalled. This is also the deriver's input: status and state are computed from it.
observedAtstring (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.
regionrequiredstring
resourceIdrequiredstring
resourceNamestring
resourceTyperequiredstring
servicerequiredstring
systemrequiredstring
tagsobjectThe 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.

FieldTypeDescription
itemsrequiredarray of ResourceSnapshot
nextCursorstring

SnapshotRef

One end of a diff — an observation by its id and time.

FieldTypeDescription
idrequiredinteger (int64)
observedAtrequiredstring (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.

FieldTypeDescription
degradedrequiredinteger (int64)
failedrequiredinteger (int64)
operationalrequiredinteger (int64)
stalerequiredinteger (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).
unknownrequiredinteger (int64)Resources whose health the inventory cannot observe (p7_83); neither healthy nor a problem.

StepUpAtProvider

FieldTypeDescription
authorizationUrlrequiredstringThe provider's address the browser goes to.

StepUpAtProviderRequest

FieldTypeDescription
returnTostringA path on this site to come back to after the provider; anything else is the portal's root.

StepUpRequest

FieldTypeDescription
passwordrequiredstringThe current user's password.

TagMatch

One tag match: a resource carrying key=value is in the app.

FieldTypeDescription
keyrequiredstring
valuerequiredstring

Team

FieldTypeDescription
accountCountrequiredinteger
archivedAtstring (date-time)Present when the team is closed. Its history is still readable.
createdAtrequiredstring (date-time)
directoryGroupstringThe provider's group the team follows, as the groups claim names it. Present when source is directory.
idrequiredstring (uuid)
lastSyncedAtstring (date-time)The last sign-in that considered this team's binding.
memberCountrequiredinteger
namerequiredstring
slugrequiredstring
sourcerequiredstring enumWho keeps the membership: administrators (manual), or the identity provider's group at each sign-in (directory, p6_6). One of manual, directory.
tagValueCountrequiredinteger

TeamAccount

FieldTypeDescription
accountIdrequiredstring
attributedSpendrequiredarray of MoneyWhat 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.
namerequiredstring
usageFromstring (date)
usageTostring (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.

FieldTypeDescription
accountIdrequiredstring
assignedrequiredboolean
rebuildEnqueuedrequiredboolean
spendMovingrequiredarray of Money
teamIdrequiredstring (uuid)
usageFromstring (date)
usageTostring (date)

TeamCreate

FieldTypeDescription
namerequiredstring
slugstringDerived from the name when omitted.

TeamDetail

FieldTypeDescription
accountsrequiredarray of TeamAccount
membersrequiredarray of TeamMember
tagValuesrequiredarray of string
teamrequiredTeam

TeamDirectoryGroup

FieldTypeDescription
grouprequiredstringThe group exactly as the provider names it in the groups claim — an id or a name, whichever the provider emits.

TeamList

FieldTypeDescription
itemsrequiredarray of Team

TeamMember

FieldTypeDescription
emailrequiredstring
platformRolerequiredstring enum One of viewer, engineer, admin.
teamRolerequiredTeamRoleA 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.
userIdrequiredstring (uuid)

TeamMemberSet

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

FieldTypeDescription
teamIdrequiredstring (uuid)
teamNamerequiredstring
teamRolerequiredTeamRoleA 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.

FieldTypeDescription
idrequiredstring (uuid)
namerequiredstring

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

FieldTypeDescription
mappedrequiredboolean
rebuildEnqueuedrequiredboolean
tagValuerequiredstring
teamIdrequiredstring (uuid)

TeamTagValueSet

FieldTypeDescription
tagValuerequiredstring

TeamUpdate

FieldTypeDescription
archivedboolean
namestring

Template

FieldTypeDescription
archivedAtstring (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.
categoryrequiredstring
createdAtrequiredstring (date-time)
createdByrequiredActorRefWho 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.
descriptionrequiredstring
gitRefstringThe default ref new versions are published from.
gitUrlstringPresent 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.
idrequiredstring (uuid)
latestVersionintegerThe highest published version, resolved or not. Absent when the template has none, which also means it cannot be requested.
namerequiredstring
slugrequiredstringStable, human-facing and immutable. It is what a URL names and what an audit record refers to years later, so it cannot be renamed.
sourceTyperequiredTemplateSourceTypeWhere 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

FieldTypeDescription
categorystring
descriptionstring
gitRefstringDefault ref for new versions. Only for git.
gitUrlstringRequired for git, rejected for inline.
namerequiredstring
slugrequiredstring
sourceTyperequiredTemplateSourceTypeWhere 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

FieldTypeDescription
itemsrequiredarray of Template
pagerequiredinteger
pageSizerequiredinteger
totalrequiredinteger (int64)
totalPagesrequiredinteger

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.

FieldTypeDescription
archivedbooleanTrue archives, false restores. Archiving hides the template and closes it to new versions; it never touches what was provisioned from it.
categorystring
descriptionstring
gitRefstring
namestring

TemplateVersion

Immutable once published. Its source identitygitSha 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).

FieldTypeDescription
gitRefstringThe ref that was asked for. What ran is gitSha.
gitShastringThe pinned commit. Absent until an agent resolves it, which is the only reason a git version can sit pending.
gitUrlstring
inlineSha256stringsha256 of inlineSource, computed at publish.
inlineSourcestringThe 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.
needsSchemarequiredbooleanImported 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.
publishedAtrequiredstring (date-time)
publishedByrequiredActorRefWho 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.
resolveAccountIdstringThe 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.
resolveErrorstringThe 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.
resolveStaterequiredTemplateVersionResolveStateWhether 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.
resolvedAtstring (date-time)
sourceTyperequiredTemplateSourceTypeWhere 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.
templateIdrequiredstring (uuid)
variablesSchemarequiredVariablesSchemaStored 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.
versionrequiredinteger
warningsarray of stringPresent 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

FieldTypeDescription
itemsrequiredarray of TemplateVersion
pagerequiredinteger
pageSizerequiredinteger
totalrequiredinteger (int64)
totalPagesrequiredinteger

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.

FieldTypeDescription
gitRefstringFor git templates. Defaults to the template's gitRef. This is what an agent resolves; it is never what runs.
inlineSourcestringFor 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.
variablesSchemarequiredVariablesSchemaStored 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.

FieldTypeDescription
itemsrequiredarray of Transition
nextCursorstringPass 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.

FieldTypeDescription
accountIdrequiredstring
acknowledgementTransitionAcknowledgementThe 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).
fromStatestring
fromStatusTransitionStatusAbsent for the first ever observation of a resource. Those rows are written pre-suppressed: onboarding 300 accounts must not emit 40,000 notifications.
idrequiredinteger (int64)
occurredAtrequiredstring (date-time)
regionrequiredstring
resourceIdrequiredstring
resourceNamestring
resourceTyperequiredstring
runIdstring (uuid)
servicerequiredstring
systemrequiredstring
toStatestring
toStatusrequiredTransitionStatusA 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).

FieldTypeDescription
acknowledgedAtrequiredstring (date-time)
acknowledgedByrequiredActorRefWho 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.
noterequiredstring
reversedAtstring (date-time)
reversedByActorRefWho 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

FieldTypeDescription
demorequiredbooleanTrue 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.
displayNamestringThe name the person gave themselves (p7_89); absent when they gave none.
emailrequiredstringThe 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.
idrequiredstring (uuid)
preferencesPreferencesThe person's portal preferences (p7_89), applied by the portal; an absent field is the browser's own.
rolerequiredstring enumWhat this user may do. Separate from entitlements, which are what the tenant has bought. One of viewer, engineer, admin.
tenantNamerequiredstring
tenantSlugrequiredstring

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.

FieldTypeDescription
defaultVariableValueUsed 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.
descriptionstringForm rendering only.
enumarray of VariableValueClosed 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.
groupstringForm rendering only.
maxnumberInclusive, for number.
maxItemsintegerInclusive, for list(string).
maxLengthintegerInclusive, for string.
minnumberInclusive, for number.
minItemsintegerInclusive, for list(string).
minLengthintegerInclusive, for string.
namerequiredstring
patternstringRE2, 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.
requiredbooleanAbsent at submit and no default → rejected.
sensitivebooleanMasked 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.
typerequiredstring enumNothing 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.

FieldTypeDescription
variablesrequiredarray of VariableDefinition