tenant-plan-visibilityrun.md02_define/output/spec.mdtenant-directory (#887) made a tenant findable and openable, but its detail view answers only
identity questions — name, slug, organisation id, created date, users. The commercial state of the
account is nowhere: nobody can say which plan a tenant is on, how many seats it is using against
what it is allowed, or whether its subscription is healthy. That is the most basic account question
the Sustentus team and its partners get asked, and today it is answered by leaving the console and
opening the Clerk dashboard. Until the console can answer it, it is not the operations surface that
Scale the bridge / Establish product-market fit with vendor partners (2026-Q2, Objective 1)
needs it to be — a partner cannot administer an account whose commercial state they cannot see.
A commercial card on the tenant detail view, read live from Clerk on every request, showing three things and nothing else: plan name, seats used against the seat allowance, and subscription status.
The system of record is Clerk Billing, read-only — settled with Jamie 2026-08-26, revising the
stub's assumption that a subscription record already exists locally. It does not: there is no Plan
or Subscription model in Mongo, nothing commercial on the Tenant document, and no Stripe. Clerk
Billing (public beta) is the store, reached through clerkClient().billing:
billing.getOrganizationBillingSubscription(tenant.clerkOrgId) — the plan of each active
subscription item (normally exactly one) and the subscription's own status.totalCount of the Clerk
membership read the detail page already makes for its Users table — not the length of the page it
renders, which stops at 100 and would under-report a large tenant.maxAllowedMemberships, read live via
organizations.getOrganization. Clerk expresses "no cap" as 0; the card says unlimited rather
than printing a number that is not one.No money, for anyone. The read maps plan name, seat counts and status out of the Clerk objects
and nothing else — fee, annualFee, amount, nextPayment and lifetimePaid never leave the
data layer. Q-9's partner boundary is then true by construction rather than by a per-audience
branch: there is no amount in the shape the card renders, so there is none to leak to either
audience.
Partners see the identical card and can change nothing — which v1 satisfies outright, because the card carries no controls at all (see Out of scope on the plan move).
Absence is a state, not a fault. Clerk Billing is not enabled on this instance today and no
plans are defined, so the common case at merge is "no subscription". A not-found or
Billing-not-enabled answer renders a plain No subscription on record and leaves the rest of the
detail view standing — the same discipline readTenantMembers already applies to a missing
organisation. Any other Clerk failure is a genuine fault and throws.
cancelSubscriptionItem and extendSubscriptionItemFreeTrial, and a plan switch happens in the
payer's own checkout, which console users cannot reach — they are org-less by design and never
enter a tenant organisation (apps/console/AGENTS.md). It needs its own scope once a writable
subscription store exists; it is not a Build-time detail.tenant-activity-dashboard (stub 7 of the batch)
owns the trail and explicitly names plan movement among what it records.user collection; this card reads Clerk, which is what keeps it
from showing a stale roster.@experimental; Clerk advises
pinning the SDK. Non-blocking for a read this shape, but a note for Build: keep every Clerk
Billing call inside console-billing.ts so a beta API change lands in one file, and do not widen
the pinned @clerk/backend range in this run.03_build/output/notes.mdfeat: tenant-plan-visibility — plan, seats and subscription status on a tenantapps/console/lib/clerk-errors.ts (new): clerkErrorStatus — pulls the HTTP status off an
unknown thrown by Clerk. Two readers now need to tell an absent record from a genuine fault,
and they absorb different statuses; the shape test belongs in one home rather than being
re-decided in each. console-tenants.ts's existing isNotFound delegates to it.apps/console/lib/console-billing.ts (new): readTenantSubscription — plan names and status,
live from Clerk Billing, null when there is no subscription. The type carries no money by
construction: fee, annualFee, amount, nextPayment and lifetimePaid are in scope only
inside this module and are never mapped out, so Q-9's partner boundary holds without the view
knowing who is looking.apps/console/lib/console-tenants.ts: readTenantMembers superseded by readTenantRoster,
which returns the member page plus Clerk's totalCount and the organisation's
maxAllowedMemberships. Membership and seat cap are fetched together because they answer one
question and share one "is this organisation still there?" answer. maxAllowedMemberships of
0 (Clerk's "no cap") is normalised to null.apps/console/components/subscription-card.tsx (new): the commercial card — plan, status chip,
seats used against allowance. No controls, because v1 is read-only.apps/console/app/(console)/tenants/[tenantId]/page.tsx: renders the card between Details and
Users; the two live reads run in one Promise.all. The Users pagination note now states the
real total (Showing 100 of 240 users.) instead of the old "may have more" hedge, which the
member total makes unnecessary.readTenantSubscription
calls billing.getOrganizationBillingSubscription per request; nothing is cached or mirrored.seatLabel renders
14 of 25 used, or 14 used · no seat limit when seatAllowance is null.totalCount, not members.length,
which stops at the 100-row page limit.TenantSubscription to render.null and the card reads "No subscription on
record"; every other card is untouched.tenantService.findForConsole(scopeOf(viewer), tenantId), and the new reads
take tenant.clerkOrgId from the row that read already scoped.ABSENT_STATUSES = [403, 404] in
console-billing.ts is what keeps a Billing-disabled instance rendering instead of erroring on
every tenant. A bad secret key is a 401 and an outage a 5xx, so neither is swallowed — worth a
second opinion on that reasoning.@experimental on every method). Every call is confined to
console-billing.ts so a beta change lands in one file, and @clerk/backend was not re-pinned.
Verified against the version the console actually resolves — 3.4.5 via @clerk/nextjs 7.3.1,
not the 2.33.3 that packages/services resolves — for client.billing,
getOrganizationBillingSubscription, PaginatedResourceResponse.totalCount,
getOrganization({ organizationId }) and Organization.maxAllowedMemberships.apps/console has no vitest.config.ts and no test script; standing
a tier up for this app is outside the spec, and CONVENTIONS.md → Testing says not to improvise
one the config does not have. What is unit-assertable here is presentation labels
(seatLabel/planLabel) and Clerk-mocked reads; the money boundary — the criterion most worth
guarding — is proved by the type, not by a test. If a console test tier is wanted, that is its
own chore.04_verify/output/verify.md.icm/**; it was re-settled green before hand-off and
that SHA is named in the hand-off message.git diff main...HEAD | grep process.env is empty; CLERK_SECRET_KEY was
already in turbo.json → globalEnv). Auth is untouched: proxy.ts, console-access.ts,
permissions/, and the scopeOf/consoleScopeClause scoping logic are all unchanged by this
feature's diff.Review diff against CONVENTIONS.md) passed with zero posted
comments. An additional /code-review at medium (spec complexity standard) was run anyway
because the build notes had asked for a second opinion on the 403 handling — it found two real
defects, both fixed on branch (below).tenant.clerkOrgId from the row
tenantService.findForConsole(scopeOf(viewer), tenantId) already scoped, so a partner cannot
reach a tenant they could not reach before and the org id is never user-supplied. The money
boundary holds by type: TenantSubscription has no amount field, so there is nothing for the
card to leak to either audience. No new PII surface — readTenantSeats uses totalCount only
and never touches member records. No injection sink, no dangerouslySetInnerHTML.The agent could not reach the preview at all. Every route on
tenant-management-git-claude-tenant-plan-visib-444596-sustentus.vercel.app answers 302 to
vercel.com/sso-api (Vercel deployment protection), and the console is invitation-only behind a
deny-by-default proxy besides. So no acceptance criterion is agent-demonstrated, and none is
recorded as passed below. What the agent did establish is recorded as evidence, not as a pass.
readTenantSubscription is called per request
with no cache or stored copy.seatLabel is unit-tested for the capped, uncapped and over-allowance cases; those tests ran
green in CI (@sustentus/console:test, lib/subscription-labels.test.ts, 10 tests).totalCount on a limit: 1 read, so it cannot be capped by the member page size.planLabel is unit-tested for both.scopeOf or the scoped query; the reads hang off the already-scoped tenant row.main — the significant finding. tenant-user-management
(#889) and partner-assignment (#888) both merged while this run was in flight, leaving PR #890
mergeable_state: dirty. Resolved by merging origin/main in (f3f53b0), not by rebasing.
Reconciled rather than just de-conflicted:apps/console/lib/clerk-errors.ts. My duplicate was
deleted and main's isClerkNotFound adopted as the one home; isClerkForbidden was added
alongside it, in main's idiom.readTenantRoster refactor of console-tenants.ts was reverted wholesale to main's
version. #889 gave readTenantMembers several new callers, and reshaping it to carry a seat
figure would have pushed this feature's concern into all of them. Seats moved to their own
additive readTenantSeats, at the cost of one extra Clerk call.console-billing.ts filtered subscription items to status === "active" — a past_due or
canceled tenant would have rendered "No active plan" beside a "Past due" badge, blanking the
plan name at exactly the moment an operator wants it. Fixed: only genuinely superseded statuses
(ended, expired, abandoned) and the not-yet-started upcoming are filtered out.readTenantSubscription now returns a three-state result (subscribed / none /
unavailable), and the card says "Billing could not be read" rather than "None". This is the
point the build notes flagged for a second opinion; the review agreed.skipped on the previous head (9c6cc76 was markdown-only, so
Vercel's ignored-build-step correctly skipped it) — a real mismatch under this stage's rules,
since the feature ships in that app. Resolved by the merge commit, which touches apps/console
and rebuilt every preview. It was never quoted as a pass while skipped.apps/console/vitest.config.ts. The pure label logic was extracted to
lib/subscription-labels.ts (so it is testable without pulling in the UI or the Clerk client)
and covered by 10 unit tests written from the acceptance criteria. They pass in CI.05_ship/output/changelog.mdAnswering "which plan is this account on, and is it healthy?" used to mean leaving the console and opening Clerk. A tenant's page now carries that answer: the plan it is on, the seats it is using against the seats it is allowed, and whether its subscription is in good standing. The Sustentus team sees it on every tenant; a partner sees it on the tenants assigned to them.
Seats are counted properly rather than estimated. The number is the tenant's true member total, so a tenant that has grown past its allowance shows as past it — the case worth catching — instead of stopping at whatever the user list happens to display. A tenant with no seat limit says so in words.
No money appears anywhere on it, for either audience. Plan, seats and status are the whole of it — no fees, no next payment, no invoices, no payment history.
Two things to expect. Billing is not switched on yet, so plan and status read as unavailable until it is; seats are live and correct regardless. And this is a read-only view for now — moving a tenant onto a different plan still happens outside the console.
05_ship/output/investor-update.mdWho it's for: Sustentus staff and partners What shipped: A tenant's page in the console shows its plan, seats used against its allowance, and its subscription status. Why it matters: Partners can read the accounts they administer unaided — Scale the bridge / product-market fit with vendor partners.
Dig deeper: https://github.com/sustentus/sustentus/pull/890 · https://help.sustentus.com/changelog/2026-08-27-tenant-plan-visibility
05_ship/output/release.mdReady to merge is unticked; this
commit is the pre-gate work only.apps/docs/app/technical/applications/console/page.mdx — new "Plan and
subscription" section, and the /tenants/:id row of the routes table now names the subscription.business/roles and not a step on the service journey — the batch's own
breakdown places it beside the journey rather than on it..icm/runs/tenant-plan-visibility/ to
apps/docs/archive/pipeline-runs/. Not the epic: tenant-management-app still has
tenant-lifecycle and tenant-activity-dashboard in intake, so 2 of 7 stubs remain in flight.Every criterion is met in code and traced in 04_verify/output/verify.md; none was demonstrated on
the preview, because the console preview sits behind Vercel deployment protection and the app is
invitation-only. Verify records all six as operator-owned and unticked, and that remains true here.
readTenantSubscription per request.seatLabel, unit-tested.totalCount, not the rendered page length.past_due subscription; 403
absorbed as "no subscription") were fixed on this branch before this stage ran.tenant-user-management (#889) and partner-assignment (#888) merged mid-run; the conflict was
resolved by merging main in at f3f53b0 and reconciling, not by rebasing.