Skip to Content

← All archived runs

Run: tenant-user-management

run.md

Run: tenant-user-management

  • branch: claude/tenant-user-management-pipeline-6m3wmo
  • pr: #889

02_define/output/spec.md

Spec: Tenant user management

  • slug: tenant-user-management
  • personas: Admin (Sustentus team), Partner
  • touches: apps/console
  • complexity: standard

Problem

tenant-directory (#887) made a tenant's people visible and nothing more — the Users card on /tenants/:id is a read-only table by design, with the write half deferred to this stub. Yet user management is the one administrative power partners were explicitly granted [Q-5], and the commonest reason either audience opens a tenant at all. Today a partner who spots a wrong role, a departed employee, or a colleague who needs access has to ask Sustentus to go into the tenant's own platform admin on their behalf — which is exactly the dependency the console exists to remove. Until partners can administer their assigned tenants without Sustentus in the loop, Scale the bridge / Establish product-market fit with vendor partners (2026-Q2, Objective 1) has no partner-operable surface behind it.

Proposed change

The tenant detail view's Users card becomes operable. Both audiences — Sustentus staff on any tenant, a partner on their assigned tenants [Q-3] — can invite a user, revoke a pending invitation, remove a member, and change a member's role, from /tenants/:id in apps/console.

One record, not a copy. Every operation is a write to the tenant's Clerk organisation, which is authoritative for who belongs and in which role — the same record readTenantMembers already reads live. The platform's existing organizationMembership.created|updated|deleted webhook mirrors each change onto the Mongo user collection unchanged; this run adds no sync path, touches no webhook, and writes no user document itself. A change made in the console is therefore what the tenant's own platform sees, on its next request.

The assignable roles are the platform's, used as-is. admin, csm, sdm, expert, vendor, customer — the six in-tenant roles, written in Clerk's org:<role> form exactly as apps/web writes them. sales is excluded: it is a Mongo-only system role and is never offered here. Granting or revoking tenant administration is simply assigning or unassigning admin; no new role, no flag, no claim. The console's assignable set derives from USER_ROLES in @sustentus/services/shared minus sales, declared once, so a future role added to the platform does not silently go missing here.

Pending invitations are part of the roster. A Clerk organisation invite is pending until accepted, so the Users card gains a second section listing pending invitations — email address, role, invited date — each revocable. Without it an invite is fire-and-forget: a wrong address or wrong role could not be withdrawn from this app, and "remove a user" would not reach someone who had not accepted yet. This mirrors what the console already does for its own users on /access.

The last-admin invariant. A tenant can never be left with no admin-role member by an action of this app — the last admin cannot be removed, and cannot be moved off admin. The count is taken from Clerk with a role-filtered membership query, not from the displayed roster page: that page is capped at TENANT_MEMBER_PAGE_LIMIT (100) and counting from it would misjudge a large tenant. The guard runs before any write, so a blocked action leaves Clerk and the Mongo mirror both intact. It is scoped to this app's actions, exactly as the stub sets it: the platform's own in-tenant user management remains the second door and carries its own equivalent guard.

Partners get the same operations, on their own tenants only. No power-set carve-out — a partner may grant and revoke admin on an assigned tenant like any other role [Q-5]. Scoping is unchanged from tenant-directory: every action re-resolves the tenant through tenantService.findForConsole(scopeOf(viewer), tenantId), so a partner acting on a tenant that is not theirs is indistinguishable from acting on one that does not exist.

Where the code goes

  • Server actions in app/(console)/tenants/[tenantId]/actions.ts, following the shape app/(console)/access/actions.ts already sets: "use server", Zod-parsed FormData, outcome codes returned in the query string rather than free text (nothing a caller typed is echoed back into the page), revalidatePath on the tenant, redirect home.
  • requireViewer() first in every action, then the scoped tenant re-resolution above — the app's double-lock rule (apps/console/AGENTS.md): the proxy is the outer lock, never the only one. requireStaff() is deliberately not used here; this is the one power both audiences hold.
  • Clerk calls: createOrganizationInvitation (no inviterUserId — the console viewer is org-less and not a member of the tenant org, and the parameter is optional), getOrganizationInvitationList (status: ["pending"]), revokeOrganizationInvitation, updateOrganizationMembership, deleteOrganizationMembership. The invitation carries a redirectUrl into the platform, not the console — an invited tenant user is a tenant user.
  • The roster read stays where it islib/console-tenants.ts gains the pending-invitation read and the admin-count helper beside readTenantMembers, so one module owns the console's view of a tenant's people.
  • Remove means remove from the tenant, never delete the Clerk account: the person keeps their identity and loses this organisation. Deleting a platform-wide account from a tenant screen would be the wrong blast radius, and the console never does it.
  • Every action is logged as a structured console.info("[console-user-management] …", { … }) line — actor, tenant, target, before/after role — following the house [tag] event { … } convention apps/web uses for role assignment. There is no activity trail to write to yet; tenant-activity-dashboard (stub 7) introduces it and will read these actions from a real store.
  • No new environment variable, no schema change, no migration. Clerk credentials and MONGODB_URI are already provisioned on the console's Vercel project.

Acceptance criteria

  • A Sustentus staff member can invite a user to any tenant by email address and role; the invitation appears in that tenant's pending list and the person receives Clerk's invitation email.
  • A partner can invite, revoke an invitation, remove a member, and change a member's role on a tenant assigned to them, and gets the same not-found response as for a non-existent tenant on any tenant that is not.
  • A role changed in the console is the role the tenant's own platform sees — the change is written to the tenant's Clerk organisation membership, and no user document is written by this run's code.
  • admin can be granted and revoked from the console like any other role, by either audience, and takes effect in the tenant's platform session.
  • sales is not offered in the role picker for invite or role change, and an action naming it is rejected by the schema.
  • Removing the tenant's only admin-role member is refused with a plain message, and the membership is left untouched.
  • Changing the tenant's only admin-role member to any other role is refused with a plain message, and the role is left untouched.
  • The last-admin count is taken from a role-filtered Clerk query, so the guard holds on a tenant with more members than the roster page shows.
  • The Users card lists pending invitations separately from accepted members, with email, role and invited date, and each can be revoked; a revoked invitation's link stops working.
  • Removing a member removes them from the tenant's Clerk organisation and leaves their Clerk user account in place.
  • A tenant whose Clerk organisation is missing still renders its detail page, with the user actions unavailable rather than the page erroring — the null case readTenantMembers already returns.
  • Unit tests cover the assignable-role set (six roles, sales absent) and the last-admin guard (refuses on a count of one, permits on two, for both removal and role change).

Out of scope

  • Per-user permission exceptions, extra-role grants, and role-template editing — this is membership and role, nothing finer (stub).
  • Managing the console's own users, Sustentus staff and partners — that is tenant-app-access, already shipped at /access.
  • Assigning sales from the console — it stays a Mongo-only system role (stub).
  • Deleting a Clerk user account, or any change to a person's identity outside this tenant.
  • Any change to the platform's org roles, session-token claims, Clerk webhook sync, or any surface in apps/web — including the platform's own in-tenant user management, which keeps its own last-admin guard (apps/console/AGENTS.md).
  • Writing partnerId, tenant lifecycle actions, and the plan / seats / subscription panel — partner-assignment, tenant-lifecycle, tenant-plan-visibility.
  • The persisted activity trail and the landing dashboard — tenant-activity-dashboard. This run logs its actions structurally; it does not build the store.
  • Notifying anyone that a user was invited, removed, or re-roled, beyond Clerk's own invitation email [Q-12].
  • Paginating or searching the tenant's user roster — the 100-member cap and its "this tenant may have more" note are inherited from tenant-directory unchanged.

Open questions

  • none. The one that affected what gets built — whether pending invitations are shown and revocable — was put to the author and settled on 2026-08-26: show them, with revoke. Everything else was settled by the stub and scope.md [Q-3, Q-5, Q-6, Q-12].

03_build/output/notes.md

Build notes: tenant-user-management

  • commits: feat: tenant-user-management — invite, remove and re-role a tenant's users
  • ci: GREEN on b2872f6 — every blocking check and status, all seven Vercel previews included

What changed

  • apps/console/lib/console-roles.ts (new): the console's view of the platform's role model, kept pure — no Clerk, no Next. CONSOLE_ASSIGNABLE_ROLES is derived from USER_ROLES by filtering sales out, so a role added to the platform cannot go missing from the picker; wouldStrandTenant is the last-admin invariant as a single predicate covering removal and demotion alike. Split out of console-tenants.ts deliberately: that module imports @clerk/nextjs/server at module scope, which the node-environment unit tier cannot load, and the two rules the spec asks to be tested are exactly the ones with no Clerk in them.
  • apps/console/lib/console-tenants.ts: gains readTenantInvitations (pending invitations, null on a missing organisation, same as the roster read) and countTenantAdmins (role-filtered Clerk query, limit: 1 + totalCount). readTenantMembers now returns the bare role rather than a formatted label, so the role picker and the column beside it read from one value.
  • apps/console/app/(console)/tenants/[tenantId]/actions.ts (new): the four server actions. Each starts at requireViewer() — not requireStaff(), because user management is the one power both audiences hold — then re-resolves the tenant through findForConsole(scopeOf(viewer), …), so authorization is established at the server rather than inferred from the form the caller was shown.
  • apps/console/app/(console)/tenants/[tenantId]/page.tsx: the Users card becomes operable — invite form, members table with a role picker and Remove, a separate pending-invitations table with Revoke, and a notice banner. All of it is gated on the roster read being non-null, so a tenant whose Clerk organisation is missing still renders with the actions absent rather than erroring.
  • apps/console/{vitest.config.ts,package.json} + turbo.json: apps/console had no test tier. Added one mirroring apps/web's — same sharedTestConfig, same services/src alias, and the matching @sustentus/console#test turbo entry so a services change invalidates the cached result.

Decisions worth knowing

  • No inviterUserId on the invitation. Console staff and partners are org-less by design and hold no membership of the tenant they administer, so there is no member to attribute the invitation to. The parameter is optional in Clerk's backend SDK (CreateOrganizationInvitationParams), which is what makes this work at all.
  • No redirectUrl either, so Clerk's instance default applies and the invitee lands on the platform. The spec asked for the link to point at the platform rather than the console; taking the instance default gets that without introducing a new environment variable, and it is what apps/web's own inviteUser already does.
  • The last-admin count never comes from the roster. readTenantMembers is capped at 100, so on a larger tenant a count taken from it would be a guess — and this is the one guard where a wrong number strands a tenant with no administrator.

Acceptance criteria status

  • Staff can invite to any tenant by email and role — inviteTenantUser; the invitation lands in the pending table and Clerk mails it.
  • A partner can do all four on an assigned tenant, and gets not-found on any other — every action re-resolves through findForConsole(scopeOf(viewer), …).
  • A role change is written to the tenant's Clerk organisation membership; no user document is written by this run's code — grep the diff: no userService call.
  • admin is granted and revoked like any other role, by either audience.
  • sales is absent from the picker and rejected by roleSchema.
  • Removing the only admin is refused, membership untouched — the guard runs before any Clerk write.
  • Moving the only admin off admin is refused the same way.
  • The count comes from a role-filtered Clerk query, not the roster page.
  • Pending invitations listed separately with email, role and invited date, each revocable.
  • Removal deletes the organisation membership only; the Clerk account survives.
  • A tenant with a missing Clerk organisation renders with the actions unavailable.
  • Unit tests cover the assignable-role set and the last-admin guard for both removal and role change — apps/console/lib/console-roles.test.ts.

Notes for Verify

  • apps/console has a test tier for the first time — checked, and it is real. The risk was that a mis-wired @sustentus/console#test resolves to an empty task graph and exits 0, the silent failure turbo.json's own comment warns about. Read back off the Quality Project job for b2872f6: @sustentus/console:testcache miss, executing, lib/console-roles.test.ts (15 tests), 15 passed, and turbo's summary moved from 3 test tasks to 4 successful, 4 total. Nothing left to confirm here.
  • Forms are plain server-action posts, no client JS. A role change is a two-step (pick, then "Save role") rather than an on-change submit. Deliberate for a console, but it is the thing to look at first in the preview smoke.
  • isDuplicate matches Clerk's 400/422 on an already-member or already-invited address. Clerk does not give a distinguishable code for it, so the status is what there is to match on; a different 400 would also read as "already invited".

04_verify/output/verify.md

Verify: tenant-user-management

  • ci: GREEN — settled via ci-status.sh after every push in this stage, the last one included; the PR's checks carry the verdict for whatever head Ship inherits. Deliberately not naming a SHA here: a commit that records its own SHA is always one behind itself. The last code-bearing commit is c93bf76 — everything after it is this record, so the console preview below is the code being handed over.
  • previews smoked: console (tenant-management) built for c93bf76https://tenant-management-git-claude-tenant-user-manag-961350-sustentus.vercel.app · all six other projects built for it too. Vercel skipped every project on the handoff head 3f8ed0c because that commit is .icm/**-only: git diff c93bf76..3f8ed0c -- . ':!.icm' is empty, so the preview above is the code being handed over. Recorded this way rather than quoting a skipped project as a green preview. See "Preview access" below — the deployment is behind Vercel SSO, so the agent could not reach the app itself.
  • production-readiness: run — 1 blocker (unpushed fixes) + 6 non-blockers; 3 fixed on branch, 3 carried to Ship, 1 recorded as a known limit
  • code-review: medium (spec complexity standard) — 5 findings, all 5 fixed on branch
  • security-review: run — no HIGH; 1 MEDIUM raised as a scope question, not a defect
  • playwright: TODO — manual DoD smoke performed instead

Preview access — why the functional smoke is entirely the operator's

The console preview built for c93bf76, but every route answers 302 → vercel.com/sso-api: the deployment carries Vercel deployment protection, and this session holds no Vercel credentials (get_access_to_vercel_url refused to mint a share link). So the agent could establish only that the deployment is live and protected — not a single signed-in screen. Every behavioural criterion below is therefore marked operator, and none of them is ticked yet.

This is the honest state, not a formality: the gate must not pass on unverified lines.

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

Agent-verified (by code-path trace over the diff and the unit tier, not on the preview):

  • A role changed here is written to the tenant's Clerk organisation membership and no user document is written by this run's code — demonstrated by absence: grep for userService / deleteUser across the feature's two modules returns nothing; the Mongo mirror moves only through the platform's existing organizationMembership.* webhook. (agent)
  • sales is absent from the role picker and rejected by the schema — the picker maps CONSOLE_ASSIGNABLE_ROLES, and roleSchema is z.enum over that same array; console-roles.test.ts asserts the set is the six and that isAssignableRole("sales") is false. (agent)
  • The last-admin count comes from a role-filtered Clerk query, not the roster page — countTenantAdmins passes role: ["org:admin"], limit: 1 and reads totalCount; the 100-row roster is never counted. (agent)
  • Removal deletes the organisation membership only and leaves the Clerk account in place — deleteOrganizationMembership is the sole call; no users.deleteUser anywhere in the feature. (agent)
  • Unit tests cover the assignable-role set and the last-admin guard for both removal and role change — 15 tests, and confirmed executed in the Quality job (@sustentus/console:test, cache miss, 15 passed), not resolved to an empty turbo graph. (agent)
  • The console preview deploys and is reachable (302 to Vercel SSO on every route, i.e. the app is served and protected). (agent)

Operator-demonstrated — all still pending, none may be ticked by the agent:

  • A Sustentus staff member can invite a user to any tenant by email and role; the invitation appears in that tenant's pending list and the person receives Clerk's invitation email. (operator)
  • A partner can invite, revoke, remove and re-role on an assigned tenant, and gets the same not-found answer as for a non-existent tenant on any other. (operator — needs a partner-marked account and an assigned tenant)
  • admin can be granted and revoked by either audience, and takes effect in the tenant's platform session — the cross-app half no code trace can show. (operator)
  • Removing the tenant's only admin is refused with a plain message and the membership is left untouched. (operator)
  • Changing the tenant's only admin to another role is refused the same way. (operator)
  • The Users card lists pending invitations separately with email, role and invited date, and each can be revoked; a revoked invitation's link stops working. (operator)
  • A tenant whose Clerk organisation is missing still renders its detail page with the user actions unavailable rather than erroring. (operator — needs a tenant row whose clerkOrgId points at a deleted org)
  • auth: a console staff account and a console partner account both still sign in and reach the directory. (operator)
  • payments: not touched by this diff. (n/a)
  • notifications: the only message this feature sends is Clerk's own invitation email — no Resend/Ably path is involved. Confirm the invite email actually arrives; it is the one delivery that can fail silently here. (operator)

Findings & cleanup

Fixed on branch — code review (commit 7bd8a28):

  • Role cell rendered blank for a member holding a role the console does not assign (Clerk's default org:member, or sales): a Radix Select whose value matches no option shows neither item text nor placeholder, so the column that exists to show the role showed nothing. The role is now text; the picker beside it selects a new role rather than pre-selecting the current one, which fixes the blank and removes the "submitting unchanged posts an unassignable role" path with it.
  • isDuplicate reported every Clerk 4xx as "already invited", so a blocked domain or unknown org role became a confident wrong answer with nothing logged.
  • revoke / remove / role-change had no error handling — acting from a stale page was a 500, not a notice. All four writes now share one helper.
  • readTenantMembers fell back to the membership id when publicUserData was absent, putting a non-user id into the action target and making that member permanently unmanageable. It is null now, and such a row renders without actions.
  • The notice lookup indexed a plain object with an unvalidated query param (?notice=__proto__ rendered an empty alert). It is a Map.

Fixed on branch — production readiness (commit c93bf76):

  • clerkRefusal swallowed 401/403/429 into a caller-facing notice, so a rotated or under-permissioned CLERK_SECRET_KEY, or a rate limit, would present to staff and partners as routine duplicate-invite noise. Those three now rethrow as faults.
  • readMembership and countTenantAdmins were the only Clerk reads in the feature not absorbing the missing-organisation 404 their neighbours handle — the exact 500 the "renders rather than erroring" criterion rules out, on the write path. Both answer null now; the membership read moved to console-tenants.ts beside the other reads.
  • ROLE_LABEL repeated the prototype-index defect the NOTICES fix had just removed: a role named constructor or toString returned an inherited function, which React throws on as a child, taking the whole members table down. It is a Map.

Known limit — recorded, not fixed (needs your call):

  • The last-admin guard is check-then-act with no lock. Two operators removing two different admins concurrently on a tenant with exactly two both read adminCount === 2, both pass the guard, and both writes land — zero admins, the state the invariant exists to prevent. It needs two simultaneous actors and a two-admin tenant, so it is narrow. But the spec's wording ("a tenant can never lose its last admin-role user through this app") is slightly stronger than the code delivers, and Clerk offers no transaction to close it. Options: accept and reword the claim, or re-count after each write and alert on a stranded tenant.

Raised by the security review — a scope question, not a defect:

  • A partner can invite themselves into an assigned tenant's Clerk organisation, at any assignable role including admin. Accepting that invitation makes them a member of the tenant org, which grants the tenant's platform surfaces. Every control behaves as specified — the invite power is exactly what Q-5 grants — but the scope draws the opposite boundary in as many words: "partners never enter tenant orgs", and out of scope, "any partner access inside tenant platform surfaces". Constraining who may be invited is a product decision, so nothing was changed here.

Carried to Ship (its stage owns both):

  • Docs drift, concrete: apps/docs/app/technical/applications/console/page.mdx:53 still reads /tenants/:id | Both | Read-only tenant detail, which this PR makes false; the prose below it describes the user list as read-only too. docs-sync at Ship.
  • No changelog entry — user-visible to both console audiences. changelog-entry at Ship.
  • Rollback is not symmetric and the ship note should say so: there is no migration, so reverting the commit is clean for the code — but every write this feature makes lands in Clerk, and a revert undoes none of it. Invitations already sent stay live, removed memberships stay removed, changed roles stay changed. The manual undo is the Clerk dashboard.

05_ship/output/changelog.md


title: Manage a tenant's users from the tenant management console date: 2026-08-27T10:00:00Z personas: [admin] slug: tenant-user-management pr: https://github.com/sustentus/sustentus/pull/889

Manage a tenant's users from the tenant management console

A tenant's user list in the console used to be something you could only look at. Now you can act on it. From a tenant's page you can invite someone by email address and role, revoke an invitation they haven't accepted yet, remove a member, and change a member's role. The Sustentus team can do this on any tenant; a partner can do it on the tenants assigned to them.

Invitations are listed on their own until they're accepted, with the email address, the role and the date they were sent, so a wrong address or a wrong role can be withdrawn rather than left outstanding. Removing someone removes them from that tenant only — their account and any other tenant they belong to are untouched.

The roles you can assign are the tenant's own: admin, CSM, SDM, expert, vendor and customer. A change made here is the role the tenant's own platform sees, so granting someone admin gives them tenant administration straight away. One rule is enforced for you: a tenant always keeps at least one admin, so its only admin can be neither removed nor moved to another role until someone else holds it.

05_ship/output/investor-update.md

Partners can now administer their own tenants' users

Who it's for: The Sustentus team, and vendor partners. What shipped: Invite, remove and re-role a tenant's users from the console — staff on any tenant, partners on theirs. Why it matters: Scale the Bridge — the first partner-operable surface behind Establish Product-Market Fit with Vendor Partners.

Reverting the code does not undo Clerk writes already made.

Dig deeper: https://github.com/sustentus/sustentus/pull/889 · https://help.sustentus.com/changelog/2026-08-27-tenant-user-management

05_ship/output/release.md

Ship: tenant-user-management

  • pr: #889 · merge: authorised — Ready to merge ticked by Jamie; this commit rides the squash
  • CI: GREEN — established via .icm/scripts/ci-status.sh on the head that merges, after this stage's last push. Deliberately not naming a SHA: this file rides in the commit whose head that is, so it cannot record it. The last code-bearing commit before this stage was c93bf76.
  • technical docs: apps/docs/app/technical/applications/console/page.mdx — the Routes table row for /tenants/:id no longer says "Read-only tenant detail", the directory prose points at the operable list, and a new Tenant user management section documents the Clerk-is-the-record model, the assignable-role derivation, pending invitations, the last-admin invariant (including that it is not transactional), the scoping/authorisation shape, and the asymmetric rollback.
  • business docs: no business docs impact — no page under apps/docs/app/business/** mentions the console; it is a Sustentus-side surface, outside the six-persona platform matrix.
  • release notes: both — changelog entry apps/help/app/changelog/2026-08-27-tenant-user-management/page.mdx (run copy at 05_ship/output/changelog.md) and the ship note at 05_ship/output/investor-update.md.
  • sent: none at the time of writing — the ship note rides this commit and .github/workflows/ship-note.yaml sends it to #product-update on the merge.
  • close-out: archive the run to apps/docs/archive/pipeline-runs/tenant-user-management/. The epic tenant-management-app is not finished — four stubs remain in flight (partner-assignment, tenant-lifecycle, tenant-plan-visibility, tenant-activity-dashboard), so its intake folder stays put.

Acceptance check (vs spec)

Ship shipped with the operator smoke not performed. Verify established the code half by trace and unit tier, but the console preview is behind Vercel SSO and this session holds no Vercel credentials, so not one signed-in screen was exercised — by the agent or, before this merge, by the operator. The ten operator lines in 04_verify/output/verify.md were still unticked when Jamie ticked Ready to merge and ran /pipeline ship. That is his call, recorded here rather than dressed up: the criteria below marked "not demonstrated" merged unverified at runtime.

  • A role changed in the console is written to the tenant's Clerk organisation membership, and no user document is written by this run's code — verified in Verify by absence: no userService / deleteUser call exists in the feature's modules.
  • sales is not offered in the role picker and an action naming it is rejected by the schema — verified in Verify; console-roles.test.ts asserts both.
  • The last-admin count is taken from a role-filtered Clerk query rather than the roster page — verified in Verify: countTenantAdmins passes role: ["org:admin"], limit: 1.
  • Removing a member removes them from the tenant's Clerk organisation and leaves their Clerk account in place — verified in Verify: deleteOrganizationMembership is the only call.
  • Unit tests cover the assignable-role set and the last-admin guard for both removal and role change — verified in Verify: 15 tests, confirmed executed in the Quality job.
  • A Sustentus staff member can invite a user to any tenant by email and role; the invitation appears in the pending list and Clerk's invitation email arrives — not demonstrated.
  • A partner can invite, revoke, remove and re-role on an assigned tenant, and gets the not-found answer on any other — not demonstrated.
  • admin can be granted and revoked and takes effect in the tenant's platform session — not demonstrated (the cross-app half no code trace can show).
  • Removing the tenant's only admin is refused and the membership is left untouched — not demonstrated at runtime; the guard and its unit tests are in place.
  • Changing the tenant's only admin to another role is refused the same way — not demonstrated at runtime; same guard.
  • The Users card lists pending invitations separately and each can be revoked — not demonstrated.
  • A tenant whose Clerk organisation is missing still renders its detail page with the actions unavailable — not demonstrated (needs a tenant row pointing at a deleted org).

Carried forward — open decisions, unchanged by this merge

  • The last-admin guard is check-then-act with no lock. Two operators removing two different admins from a two-admin tenant concurrently can both pass it. Clerk offers no transaction. The spec's wording is stronger than the code delivers; the docs page now states the limit plainly. Still needs a decision: accept and reword, or re-count after each write and alert.
  • A partner can invite themselves into an assigned tenant's Clerk organisation, at any assignable role including admin — exactly the power Q-5 grants, but the scope's "partners never enter tenant orgs" draws the opposite boundary. A product decision, deliberately not changed here.

Context budget: within the Inputs table.