tenant-engine/docs/tenant-lifecycle-api.md
tegwick 749461b97b
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
Implement PostgreSQL production store path
Add the PostgreSQL backend, migration and stopped-write transfer tools, lease-aware deployment manifests, tenancy declarations, and shared conformance coverage. Persist grouping mutations in durable stores and separate process liveness from database readiness.
2026-08-19 14:43:08 +02:00

8.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.

Retention and erasure semantics are normative in docs/data-retention-policy.md. In particular, retirement does not erase contact data, and the 30-day PostgreSQL backup window is a copy horizon rather than a primary-record expiry rule.


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 grouping field 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" 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
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 TenantStore Protocol keeps the seam for a future backend change.