tenant-engine/docs/tenant-lifecycle-api.md
tegwick d6fd73bd42 Implement tenant update and reversible retirement API (TEN-WP-0005 T01-T04)
Add an explicit tenant lifecycle (active/retired), allow-listed mutable
metadata, record versioning, and lifecycle timestamps to the tenant authority.

- domain: TenantLifecycle, with_metadata/retire/reactivate, immutability and
  transition invariants. Identifier stays immutable -- it is the IAM Profile
  `tenant` claim key-cape mints into tokens.
- store: mutate_tenant() commits idempotency replay, version CAS, mutation,
  and audit event together; durable receipts survive restart. Retired tenants
  refuse new grants and plan changes but keep their history.
- sqlite: forward-only idempotent migration; existing rows default to active
  at version 1. Reads now take the write lock -- the concurrent-writer test
  caught unguarded reads on the shared connection observing mid-transaction
  state as a spurious tenant_not_found.
- api: GET/PATCH /tenants/{id}, POST retire|reactivate. Idempotency-Key and
  If-Match required, distinct flex-auth actions per operation, stable error
  schema, redacted 503s.
- docs/tenant-lifecycle-api.md: consumer contract for user-engine.

Implemented against SQLite, not PostgreSQL as the workplan assumed --
TEN-WP-0004 shipped SQLite on a PVC as the production store.

124 tests pass (was 66); no breaking change to existing endpoints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 20:00:43 +02:00

6.8 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" 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 request409 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 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.