Pin tenant-engine to the CI-built digest from 7e68cc8, record rollback
digests, and close T05 after live create/update/retire/replay/reactivate
evidence against a disposable production tenant. Hands the 0.1.0 contract
to USER-WP-0021.
6.9 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, identifier, and grouping 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.
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 |
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.