tenant-user-managementrun.md02_define/output/spec.mdtenant-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.
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.
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.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.lib/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.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.MONGODB_URI are already provisioned on the console's Vercel project.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.admin-role member is refused with a plain message, and the
membership is left untouched.admin-role member to any other role is refused with a plain
message, and the role is left untouched.null case readTenantMembers
already returns.sales absent) and the last-admin guard
(refuses on a count of one, permits on two, for both removal and role change).tenant-app-access,
already shipped at /access.sales from the console — it stays a Mongo-only system role (stub).apps/web — including the platform's own in-tenant user management, which keeps its own
last-admin guard (apps/console/AGENTS.md).partnerId, tenant lifecycle actions, and the plan / seats / subscription panel —
partner-assignment, tenant-lifecycle, tenant-plan-visibility.tenant-activity-dashboard. This run
logs its actions structurally; it does not build the store.tenant-directory unchanged.scope.md [Q-3, Q-5, Q-6, Q-12].03_build/output/notes.mdfeat: tenant-user-management — invite, remove and re-role a tenant's usersb2872f6 — every blocking check and status, all seven Vercel previews includedapps/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.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.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.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.inviteTenantUser; the
invitation lands in the pending table and Clerk mails it.findForConsole(scopeOf(viewer), …).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.admin is refused, membership untouched — the guard runs
before any Clerk write.admin off admin is refused the same way.apps/console/lib/console-roles.test.ts.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:test — cache 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.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.mdci-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.tenant-management) built for c93bf76 —
https://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.standard) — 5 findings, all 5 fixed on branchThe 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.
Agent-verified (by code-path trace over the diff and the unit tier, not on the preview):
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)countTenantAdmins passes role: ["org:admin"], limit: 1 and reads
totalCount; the 100-row roster is never counted. (agent)deleteOrganizationMembership is the sole call; no users.deleteUser
anywhere in the feature. (agent)Quality
job (@sustentus/console:test, cache miss, 15 passed), not resolved to an empty
turbo graph. (agent)Operator-demonstrated — all still pending, none may be ticked by the agent:
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)admin is refused with a plain message and the
membership is left untouched. (operator)admin to another role is refused the same way.
(operator)clerkOrgId points at a deleted org)Fixed on branch — code review (commit 7bd8a28):
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.?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):
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:
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):
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.changelog-entry at
Ship.05_ship/output/changelog.mdA 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.mdWho 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.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.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.apps/docs/app/business/** mentions the
console; it is a Sustentus-side surface, outside the six-persona platform matrix.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..github/workflows/ship-note.yaml sends it to #product-update on the merge.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.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.
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.countTenantAdmins passes role: ["org:admin"], limit: 1.deleteOrganizationMembership is the only call.Quality job.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).admin is refused and the membership is left untouched —
not demonstrated at runtime; the guard and its unit tests are in place.admin to another role is refused the same way — not
demonstrated at runtime; same guard.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.