# Silicon IAM backend API
This document explains the behavior of the public, organization, application,
provider-callback, and platform-administration endpoints in
[`openapi.yaml`](./openapi.yaml). The OpenAPI file is the normative HTTP
contract. [`UNDERSTANDING.md`](../UNDERSTANDING.md), at the repository root, is
the authoritative product-scope source.
The production origin is:
```text
https://backend.iam.teamofsilicons.com
```
Versioned JSON APIs are under `/api/v1`. The compatibility handshake is the
unversioned `/api/version`; liveness and readiness remain at `/healthz` and
`/readyz`.
The same origin also serves three HTML surfaces, which are deliberately outside
the JSON contract and are not described in `openapi.yaml`:
| `/docs/api/` | The sectioned HTTP contract |
| `/docs/client/` | The sectioned official Rust SDK manual |
| `/openapi.yaml` | The normative contract itself |
| `/admin` | The platform-administration console |
`scripts/check-openapi-routes.rb` enforces that separation: a route declared in
`src/web/mod.rs` must sit under `/admin`, `/docs`, `/_static` or
`/openapi.yaml`, and must not appear in the specification. Contract routes
belong in a feature router and in `openapi.yaml`, as they always have.
Applications integrating in Rust can use the official SDK for typed API methods
and wire models, explicit version negotiation, credential transports, and
webhook verification. It deliberately leaves credential persistence, refresh
coordination, and retry decisions with the calling application. See
`/docs/client/`.
Application bootstrap and local authorization-cache recovery use the
`authorization` snapshot on `POST /api/v1/oauth/introspect`. Successful OBO
verification returns the same current, scope-filtered membership binding for
its exact delegated request. See [application tokens](api/applications.html),
[OBO](api/obo.html), and the [local integration-fix report](INTEGRATION_FIXES_2026-09-05.md)
for rollout order and verified behavior. Application sessions do not gain
first-party directory-management access through this contract.
The `/admin` console is a thin client over `/api/v1/admin/*`. It performs no
authentication of its own and executes no SQL; authority stays entirely in the
endpoints the contract already publishes.
## Security model
### Principals and identifiers
There are three public principal types:
- **Carbon** — a human account.
- **Silicon** — an organization-scoped machine identity.
- **Application** — a registered confidential OAuth client and OBO actor.
Persistent entities use UUIDv7 primary identifiers. Public handles
(`carbon_id`, `org_id`, `app_id`, and the global Silicon ID) are
immutable normalized labels, never foreign keys, and are not reused after
deletion. New Carbon IDs accept lowercase `a-z`, digits `1-9`, `_`, and `-`
and are 3–30 characters long. Immutable legacy Carbon IDs containing `0`
remain addressable for login and existing-account lookup but cannot be newly
registered. A global Silicon ID is
`{handle}:{org_id}`; the handle is only creation input and is never an
independently addressable public ID.
A typed `principal_id` prevents collisions between a Carbon and a Silicon
whose public labels happen to look alike. Organization resources use
`membership_id`, not a public actor handle, for tenant-qualified references.
Cross-tenant resources return `404 not_found` rather than disclose existence.
### Authentication transports
| Public | no credential | Signup, login initiation/verification, availability, and callbacks |
| IAM bearer | `Authorization: Bearer …` | Carbon or Silicon API access |
| Browser session | secure `iam_session` cookie | Interactive application login and SSO navigation |
| Application | HTTP Basic, app ID and app secret | OAuth token operations, introspection, and same-organization OBO |
| Platform admin | IAM bearer whose Carbon is a current platform admin | `/api/v1/admin/*` |
| Step-up | `X-Step-Up-Token` in addition to bearer | Ownership, credentials, SSO, deletion, and privileged grants |
| WorkOS | verified `WorkOS-Signature` | WorkOS webhook receiver |
Application secrets contain 256 random bits. Silicon secrets contain 128 random
bits and use `stk-` plus 32 lowercase hexadecimal characters. Raw application
secrets are returned only on creation; raw Silicon tokens are returned on
creation or an approved token rotation. Both are retained only as keyed
digests. Versioned webhook HMAC secrets are encrypted at rest. An Application
supplies its own 32–512 character webhook secret during creation or rotation;
IAM never generates one. Replacing its webhook URL reuses that encrypted secret
unless the caller explicitly supplies a replacement.
Only Silicon endpoint configuration or replacement returns a new `swhs_…`
signing secret. That Silicon response can be replayed idempotently for ten
minutes.
Per-credential salts and versioned server-side peppers are internal
implementation material. Pepper/salt rotation has no public endpoint and never
uses `SID + STK + Salt` to derive a bearer token.
Normalized email and phone identities are authenticated-encrypted at the
application boundary. Exact lookup and uniqueness use a versioned HMAC blind
index. Raw contact identities, credentials, OTPs, and provider records are
excluded from logs, traces, metrics, error details, audit diffs, and webhooks.
### Credential lifetimes
| Signup session | 48 hours |
| Email/phone OTP | 10 minutes; after 10 failed attempts, a reusable challenge cools down for 1 minute before a fresh 10-attempt window |
| IAM/OAuth access token | 30 minutes |
| IAM session refresh family (Carbon or Silicon) | 900 days absolute |
| Application OAuth refresh family | 900 days absolute |
| Short-lived login token | 2 minutes, single use |
| Step-up token | 5 minutes, action/resource bound |
| Carbon invitation | 48 hours |
| WorkOS setup link | 5 minutes (`expires_in: 300`) |
| OBO proof | 60 seconds maximum, single use |
| One-time secret replay envelope | 10 minutes |
Access and refresh tokens are opaque 256-bit random values. Refresh tokens
rotate on every successful use, and reuse of a consumed token compromises its
own family and creates a security audit event. An IAM family belongs to one
Carbon device session or Silicon session; compromise invalidates that session
and authority descended from it, not the principal's other device sessions. An
OAuth family belongs to one parent IAM session and one client Application;
compromise revokes that Application family and its access tokens without
revoking the parent IAM session or another Application's tokens. Applications
use authenticated introspection when they need immediate revocation and current
organization membership state. OAuth access and refresh tokens remain opaque
and are never published through a signing-key discovery surface.
### Request headers and concurrency
Every externally initiated mutation requires an `Idempotency-Key` of 16–255
characters. The key is scoped to authenticated caller, route, and request
digest. Repeating the same validated request returns the stored result and may
include `Idempotency-Replayed: true`; changing any canonical request field under
the same key returns `409 idempotency_conflict`. JSON whitespace and object-key
ordering are not semantically significant.
OBO proof verification is the deliberate single-use exception: it does not
accept an `Idempotency-Key`, never stores or replays a successful verification
response, and every attempt after the proof has been consumed returns `409`.
An OBO exchange remains idempotent, but its replay envelope expires no later
than the proof itself.
Versioned aggregate mutations require a strong `If-Match: "{version}"` header.
Non-versioned commands, such as deleting the authenticated session, do not.
Successful aggregate reads and mutations expose their version as `ETag`; when
a response body also contains `version`, it is the same version represented by
that ETag. A stale value returns `412 version_mismatch`; an omitted required
precondition returns `428 precondition_required`. Every externally visible
aggregate mutation increments the version by exactly one.
Every `PATCH` route consumes `application/merge-patch+json`. For a nullable
property, the three JSON states are distinct: omitting the property leaves it
unchanged, sending it as `null` clears it, and sending a concrete value replaces
it. Use `null` only where the OpenAPI property is nullable. Clients must not
serialize an absent nullable optional as `null`, because that turns "leave
unchanged" into "clear this field."
`X-Request-ID` is accepted when valid and otherwise generated. On errors it is
returned as `error.request_id`. `X-Org-ID` may be sent to introspection, but it
must agree with the credential and grant; it can never expand authority. OBO
does not accept `X-Org-ID`; IAM derives its organization from the authenticated
Applications and rejects cross-organization use.
### Pagination
List endpoints use opaque cursor pagination:
```http
GET /api/v1/organizations?limit=50&cursor=opaque-value
```
The maximum page size is 100 and the default is 50. Responses contain:
```json
{
"items": [],
"page": {
"next_cursor": null,
"has_more": false
}
}
```
A cursor is bound to the caller, filters, sort order, and tenant context. It is
not an offset and clients must not interpret it.
### Errors and status semantics
All JSON API errors use:
```json
{
"error": {
"code": "machine_readable_code",
"message": "Safe human-readable explanation",
"details": {},
"request_id": "trace identifier"
}
}
```
A login that fails before a short-lived token is minted answers with the JSON
envelope above rather than redirecting: there is nothing to deliver, and the
redirect URI came from the caller rather than from a registration, so it is not
a trusted place to report an error to.
The HTML surfaces answer with HTML rather than this envelope. They are merged
outside the JSON router's error normalisation, so an unknown documentation
section returns a readable page with a way back rather than a machine-readable
code that no reader asked for.
| 400 | Malformed or unsupported request/protocol input | `invalid_request`, `unsupported_grant_type` |
| 401 | Missing, invalid, expired, or revoked authentication | `invalid_credentials`, `token_expired`, `token_revoked` |
| 403 | Actor type, capability, scope, consent, or step-up is insufficient | `forbidden`, `insufficient_scope`, `step_up_required` |
| 404 | Missing or tenant-hidden resource | `not_found` |
| 409 | Unique, idempotency, replay, lifecycle, or terminal-state conflict | `identifier_unavailable`, `idempotency_conflict`, `state_conflict` |
| 410 | Expired one-time state | `challenge_expired`, `invite_expired`, `authorization_code_expired`, `proof_expired` |
| 412 | Stale ETag | `version_mismatch` |
| 413 | Body exceeds the endpoint limit | `payload_too_large` |
| 422 | Well-formed input violates field, tenant, policy, hierarchy, or quorum rules | `validation_failed`, `invalid_code`, `hierarchy_cycle` |
| 428 | Required idempotency, version, or step-up precondition is absent | `precondition_required` |
| 429 | A distributed rate-limit bucket is exhausted | `rate_limited` |
| 502 | Upstream provider returned a failed or invalid response | `provider_error` |
| 503 | A required dependency is unavailable | `service_unavailable` |
| 504 | A bounded server or provider deadline elapsed | `gateway_timeout` |
A `validation_failed` body carries `details.fields`, each entry naming the
offending `field` and its safe `message`, as an array such as
`[{"field":"job_role","message":"at most 5000 characters"}]`. A body that
cannot be decoded at all still names the
responsible property: an unrecognized property reports that property with
`is not a recognized field`, a missing required property reports it with
`is required`, a request without `Content-Type: application/json` reports
`content-type`, and malformed JSON reports `body`. Submitted values are never
echoed back, so any rejection whose explanation would have to quote a value
degrades to `body` with `must match the documented JSON schema`.
A mutation that would leave the resource unchanged returns a stable `409`
rather than incrementing its version or writing audit/outbox work. The current
no-op codes are `carbon_profile_unchanged`, `organization_unchanged`,
`member_directory_unchanged`, `job_role_unchanged`, `tag_set_unchanged`,
`tag_name_unchanged`, `trust_default_unchanged`, `trust_rule_unchanged`,
`silicon_profile_unchanged`, `silicon_webhook_subscription_unchanged`,
`application_unchanged`, `application_webhook_unchanged`, and
`testing_environment_unchanged`. `approval_request_exists` separately means an
equivalent pending governance request already exists. Treat these as completed
or duplicate intent, not as transient failures to retry with a new idempotency
key.
`429` includes `Retry-After`, `RateLimit-Limit`,
`RateLimit-Remaining`, and `RateLimit-Reset`. Carbon login initiation returns
`404` when the submitted identity is not registered. Signup contact initiation
returns the product contract's exact `already_exists` boolean and sends no OTP
when it is true. Default signup initiation protection permits ten requests and
then a ten-minute cooldown in the tightest bucket, while provider policy can be
stricter.
The current baseline enforces keyed buckets across normalized identity,
session, purpose, channel, and provider. Signup send protection includes a
contact-global bucket that is independent of the temporary signup-session ID,
plus a per-session bucket. IP/subnet buckets remain disabled until a deployment
defines trusted proxy extraction and allowlisted ingress hops; IAM never trusts
arbitrary forwarding headers. Issuing a new code invalidates the older code.
Verification-attempt cooldowns are separate from initiation limits. Signup
email/phone, Carbon login (including either Carbon-ID delivery channel), email
invitation join, and verified-channel step-up challenges allow ten failed
verifications in a window. The tenth failure starts a 60-second cooldown. The
partial failed-attempt window and any active cooldown carry into a replacement
code, so resend cannot reset either. After the cooldown, the current
still-unexpired challenge receives a fresh ten-attempt window; a cooldown never
extends that code's original ten-minute expiry.
Application/OAuth routes additionally enforce shared one-minute buckets for
well-formed bearer credentials before token lookup, authenticated principals,
verified browser-session identifiers before session lookup, and app ID plus
route before client-secret verification. Exhaustion uses the same `429` body
and complete retry/rate-limit header set.
## System
| GET | `/healthz` | Process liveness only |
| GET | `/readyz` | Readiness of required dependencies |
| GET | `/api/version` | Negotiate the highest mutually supported public API version |
| GET | `/api/v1/version` | Service, API, build, and commit versions |
Health responses contain no dependency credentials or sensitive topology.
A client must perform the unversioned `/api/version` handshake before its first
versioned request. The Rust SDK exposes this as `client.system().negotiate()`;
it is an explicit integration step. The request sends distinct supported
versions in descending preference order:
```http
Silicon-IAM-Supported-API-Versions: v1
```
IAM selects the highest mutually supported version and returns it in both
`selected_api_version` and `Silicon-IAM-API-Version`; `Vary` identifies the
advertisement header for intermediary caches. The response also lists the
server's supported versions in descending preference order. A client must
fail closed if the response disagrees with the advertised intersection. When
there is no common version, IAM returns `406 api_version_not_acceptable` with
the server's supported version list. `/api/v1/version` remains available as a
version-specific diagnostic endpoint; it is not the negotiation handshake.
## Carbon signup
Signup binds both verified contact identities to one 48-hour temporary session:
```text
POST /signup/sessions
-> POST /{session}/email
-> POST /{session}/email/verify
-> POST /{session}/phone
-> POST /{session}/phone/verify
-> POST /{session}/complete
```
All paths above are under `/api/v1/signup/sessions`.
- Creating a session returns only its random UUID and expiry.
- Email is delivered through Postmark from `auth@teamofsilicons.com`.
- Phone verification uses Twilio Verify to generate, deliver, and validate SMS
codes and requires E.164 input. IAM retains the Verify attempt identifier and
stores no code digest at all for a provider-managed phone challenge, so no
undelivered secret exists at rest. Verification of such a challenge is
answered only by the provider; a missing attempt identifier or an unavailable
provider fails closed rather than falling back to a local comparison.
- Each send operation returns `already_exists`. When it is `true`, IAM sends no
code; when it is `false`, the response also includes `expires_in: 600` and a
new code is sent.
- Email codes are purpose-, channel-, and session-bound keyed digests. Phone
codes are scoped to a single stored Twilio Verify attempt.
- Each still-unexpired email or phone code follows the ten-failure,
one-minute-cooldown verification policy described above.
- Completion requires both verified identities and atomically rechecks their
uniqueness before inserting the Carbon.
- Completion accepts optional `timezone` as an exact IANA TZDB identifier such
as `UTC` or `Asia/Kolkata`; omission defaults it to `UTC`. Unknown identifiers
and whitespace variants are rejected.
- `profile_photo` defaults to
`https://iris.teamofsilicons.com/pfp/carbon?id={carbon_id}` when omitted.
- A positive `GET /api/v1/carbon-ids/{carbon_id}/availability` never reserves
the ID.
`GET /api/v1/carbons/search?q=…&limit=…` is bearer-authenticated, may use
fuzzy public-handle matching, and returns zero to ten objects containing only a
`carbon_id`. Authenticated direct Carbons can resolve an exact active verified
contact through `POST /api/v1/carbons/resolve/email` or
`POST /api/v1/carbons/resolve/phone`; each returns only the matching
`carbon_id`, or `404` when none exists. These lookup endpoints are independently
rate-limited and never return contact or profile data.
## Carbon and Silicon authentication
### Carbon passwordless login
`POST /api/v1/login/challenges` accepts exactly one of `email`,
`phone_number`, or `carbon_id`. Email and phone targets receive a six-digit
code. Carbon-ID login dispatches the same code to both verified channels; a code
received through either channel succeeds and atomically consumes the challenge.
An identifier that does not belong to an active Carbon
returns `404`; login never creates an account.
`POST /api/v1/login/challenges/{session_id}/verify` returns a 30-minute access
token and a rotating refresh token. Bad codes use a safe verification failure.
Email, phone, and Carbon-ID challenges share the same ten-failure window and
one-minute cooldown policy.
`POST /api/v1/auth/tokens/refresh` supports Carbon and Silicon IAM refresh
families. IAM session revocation is exposed only through the logout and
step-up-protected session-deletion flows below. Configured applications use
`POST /api/v1/oauth/introspect` and `POST /api/v1/oauth/revoke` for their OAuth
token lifecycle.
`POST /api/v1/logout` defaults to the current session family and supports
`mode=all_sessions`. Revocation is immediate in IAM; logout webhooks are
queued for applications but are not the enforcement mechanism. Current-session
logout is always permitted. A signed `iam_session` cookie may authenticate this
operation only when `X-CSRF-Token` exactly matches the CSRF value protected by
that cookie; a first-party Carbon bearer does not require the CSRF header. If
`mode=all_sessions` would revoke another active
session, every other target and the authenticating session must be at least 12
hours old. The request must also include a verified-channel
`account.sessions_revoke_all` step-up assertion bound to the current Carbon's
`principal_id`; the transaction fails without revoking anything if any target
is too young. When there are no other active sessions, `all_sessions` safely
collapses to immediate current-session logout and does not require step-up.
The endpoint also accepts a live Carbon OAuth bearer issued to the Application
that is triggering logout, provided that Application is both the token client
and audience. This Application-triggered form always performs global logout for
the bearer token's parent IAM session: it revokes that session family and all
IAM/Application access, refresh, consent, authorization-code/request, and OBO
authority bound to it, then emits the same `session.logout.v1` event to every
Application authorized immediately before revocation. It cannot request
account-wide `all_sessions`. Authorization is rechecked under lock, and an
exact idempotent retry may replay only its already-completed response after the
triggering credential has been revoked.
`DELETE /api/v1/me/sessions/{session_id}` is the explicit single-session
revocation flow. The target must be at least 12 hours old, and when it differs
from the current session, the authenticating session must also be at least 12
hours old. Every request requires a verified-channel
`account.session_revoke` step-up assertion whose `resource_id` is exactly the
target session UUID. Assertion consumption and revocation commit atomically.
IAM reports failed age checks with the stable precondition codes
`session_revocation_target_too_young`,
`session_revocation_authority_too_young`, and
`session_revoke_all_target_too_young`, rather than a generic authorization
failure.
### Silicon login
`POST /api/v1/silicon-auth/token` verifies the global Silicon ID and current
`stk-{32 lowercase hex}` credential, then independently mints access and
refresh tokens. It never derives a bearer token from SID/STK concatenation.
Removed Silicons, rotated credentials, and stale authorization epochs fail
immediately.
### Step-up
`POST /api/v1/step-up/challenges` sends a reauthentication code to an existing
verified channel for one declared sensitive action. Verification at
`/{session_id}/verify` returns a five-minute token bound to that action and
required `resource_id` with `assurance=verified_channel`. A token for one action
or resource cannot authorize another. Session-revocation challenges validate
that the target belongs to the current Carbon before dispatch; all-session
challenges bind the resource to the current Carbon principal UUID.
Signup, login, and step-up initiation first commits only an unverifiable,
digest-backed pending challenge and its exact idempotency reservation. Provider
I/O then runs without an open database transaction, and IAM activates the
challenge only after every required delivery succeeds. A definitive pre-send
rejection permits an exact retry; ambiguous or partially successful delivery
remains fail-closed as `idempotency_in_progress` for the same key, while a new
key safely supersedes the unusable pending challenge. Plaintext OTPs are never
persisted. Superseding a challenge carries its partial failed-attempt count or
active 60-second cooldown into the replacement.
## Current Carbon account
| GET/PATCH | `/api/v1/me` | Read or update the current Carbon profile |
| GET | `/api/v1/me/sessions` | Paginated device/session families |
| DELETE | `/api/v1/me/sessions/{session_id}` | Revoke a 12-hour-old session with target-bound verified-channel step-up; cross-session revocation also requires a 12-hour-old authenticating session |
| GET | `/api/v1/me/login-history` | User-wide and app-specific login events |
`GET /api/v1/me` returns the Carbon's mutable display name, description,
effective profile-photo URL, and IANA `timezone`. `PATCH /api/v1/me` may edit
those four fields under the current strong ETag; the public `carbon_id` remains
immutable. Existing accounts migrated before time zones were captured use the
safe `UTC` default until the Carbon chooses another identifier.
## OAuth 2.0
Silicon IAM implements one login for applications: a short-lived token that the
application exchanges, using its own credential, for a session. There is no
authorization-code negotiation, no PKCE exchange, no `state` round-trip and no
consent screen.
**An application never receives anyone's credentials.** Nothing in this flow
hands an application a password, a verification code, or any other
authentication secret. The only thing it receives is the short-lived token.
### Login sequence
```text
Browser -> GET <auth_base_url>/login
app_id names the application; without it this is an ordinary
Silicon IAM login and no token is minted
redirect_uri optional; decides delivery only
optional org_id
IAM -> redirect_uri?slt=... when a redirect URI was given
IAM -> a page showing the token when one was not
Application backend -> POST /api/v1/app-auth/tokens
HTTP Basic app credentials
app_id + slt
IAM -> opaque access token + rotating refresh token
```
Browser navigation uses the secure HttpOnly `iam_session` cookie, which is how
IAM recognises somebody already signed in and goes straight to minting. The
login endpoint never expects a bearer header on a top-level navigation.
Redirect URIs are not registered. The caller names one in the query string and
IAM appends `slt` to it. Short-lived tokens are keyed-digest stored, two-minute,
single-use, and bound to client, actor and parent session. When the login names
`org_id` (or its IAM bearer already carries organization context), the token is
additionally bound to that exact active membership; otherwise it remains
unscoped.
Before consuming a token, the exchange locks and revalidates current authority:
the client application must still be verified on its current authentication
epoch; the subject principal must be active; the exact parent session must be
active, unexpired, subject-bound, and on the principal's current authentication
epoch; any organization membership must still be active and match the original
tenant and subject; and the exact consent grant must remain active and bound to
that session and context. A failed revalidation returns the uniform
`invalid_grant` response without consuming the token.
The consent grant is no longer a prompt. It is written implicitly by the login
and remains the record of which applications a principal is authorized in,
which is what decides webhook recipients.
Every successful exchange returns one rotating `ort_` refresh token. Reuse of
any consumed family member compromises the whole family, revokes every member,
and immediately revokes access tokens for that parent session and client
application without revoking the session or another application's tokens.
A login carries the whole scope catalogue. `scope` on an application is the
webhook's scope — which changes it is told about — and does not bound what a
session may read.
| GET | `/api/v1/login` | Mint a short-lived token; redirect with it or show it |
| GET | `/api/v1/login/status` | Report whether a shown token was spent or expired |
| POST | `/api/v1/app-auth/short-lived-tokens` | Mint one for a caller already signed in |
| POST | `/api/v1/app-auth/tokens` | Exchange a short-lived token or rotate a refresh token |
`POST /api/v1/app-auth/short-lived-tokens` is how a Silicon signs in to an
application: it has no browser to be redirected in. A Carbon that already holds
a session uses the same route rather than starting another login. Its JSON body
always names `app_id` and may name `org_id`. Supplying `org_id` requires the
actor's active membership and binds the exchanged Application token family to
that organization. Omitting it preserves an unscoped login unless the IAM
bearer already carries organization context. OBO requires an organization-bound
Application access token.
The token page's Content-Security-Policy is `default-src 'none'` widened only
to `style-src 'self'`, `img-src 'self' data:` and `font-src` for the webfont. It
has no `script-src` at all, which is why the page reports expiry with a meta
refresh onto `/api/v1/login/status` rather than a timer.
| POST | `/api/v1/oauth/introspect` | Authenticated current-state introspection |
| POST | `/api/v1/oauth/revoke` | Idempotent token/family revocation |
Both routes use Application Basic authentication and form fields `token` plus
optional `token_type_hint=access_token|refresh_token`. Introspection returns
`active: false` for an unknown token, a token owned by another Application, a
currently invalid token, or a valid `X-Org-ID` that does not match its authority.
A malformed or duplicated `X-Org-ID`, or an unsupported token hint, returns
`400 invalid_request` instead. Revoking an access token affects only that token;
revoking a refresh token affects its complete OAuth family and access authority
for the same Application session. An unknown token deliberately returns `200`.
Neither operation logs out the parent IAM session; Application-triggered global
logout uses `POST /api/v1/logout` with that Application's OAuth bearer.
OAuth access tokens remain linked to the parent IAM session, application,
consent grant, and organization membership. Logout, app suspension, consent
revocation, member removal, credential rotation, or authorization-epoch changes
therefore invalidate them without waiting for webhook delivery.
## Organizations
`GET /api/v1/organization-ids/{org_id}/availability` gives non-reserving
availability feedback. `POST /api/v1/organizations` creates an organization
and its sole owner membership in one transaction. The default join method is
`email`; `org_id` can never change.
| GET/POST | `/api/v1/organizations` | List a Carbon's organizations or create one |
| GET/PATCH | `/api/v1/organizations/{org_id}` | Read or update non-secret configuration |
| POST | `/api/v1/organizations/{org_id}/ownership-transfers` | Atomically transfer the single owner |
Organization listing is Carbon-only. A Silicon already belongs to exactly one
organization and derives its tenant from its credential. Switching
`join_method` to `sso` is rejected until platform SSO entitlement, an active
connection, and an active SSO configuration exist. Disabling SSO requires first
moving the organization to a safe join method.
`GET /api/v1/organizations` defaults to `status=active`. Its `status=active` or
`status=removed` query filters the authenticated Carbon's membership in each
organization, not the organization's lifecycle state. The `status` field in
each returned Organization still describes the organization itself and remains
`active` or `disabled`; a row reached through a removed membership can therefore
still describe an active organization.
Ownership transfer requires the current owner, step-up, an ETag, and an active
Carbon membership as the target. The new owner becomes the sole owner in the
same transaction. The former owner becomes an admin with no delegated
capabilities until explicitly configured. The owner cannot be removed and a
sole owner must transfer ownership before they can be removed from the
organization.
## Memberships and authorization
Membership identity survives removal and later deliberate reactivation:
one organization/principal pair retains one `membership_id`. Removal sets the
membership to `removed`, increments its authorization epoch, revokes relevant
sessions/grants, and preserves directory/governance history. Reactivation uses
the same row, increments the epoch again, and applies fresh invitation or SSO
defaults; it never revives old sessions or capabilities.
| GET | `/api/v1/organizations/{org_id}/members` | Filter by principal type, tag, or status |
| GET/PATCH/DELETE | `.../members/{membership_id}` | Read/update directory fields or remove |
| GET | `.../members/{membership_id}/authorization` | Read tier, explicit grants, and authorization epoch |
| POST | `.../members/{membership_id}/admin-promotions` | Promote an active Carbon member without implicit grants |
| POST | `.../members/{membership_id}/admin-demotions` | Demote an admin and revoke organization grants |
| PUT | `.../members/{membership_id}/capabilities` | Replace explicit organization capabilities without changing tier |
The free-text `job_role` is directory metadata and never grants authority.
`org_role` is `owner`, `admin`, or `member`. Silicons cannot be owners
or admins. Owner authority is intrinsic. Admins and specially delegated members
receive only explicit capabilities:
- `organization.update`
- `members.invite`, `members.update_directory`, `members.remove`
- `silicons.create`, `silicons.update_directory`,
`silicons.manage_hierarchy`, `silicons.remove`,
`silicons.rotate_token`
- `tags.manage`, `trust.manage`
- `roles.request`, `roles.approve`
- `admins.create`, `admins.manage`
- `sso.manage`
Same-organization directory read is baseline and therefore has no
`members.read` capability. `admins.create` permits only a member-to-admin
promotion; the new admin receives no implicit grants. `admins.manage` controls
capability replacement and admin demotion. Promotion, grant replacement, and
demotion are deliberately separate audited mutations.
Every authorization evaluation is deny-by-default and uses current principal
status, membership status, organization role, explicit grants, application
scope, organization context, and authorization epochs. It never infers
permission from job-role text, tags, reporting hierarchy, first Silicon, or
trust metadata.
Directory updates may change first Silicon, explicit extra Silicons, a
Carbon's organization-wide advisory trust default, profile data, or a Silicon
reporting line. Changing the Carbon trust default requires `trust.manage` and
is rejected for Silicon memberships. They cannot directly change tags or a
job role; both use governance approval. Effective Carbon-to-Silicon visibility
is the union of shared-tag access and explicit extra-Silicon grants.
The product-facing directory endpoints are deliberately separate from the
administrative membership records:
| GET | `/api/v1/organizations/{org_id}/directory/self` | Current member's directory projection |
| GET | `/api/v1/organizations/{org_id}/directory/members` | Paginated active team directory |
| GET | `/api/v1/organizations/{org_id}/directory/members/{membership_id}` | One active team member |
They return `name`, public `id`, `role`, `org`, `tags`, and advisory `trust` by
default. `role` keeps authorization and description distinct as
`{org_role, job_role}`. A comma-separated `fields` query may select any subset
of `name,id,role,org,tags,trust`; unrequested properties are omitted. Trust is
evaluated from the requester's point of view for each row. The defined trust
directions are Carbon-to-Silicon and Silicon-to-Silicon. Carbon-to-Carbon and
Silicon-to-Carbon trust are undefined and therefore serialized as `null`.
Removing a Carbon disables only that organization's authority. Removing a
Silicon revokes every Silicon credential and session. If the Silicon has direct
reports, `reassign_reports_to` is required and the graph rewrite is atomic.
## Carbon invitations and joining
An authorized caller creates a 48-hour invitation with exactly one existing
`carbon_id` or email. The backend resolves the target privately and emails the
registered address. Invitation responses expose a public Carbon projection and
a masked delivery address, never a raw reverse lookup.
| GET/POST | `/api/v1/organizations/{org_id}/carbon-invites` | List or create |
| GET/DELETE | `.../carbon-invites/{invite_id}` | Inspect or revoke |
| POST | `/api/v1/organizations/{org_id}/join/email-verification-code` | Resolve the authenticated invitee's submitted email and send/replace the join OTP |
| POST | `/api/v1/organizations/{org_id}/join` | Accept invite with bound OTP |
Invitation defaults contain a descriptive job role, tags, optional first
Silicon, explicit extra Silicons, and advisory trust. Advisory trust requires
an organization-wide default and may include one override per active tag and
one override per active Silicon. Effective trust follows organization default
→ tag override → exact-Silicon override. The complete trust configuration is
stored with the invitation as an immutable snapshot. `first_silicon` may be
null when the organization has none. An invitation always creates or
reactivates `org_role=member`; it can never grant admin authority or admin
capabilities. Promotion is a separate step-up-protected audited operation.
The notification link is built only from configured frontend origin plus
`/join/{org_id}?app={redirect_app_id}`; the optional app ID is validated and
does not introduce a caller-controlled redirect URL.
From that link, an authenticated direct Carbon submits the invited email to the
email-verification-code endpoint. The email must still be the exact active,
verified address immutably bound when that Carbon's invitation was created, and
the invitation must be pending and unexpired for the active email-join
organization. A match sends a six-digit Postmark code,
supersedes any prior live code, and returns only `accepted`, `invite_id`, and
the code's `expires_in`; it never returns contact data. A missing or mismatched
email/invitation returns the same `404 not_invited` response. The
returned invitation ID is then supplied with the code to the join endpoint.
Only one pending invitation per organization/Carbon is allowed. Join verifies
that the authenticated Carbon is the target, the invitation and code are
pending/unexpired, the organization matches, and every referenced tag/Silicon
is still active. Ten failed code verifications start a one-minute cooldown; the
same code may be tried in a fresh ten-attempt window afterward if it has not
reached its original expiry. Email-code initiation allows ten attempts per
authenticated Carbon and organization, including non-matching emails; reaching
the limit starts a full one-minute cooldown that cannot be evaded at a
wall-clock window boundary or by varying the email. A successful idempotent
replay does not consume another send attempt. Acceptance, membership
creation/reactivation, directory defaults, and trust-rule materialization are
atomic.
The email-code endpoint returns `202 Accepted` and marks the challenge
`delivered` only after Postmark confirms delivery acceptance. A definitive
provider rejection marks the pending challenge failed and returns `503`; an
ambiguous timeout or transport failure leaves it pending and unverifiable so a
false-success response can never make an undelivered OTP consumable. Provider
I/O runs outside database transactions. Exact replays return success only for a
previously confirmed delivery; an unresolved reservation remains
`409 idempotency_in_progress` until safely superseded with a new key.
## Silicons
| GET/POST | `/api/v1/organizations/{org_id}/silicons` | List or create |
| GET/PATCH/DELETE | `.../silicons/{silicon_id}` | Read/update/remove |
| GET/PUT/DELETE | `.../silicons/{silicon_id}/webhook` | Inspect, configure/replace, or disable the subscriber-managed endpoint |
| GET/PUT/DELETE | `.../silicons/{silicon_id}/webhook/subscription` | Inspect, replace, or remove organization-event topics |
| GET | `.../silicons/{silicon_id}/webhook/dead-letters` | List visible dead-letter deliveries |
| POST | `.../silicons/{silicon_id}/webhook/dead-letters/replays` | Replay one or an ordered batch of dead letters |
| POST | `.../silicons/{silicon_id}/token-rotation-requests` | Request owner-approved rotation |
| POST | `.../token-rotation-requests/{request_id}/complete` | Apply approved rotation and reveal token once |
Creation accepts a non-addressable handle component and constructs the only
public Silicon ID as `{handle}:{org_id}`. It requires a job role and accepts an
optional bounded display name, exact IANA `timezone`, description, and
profile-photo URL. An omitted display name defaults to the local handle and an
omitted timezone defaults to `UTC`. The resulting public ID is immutable and
tombstoned on removal. IAM returns the raw 128-bit Silicon token once. Silicon
activation does not depend on a webhook: an endpoint and subscription are
configured separately when the Silicon needs organization events.
Silicon reads return the full mutable profile. PATCH may change display name,
timezone, description, profile photo, or reporting parent with the corresponding
directory or hierarchy capability and current ETag. It never changes the public
Silicon ID or the job role; role and tag changes use their dedicated request or
direct owner/admin control routes. Profiles predating this contract are backfilled to `UTC`
and use their stored handle component as the display name.
When no custom photo is provided, IAM generates:
```text
https://iris.teamofsilicons.com/pfp/silicon?id={global_silicon_id}&level={level}
```
The root of a reporting tree is level 1; each child is parent level plus one.
Reporting edges are Silicon-to-Silicon inside one organization. Self-links,
cycles, removed targets, and cross-tenant references are rejected.
Token rotation creates an immutable approval request. The current owner must
approve using step-up authentication. Approval immediately invalidates the old
credential, advances authorization state, and revokes every Silicon access,
refresh, and IAM session authority; it does not create a replacement secret.
After approval, a separate completion request consciously generates and
reveals the new secret, again advances credential/auth epochs, and returns the
raw value under the ten-minute idempotent replay rule.
An active Silicon may manage its own webhook and subscription. A Carbon with
`silicons.update_directory` may manage any active Silicon in the organization
and must present a verified-channel step-up token for each mutation. Other
Silicons and applications are rejected. Every mutation
requires `Idempotency-Key`. Initial endpoint or subscription creation may omit
`If-Match` because no representation exists; replacing either requires its
current strong ETag. Endpoint and subscription deletion always require
`If-Match`.
Endpoint, subscription, and destination changes use the
`organization.silicon_webhook.redirect` step-up action and bind `resource_id`
to the target Silicon membership UUID.
`PUT .../webhook` accepts one SSRF-validated HTTPS URL and returns a new
`swhs_…` HMAC secret only in that no-store response. Replacing the URL also
rotates the secret. IAM retains encrypted URL and signing material; `GET` never
returns the secret.
Disabling the endpoint also removes its subscription, and a Silicon cannot
create a subscription before it has an active endpoint.
A subscription uses `mode=all`, which ignores any supplied `topics` and
canonicalizes the response to all three topic values, or `mode=selected` with
one or more of `membership_lifecycle`, `member_updates`, and `trust_updates`.
`all` receives every organization event explicitly routed to
Silicon subscribers, including organization metadata, tag-catalog,
invitation, governance-control, credential, and configuration events that do
not belong to a selected topic. For `selected`, `membership_lifecycle` is only
actual member or Silicon creation, reactivation, and removal;
`member_updates` covers applied existing-member role, tag, profile, hierarchy,
authorization, and ownership changes; and `trust_updates` is only trust state.
Optional `tag_filter` may be combined with either mode. When present, it always
contains the Silicon's own tag audience and may add up to 100 active
organization tags through `additional_tag_ids`. IAM then delivers only events
whose normalized before/after affected-tag union intersects either the
Silicon's own tags immediately before or after that mutation, or one of those
explicit extra tags. The own-tag relationship is captured in the domain
transaction: losing a shared tag does not suppress that event, and gaining a
tag later cannot expose an older event. Extra-tag authorization is rechecked
from the current subscription. Organization-wide, unattributed, and disjoint
events fail closed when filtering is enabled.
## Tags, visibility, and trust
Tags are stable, normalized, organization-scoped entities rather than free-form
strings embedded in memberships.
| GET/POST | `/api/v1/organizations/{org_id}/tags` | List or create tags |
| GET/PATCH/DELETE | `.../tags/{tag_id}` | Read, rename, or delete |
| GET | `.../tags/{tag_id}/members` | List attached Carbons and Silicons |
| PUT | `.../members/{membership_id}/tags` | Owner/admin directly replaces the complete tag set |
| POST | `.../members/{membership_id}/tag-change-requests` | Request tag additions or removals |
| GET | `.../members/{membership_id}/tag-history` | List applied tag sets with requester and approvers |
Renaming preserves the tag UUID and therefore does not break references.
Deleting a tag requires `tags.manage`, which an owner holds implicitly and an
admin by grant, and takes `If-Match` on the tag version. It is archival
underneath, because the tag identifier is referenced by append-only tag
history, by trust rules, by pending invitations and by Silicon webhook
subscription filters. What a caller observes is nonetheless a tag that no
longer exists:
- it leaves tag listings, and reads of it answer `404`;
- it disappears from every member's tag set;
- it stops conferring Silicon access and stops matching Silicon delivery
filters;
- its name becomes immediately available for a new tag.
The cascade is atomic with the deletion. Assignments are removed from every
member that held the tag, tag-scoped trust rules are archived, and the affected
memberships' authorization epochs advance so any cached authority is invalidated
at once. Each affected member's tag history gains a row recording the tag set
before and after, attributed to the deleting owner or admin. The change is
published as `organization.tag_archived.v1`, carrying the affected memberships
and the archived trust rules as disjoint sets, and routed to Silicon
subscribers on `member_updates`, on `trust_updates`, or both, according to what
the deletion actually changed.
Only an active Silicon may request a tag addition or removal for any active
Carbon or Silicon membership, including itself. A Carbon target requires
approval from the affected Carbon and one eligible owner/admin. A Silicon
target requires one eligible owner/admin. A Carbon owner, or an admin with
`tags.manage`, may instead directly replace any active Carbon or Silicon tag
set through the versioned PUT route. Creation and admission may still set
initial tags. Every applied or direct change preserves its initiating actor,
applicable approvers, before/after tag sets, membership version, and timestamp.
Trust is reliable advisory metadata, never an authorization decision. It has:
- boundary: `internal` or `external`
- level: `not_trusted`, `needs_approval`, or `trusted`
`GET/PUT /api/v1/organizations/{org_id}/trust/default` manages the initial
`internal/not_trusted` value. `GET/POST .../trust/rules` and
`GET/PATCH/DELETE .../trust/rules/{rule_id}` manage typed rules. Rule selectors
are `tag` or `membership` objects; strings such as `tag:finance` are not parsed
as foreign keys. Organization-wide baseline trust is represented only by the
separate `/trust/default` resource.
`POST .../trust/effective` explains the value for one subject and target
Silicon. A Carbon subject starts from its membership-wide default; a Silicon
subject starts from the organization default. Precedence then applies a tag
rule followed by an exact membership/Silicon rule. More specific rules win.
Conflicts at the same specificity choose the more restrictive level and return
every matching rule ID. The result always contains `advisory=true`.
Inter-Silicon department matrices use tag-subject to tag-target rules. Carbon
overrides use Carbon membership or tag subjects and Silicon membership or tag
targets.
## Role and tag governance
Job-role and tag changes never use the membership patch endpoint:
| POST | `/api/v1/organizations/{org_id}/role-change-requests` | Create immutable request |
| GET | `.../approval-requests` | Filter pending/history/actionable requests |
| GET | `.../approval-requests/{request_id}` | Inspect payload and decisions |
| POST | `.../approval-requests/{request_id}/decisions` | Approve or reject |
| GET | `.../members/{membership_id}/job-role-history` | Applied role history |
| PUT | `.../members/{membership_id}/job-role` | Owner/admin directly replaces the descriptive job role |
| POST | `.../members/{membership_id}/tag-change-requests` | Create immutable tag request |
| PUT | `.../members/{membership_id}/tags` | Owner/admin directly replaces the complete tag set |
| GET | `.../members/{membership_id}/tag-history` | Applied tag history |
Only an active Silicon may create a role- or tag-change request; a regular
Carbon cannot request either change. A requested Carbon role change requires
the affected Carbon and one currently eligible owner/admin with
`roles.approve`; a requested Silicon role change requires one currently
eligible owner/admin. Carbon owners and admins with the corresponding
`roles.approve` or `tags.manage` capability may directly control either field
for any active Carbon or Silicon through the versioned PUT routes. A
Silicon-token rotation requires the owner and step-up. Eligibility is rechecked
when a decision is made and when a terminal operation is applied.
A Carbon tag change uses the same affected-Carbon plus owner/admin quorum. A
Silicon tag change requires owner/admin approval only. The request captures the
exact previous, added, removed, and proposed tag sets; if the target or its tag
set changes before quorum, the stale request fails instead of overwriting the
intervening change.
Payloads are immutable. Each approver may decide once. Rejection is terminal.
Once quorum exists, the role change, role history record, aggregate version,
redacted audit event, and outbox events commit in one transaction and can be
applied only once.
## WorkOS SSO
SSO is initially locked. A platform administrator first changes
`PUT /api/v1/admin/organizations/{org_id}/sso-entitlement`. An owner or
appropriately authorized admin may then configure it:
| GET/DELETE | `/api/v1/organizations/{org_id}/sso` | Inspect or safely disable |
| POST | `.../sso/setup-link` | Create five-minute WorkOS Admin Portal setup link |
| GET | `.../sso/authorize` | Begin authenticated Carbon SSO |
| GET | `/api/v1/sso/callback` | Verify callback and admit/link |
| POST | `.../sso/test` | Read-only active-connection test |
| POST | `/api/v1/provider-webhooks/workos` | Signature/replay-verified provider events |
IAM permanently stores the IAM organization ↔ WorkOS organization/connection
mapping. Provider secrets never appear in organization reads.
SSO never creates a Carbon. The Carbon must first complete normal IAM signup,
begin SSO while authenticated, and return on the same bound browser session.
Before calling WorkOS, the callback performs a database correlation preflight
that requires the current Carbon and bound browser session, a pending unexpired
authorization transaction, both IAM-generated correlation digests, and active
organization, entitlement, SSO configuration, and connection state. The value
named `nonce` is the second IAM-generated correlation component packed into the
returned `state`; it is not a claim that WorkOS attested a provider nonce. No
database transaction remains open during the WorkOS exchange. After the
provider returns, completion revalidates the correlation, organization and
connection mapping, verified identity, existing Carbon contact match, and
current tenant authority before atomically consuming the transaction.
An organization using `join_method=sso` must have an active WorkOS organization
and connection mapping. That tenant-bound connection may admit an already
existing Carbon whose verified WorkOS email matches an active verified Carbon
email contact. Initial admission and reactivation always use `org_role=member`,
an empty job role, no tags or first Silicon, and advisory
`internal`/`not_trusted` trust. SSO does not consume an email invitation;
email-code joining remains the separate `join_method=email` flow.
Setup-link requests durably reserve their request-bound `Idempotency-Key`
before calling WorkOS. A concurrent identical request receives the retryable
`idempotency_in_progress` conflict and cannot create another provider link;
after successful completion, the encrypted response is replayable for its
five-minute lifetime. WorkOS does not offer provider-side idempotency for this
operation, so a process failure after WorkOS returns but before local completion
leaves the key in an outcome-unknown processing state and IAM does not issue a
second link automatically. The configuration test reads both the exact WorkOS
organization and connection and succeeds only when the connection is active and
belongs to the permanently mapped organization.
WorkOS webhook bodies are bounded, signature checked over the raw bytes,
timestamp-window checked, and deduplicated by provider event ID.
`WorkOS-Signature` is the only signature header and has
the comma-delimited form `t=<epoch_ms>,v1=<hex_hmac>`; there is no separate
trusted timestamp header. IAM verifies HMAC-SHA-256 in constant time over
`timestamp + '.' + exact raw UTF-8 body`, rejects timestamps outside a
300-second tolerance, and only then deduplicates the provider event ID.
Provider calls use rustls, explicit deadlines, bounded
responses, and validated redirect behavior. Deadline expiry is `504`; an
invalid upstream response is `502`; temporary dependency loss is `503`.
## Applications
Applications are owned by organizations, not individual Carbons. There is no
separate developer email/password identity. Registration requires `org_id`, and
the creating Carbon must be a current active owner or admin of that
organization. The creator is retained as immutable `created_by` provenance;
ownership and management authority remain with the organization. Any current
active owner or admin of that organization can manage its Applications.
Organization-facing Application and webhook management routes require a direct
`silicon-iam` bearer with `iam.self`, no client Application, and a current active
owner/admin membership in the target Application's organization. Promotion,
demotion, removal, or ownership transfer therefore changes Application
management authority immediately. `/api/v1/admin/applications*` review routes
instead require current platform-administrator authority and do not depend on
tenant membership. A delegated OAuth `oat_` cannot become a confused deputy for
the Carbon subject. Client-secret authentication locks the matching secret,
rechecks its active/retiring window and active verified Application principal,
and records usage in one transaction so concurrent revocation cannot
authenticate after it commits.
| GET/POST | `/api/v1/applications` | List apps in organizations the Carbon owns/administers, or submit registration |
| GET | `/api/v1/application-directory/{app_id}` | Application-authenticated, cross-organization base-URL discovery |
| GET/PATCH | `/api/v1/applications/{app_id}` | Read/update |
| POST | `.../client-secret-rotations` | Rotate and reveal a new client secret once |
| POST | `.../webhook-secret-rotations` | Install a caller-supplied successor webhook signing secret |
| GET/PUT | `.../webhook` | Inspect active endpoint or propose replacement |
| POST | `.../webhook/approvals` | Approve a verified Application's pending webhook with owning-org or platform review authority |
| GET | `.../webhook/dead-letters` | List dead-letter deliveries |
| POST | `.../webhook/dead-letters/replays` | Replay one or an ordered batch of dead letters |
| GET | `.../login-history` | App-specific authorization/login history |
Registration requires a local Application handle, `org_id`, one HTTPS webhook
URL, a caller-chosen `webhook_secret`, and the Application backend's
`base_url`; it may also include the callable OBO endpoint registry. IAM turns
the local handle into the only public
identifier, `{org_id}>{handle}`. For example, creating `drive` in `google`
returns `google>drive`; that canonical value is used for authentication, login,
path parameters, discovery, and OBO. The base URL is an origin: it must contain
no trailing slash, path, userinfo, query, or fragment, and must use HTTPS except
for literal loopback HTTP in local development. For example,
`https://billing.example` is valid and `https://billing.example/` is not.
```http
POST /api/v1/applications
Authorization: Bearer <Carbon access token>
Idempotency-Key: <one logical creation>
Content-Type: application/json
{
"app_id": "billing",
"org_id": "acme",
"app_name": "Billing",
"webhook_url": "https://billing.example/hooks/iam",
"webhook_secret": "replace-with-at-least-32-random-characters",
"base_url": "https://billing.example"
}
```
IAM rechecks current organization owner/admin authority before claiming or
replaying the request. It returns a `verified` Application plus the one-time
generated Application secret and echoes the caller-supplied webhook signing
secret for v1 compatibility. Application representations expose
`org_id`, `base_url`, and the Carbon `created_by`; they never model that Carbon
as the owner. There is no review to wait behind: an Application can sign users
in, introspect tokens and issue OBO proofs from the moment it exists.
`GET /api/v1/application-directory/{app_id}` is deliberately broader than OBO
discovery. It authenticates the requesting Application with HTTP Basic and
returns only `{app_id, base_url}` for any verified target Application, even one
owned by another organization. The target `app_id` comes from the path; IAM
never trusts a caller identity in the body or query. In a testing environment,
the environment header, requesting credential, and target must all resolve in
that environment, so the lookup cannot fall through to production.
There are no redirect URIs to register. A login names the one it wants in the
query string, and IAM appends the short-lived token to it.
An Application holds the whole scope catalogue: a login carries all of it, so
there is nothing to request and nothing to approve. An Application webhook
replacement in production keeps the previous endpoint active until approval; v1
exposes exactly one active destination. During initial production registration
there is truthfully no active destination: `active_url` is `null`,
`pending_url` contains the submitted URL, and webhook status is
`pending_review`. A later production replacement uses
`replacement_under_review` while preserving the existing `active_url`.
For an already verified Application, its current owning-org Carbon owner/admin
or an IAM platform administrator with `applications.review` can approve either
pending endpoint using the narrow webhook-approval operation below. The
Application's creator is audit metadata, not a separate owner or authority.
Testing environments have no platform-reviewer control plane, so creation and
replacement activate their endpoint immediately and return an `active`
projection. This makes a fresh test Application able to receive its first
webhook immediately. The webhook representation's `version` is the application aggregate version,
not an endpoint-row version; it is identical to the response `ETag` and is the
value required by `If-Match`. Replacement reuses the application's existing
encrypted webhook signing secret and normally does not return secret material.
The one exception is an imported test Application still using an inherited
production secret: its first test URL replacement requires a caller-supplied
test-only signing secret and echoes it with its short replay deadline.
Initial application and webhook secrets are versioned credentials. Permanent
deletion is available only through the backend-admin decision workflow. It
immediately disables the client, compromises its credentials, revokes token
families, access tokens, OBO proofs, consents, and delivery scheduling, and
tombstones the app ID.
Client-secret rotation requires the current Application ETag, an idempotency
key, and verified-channel step-up bound to that Application. It atomically
retires every prior usable client secret, creates exactly one active successor,
increments the Application version, and returns the raw secret only in a
`no-store` response. An exact replay may recover that response for ten minutes;
the secret never appears in ordinary reads, audit diffs, or webhooks.
Webhook-secret rotation is a separate operation with the same concurrency,
idempotency, authorization, no-store, and ten-minute replay guarantees. Its
JSON body supplies the successor as `webhook_secret`; IAM never generates it.
Its step-up action is `application.webhook_secret.rotate`, bound to the internal
Application UUID. For v1 compatibility the response echoes
`webhook_signing_secret`, its `webhook_secret_version`, and the incremented
`application_version`. New
deliveries use the successor immediately. Consumers retain prior key versions
long enough to verify already in-flight signed bodies. Changing a webhook URL
reuses a production or already test-owned signing secret unless the replacement
request explicitly supplies a new one.
Repeating the active or pending URL without a replacement secret is rejected
with `409 application_webhook_unchanged`; supplying the exact current secret
with the current endpoint is rejected by the same stable code. These no-op
requests do not increment the Application version or emit audit/outbox events.
### Approving a pending webhook
`POST /api/v1/applications/{app_id}/webhook/approvals` activates the pending
endpoint of an already `verified` Application and retires its former active
endpoint if there is one. It changes neither Application status nor approved
scopes. Current owning-organization Carbon owners/admins and IAM platform
administrators with `applications.review` are eligible; past creator identity
alone is not permission. A newly created verified Application can have its
first webhook approved this way. A legacy Application itself still
`under_review` requires a separate platform application decision.
Send an IAM bearer, `Idempotency-Key`, the current Application `If-Match`, and
`X-Step-Up-Token` for verified-channel action `application.webhook.approve`
bound to the internal Application UUID. No request fields are needed: omit the
body or send `{}`. Read the
webhook first to obtain `application_id` and the current aggregate version;
this read also permits platform administrators with `applications.review`.
The UUID field is optional for old persisted idempotency responses, but current
reads and new responses include it.
```http
POST /api/v1/applications/acme%3Ebilling/webhook/approvals
Authorization: Bearer <current Carbon access token>
Idempotency-Key: <one logical approval>
If-Match: "<current Application version>"
X-Step-Up-Token: <verified-channel assertion bound to Application UUID>
```
Success is `200` with the existing `ApplicationWebhook` shape and matching
Application-version `ETag`; it does not return a signing secret. An absent
pending endpoint or a non-verified Application is `409`, a stale version is
`412`, and missing preconditions or step-up are rejected. Preserve the same
key and original input after an uncertain outcome. Testing endpoints normally
activate immediately and have nothing pending to approve.
IAM revalidates public HTTPS/DNS safety at approval; an unsafe destination
returns `422` without activating it.
### Platform application review
`GET /api/v1/admin/applications` lists the inventory and pending review queue.
`POST /api/v1/admin/applications/{app_id}/decisions` supports:
- suspend or reactivate
- approve or reject a pending webhook replacement
- permanently soft-delete an application and revoke all application authority
New Applications arrive verified, so their pending first webhook does not mean
their registration is awaiting admission. Platform review can still handle
legacy `under_review` Applications and broader status/configuration decisions.
The public webhook-approval route above is deliberately narrower and does not
grant organization administrators any of those platform-only powers.
Review requires a current platform administrator, step-up, idempotency key, and
ETag. Suspension immediately revokes active application authority. Every
decision records reviewer, reason, old/new redacted state, request ID, and
timestamp. Removing an approved scope atomically revokes every active `oat_`
token for that client that carries the removed scope; refresh rotation can
retain only the reduced current-scope intersection.
## OBO Access
OBO is available only between verified Applications owned by the same
organization. IAM derives this organization from the authenticated Application
credentials; neither the exchange nor verification API accepts caller-supplied
`org_id` or `X-Org-ID` context. Cross-organization and nonexistent target
Applications are indistinguishable as `404 not_found`.
An authenticated Application can discover another verified Application's
active callable endpoints and metadata contract with
`GET /api/v1/obo-access/applications/{app_id}/endpoints`. Discovery is limited
to the caller's organization and returns the target Application's `{app_id,
org_id}` plus at most 50 endpoint definitions in deterministic `endpoint_id`
order. Only a current organization owner/admin can configure those definitions
through Application registration or update. Endpoint identifiers and paths are
stable. Metadata definitions and exchange metadata must be JSON objects no
larger than 16 KiB, with bounded nesting and complexity. Every top-level
registered key is required, unregistered keys are rejected, and a descriptor
may enforce `string`, `number`, `integer`, `boolean`, `object`, `array`, or
`null`.
`POST /api/v1/obo-access/exchanges` authenticates App A with HTTP Basic and
requires these additional headers:
```http
Idempotency-Key: <16-255 character key>
X-OBO-Timestamp: <canonical Unix seconds>
X-OBO-Signature: <64 lowercase hexadecimal characters>
```
`X-OBO-Timestamp` must be within 60 seconds of IAM's clock. App A computes the
signature with its current Application secret and IAM verifies it in constant
time:
```text
HMAC-SHA256(
app_secret,
timestamp + "." + method + "." + registered_path + "." +
body_sha256 + "." + Idempotency-Key
)
```
The method is the canonical uppercase method from `request.method`,
`registered_path` is loaded from App B's selected endpoint rather than accepted
from the exchange body, and `body_sha256` is the 64-character lowercase SHA-256
hex digest of the exact downstream body bytes. App A sends only that digest and
the endpoint's JSON metadata to IAM—never the actual downstream body or file.
The exchange request is:
```json
{
"subject_token": "oat_...",
"audience": "application-b-id",
"endpoint_id": "files.upload",
"metadata": {
"filename": "report.pdf",
"content_type": "application/pdf"
},
"request": {
"method": "POST",
"body_sha256": "<64 lowercase hexadecimal characters>"
}
}
```
IAM confirms that App A and App B are verified and belong to the same
organization; the subject token was issued to App A, is active, and was minted
from an organization-bound login for that same organization; its actor
has an active membership in that same organization; App A's reviewed scopes
permit OBO issuance; and the selected App B endpoint and metadata are current
and valid. It returns a random `obo_` proof with a unique ID and at most 60
seconds of life. The proof is bound to the source Application, audience
Application, subject token, actor, organization, registered endpoint, method,
path, exact body digest, and metadata. An exact exchange replay can recover the
same response only while the proof remains valid; its idempotency response
never outlives `expires_at`.
App A sends the proof alongside the actual downstream request to App B. Before
executing it, App B authenticates to `POST /api/v1/obo-access/verify` with HTTP
Basic and submits the proof plus details calculated from the actual request:
```json
{
"access_proof": "obo_...",
"request": {
"method": "POST",
"path": "/v1/files",
"body_sha256": "<64 lowercase hexadecimal characters>"
}
}
```
Audience identity comes from App B's authenticated credential. IAM verifies
the exact method, registered path, and body digest before atomically consuming
the proof and returning the represented actor, endpoint, and metadata. Exactly
one concurrent verification can succeed. Verification is intentionally not
idempotently replayable: a consumed proof always returns `409`, and an expired
proof returns `410`. App B executes the underlying operation only after a
successful verification and must still apply its own endpoint and business
authorization.
## Outbound events and webhooks
Every security-relevant mutation commits its domain change, redacted audit
record, aggregate version increment, and outbox event in one PostgreSQL
transaction. Workers claim outbox rows with bounded leases, deliver at least
once, use capped exponential backoff with jitter, and retain dead-letter state
under the configured retention policy.
Current organization owners/admins and authorized Silicon webhook managers can
list dead letters at their recipient-specific routes and replay one or a batch
of at most 100 by submitting `delivery_ids` with an idempotency key. Replay
preserves the original `event_id`, payload, `occurred_at`, aggregate version,
and complete attempt history; it resets only the cycle attempt counter and
increments the manual replay count. The transaction rechecks current Application
authorization or the current Silicon endpoint/subscription, then targets the
currently configured URL and signing-key version. `session.logout.v1` is the
narrow exception: because the event itself revokes delegated Application
authority, its secret-free revocation notification may be replayed only when
the exact persisted dead-letter recipient is bound to that Application. Other
revoked recipients fail closed. Batches are requeued and delivered in original
global event order, and each replay request records its initiating actor in the
audit trail.
The OpenAPI `webhooks` section defines application and subscriber-configured
Silicon deliveries. Event bodies use this envelope:
```json
{
"spec_version": "1.0",
"event_id": "uuid",
"event_type": "organization.membership.removed.v1",
"occurred_at": "2026-08-31T12:00:00Z",
"aggregate": {
"id": "uuid",
"type": "membership",
"version": 7
},
"data": {}
}
```
Event names are stable dotted identifiers ending in their positive schema
version, such as:
- `carbon.updated.v1`
- `organization.updated.v1`
- `organization.membership.created.v1`
- `organization.membership.updated.v1`
- `organization.membership.profile_updated.v1`
- `organization.membership.removed.v1`
- `organization.silicon.updated.v1`
- `organization.tag_updated.v1`
- `organization.trust.rule_updated.v1`
- `session.logout.v1`
Organization invitation, ownership, approval, SSO, tag, trust, Silicon
credential, and other directory transitions use the same versioned naming
rule. Consumers must deduplicate by `event_id`, process the event types they
understand, and safely ignore unknown event types so additive events do not
break delivery.
### Silicon Full event catalog (38)
`mode=all` is a closed set of exactly the following 38 event types.
| `organization.membership.created.v1` | A new Carbon membership was created. |
| `organization.membership.reactivated.v1` | An inactive Carbon membership was restored. |
| `organization.membership.removed.v1` | A Carbon membership was removed or deactivated. |
| `organization.silicon.created.v1` | A Silicon identity was added. |
| `organization.silicon.removed.v1` | A Silicon identity was removed. |
| `organization.membership.updated.v1` | Centrally managed membership directory, tag, role, or trust-related state changed. |
| `organization.membership.profile_updated.v1` | A Carbon profile was projected into this organization. |
| `organization.membership.authorization_updated.v1` | Explicitly delegated member capabilities changed. |
| `organization.ownership_transferred.v1` | Organization ownership moved to another Carbon. |
| `organization.admin.promoted.v1` | A Carbon member became an administrator. |
| `organization.admin.demoted.v1` | An administrator became a regular member. |
| `organization.silicon.updated.v1` | Centrally managed Silicon organization attributes changed. |
| `organization.tag_updated.v1` | A tag definition changed, including effects on assigned members. |
| `organization.trust.default_updated.v1` | The organization default trust value changed. |
| `organization.trust.rule_created.v1` | A trust rule was created. |
| `organization.trust.rule_updated.v1` | A trust rule was modified. |
| `organization.trust.rule_archived.v1` | A trust rule was archived. |
| `organization.created.v1` | The organization was created. |
| `organization.updated.v1` | Organization-level details changed. |
| `organization.tag_created.v1` | An organization tag was created. |
| `organization.invitation.created.v1` | A Carbon invitation was issued. |
| `organization.invitation.accepted.v1` | Invitation admission completed. |
| `organization.invitation.revoked.v1` | A pending invitation was revoked. |
| `organization.role_change.requested.v1` | A governed job-role change was requested. |
| `organization.tag_change.requested.v1` | A governed tag-set change was requested. |
| `organization.approval.decided.v1` | A governance request was approved or rejected. |
| `organization.silicon.rotation_requested.v1` | Silicon credential rotation was requested. |
| `organization.silicon.credential_rotated.v1` | A replacement Silicon credential was created. |
| `organization.silicon.webhook.configured.v1` | A Silicon webhook endpoint/signing secret was configured or replaced. |
| `organization.silicon.webhook.deleted.v1` | A Silicon webhook endpoint was disabled or deleted. |
| `organization.silicon.webhook_subscription.updated.v1` | A Silicon subscription mode, topics, or tag filter changed. |
| `organization.silicon.webhook_subscription.deleted.v1` | A Silicon subscription was removed. |
| `sso.setup_link.created.v1` | A provider setup link was created. |
| `sso.configuration.disabled.v1` | Organization SSO was disabled. |
| `sso.entitlement.replaced.v1` | The SSO entitlement/configuration was replaced. |
| `sso.connection.activated.v1` | An SSO connection became active. |
| `sso.connection.deactivated.v1` | An SSO connection was disabled without deletion. |
| `sso.connection.deleted.v1` | An SSO connection was permanently removed. |
Events never contain OTPs, raw tokens/secrets, provider credentials, encrypted
database records, or unrelated organization state. A Carbon profile change is
delivered to the union of Applications authorized immediately before and after
the transaction. Its `data.changed_fields` and complete `data.current` snapshot
are captured at that exact Carbon version and projected per recipient: profile
fields require the effective `profile` consent scope, while email and phone
require their respective effective scopes. Workers deliver this immutable
snapshot and never hydrate a later Carbon version.
The same capture rule applies to the closed Application organization-member
vocabulary: organization update and ownership transfer; tag update;
trust default and rule create/update/archive; membership create, reactivate,
remove, directory update, authorization update, promotion, and demotion; and
Silicon create, update, remove, and completed credential rotation. IAM resolves
the exact affected membership set, captures the union of Applications
authorized immediately before or after the mutation, and encrypts one distinct
projection per Application in the domain transaction. `profile`,
`organizations.read`, `memberships.read`, and `roles.read` disclose only their
corresponding sections; `email` and `phone` may additionally disclose the
affected Carbon's primary contact and never apply to a Silicon. A before-only
recipient receives scope-filtered `changed_fields` but only stable
resource/version authorization tombstones, never stale privileged state.
Here, an affected resource means a principal or organization projection the
Application can read through at least one effective data scope. Invitations,
SSO configuration, webhook configuration, and administrative or protocol
controls have no Application data scope and are excluded. Creating an
unassigned tag affects no member and therefore produces no Application member
projection.
Application member-event data always uses
`current: {"members":[...]}`, including a one-member event. An organization
update instead uses `current: {"organization": ...}` and is delivered only to
Applications with `organizations.read`. Tag and trust events use
`current: {"resource": ..., "members":[...]}` so the independently versioned
tag/default/rule state is not lost; trust-rule archive events carry a resource
tombstone.
All shapes and `changed_fields` are frozen at commit and cannot be hydrated from
later state. `organization.membership.profile_updated.v1` remains Silicon-only
because the same Carbon mutation is already represented to Applications by
`carbon.updated.v1`. Rotation-request/control, subscription/configuration, and
other protocol events are likewise outside this Application projection
allowlist.
For each active organization membership, a Carbon profile transaction also
captures an `organization.membership.profile_updated.v1` Silicon event under
the `member_updates` topic. That event carries the profile fields changed at
the exact Carbon version, the complete current same-organization membership
state, and the affected membership tags before and after the change. Email,
phone, credentials, and other contact or secret material are excluded.
### Application signature verification
Each request includes:
```text
X-Silicon-IAM-Event-ID: <uuid>
X-Silicon-IAM-Timestamp: <unix-seconds>
X-Silicon-IAM-Key-Version: <integer>
X-Silicon-IAM-Signature: v1=<64 lowercase hexadecimal characters>
```
The signature is HMAC-SHA-256 over:
```text
{timestamp}.{exact raw request body bytes}
```
using the indicated version of the application's webhook secret. Consumers
must reject timestamps outside five minutes, verify with constant-time
comparison, and deduplicate `event_id` before applying state. A success is any
`200`, `202`, or `204` response. Redirects are not followed. Delivery is
ordered by aggregate version when order matters, but consumers must tolerate
at-least-once duplicates and gaps while retries are pending.
IAM validates outbound HTTPS endpoints against SSRF and DNS-rebinding attacks:
no userinfo, fragments, trailing-dot DNS hostnames,
private/link-local/loopback/multicast destinations, or post-resolution address
changes are allowed. Registration and delivery-time transport validation each
reject trailing-dot hosts independently, so historical encrypted destinations
cannot bypass the check. Connection, TLS, total-request, response-size, and
concurrency limits are bounded.
Silicon webhook deliveries use the same event-ID, timestamp, key-version, and
signature headers and the same `{timestamp}.{body}` HMAC construction. The key
version identifies the subscriber-managed `swhs_…` secret returned when the
endpoint is configured or replaced. IAM never sends a provider bearer
credential to the destination.
### Silicon subscriptions
Silicon delivery begins only after both an active endpoint and a subscription
exist. `all` represents the complete closed topic vocabulary in API responses
and also receives explicitly routed Full-only organization events;
`selected` keeps the exact requested subset and never receives those unscoped
events. Multiple matching topics still produce one delivery per endpoint and
event. Subscription routing metadata—including the affected membership and
tag IDs and the event-time own-tag audience—is stored separately from the
public event payload and is never serialized to receivers.
Delivery is at least once, ordered and retried through the same durable worker
as application webhooks. Operator-wide failures use destination type
`silicon_webhook`. The old `silicon_hook` value remains readable only for
historical delivery records and cannot be selected for new deliveries.
## Audit and history
Audit records are internal, append-only security and lifecycle evidence; IAM
does not expose a generic organization-wide or global audit-browser HTTP API.
They include:
- initiating and effective actor
- organization/application context
- action and typed target
- request ID and authentication method
- timestamp, with reserved coarse-IP-prefix and safe-user-agent-summary fields
- redacted before/after diff
They exclude secrets, tokens, OTPs, complete contact details, and raw provider
payloads. The reserved IP and user-agent history fields remain null until a
deployment defines trusted ingress metadata and allowlisted proxy hops; IAM
never derives them from arbitrary forwarding headers. Login history is
separately available to the Carbon and current owner/admin of the relevant
Application's organization. Job-role history includes requester, approvers,
immutable request ID, old/new text, and application time.
The worker applies configurable retention as one independently committed
database phase per maintenance tick, selected round-robin from a closed
21-phase vocabulary. Each selected phase claims at most the configured batch
size, which is bounded at 1,000 root rows, with ordered locking; a failure is
isolated to that phase and the next tick advances to the following phase. The
initial cursor follows the global wall-clock sweep slot so rolling restarts do
not starve later phases. Defaults are 365 days for login/authentication history,
30 days for expired challenges and abandoned authorization transactions,
90 days for expired or revoked
access/OBO/refresh metadata, 365 days for compromised refresh families, 45 days
for webhook-attempt telemetry, and 2,555 days for security audit events.
Approval-linked step-up records retain only a skeletal identifier, purpose,
assurance, and timing record after their digest is erased.
Authentication-session skeletons similarly remain only while a retained
audit, consent, governance, or lifecycle FK needs them; optional fingerprint and
revocation-detail fields are erased at the login history cutoff.
## Platform administration
There is no source-controlled default administrator password or runtime
bootstrap secret. The first platform administrator is bootstrapped only by the
one-time `iam-bootstrap-admin` operator command using the migrator database
credential. Platform administrators are existing Carbon principals with a
privileged role and strong step-up requirements. The administrator role is not
listed, granted, or revoked through the HTTP API.
| GET | `/api/v1/admin/applications` | App inventory and review queue |
| POST | `.../applications/{app_id}/decisions` | Review/configure/suspend/delete app |
| PUT | `.../organizations/{org_id}/sso-entitlement` | Backend-only SSO unlock |
Admin authorization is checked against current Carbon, admin status, session,
and verified-channel step-up state on every mutation. Admin endpoints never
return provider credentials, encryption keys, secret digests, or raw one-time
response envelopes.
## Testing environments
A testing environment is an organization-owned replica of Silicon IAM: the same
routes, the same rules, the same schema, running against a separate testing
database and starting with nothing in it -- no organizations, no applications,
no Carbons, no Silicons. It is a change of database rather than a second
implementation, so anything the product can do, an environment can do.
The shipped AWS production stack provisions this plane on a private,
single-AZ `db.t4g.micro` PostgreSQL instance with minimal storage, migrates it,
applies runtime grants, and supplies both the API and worker connection URLs.
Every plane-selectable route accepts the header:
```
X-Testing-Environment-Key: <32 alphanumeric characters>
```
Supplying it executes that request inside the named environment. Omitting it
executes against production. The organization-prefixed lifecycle routes below
manage environments themselves, always operate on production, and therefore
never accept the header. The singular `/api/v1/testing-environment...` routes
are test-only and require it.
Email and SMS delivery is suppressed because test contacts are invented. The
fixed code `000000` succeeds wherever a delivered OTP would be expected:
signup, login, step-up, and invitation acceptance. Webhooks are delivered so an
integration can prove its receiver, but their payload is visibly and
structurally test-only as described below.
An environment shares its database with every other environment, and every row
in it carries the environment that owns it. Isolation is enforced by row-level
security rather than by application code, including for the internal functions
that resolve handles, contacts and credentials, so two environments can hold
the same Carbon handle or the same email address without seeing each other.
| GET | `/api/v1/organizations/{org_id}/testing-environments` | List; deleted are hidden unless `status=deleted` or `status=all` |
| POST | `.../testing-environments` | Create, returning the key |
| GET | `.../testing-environments/{environment_id}` | Read |
| PATCH | `.../testing-environments/{environment_id}` | Rename or re-describe |
| DELETE | `.../testing-environments/{environment_id}` | Retire, recoverable until `purge_after` |
| GET | `.../testing-environments/{environment_id}/key` | Read the current key back |
| POST | `.../testing-environments/{environment_id}/key-rotations` | Issue a new key, invalidating the old one |
| POST | `.../testing-environments/{environment_id}/cleanings` | Erase all data, keep the environment |
| POST | `.../testing-environments/{environment_id}/restorations` | Revive a retired environment |
| GET | `/api/v1/testing-environment` | Describe the environment the presented key opens |
| POST | `/api/v1/testing-environment/cleanings` | Erase all data, authorized by the key alone |
| POST | `/api/v1/testing-environment/applications/imports` | Import a production Application; requires the key and a test Carbon bearer |
Any active member may create an environment, Carbon or Silicon, and becomes its
creator. The creator keeps administrative authority for as long as their
membership is active, and every organization owner and admin holds the same
authority over every environment regardless of who created it. Administrative
authority covers reading and rotating the key, cleaning, retiring, restoring,
and renaming.
The key is the environment's root authority: anyone holding it can do anything
inside that environment. It is returned on creation and on rotation, and stays
retrievable afterwards from its own route, which is restricted to
administrators and audited on every read. It is deliberately absent from the
list and read projections so the calls an operator makes routinely carry no
credential.
The key selects a database plane; it does not replace route authentication. A
request to a Carbon route still needs a Carbon bearer issued in that same
environment, and an Application route still needs that environment's
Application credential. Production and test access tokens, refresh tokens,
short-lived tokens, STKs, Application secrets, sessions, and OBO proofs are
cryptographically or durably bound to their plane. Production rejects test
credentials and a test environment rejects production credentials. IAM does
not currently expose a caller API-key credential; any future API-key surface
must preserve this invariant. Never implement a fallback lookup across that
boundary.
### Applications inside an environment
There are two explicit ways to obtain an Application:
1. Call ordinary `POST /api/v1/applications` with the environment key and a
test Carbon owner/admin bearer. This creates test-owned configuration through
the same code path as production. The body must include the local handle,
`org_id`, `base_url`, webhook URL, and a caller-chosen `webhook_secret`; IAM
generates only the test Application client secret and echoes the supplied
webhook secret for v1 compatibility. Its canonical ID must not already exist
in production.
2. Call `POST /api/v1/testing-environment/applications/imports` with body
`{ "app_id": "google>drive" }`, the environment key, an idempotency key,
and a bearer for a Carbon created inside the environment. Import preserves
the production canonical ID, base URL, webhook URL, and OBO registry. If
`google` does not yet exist in the environment, IAM creates it and makes the
requesting test Carbon its owner. An imported Application cannot be placed
under any other organization.
Import returns a new test-only `app_secret`, never the production Application
secret. It inherits the production webhook signing secret so an existing test
receiver can verify it, but the response exposes only
`webhook_secret_inherited: true`, never that signing secret or its value. The
secret-bearing import response is no-store and replayable under the exact same
idempotency key for ten minutes.
An imported Application that reconfigures its webhook URL is moved off the
inherited production signing key onto a caller-supplied test signing key. The
replacement request includes `webhook_secret`; the response echoes it as
`webhook_signing_secret` and includes
`secret_replay_expires_at` only in this transition. Ordinary production and
already test-owned replacements continue to reuse their existing signing key.
Base-URL discovery uses the same endpoint in both planes. A test Application
authenticates with its test-only Basic credential and presents the environment
key; IAM returns the target's test-plane `base_url`. A missing target is not
looked up in production.
### Test webhook envelope
Production continues to deliver the ordinary top-level event shape. A test
delivery instead signs and sends this exact outer structure:
```json
{
"test": {
"testing_key": "<32-character environment key>",
"metadata": {
"spec_version": "1.0",
"event_id": "<uuid>",
"event_type": "organization.membership.created.v1",
"occurred_at": "2026-09-04T08:00:00Z",
"organization_id": "<uuid>",
"aggregate": { "type": "membership", "id": "<uuid>", "version": 1 }
},
"data": {}
}
}
```
Verify `X-Silicon-IAM-Signature` over the exact outer body bytes before parsing
it. Then route by the test envelope, deduplicate on
`test.metadata.event_id`, and order on `test.metadata.aggregate.version`.
`testing_key` is live root authority: use it only to associate the delivery
with an isolated run, compare it without timing leakage, redact it from all
logs and traces, and never persist it beside event data.
### Minimal end-to-end API proof
1. Authenticate in production and create an environment. Store its UUID as
ordinary metadata and its returned key as a secret.
2. Add `X-Testing-Environment-Key` to the normal signup routes, use `000000`
for both verifications, create a test Carbon, and log that Carbon in. Store
those tokens under the environment UUID, separate from production.
3. Create a test organization and Application through the normal routes, or
import a production Application through the test-only import route. Persist
the returned test-only Application secret during its ten-minute replay
window.
4. Drive the ordinary login URL/API with the environment key, exchange the
short-lived token using the test Application credential, and introspect the
result in the same environment. Each attempt with a production credential
should fail; include those negative assertions in the proof.
5. Trigger a directory mutation, verify and deduplicate the wrapped test
webhook, then exercise OBO discovery/exchange/verification if the
Application exposes OBO endpoints.
6. Clean with `POST /api/v1/testing-environment/cleanings` to retain the same
environment/key, or retire it from the production control plane. A retired
environment stays recoverable until `purge_after`.
Deletion is reversible. A retired environment keeps its record and its data
until `purge_after`, and a restore before that deadline brings it back intact.
Restoring can conflict: the name is released on deletion, so another
environment may have taken it. After the deadline the worker erases the data
and removes the record permanently.
An environment with no activity for the configured window -- 30 days by default,
measured from the last accepted request in it -- is retired automatically into
exactly that same recoverable state.
Testing environments are available only where the deployment configures a
testing database. The AWS production template provisions and wires a minimal
private `db.t4g.micro` database automatically. Custom deployments without a
testing database answer `503` on every route in this section and on any request
presenting the header.
## Reliability and revocation guarantees
PostgreSQL is authoritative for identities, sessions, organizations,
memberships, permissions, governance, applications, SSO mappings, idempotency,
audit, rate limits, cooldowns, replay markers, and outbox state.
Security mutations use one database transaction. No transaction remains open
while contacting Postmark, Twilio, WorkOS, Iris, or an application or Silicon
webhook. Provider work is either a bounded request whose result is required
for the response or an outbox job with visible retry state.
Membership/app/session/credential authorization epochs allow immediate central
revocation. Consumers must introspect opaque tokens at the appropriate trust
boundary; a delayed or permanently failed webhook cannot keep authority alive.
## Retention defaults
Retention is configurable policy and requires jurisdiction-specific compliance
review before production:
| Security audit and governance history | 2,555 days |
| Login history | 365 days |
| Expired OTP/challenge metadata | 30 days |
| Expired/revoked token metadata | 90 days |
| Token metadata after detected refresh replay | 365 days |
| Webhook delivery attempts | 45 days |
| Completed idempotency responses | 24 hours |
| Raw one-time secret response envelope | destroyed after 10 minutes |
Retention never makes an expired credential valid and does not permit raw
secret persistence.
## Transactional invariants
Implementations and contract tests must preserve at least these invariants:
1. Exactly one active owner exists per active organization.
2. One organization/principal pair maps to one durable membership ID.
3. Public handles remain immutable, globally unique in their namespace, and
tombstoned after deletion.
4. Every tenant-owned reference belongs to the same organization.
5. Job-role text, tags, trust, and reporting edges never grant authority.
6. Silicon reporting graphs are acyclic and organization-local.
7. Invitation acceptance, signup completion, authorization-code exchange,
refresh rotation, approval completion, OBO consumption, and Silicon token
rotation are atomic and replay-safe.
8. Domain mutation, audit, aggregate version, and outbox record commit together.
9. Owner, membership, app, session, consent, and credential revocation are
effective centrally before webhook delivery.
10. Raw credentials and contact identities never enter logs or public events.
## Provider and non-public boundaries
Postmark, Twilio Verify, Twilio Messaging, WorkOS, and Iris are accessed behind
application ports. Twilio Verify owns phone-code generation, SMS routing, and
code validation; Twilio Messaging remains the transport for non-OTP SMS
notifications. Their raw provider-specific payloads and outbound management
APIs are intentionally not public IAM endpoints. Production startup refuses
local/no-op provider implementations. Subscriber-managed Silicon endpoints use
IAM's shared SSRF-hardened outbound webhook transport rather than a
provisioning provider.
The public contract fixes IAM-visible behavior—timeouts, uniform OTP responses,
callback/webhook validation, durable delivery state, idempotency, and error
mapping—without coupling clients to a provider SDK. Provider API version
upgrades therefore do not silently change this contract.