8.7 KiB
Tenant lifecycle API (TEN-WP-0005)
Consumer contract for tenant metadata update and reversible retirement.
Primary consumer: user-engine (USER-WP-0021, platform operator UI/API).
tenant-engine remains the sole authority for tenant existence and
lifecycle. Consumers call this API; they do not keep their own tenant table
and do not implement their own retirement semantics.
There is no hard-delete endpoint, by design. Retirement is reversible and preserves the tenant record, its grant history, and its plan history so audit correlation and recovery stay intact.
Lifecycle
┌──────────────── retire ────────────────┐
│ ▼
[ active ] [ retired ]
▲ │
└────────────── reactivate ──────────────┘
| State | New role grants | Plan changes | Metadata updates | Role reads |
|---|---|---|---|---|
active |
allowed | allowed | allowed | allowed |
retired |
409 | 409 | 409 | allowed (unchanged) |
Role revocation stays available while retired: it only reduces privilege, and blocking it would be a fail-open behaviour.
Reactivation restores the tenant's ability to receive new grants and plan changes. It deliberately does not resurrect revoked grants or invent plan state — those stay exactly as retirement left them.
Immutability
tenant_id and identifier are immutable. The identifier is the IAM Profile
tenant claim value that key-cape mints into tokens and flex-auth
authorizes against; mutating it would silently invalidate every issued token
that references it.
Mutable metadata is exactly: display_name, contact_email. Unknown fields
are rejected by the request schema (extra: forbid), so the allow-list is
visible in the OpenAPI document rather than discovered from a 400.
grouping is mutable as of TEN-WP-0010, but not through PATCH — see
below. This document previously said it was immutable; that is no longer true.
Reclassification (grouping)
POST /tenants/{tenant_id}/grouping
POST /tenants/t-1/grouping
Idempotency-Key: 4f1c…
If-Match: "1"
{"grouping": "large", "actor": "ops",
"reason": "grew past the band", "correlation_id": "corr-1"}
Returns the full tenant record, ETag, and Idempotent-Replay, like the other
mutations.
Why this is not a PATCH field. display_name and contact_email are
cosmetic. grouping resolves spend ceilings (see
tenant-guardrail-policy.md), so a reclassification moves money. It gets its
own route, its own flex-auth action tenant.grouping.set, and its own audit
event tenant_grouping_changed, so policy can permit a rename without
permitting a reclassification and the audit trail shows which one happened.
The identifier's grouping segment is historical. A tenant created as
tenant:small:acme and reclassified to large keeps the identifier
tenant:small:acme and reports "grouping": "large". The two are expected
to diverge.
Do not parse grouping out of the identifier. The segment records the grouping at onboarding and is not authoritative afterwards. Read the
groupingfield from this API. (ADR-0013 amendment proposed under TEN-WP-0010; the identifier itself remains immutable either way.)
| Rule | Behaviour |
|---|---|
| unknown grouping | 400 invalid_grouping |
| no-op | 400 invalid_update |
reserved tenant (tenant:platform, tenant:coulomb) |
400 — ungrouped by design; they resolve guardrails through the reserved profile |
| retired tenant | 409 invalid_lifecycle_transition — reactivate first |
Endpoints
GET /tenants/{tenant_id}
Authoritative record read. tenant_id accepts either the internal id or the
profile identifier (e.g. tenant:friendly:binky) — external callers only ever
hold the identifier.
Returns the record and an ETag header carrying the record version. Read
first, echo the ETag back as If-Match on any mutation.
{
"tenant_id": "t-1",
"identifier": "tenant:friendly:binky",
"grouping": "friendly",
"display_name": "Binky",
"contact_email": null,
"lifecycle": "active",
"version": 1,
"created_at": "2026-08-10T12:00:00+00:00",
"updated_at": "2026-08-10T12:00:00+00:00",
"retired_at": null,
"reactivated_at": null
}
PATCH /tenants/{tenant_id}
PATCH /tenants/t-1
Idempotency-Key: 4f1c…
If-Match: "1"
{"metadata": {"display_name": "Binky Ltd"},
"actor": "user-engine-portal", "reason": "operator rename", "correlation_id": "corr-1"}
POST /tenants/{tenant_id}/retire · POST /tenants/{tenant_id}/reactivate
Same headers; body is {"actor", "reason", "correlation_id"}.
All three mutations return the full record (as above) plus ETag and
Idempotent-Replay: true|false.
Concurrency and idempotency
Every mutation requires both headers:
If-Match— the record version as an ETag ("1"orW/"1"). Enforced as an atomic compare-and-swap.*is rejected: it would mean "whatever version is current", which is the unconditional write these endpoints exist to prevent.Idempotency-Key— caller-generated, unique per logical mutation.
Replay semantics:
- Same key, same request → the original result is replayed verbatim with
Idempotent-Replay: true. The mutation is not applied twice, and the version does not advance. Receipts are durable, so a replay works across atenant-enginerestart. - Same key, different request →
409 idempotency_key_conflict. - A mutation that failed leaves no receipt, so its key is reusable.
A replayed key short-circuits before the version check — a genuine retry
necessarily carries a now-stale If-Match. This is why retries do not need to
re-read the record first.
Errors
Stable schema on every lifecycle endpoint:
{"error_code": "version_conflict", "detail": "record version is 2, not 1", "correlation_id": "corr-1"}
| Status | error_code |
Cause |
|---|---|---|
| 400 | idempotency_key_required |
Idempotency-Key header missing |
| 400 | invalid_if_match |
If-Match is * or not a version ETag |
| 400 | invalid_update |
empty change set, or a change that is a no-op |
| 403 | write_denied |
flex-auth denied the action |
| 404 | tenant_not_found |
unknown tenant |
| 409 | version_conflict |
stale If-Match — re-read and retry |
| 409 | idempotency_key_conflict |
key reused for a different request |
| 409 | invalid_lifecycle_transition |
double retirement, reactivating an active tenant, updating a retired tenant |
| 422 | (schema) | unknown or immutable field in metadata |
| 428 | if_match_required |
If-Match header missing |
| 503 | tenant_authority_unavailable |
store or authority unavailable |
A no-op update is rejected rather than silently accepted, so a caller never reads a version bump as evidence that a value actually changed.
503 responses are redacted: they never carry a database path, driver text,
or policy detail.
Authorization
Mutations are gated by flex-auth; tenant-engine never self-authorizes.
Three distinct actions, so policy can grant a metadata edit without
thereby granting a retirement:
| Action | Resource type |
|---|---|
tenant.update |
tenant |
tenant.retire |
tenant |
tenant.reactivate |
tenant |
tenant.grouping.set |
tenant |
These sit alongside the existing tenant.create, tenant.role.grant,
tenant.role.revoke, and tenant.plan.assign actions in package
tenant-engine.write-api.mutate v1. Production flex-auth serves the
seven-action revision from image
forgejo.coulomb.social/coulomb/flex-auth@sha256:9320df394a642eff24da8af4a0ee8886a7bb78b0f14d8ee1deeb30ea8eeeaba7
(commit e9911eb). Unknown action strings still deny.
Compatibility
No breaking change to existing clients. POST /tenants now returns a superset
of its previous body (tenant_id, identifier, grouping unchanged) and
optionally accepts display_name / contact_email. Role read, grant, revoke,
and plan endpoints are unchanged except that grant and plan-assign now return
409 tenant_retired against a retired tenant.
Existing databases are migrated forward-only: lifecycle columns are added and
existing tenants default to active at version 1, with identifiers, grants,
and plan assignments untouched.
Note on the backend. TEN-WP-0005 was drafted against PostgreSQL, but TEN-WP-0004 shipped SQLite on a PVC as the production store. The migration and conformance suite target the store that actually runs; the
TenantStoreProtocol keeps the seam for a future backend change.