tenant-lifecyclerun.md02_define/output/spec.mdThe 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.
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.
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.
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.
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):
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.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 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.
admin invitation to that organisation, and the tenant appears in the directory.partnerId — written through tenantService.setPartner, not a second
write path — so it appears in that partner's directory and no other partner's.apps/web is redirected to the
workspace-unavailable page instead of their dashboard.apps/web.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.tenant-plan-visibility's, already shipped [Q-8].tenant-activity-dashboard, the stub that depends on this one [Q-10].isDeleted in favour of the lifecycle field across the rest of the platform.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.md2140db7 feat: tenant-lifecycle — create, suspend, delete with 30-day recovery,
permanent removal · fd556cc fix: formatting on the lifecycle migration (+ these notes)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.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./tenants/new — creation for both audiences; a partner's tenant is assigned to them through
tenantService.setPartner, the write partner-assignment already owns./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".lib/tenant-availability.ts + proxy.ts: a suspended or deleted tenant's users are redirected
to /workspace-unavailable before the route policy runs.admin invitation, appears in the directory.setPartner, not a second write path./workspace-unavailable in apps/web.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.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.mdci-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.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.complexity: complex); the CI Claude review is off
(Review diff against CONVENTIONS.md concluded skipped — ENABLE_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.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.
Agent-run (code paths traced end-to-end in the diff; no preview credentials, so nothing signed-in is claimed here):
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)lifecycle.status: "deleted" and isDeleted: true cannot disagree — single
writer buildLifecycleUpdate, unit-tested in lifecycle.test.ts (agent)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).email() before
any write; Clerk-first ordering means a Clerk failure writes nothing at all (agent){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:
admin invitation, the tenant is in the directory (operator)partnerId and appears
in their directory and no other partner's (operator)apps/web lands on
/workspace-unavailable, not their dashboard (operator)apps/web (operator)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)#build (operator)Fixed on branch (all in ff8281a):
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.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.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.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.upsertFromClerk write a fresh Tenant document over the purge in progress).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.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.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.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:
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.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.
Run against the diff because it touches auth, route policies and a destructive path. What was checked and held:
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.buildConsoleDeletedTenantFilter takes no
ConsoleTenantScope at all, so a partner-visible deleted read cannot be constructed.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./workspace-unavailable echoes
nothing; no dangerouslySetInnerHTML anywhere in the diff.process.env read, so turbo.json's
globalEnv needs nothing (confirmed by grepping the whole diff).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.mdUntil 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.mdWho 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.mdQuality 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/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/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)./workspace-unavailable page for end
users; create/suspend/delete/recover/remove for the console).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..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.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.email() before any write; Clerk-first
ordering with compensation/tenants/deleted(.*) is a staff route, both deleted pages call
requireStaff() server-side, and every lifecycle action carries its own requireStaff()partnerId via
tenantService.setPartner — not demonstrated on a preview/workspace-unavailable — not demonstrated1788240000000-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.