Skip to Content

← All archived runs

Run: tenant-lifecycle

run.md

Run: tenant-lifecycle

  • branch: claude/pipeline-tenant-lifecycle-g3om2l
  • pr: #891

02_define/output/spec.md

Spec: Tenant lifecycle — create, suspend, delete, recover

  • slug: tenant-lifecycle
  • personas: Admin (Sustentus team), Partner
  • touches: apps/console, apps/web, packages/services/server
  • complexity: complex

Problem

The console can find a tenant, read it, and assign it a partner — but nobody can bring a tenant into existence, pause one, or end one. The provisioning motion the whole tenant-management app exists to serve (a partner provisions their client's tenant; Sustentus oversees the estate) has no home, so every new tenant still arrives through the platform's own self-registration and no tenant can ever be stood down. That blocks tenant-activity-dashboard, which is meant to record these very actions, and it is the missing half of "Establish product-market fit with vendor partners" (Scale the bridge, 2026-Q2, Objective 1): partners cannot be handed the provisioning motion they were invited for while the only way to create a tenant is outside the app they were given.

There is also a live defect this feature is obliged to close. apps/web's getTenant() falls back to tenantService.upsertFromClerk(), and softDeletePlugin hooks pre(/^find/) — which findOneAndUpdate matches. A soft-deleted tenant is therefore invisible to that upsert, so the first sign-in by one of its users silently creates a second Tenant document for the same Clerk organisation. Today nothing soft-deletes a tenant, so nothing triggers it. This feature does, so it must be fixed here.

Proposed change

Lifecycle actions on a tenant, split by audience, with one explicit lifecycle state on the Tenant document driving every surface.

Create — both audiences. A form asks for the tenant's name and the first admin's email. The console creates the Clerk organisation through the backend API (not Clerk's own org-creation flow), writes the Tenant document, and invites the named person into the organisation with the existing admin role — exactly as tenants get their admin today. A tenant created by a partner is assigned to that partner in the same operation, through tenantService.setPartner, the shared write partner-assignment already built [Q-3, Q-4].

Suspend / reactivate — Sustentus only. A suspended tenant stays in the directory for both audiences, badged as suspended, and its users are turned away from apps/web at the proxy with a plain "workspace unavailable" page. Suspension is a real operational state, not a console annotation [Q-5, Q-6].

Delete, in two steps — Sustentus only. Deleting marks the tenant deleted and starts a 30-day recovery clock. It leaves the directory for both audiences and appears on a new staff-only deleted-tenants list showing the days remaining; its users are turned away from apps/web the same way a suspended tenant's are. Within the window a Sustentus person restores it intact — the Clerk organisation, its members and all its data are untouched by the delete, so recovery is a state change and nothing more. Past 30 days the tenant shows as expired and the Recover control is gone; the tenant is not removed on its own. Permanent removal is always a deliberate Sustentus action: a staff-only confirmation route where the tenant's name must be typed exactly, validated server-side. It deletes the Clerk organisation, deletes the Tenant document, and empties every tenant-scoped collection [Q-7].

Partners see none of the suspend, delete, recover or permanent-removal controls, and the deleted-tenants list and the removal route are closed to them at the proxy and again in every action [Q-5].

Nobody is notified of any of this [Q-12]. Recording these actions in an activity trail is tenant-activity-dashboard's job [Q-10], not this run's.

How the state is held

One lifecycle sub-document on the Tenant, in the shape the onboarding field already established — a const tuple plus a derived type:

TENANT_LIFECYCLE_STATUSES = ["active", "suspended", "deleted"]

lifecycle?: {
  status: TenantLifecycleStatus;
  changedAt: Date;
  changedBy: string;    // the acting Clerk user id
  deletedAt?: Date;     // set on delete, cleared on recover — the 30-day clock
}

A tenant with no lifecycle field reads as active, as onboarding reads as not_started; a migration backfills existing tenants and indexes what the new reads sort and filter on.

isDeleted is not replaced — softDeletePlugin and every pre(/^find/) in the codebase depend on it. It is kept in lockstep instead: the delete write sets lifecycle.status: "deleted" and isDeleted: true together, recovery clears both together, and a unit test asserts the two can never disagree. The 30-day window is a named constant (TENANT_RECOVERY_WINDOW_DAYS = 30), and whether a window has expired is a pure, tested function of deletedAt and a passed-in "now" — never Date.now() read inside the predicate.

Where the reads change

buildConsoleTenantListFilter and buildConsoleTenantIdFilter currently hard-code LIVE_ONLY. They take a lifecycle selector instead — live (active + suspended) for the directory and detail view of both audiences, deleted-only for the staff list — so a partner still cannot construct a read that reaches a deleted tenant, and the "does not exist / not yours / not an id" sameness that buildConsoleTenantIdFilter was built to protect is preserved unchanged.

The apps/web guard

Two changes, both narrow, neither touching identity (the apps/console/AGENTS.md prohibition is about org roles, claims and the webhook sync — none of which move):

  1. apps/web/proxy.ts reads the signed-in org's lifecycle status the same way it already reads getTenantOnboardingStatusByOrg(orgId), and redirects a suspended or deleted tenant's users to a "workspace unavailable" page.
  2. upsertFromClerk stops resurrecting. It resolves the existing row including soft-deleted ones and refuses rather than inserting a duplicate when the organisation names a deleted tenant. This is the defect above, and it is correct independently of the proxy guard.

The purge

The permanent-removal purge does not get a second copy of the collection list. demo-reset already owns a deny-by-default policy (PURGED_MODEL_FILES / kept, with a drift test that fails when a new tenant-scoped model appears in neither list), but the purge itself is buried in the private purgeAndSeed. The purge half is extracted into a shared, policy-driven tenant purge that both demo-reset and permanent removal call — the same reasoning setPartner records for being shared rather than duplicated. demo-reset's own behaviour is unchanged, and its existing tests are the proof of that.

Acceptance criteria

  • A Sustentus person creates a tenant from the console by giving a name and a first admin email; a Clerk organisation and a Tenant document exist afterwards, the named person holds a pending admin invitation to that organisation, and the tenant appears in the directory.
  • A partner creates a tenant the same way, and the created tenant carries the partner's Clerk user id in partnerId — written through tenantService.setPartner, not a second write path — so it appears in that partner's directory and no other partner's.
  • Creation refuses without a valid first-admin email, and a Clerk failure part-way through leaves no Tenant document whose organisation does not exist.
  • A Sustentus person suspends a tenant; it stays in the directory for both audiences badged as suspended, and reactivating returns it to active.
  • A user of a suspended tenant signing in to apps/web is redirected to the workspace-unavailable page instead of their dashboard.
  • Deleting a tenant removes it from the directory for both audiences, places it on the staff-only deleted list with the days remaining shown, and turns its users away from apps/web.
  • Recovering a tenant inside 30 days returns it to active and to the directory with its Clerk organisation, members and data intact — nothing was removed by the delete.
  • A tenant past its 30-day window shows as expired on the staff list and offers no Recover control; it is still present, because nothing removes it unattended.
  • Permanent removal requires the tenant's name typed exactly — validated server-side, so a mismatched or absent name refuses even when posted directly — and afterwards the Clerk organisation is gone, the Tenant document is gone, and every tenant-scoped collection holds no rows for that tenant id.
  • A partner sees no suspend, delete, recover or permanent-removal control anywhere, and is refused by both the proxy and the action itself when POSTing any of them directly — the test that matters, since server actions dispatch by action id from any path.
  • A deleted tenant's Clerk organisation no longer resurrects a duplicate Tenant document: upsertFromClerk refuses instead of inserting a second row for the same clerkOrgId.
  • lifecycle.status: "deleted" and isDeleted: true are written and cleared together, with a unit test asserting they cannot disagree.
  • demo-reset behaves exactly as before after the purge is extracted — proven by its existing tests, unmodified.

Out of scope

  • Plan selection at creation beyond the existing default — plan movement is tenant-plan-visibility's, already shipped [Q-8].
  • Notifying anyone of a create, suspend, delete or removal [Q-12].
  • Recording these actions in an activity trail or surfacing them on a dashboard — that is tenant-activity-dashboard, the stub that depends on this one [Q-10].
  • Any automatic sweep of expired tenants. No cron is added to the console project; permanent removal is only ever a person typing a confirmation.
  • Bulk lifecycle actions — one tenant at a time.
  • Editing a tenant's name, slug or logo after creation.
  • Partners suspending, deleting, recovering or permanently removing anything, ever [Q-5].
  • Retiring isDeleted in favour of the lifecycle field across the rest of the platform.
  • Exporting or archiving a tenant's data before permanent removal.

Open questions

  • none — the four Define-level questions (whether suspension and deletion bite outside the console, what permanent removal removes, what happens at the end of the 30 days, and whether creation must name the first admin) were put to Jamie and answered before this spec was written. Their answers are the Proposed change above.

Context budget: over. Define's Inputs allow "a few targeted greps"; grounding the lifecycle state model, the partner boundary and the purge needed fuller reads of apps/console/lib/{console-tenants,console-access,console-roles}.ts, apps/console/proxy.ts, packages/services/src/db/services/tenant/{index,console-scope}.ts, db/plugins/soft-delete.ts, db/services/demo-reset/policy.ts and apps/web/lib/tenant.ts. That is how the upsertFromClerk resurrection defect was found, which no amount of reading the stub would have surfaced.

03_build/output/notes.md

Build notes: tenant-lifecycle

  • commits: 2140db7 feat: tenant-lifecycle — create, suspend, delete with 30-day recovery, permanent removal · fd556cc fix: formatting on the lifecycle migration (+ these notes)
  • ci: Quality Project GREEN on fd556cc — format, lint, typecheck and tests all pass, as do every Vercel preview that builds real code (web, tenant-management, demo, docs, help-centre, marketing, storybook). ci-status.sh still reports RED for one reason only: Vercel – agentic-interface, which is not this PR's — see Notes for Verify.

What changed

packages/services

  • db/models/tenant.ts: TENANT_LIFECYCLE_STATUSES + a lifecycle sub-document (status, changedAt, changedBy?, deletedAt?). Absence reads as active, matching the shape onboarding established. Deliberately not declared as a Schema.index(...) — the model's own comment explains why the tenant collection's indexes belong to migrations (mongoose stamps the schema collation onto declared indexes and would conflict).
  • db/services/tenant/lifecycle.ts (new, pure): the 30-day window, the four transitions, and buildLifecycleUpdate — the one place lifecycle.status and isDeleted are written, so they cannot be written apart. lifecycle.test.ts asserts that from the acceptance criteria.
  • db/services/tenant/console-scope.ts: buildConsoleDeletedTenantFilter / buildDeletedTenantIdFilter — the mirror of the live filters. Neither takes a ConsoleTenantScope, so no call site can build a partner-visible deleted read.
  • db/services/tenant/index.ts: createForConsole, setLifecycle, listDeletedForConsole, findDeletedForConsole, removeForConsole, findLifecycleStatusByClerkOrgId; the upsertFromClerk resurrection guard.
  • db/services/tenant-purge/ (new): the purge extracted out of demo-reset's private purgeAndSeed, keyed on the policy's own model list. demo-reset now calls it with PURGED_MODEL_FILES and its roster narrowing; permanent removal calls it with every tenant-scoped collection. policy.ts gained TENANT_SCOPED_MODEL_FILES for the second case.
  • db/migrations/1788240000000-tenant-lifecycle.ts: backfills active onto live tenants and builds { isDeleted, lifecycle.deletedAt } for the deleted list's read.

apps/console

  • /tenants/new — creation for both audiences; a partner's tenant is assigned to them through tenantService.setPartner, the write partner-assignment already owns.
  • Lifecycle panel on the tenant detail view (staff only): suspend / reactivate / delete.
  • /tenants/deleted — staff-only list with the window countdown and Recover.
  • /tenants/deleted/:id/remove — the typed confirmation, checked server-side against the record.
  • proxy.ts: /tenants/deleted(.*) added to the staff matcher.
  • lib/clerk-errors.ts: clerkRefusal moved here from the detail actions (now two callers) and joined by isClerkRefusal. No second copy — CONVENTIONS.md → "grep before writing a helper".

apps/web

  • lib/tenant-availability.ts + proxy.ts: a suspended or deleted tenant's users are redirected to /workspace-unavailable before the route policy runs.

Acceptance criteria status

  • Staff create a tenant (name + first admin email) — Clerk org, Tenant document, pending admin invitation, appears in the directory.
  • A partner's created tenant carries their Clerk user id via setPartner, not a second write path.
  • Creation refuses without a valid email; if the Tenant write fails the Clerk organisation is deleted again, so no Tenant document points at a missing organisation.
  • Suspend / reactivate, badged in the directory and on the detail view for both audiences.
  • A suspended tenant's users are redirected to /workspace-unavailable in apps/web.
  • Delete removes it from both directories, lists it on the staff-only page with days remaining, and turns its users away.
  • Recovery inside 30 days restores it intact — the delete destroys nothing.
  • Past 30 days: shown as expired, no Recover control, still present.
  • Permanent removal needs the name typed exactly, validated server-side against the record (not a hidden form field), then removes the Clerk org, the Tenant document and every tenant-scoped collection's rows.
  • Partners see no suspend/delete/recover/remove control, and every one of those actions carries its own requireStaff() — the actions, not the route, are the boundary.
  • upsertFromClerk refuses a deleted tenant instead of inserting a duplicate.
  • lifecycle.status: "deleted" and isDeleted: true move together — asserted in lifecycle.test.ts, including the "never one without the other" case across all four actions.
  • demo-reset unchanged in behaviour; its own tests are untouched and are the proof.

Notes for Verify

  • Two CI notes, both recorded on the PR already.

  • Vercel – agentic-interface is failing and it is not this PR's. Its build log reads The specified Root Directory "apps/agent" does not exist — it fails at clone, before any code is compiled. apps/agent exists on no branch (git ls-tree origin/main apps/ lists seven apps, none of them agent), and the project first appeared on this PR between the Define push (11:03, not present) and the Build push (11:24). There is no in-repo fix: creating apps/agent would be inventing an app. The fix is in Vercel project settings — repoint or remove the project. It is currently a blocking check, so it will block Ship's merge gate too.

  • The apps/web proxy now does a tenant read on every authenticated request with an org. A projected findOne on the unique clerkOrgId index. The existing onboarding gate already does the same read but only for setup-capable roles; this one cannot be narrowed that way, because the question is whether the workspace may be used at all. Worth a look if middleware latency matters more than I've assumed.

  • The deleted-tenant reads go through the raw driver, not the model. softDeletePlugin hooks pre(/^find/), so a deleted tenant is invisible to every mongoose query — that is the plugin working correctly, and it is why these reads go under it. All raw access is behind one structurally-typed RawTenantCollection in tenant/index.ts; nothing else in the service touches Tenant.collection.

  • listDeletedForConsole does not use findPaginated. That helper's model type is a mongoose Query, and the whole reason this read exists is that a mongoose query cannot see these rows. It returns the canonical PaginatedResult envelope, which is what the convention protects.

  • The migration has not been run. The sandbox cannot reach the database; db-migrate.yaml applies it on merge to main. Both directions were written to be symmetric and re-runnable — down un-stamps only the rows up stamped (status active, no changedBy), so a tenant suspended or deleted through the console since is left alone.

  • Not covered by tests: everything that needs Mongo or Clerk — the purge, the raw reads, the Clerk org create/delete, and the proxy gate. Only the unit tier is configured (CONVENTIONS.md → Testing), so those are the operator smoke pass's on the preview.

  • GitHub Actions did not fire on either push to this branch. No workflow run was created for 2140db7 or fd556cc — Vercel reacted to both within seconds, Actions produced nothing, and the same git push had triggered runs earlier at 11:03. Both Quality runs above were obtained by workflow_dispatch on the branch, which is why they are attributed to a manual trigger and why Detect changed paths reads skipped (dispatch has no base to diff, so the full path ran — nothing was short-circuited). The consequence for Ship: the PR head carries a Quality Project check from a dispatched run, not from the pull_request event. If branch protection requires the pull_request-event check specifically, it will not be satisfied by these runs.

  • A Husky/CI formatting disagreement, worth its own chore if it recurs. 2140db7 failed prettier --check on the lifecycle migration even though Husky's lint-staged had run prettier --write over that exact file in the same commit (same prettier 3.8.3 locally and in the lockfile; the committed blob matched the working tree). It could not be reproduced here — block-local-checks.sh refuses prettier, correctly — so rather than guess-and-retry the call was rewritten to have only one possible shape (the BACKFILLED const). That fixed the symptom. If a Husky-formatted file fails CI formatting again, the pre-commit hook is not doing what the conventions promise and that is a repo-level bug, not a per-PR one.

04_verify/output/verify.md

Verify: tenant-lifecycle

  • ci: RED, settled via ci-status.sh after each push. Every GitHub Actions check passes — Quality Project among them, on ff8281a (the commit carrying the code) and on every .icm/**-only commit since. The single failing status is Vercel – agentic-interface, which is not this PR's (see Findings). The stage is therefore handed over on a RED verdict with a named cause, not on a green one. See "CI, honestly" below. The exact handover SHA is named in the hand-off message rather than here, because writing this file moves the head.
  • previews smoked: the code head is ff8281a, where both apps this change is visible in built successfully — console https://tenant-management-git-claude-pipeline-tenant-l-9df233-sustentus.vercel.app and web https://web-git-claude-pipeline-tenant-lifecycle-g3om2l-sustentus.vercel.app. Every commit after ff8281a touches only this file, so turbo-ignore correctly skipped tenant-management, demo, docs, marketing and storybook on them. Those skips are recorded as skips, not quoted as passes; the operator smoke below is performed against the ff8281a previews, whose application code is byte-identical to the head.
  • production-readiness: run — 4 findings (1 confirmed perf defect, 2 correctness/UX, 1 delivery). All fixed on branch except the delivery one, which is Ship's by contract.
  • code-review: high (spec complexity: complex); the CI Claude review is off (Review diff against CONVENTIONS.md concluded skippedENABLE_CLAUDE_REVIEW is not true), so the pass was run here as the contract's step 4 requires. 8 findings — 7 fixed on branch, 1 accepted with evidence.
  • security-review: run — the diff touches auth, route policies and a destructive path. No HIGH or MEDIUM findings. Detail below.
  • playwright: TODO — manual DoD smoke performed instead.

CI, honestly

RESULT: RED on every head of this branch, caused each time by exactly one status: Vercel – agentic-interface. Its build log reads The specified Root Directory "apps/agent" does not exist. — and apps/agent exists on no branch of this repository (git ls-tree origin/main apps/ lists seven apps, none of them agent). The project was connected to this repo between the Define push and the Build push. There is no in-repo fix: creating apps/agent would be inventing an app. It is a deterministic configuration error, not a flake, so no re-run was spent on it. One standing-down comment is on the PR.

Everything that is this PR's is green. Quality Project (format · lint · typecheck · test) concluded success on ff8281a — the commit carrying the code — and again on the .icm-only commits after it; Project run labels, Migrate preview database and Audit database likewise. Migrate preview database passing is real evidence the new migration's up applies cleanly to a live database — it has still never been run anywhere else.

Quality Project on ff8281a is the run that matters for the code: everything after it is an .icm/**-only diff, which the workflow short-circuits to success by design, so those greens say nothing about lint or tests. All are green, so nothing rests on the distinction — but the code verdict is ff8281a's.

Two CI observations from Build have cleared: GitHub Actions fired on its own for these pushes (no workflow_dispatch needed), and Vercel – tenant-management built on ff8281a, so the console preview is real for the code rather than skipped as it was on ad65b32.

DoD smoke (on the preview — each line says who verified it)

Agent-run (code paths traced end-to-end in the diff; no preview credentials, so nothing signed-in is claimed here):

  • AC-11 upsertFromClerk refuses a deleted tenant instead of inserting a duplicate — traced: raw pre-check under softDeletePlugin (tenant/index.ts), and the refusal is now classified as an expected failure in apps/web/lib/tenant.ts so it renders as "no tenant" rather than a 500 (agent)
  • AC-12 lifecycle.status: "deleted" and isDeleted: true cannot disagree — single writer buildLifecycleUpdate, unit-tested in lifecycle.test.ts (agent)
  • AC-13 demo-reset unchanged after the purge extraction — its test files are not in the diff (git diff --name-only origin/main...HEAD confirms), and RESET_NARROWING reproduces the SEEDED_USER carve-out exactly (agent)
  • AC-3 (half) creation refuses without a valid first-admin email — zod .email() before any write; Clerk-first ordering means a Clerk failure writes nothing at all (agent)
  • AC-10 (half) a partner sees no suspend / delete / recover / permanent-removal control — the Lifecycle card is inside {staff ? …} on the detail page, /tenants/deleted(.*) is in isStaffRoute, both deleted pages call requireStaff() server-side, and every lifecycle action carries its own requireStaff() so a direct POST is refused independently of the route gate (agent)
  • /workspace-unavailable is public, outside the app shell, and re-reads status so a reactivated tenant's user on a stale link is sent home (agent)

Operator-demonstrated — NOT YET DONE. These are the gate. Every one of them needs a signed-in session on the preview, which the agent does not have:

  • AC-1 staff creates a tenant: Clerk org + Tenant document exist, the named person holds a pending admin invitation, the tenant is in the directory (operator)
  • AC-2 partner creates a tenant; it carries their Clerk user id in partnerId and appears in their directory and no other partner's (operator)
  • AC-4 suspend → badged suspended for both audiences; reactivate → active (operator)
  • AC-5 a suspended tenant's user signing in to apps/web lands on /workspace-unavailable, not their dashboard (operator)
  • AC-6 delete → gone from both directories, on the staff deleted list with days remaining, users turned away from apps/web (operator)
  • AC-7 recover inside 30 days → active, back in the directory, org/members/data intact (operator)
  • AC-8 a tenant past 30 days shows expired and offers no Recover, and is still there (operator)
  • AC-9 permanent removal requires the name typed exactly, refuses a direct POST with a wrong or absent name, and afterwards the Clerk org, the Tenant document and every tenant-scoped collection's rows for that tenant id are gone (operator)
  • AC-10 (other half) a partner POSTing a lifecycle action id directly is refused (operator)
  • auth: admin, CSM, SDM, expert, vendor, customer still sign in and reach their dashboards — the new proxy gate runs on every authenticated request (operator)
  • payments: not touched (no payment surface in the diff) (operator)
  • notifications: none expected — the spec puts them out of scope and no notification call was added anywhere in the diff (agent, from the diff)
  • explain() on the preview database for findLifecycleStatusByClerkOrgId — confirm it uses clerkOrgId_1 and is not a COLLSCAN. Nothing in CI can catch this; see finding 1 (operator)
  • walkthrough clip posted to the feature thread in #build (operator)

Findings & cleanup

Fixed on branch (all in ff8281a):

  1. Collection scan on every authenticated apps/web request. schemaPlugin sets { locale: "en", strength: 2 } on the schema and mongoose stamps it onto clerkOrgId_1 (from unique: true). The two new raw-driver reads carried no collation, so they ran under the collection's default (simple) collation — and MongoDB will not use a collated index for a string comparison under a different one. findLifecycleStatusByClerkOrgId is called from apps/web/proxy.ts on every authenticated request with an orgId, all roles, all paths, /api/* included. Both reads now pass the collation explicitly. The _id and isDeleted raw reads need none, and the migration's index is correctly uncollated to match them.
  2. upsertFromClerk's deleted-tenant refusal 500'd. The new throw was not matched by isExpectedTenantFailure, so getTenantOrNull rethrew and the documented "backstop for every other path" became an error page. Now classified, with its own log reason.
  3. Recovering a tenant whose Clerk organisation is gone. Permanent removal deletes the organisation first, so an interruption leaves a Tenant document with no organisation — and the row went on offering Recover, which would restore a workspace nobody can sign in to over data that may be partly purged. recoverTenant now reads the organisation and refuses. The notice states the fact, not the cause: an organisation deleted straight from Clerk's dashboard produces the same 404.
  4. A recovery landing mid-removal was reported as "removed". If a staff recovery lands between the Clerk delete and removeForConsole, the result is a live tenant with no organisation, reading Active — and the action said "removed". It now distinguishes the two cases and says which happened.
  5. The permanent-removal doc comment asserted an order the code does not run. It claimed data → org → document; the code runs org → (data, document). Rewritten to describe the real order and why identity leads (while the organisation lives, a member signing in can have upsertFromClerk write a fresh Tenant document over the purge in progress).
  6. The lifecycle gate threw in middleware. An unguarded database read on every authenticated request turned a Mongo blip into a 500 across the whole platform. It now fails open and logs. This is a deliberate posture choice and it is reversible — flag it at the gate if you want the opposite: suspension is administrative rather than a security boundary, and with the database down there is nothing behind the gate to reach.
  7. Lifecycle writes left updatedAt stale. setLifecycle writes through the raw driver, which knows nothing of timestamps: true, so a suspension or deletion did not bump updatedAt — the field anything syncing or caching tenants would key on. Now stamped in buildLifecycleUpdate, with a test.
  8. setPartner's result was discarded on create. A failed assignment left a partner a tenant they cannot see, cannot fix (assignPartner is staff-only) and cannot retry without minting a second organisation under a slug the first one now holds. The result is read and reported. Not restructured into createForConsole: AC-2 requires the write go through tenantService.setPartner, and the tenant and its organisation are both real and correct, so rolling back would destroy good work to hide a missing field.
  9. slugify trimmed hyphens before the 50-character slice, so a long name could yield a trailing-hyphen slug that Clerk refuses — surfaced to the operator as "another tenant already uses this name", which was false. Trimmed after the slice now.
  10. runLifecycle's stale comment was wrong for two of four actions. suspend and reactivate both resolve through the live filter, so asking for the state a tenant is already in matches and re-stamps lifecycle.changedBy/changedAt rather than being a no-op. Harmless, but the comment claimed "nothing was written". Both comments corrected.

Accepted, with the reason:

  • The deleted list sorts legacy dateless rows first. Ascending on lifecycle.deletedAt puts rows missing the field ahead of everything, which would push the tenants nearest their deadline off page one — the opposite of the sort's stated purpose. Accepted because that population is provably empty: no code path anywhere soft-deletes a Tenant document today (the softDelete methods belong to location, skill, taxonomy and tenant-setting; none to Tenant), and every row this feature deletes gets a deletedAt from buildLifecycleUpdate. Fixing it properly means an aggregation pipeline that gives up the index, to order a set that has no members. Worth revisiting only if a tenant is ever soft-deleted by some other route.

Needs a decision outside this PR — neither is code:

  • Vercel – agentic-interface must be repointed or disconnected. Its Root Directory is apps/agent, which exists on no branch. It is a blocking status on every PR in this repository, this one included, so it blocks the merge gate until someone fixes the Vercel project settings. Nothing in the repo can fix it.
  • The migration has never been executed anywhere. db-migrate.yaml applies it on merge to main, and Migrate preview database proves up works against the preview database — but the first production run will be the merge itself. down is symmetric and precise (the readiness audit tried to break it and could not): its BACKFILLED guard is exactly up's write set, and no code path writes status: "active" without a changedBy, so a tenant created or reactivated through the console is untouched by a rollback.

Ship's, by contract, not skipped here: the readiness audit flagged no apps/docs/** or apps/help/** change. This is user-visible on both counts (a new /workspace-unavailable page for end users; create/suspend/delete/recover/remove for the console). .icm/stages/05_ship owns docs-sync and changelog-entry — they belong to the next stage, in this same PR.

Security review — no HIGH or MEDIUM findings

Run against the diff because it touches auth, route policies and a destructive path. What was checked and held:

  • Authorization is layered, not single-point. Every lifecycle mutation is requireStaff(); both deleted pages call requireStaff() server-side as well as being behind isStaffRoute; createTenant is requireViewer() by design (both audiences create) and takes the partner id from viewer.userId, never from the form.
  • The partner boundary is structural. buildConsoleDeletedTenantFilter takes no ConsoleTenantScope at all, so a partner-visible deleted read cannot be constructed.
  • No NoSQL injection surface. Every tenantId passes Types.ObjectId.isValid before becoming an ObjectId; clerkOrgId comes from the Clerk-verified session, not from input; the directory search is escapeRegex'd; page is clamped to a positive integer.
  • No XSS. Tenant names render as JSX text nodes; /workspace-unavailable echoes nothing; no dangerouslySetInnerHTML anywhere in the diff.
  • No secrets, no new env vars. The diff adds no process.env read, so turbo.json's globalEnv needs nothing (confirmed by grepping the whole diff).
  • The one posture change is finding 6 above, declared rather than buried: the lifecycle gate now fails open on a read error.

Note on method: the /security-review and /production-readiness passes both ran; the security analysis was performed directly against the code rather than fanned out to sub-agents, and the file-and-line claims above were each re-read in the source.

Context budget: over. The Inputs table was read as specified, but the two review passes pulled in apps/console/lib/console-tenants.ts, apps/web/lib/tenant.ts, packages/services/src/db/plugins/schema.ts, apps/console/proxy.ts and several console pages to confirm findings — reporting a finding without reading the line it names is how a review invents defects.

05_ship/output/changelog.md


title: Create, suspend and end a tenant from the tenant management console date: 2026-08-27T13:00:00Z personas: [admin] slug: tenant-lifecycle pr: https://github.com/sustentus/sustentus/pull/891

Create, suspend and end a tenant from the tenant management console

Until now a tenant could only be found and read in the console — bringing one into existence happened elsewhere, and there was no way to pause or close one at all. The whole life of a tenant now happens here.

Creating one takes a name and an email address for its first administrator. The workspace and the invitation are made together, and nobody else can get in until someone with access invites them. Both audiences can create: the Sustentus team, and a partner provisioning a workspace for their own client. A partner's new tenant is assigned to them as it is created, so it appears in their directory straight away.

Suspending closes a workspace to its people without removing anything. The tenant stays in the directory, badged as suspended, and its users are turned away from the platform with a page that tells them their workspace is unavailable and points them at whoever set the organisation up — it never says why, because that is a conversation between Sustentus and the account, not a notice on a screen. Reactivating is one click, and everything is where it was.

Ending a tenant is two steps, on purpose. Deleting it removes it from the directory and turns its people away, but destroys nothing: it moves to a Sustentus-only list of deleted tenants showing how many of its 30 days are left, and recovering it inside that window brings back its workspace, its people and all of its data exactly as they were. After 30 days it is still there — nothing is ever removed unattended — the list simply stops offering to recover it.

Permanent removal is the only irreversible step, and it asks you to type the tenant's name before it will proceed. Afterwards the workspace, its people and every piece of its data are gone.

Suspending, deleting, recovering and permanently removing are the Sustentus team's alone. Partners administer the tenants assigned to them; whether a client still has a workspace is not their decision.

05_ship/output/investor-update.md

Partners can now provision a client's workspace themselves

Who it's for: Sustentus team, vendor partners What shipped: Creating, suspending, deleting with 30-day recovery, and permanently removing a tenant — all from the console. Why it matters: Partners finally hold the provisioning motion they were invited for. Establish Product-Market Fit with Vendor Partners, Scale the Bridge.

Dig deeper: https://github.com/sustentus/sustentus/pull/891 · https://help.sustentus.com/changelog/2026-08-27-tenant-lifecycle

05_ship/output/release.md

Ship: tenant-lifecycle

  • pr: #891 · merge: authorised in conversation, not by the box — Jamie instructed "pipeline ship tenant-lifecycle ignore the red ci for agentic interface. merge if everything else is green". The Ready to merge checkbox was left unticked and was not ticked by the agent; this line is the record of where the authorisation actually came from.
  • CI: not GREEN — merged on an explicit, named override. Quality Project (format · lint · typecheck · test), Project run labels, Migrate preview database, Audit database and every Vercel preview belonging to this repository are green on the merged head. The one failing status is Vercel – agentic-interface, whose Root Directory is apps/agent — a directory that exists on no branch of this repository. It is a Vercel project misconfiguration that blocks every PR here, is not fixable from the repo, and Jamie directed that it be ignored for this merge. Ship's contract otherwise requires a settled GREEN; that requirement was waived by the gate-holder, not met.
  • technical docs: technical/applications/console — Routes table (/tenants/new, /tenants/deleted, /tenants/deleted/:id/remove), the Access model's staff-matched-routes line (now two), and a new Tenant lifecycle section covering the states, who holds each action, reading deleted tenants under softDeletePlugin, permanent removal's ordering, and enforcement in apps/web. No impact on technical/applications (the apps/web and console summaries there are still accurate at their granularity) or technical/packages/services (its source structure is directory-level; no new top-level directory, and it documents neither the tenant service nor demo-reset).
  • business docs: business/platform-overview — a Managed lifecycle bullet under Multi-Tenancy, which previously described a tenant as a permanent isolated instance. No impact on business/feature-role-matrix (its seams are the six platform roles; console audiences are explicitly not those, and no platform role's capabilities changed) or business/roles (no persona capability changed — a suspended tenant is a tenant state, not a role permission).
  • release notes: both — user-facing on both counts (a new /workspace-unavailable page for end users; create/suspend/delete/recover/remove for the console).
  • sent: ship note queued — ship-note.yaml fires on this merge and emails 05_ship/output/investor-update.md to #product-update. Dig deeper carries the real PR and changelog URLs; no placeholder.
  • close-out: archive .icm/runs/tenant-lifecycle/ to apps/docs/archive/pipeline-runs/. The tenant-management-app epic has live siblings (tenant-activity-dashboard, tenant-notification-centre, tenant-notification-email), so the intake folder stays.

Acceptance check (vs spec)

Ticked = established. Unticked = implemented and traced in the diff, but never demonstrated on a preview — the Verify stage's operator smoke was not performed before this merge, and these lines say so rather than implying a walkthrough that did not happen.

  • lifecycle.status: "deleted" and isDeleted are written and cleared together — single writer buildLifecycleUpdate, pinned by lifecycle.test.ts (Verify, unit tier)
  • upsertFromClerk refuses a deleted tenant instead of inserting a duplicate — traced in Verify; the refusal is classified as an expected failure so it renders as "no tenant"
  • demo-reset behaves exactly as before after the purge extraction — its test files are not in the diff, and RESET_NARROWING reproduces the SEEDED_USER carve-out exactly
  • Creation refuses without a valid first-admin email, and a Clerk failure part-way leaves no Tenant document whose organisation does not exist — zod .email() before any write; Clerk-first ordering with compensation
  • A partner sees no suspend / delete / recover / permanent-removal control (UI half) — the Lifecycle card is staff-gated, /tenants/deleted(.*) is a staff route, both deleted pages call requireStaff() server-side, and every lifecycle action carries its own requireStaff()
  • A Sustentus person creates a tenant; org + document exist, admin invitation pending, tenant in the directory — not demonstrated on a preview
  • A partner creates a tenant and it carries their Clerk user id in partnerId via tenantService.setPartnernot demonstrated on a preview
  • Suspend badges in both directories; reactivate returns it to active — not demonstrated
  • A suspended tenant's user lands on /workspace-unavailablenot demonstrated
  • Delete removes it from both directories, lists it with days remaining, turns its users away — not demonstrated
  • Recovery inside 30 days restores org, members and data — not demonstrated
  • A tenant past 30 days shows expired and offers no Recover — not demonstrated
  • Permanent removal requires the typed name, refuses a direct POST without it, and empties every tenant-scoped collection — not demonstrated
  • A partner POSTing a lifecycle action id directly is refused — not demonstrated

Carried into production unverified

  • The migration 1788240000000-tenant-lifecycle has run only against the preview database (Migrate preview database green). This merge is its first production execution. down is symmetric and its BACKFILLED guard is exactly up's write set, so a rollback un-stamps only what the backfill stamped.
  • findLifecycleStatusByClerkOrgId runs on every authenticated apps/web request. Verify fixed the missing collation that would otherwise have made it a collection scan; the explain() that would confirm it now uses clerkOrgId_1 was never run. Worth doing against production early.

Context budget: over. The Inputs table was read as specified; docs-sync additionally required technical/applications/{page,console}, technical/packages/services, business/{platform-overview,feature-role-matrix,initiatives,okrs/2026-Q2} and an existing changelog entry to match voice and frontmatter.