# 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. ```json { "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}` ```http 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"` or `W/"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 a `tenant-engine` restart. - **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: ```json {"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 must be added to the flex-auth policy package alongside the existing `tenant.create`, `tenant.role.grant`, `tenant.role.revoke`, and `tenant.plan.assign` actions. Until they exist, checks resolve to deny — correct fail-closed behaviour, not a defect. --- ## 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 `TenantStore` > Protocol keeps the seam for a future backend change.