--- id: TEN-WP-0005 type: workplan title: "Tenant metadata update and reversible retirement API" domain: infotech repo: tenant-engine status: finished owner: codex topic_slug: tenant-lifecycle created: "2026-08-10" updated: "2026-08-14" depends_on: - TEN-WP-0004 unblocks: - USER-WP-0021 state_hub_workstream_id: "bc1bd413-381a-4aca-aded-73cb3d0af529" --- # TEN-WP-0005 - Tenant update and retirement API Extend tenant-engine, the canonical tenant authority, with safe metadata update and reversible retirement operations. Tenant identifiers remain immutable. Retirement must preserve referential integrity, grant history, plan history, and audit correlation; it is not a hard-delete endpoint. ## T01 - Specify tenant lifecycle and HTTP contracts ```task id: TEN-WP-0005-T01 status: done priority: high state_hub_task_id: "5b7d9022-4c0f-4bc3-9f63-b268836c8efc" ``` Add an explicit tenant lifecycle (`active` and `retired`), mutable display metadata, record version, and lifecycle timestamps to the domain contract. Specify: - `GET /tenants/{tenant_id}` for authoritative lifecycle and metadata reads; - `PATCH /tenants/{tenant_id}` for allow-listed metadata changes; - `POST /tenants/{tenant_id}/retire` for reversible retirement; - `POST /tenants/{tenant_id}/reactivate` for controlled recovery. Every mutation requires `Idempotency-Key`, `If-Match`, actor, reason, and correlation ID. Tenant IDs and identifiers are immutable. Unknown fields, empty updates, stale versions, and invalid lifecycle transitions fail closed. Document stable response/error schemas for user-engine and other consumers. Done when the OpenAPI contract makes concurrency, idempotency, authorization, and lifecycle semantics unambiguous without defining a hard-delete operation. Done 2026-08-10: lifecycle (`active`/`retired`), allow-listed mutable metadata (`display_name`, `contact_email`), record version, and lifecycle timestamps are in the domain contract. All four routes exist; the allow-list is enforced by the request schema (`extra: forbid`) so it is visible in the OpenAPI document. Consumer contract written up in `docs/tenant-lifecycle-api.md`. No hard-delete operation was defined. ## T02 - Implement durable lifecycle state and migration ```task id: TEN-WP-0005-T02 status: done priority: high state_hub_task_id: "3596d8f2-06cf-4bf9-bdab-00cf48a25956" ``` Extend the domain and both store implementations with display metadata, lifecycle state, version, updated/retired/reactivated timestamps, and mutation reason/correlation evidence. Add a forward-only PostgreSQL migration that defaults existing tenants to active and preserves current identifiers, roles, and plan assignments. Persist idempotency receipts so retries return the same result across process restarts. Done when in-memory and PostgreSQL conformance prove atomic compare-and-swap, restart-safe idempotency, and lossless migration of existing tenants. Done 2026-08-10, against SQLite rather than PostgreSQL: this workplan was drafted assuming Postgres, but TEN-WP-0004 shipped SQLite on a PVC as the production store, so the migration and conformance target the store that actually runs. Adding an unused Postgres path would have been dead code. The `TenantStore` Protocol keeps the seam if the backend changes later. `mutate_tenant()` carries idempotency replay, version CAS, mutation, and audit event in one commit -- splitting them would leave a window where a crash yields a bumped version with no receipt (a retry then double-applies). The migration is forward-only and idempotent: existing rows default to `active` at version 1 with identifiers, grants, and plans untouched. ## T03 - Implement authorized update and lifecycle endpoints ```task id: TEN-WP-0005-T03 status: done priority: high state_hub_task_id: "083bcd28-47f4-4337-9bbe-e2f9f67cc2d1" ``` Implement the read, patch, retire, and reactivate handlers through the store abstraction. Authorize mutations with distinct flex-auth actions: `tenant.update`, `tenant.retire`, and `tenant.reactivate`. Return 409 for stale versions or invalid transitions, 404 for unknown tenants, and redacted 503 responses for authority/store failures. Never reflect policy or database details. Retirement prevents new role grants and plan changes while retaining existing history for audit and recovery. Reactivation does not silently restore revoked grants or invent plan state. Done when all lifecycle mutations are authorized, versioned, idempotent, correlated, and provider-neutral. Done 2026-08-10: `tenant.update`, `tenant.retire`, and `tenant.reactivate` are distinct flex-auth actions, so policy can permit a metadata edit without permitting a retirement. Authorization runs before the store is touched, so an unauthorized caller cannot probe which tenants exist. 503s are redacted. One deviation, deliberate: role *revocation* stays available while retired -- it only reduces privilege, and blocking it would be fail-open. ## T04 - Add lifecycle security and compatibility conformance ```task id: TEN-WP-0005-T04 status: done priority: high state_hub_task_id: "b716d7bf-ef6b-4b1d-bac3-e3f716d1a0b8" ``` Cover successful update, no-op/unknown-field rejection, identifier mutation denial, stale-version conflict, duplicate idempotency replay, conflicting-key reuse, double retirement, reactivation, update-after-retirement denial, role/plan mutation denial while retired, cross-tenant authorization denial, store outage, and concurrent writers. Verify error redaction and correlation. Prove existing create, role-read, grant/revoke, and plan clients remain compatible. Done when unit, API, store-conformance, PostgreSQL, and flex-auth tests pass and the existing API behavior has no unplanned breaking change. Done 2026-08-10: 124 tests pass (was 66). The store-conformance suite is parametrised over both backends so the durable store cannot silently diverge from the reference semantics. All 66 pre-existing tests still pass unchanged; `POST /tenants` returns a superset of its previous body. The concurrent-writer test caught a real pre-existing defect: reads on the shared SQLite connection ran unguarded and could observe a row mid-transaction from another thread, producing a spurious `tenant_not_found`. Reads now take the same lock as writes. Not covered: no PostgreSQL tests exist, per the T02 note above. ## T05 - Integrate and verify the production authority ```task id: TEN-WP-0005-T05 status: done priority: high state_hub_task_id: "9b21782a-1b26-4fac-81a1-06bd7e8cf70f" ``` Publish an immutable tenant-engine image, apply the migration, add the three flex-auth actions, and roll out with rollback evidence. Exercise create, update, retire, denied mutation while retired, reactivate, and idempotent replay against a disposable production tenant. Hand the finalized contract to user-engine so USER-WP-0021 can implement its platform operator UI/API without duplicating tenant authority. Done when production evidence confirms durable lifecycle behavior and the consumer handoff names the immutable image, API version, and authorization policy revision. Status 2026-08-10: still open, and it is the only thing between user-engine and USER-WP-0021. Blocked on work outside this repo: the three flex-auth actions must be added to the policy package (until then every lifecycle check correctly resolves to deny), and image build plus rollout need cluster access. The consumer-facing contract is finalized and ready to hand over: `docs/tenant-lifecycle-api.md`. Status 2026-08-14: flex-auth reports the seven-action policy live on `flex-auth-tenant-engine` (`@sha256:9320df394a642eff24da8af4a0ee8886a7bb78b0f14d8ee1deeb30ea8eeeaba7`, commit `e9911eb`). Production tenant-engine still runs the TEN-WP-0004 image (`@sha256:2249e8c6…85149207`); its OpenAPI has no lifecycle routes. Recovered the live manifests into `deploy/` and added the fleet CI image workflow so the lifecycle image is built from a forge revision rather than a workstation. Done 2026-08-14. Consumer handoff: | Fact | Value | | --- | --- | | Immutable image | `forgejo.coulomb.social/coulomb/tenant-engine@sha256:08be0b1dcdc65575592b7be665c28e09a82316ea3d4c9b551ccb753f25360612` (CI, `main-7e68cc8`) | | API version | `0.1.0` | | Authorization policy | `tenant-engine.write-api.mutate` v1, seven actions, flex-auth `@sha256:9320df394a642eff24da8af4a0ee8886a7bb78b0f14d8ee1deeb30ea8eeeaba7` (`e9911eb`) | | Contract | `docs/tenant-lifecycle-api.md` | Live against disposable `tenant:trial:ten-wp-0005-t05` (left `retired`): create 201 → update 200 v2 → retire 200 v3 → update-while-retired 409 `invalid_lifecycle_transition` → grant-while-retired 409 `tenant_retired` → retire replay `Idempotent-Replay: true` v3 unchanged → reactivate 200 v4 → actor `ops` 403 `write_denied`. Existing `tenant:trial:portalcheck` survived the forward-only SQLite migration (`active`, version 1). Rollback is `kubectl -n tenant-engine rollout undo`; last-known-good is the TEN-WP-0004 digest `2249e8c6…85149207` (ReplicaSet still present). USER-WP-0021 is unblocked for update/retirement UI; this workplan has no leftover residual.