tenant-directoryrun.md02_define/output/spec.mdtenant-app-access (#886) stood the console up and settled who gets in, but it administers
nothing: the signed-in page says, in as many words, that there is nothing to look at yet. Staff
cannot find a tenant, partners cannot see the tenants referred to them, and every later stub in
the batch — assignment, lifecycle, user management, plan visibility, the activity dashboard —
hangs off a tenant you first have to be able to select. Until a tenant can be found and opened,
the console cannot advance Scale the bridge / Establish product-market fit with vendor
partners (2026-Q2, Objective 1) at all, because there is no surface on which partner-facing
administration can happen.
This is the read half of the settled "bare minimum" definition [Q-6]; the write half arrives in the stubs that follow.
A searchable tenant directory at /tenants and a read-only tenant detail view at
/tenants/:id, both scoped by audience, inside apps/console.
Audience scoping. Sustentus staff (audience: "sustentus") see every live tenant. A partner
(audience: "partner") sees exactly the tenants whose partnerId equals their Clerk user id
[Q-3] — and no trace that any other tenant exists: no count, no link, no distinguishable error.
partner-assignment (the next stub) owns every write to that field, so until it lands a
partner's directory is legitimately empty and must say so plainly rather than look broken.
The directory. Each row shows the tenant's name, its derived status, and its setup state. Search filters by name, case-insensitively, on a substring. The list is server-paginated so it stays bounded as the tenant count grows.
The detail view. Identity (name, slug, Clerk organisation id, created date, logo), the same derived status and setup state, and the tenant's user list — each person's name, email address and organisation role — read live from Clerk organisation memberships. Read-only throughout: no action on this page changes anything.
Status, honestly derived. The Tenant document carries no lifecycle status today —
tenant-lifecycle introduces suspend/reactivate and the two-step delete. What exists is
softDeletePlugin's isDeleted (which excludes deleted tenants from every find, so they
simply do not appear here) and onboarding.status. So every tenant reachable in this run reads
Active, and the real signal is a secondary Setup chip carrying
not_started | in_progress | completed | skipped. Suspended, and any view of deleted tenants,
arrive with tenant-lifecycle — this run invents neither.
No money, no plan. Nothing in the codebase records a plan or subscription today
(subscription-tiers-foundation, in a different scope, is unbuilt), and tenant-plan-visibility
owns plan, seats and subscription status. This run therefore shows no plan panel at all rather
than a placeholder that would be built twice. Amounts, invoices and payment history stay out of
the console for partners permanently [Q-9].
packages/services/src/db/services/tenant
gains a console-scoped read pair — list (audience + optional search + page) and find-one
(audience + tenant id, resolving to null when out of scope). Authorization lives there, in
one testable place, so no page can accidentally widen it.partnerId is declared here, written elsewhere. The field goes onto the Tenant schema in
this run — optional Clerk user id string — with a { partnerId: 1 } index created by a
migration in lockstep with the Schema declaration, per packages/services/AGENTS.md. The
partner filter has to typecheck and has to be indexed before it can be relied on; the stub's
"partner-assignment owns the field and its writes" is honoured in that no write path to
partnerId exists in this run.apps/console/lib/), using
clerkClient() exactly as lib/console-access.ts already does. Note for Build: the
apps/console/AGENTS.md rule "never read an org role here" is about the viewer's own
identity — staff and partners are org-less by design. Reading the org roles of a tenant's
members is the subject of this page, not the viewer's authorization, and does not breach it.app/(console)/, so proxy.ts covers them
with no change; each page still calls requireViewer(), per the app's double-lock rule.tenant-activity-dashboard's.No new environment variable. MONGODB_URI is already declared in turbo.json → globalEnv
and is provisioned on the console's Vercel project (confirmed by the author, 2026-08-26), so the
console's first database read needs no configuration change. apps/console/.env.example gains
the variable for local development only.
/tenants sees every live tenant, listed with
name, status and setup state.partnerId equals their Clerk user id; a
partner with no assigned tenants sees an empty-state that says so, not an error./tenants/:id for a tenant not assigned to them gets the same
not-found response as for a tenant id that does not exist, and for a malformed id.partnerId is declared on the Tenant schema with a matching index migration, and no code
path in this run writes it.null, and search matches case-insensitively.partnerId — partner-assignment owns assignment, re-assignment and clearing.tenant-plan-visibility.suspended status, the two-step delete, and any view of deleted or recoverable tenants —
tenant-lifecycle.tenant-activity-dashboard.apps/web.tenant-plan-visibility; derive status from what exists today; read the user list
live from Clerk organisation memberships.03_build/output/notes.mdfeat: tenant-directory — scoped tenant reads + directory indexes, feat: tenant-directory — directory and detail views in the console0b193b0 — first push, no red round-trip. Quality Project (format, lint,
typecheck, tests), Review diff against CONVENTIONS.md, Audit database, both migrate jobs,
and the tenant-management / web / marketing previews all pass.packages/services/src/db/models/tenant.ts: added partnerId (the assigned partner's Clerk
user id) and the two directory read indexes, { name: 1 } and { partnerId: 1, name: 1 },
cross-referenced to the migration. No write path to partnerId exists in this run.packages/services/src/db/migrations/1787966000000-tenant-directory-indexes.ts: creates both
indexes, in lockstep with the schema. Numbered after every existing migration so it is the
newest rather than being inserted before one that has already run.packages/services/src/db/services/tenant/console-scope.ts (+ .test.ts): the scoping algebra
as pure functions — the scope clause, the list filter (escaped case-insensitive name regex), and
the id filter that returns null for a malformed id. This is where partner authorization lives,
so it is the thing under test.packages/services/src/db/services/tenant/index.ts: listForConsole and findForConsole,
both taking the scope and applying it inside the query rather than after the read.apps/console/lib/console-tenants.ts: viewer → scope, the Clerk organisation-membership read,
and the page / search URL contract.apps/console/app/(console)/tenants/{page.tsx,[tenantId]/page.tsx}: the directory and the
read-only detail view. apps/console/components/tenant-status.tsx renders the derived status.apps/console/app/(console)/layout.tsx + page.tsx: a Tenants nav link, and the home
placeholder no longer claims there is nothing to administer.listForConsole adds no
clause for the sustentus audience.escapeRegex, per the
list-surface convention.partnerId is their Clerk user id; an empty assigned set
renders an explanatory empty state, not an error.notFound() —
the scope rides in the query and a bad id resolves to null before any read.softDeletePlugin excludes them from every
find, for both audiences.findPaginated, 25 per page, with indexes that serve
the sort.partnerId declared and indexed with a matching migration, and nothing writes it.The detail view 500'd on the preview with a Clerk resource_not_found (404) out of
getOrganizationMembershipList. Cause: a Tenant document can name a Clerk organisation that no
longer exists — a seeded row, or an org deleted in Clerk directly — and Clerk raises a 404 for
that rather than returning an empty list. readTenantMembers now absorbs only that
not-found case and returns null; any other Clerk failure still throws, because it is a genuine
fault. The page renders "this tenant's organisation could not be found in Clerk" in the users
card and shows the rest of the tenant normally — the tenant record is still real and the other
fields are still true, so a dangling organisation id must not take the whole view down.
No unit test accompanies it: apps/console has no vitest config or test script, and
CONVENTIONS.md → Testing forbids improvising a tier the config does not have. Standing up a
test tier for the console is its own piece of work.
null". Only the unit tier is configured
(node env, no DB), and CONVENTIONS.md → Testing forbids improvising a tier the config does not
have. So the assertions land one level in, on console-scope.ts: that a partner's filter carries
their partnerId and a staff filter carries none, that the id filter is null for a malformed
id, and that search is escaped and case-insensitive. That is the authorization rule itself, and
it is why the rule was extracted into pure functions rather than written inline in the query.
What is not asserted is that Mongo honours those filters — that waits for the integration tier.apps/console/.env.example was not updated. The spec said to add MONGODB_URI to it for
local development. That path is blocked by this session's permission settings (both read and
write), so it is deliberately not done rather than silently skipped. Nothing functional depends
on it: MONGODB_URI is already in turbo.json → globalEnv and provisioned on the console's
Vercel project, which is what makes the preview work. Worth a one-line follow-up.apps/console/AGENTS.md says "never read an org role here". That rule is about the viewer's
identity — staff and partners are org-less. The detail view reads the tenant's members' roles,
which is the subject of the page, not the viewer's authorization. Called out so the review does
not read it as a breach.04_verify/output/verify.md30b6df3 — settled via ci-status.sh after the verify push (the head handed to Ship). Quality Project, Project run labels, Audit database, both migrate jobs and all three advisories pass.standard); the CI Claude review is off (Review diff against CONVENTIONS.md reports skipped — ENABLE_CLAUDE_REVIEW is not set), so it was run here rather than triaged. Three findings, all fixed on branch.partnerId is their Clerk user id; empty assigned set shows the explanatory empty state — (operator; note partnerId is unwritten until partner-assignment, so today every partner's directory is legitimately empty — that empty state is what there is to demonstrate)buildConsoleTenantIdFilter and unit-tested, but the HTTP behaviour is unproven)softDeletePlugin excludes them from find, and the count now excludes them too (fix below) (agent)findPaginated, 25/page, with the indexes that serve the sort (agent)partnerId declared and indexed with a matching migration, nothing writes it — traced: no write path exists in the diff (agent)Fixed on branch:
autoIndex.
1787966000000-tenant-directory-indexes created {name:1} and {partnerId:1,name:1} with no
collation, but schemaPlugin puts {locale:"en",strength:2} on the Tenant schema, so every
console query and its name sort carries it — and an index only serves a sort whose collation it
matches. Verified in mongoose/lib/helpers/indexes/applySchemaCollation.js: mongoose stamps the
schema collation onto declared indexes, so autoIndex would also have built the same two keys
collated and collided with the migration (IndexOptionsConflict), aborting the chain. The
migration had already run against preview (up: 1787966000000-… in the migrate job), so
packages/services/AGENTS.md forbids editing it — fixed forward with
1787970000000-tenant-directory-indexes-collation, which drops and recreates both with the
collation, and by removing the two Schema.index(...) declarations so the migrations own these
indexes outright and nothing races them. Every other Tenant index still autoIndexes as before.countDocuments counted soft-deleted tenants. softDeletePlugin hooks pre(/^find/), which
countDocuments does not match, so findPaginated's total and page count included deleted
tenants while the rows correctly excluded them — an inflated "Showing 1–25 of N" and a phantom
final page. The clause is now stated in the filter itself, so find and count agree. Unit tests
extended to pin it.?search=a&search=b resolves to string[] in
Next; .trim() on it threw. Both page and search are now narrowed with the same
typeof === "string" guard the platform's parseListParams uses.?page told a partner they had no tenants. The empty state keyed on rows in
hand rather than the scoped total, so ?page=99 said "No tenants are assigned to you yet" to a
partner who has some. It now keys on pagination.total and offers a way back to page 1.Accepted / carried forward (not fixed here):
findPaginated's count/soft-delete mismatch is repo-wide. Every list surface built on the
shared helper has it. Fixed locally for this surface only — repairing the helper would change
every other list's totals in a run that is not theirs. Worth its own chore.apps/console/.env.example still lacks MONGODB_URI. Carried from Build: this session's
permission settings deny that path for both read and write. Nothing functional depends on it —
the variable is in turbo.json → globalEnv and set in Vercel — but it is a real one-line gap.apps/docs has no page describing apps/console at all. Pre-existing from the
tenant-app-access run, not a regression here, but the console is now a real operational surface.
Ship's docs-sync should be pointed at it.Needs an owner decision before merge (both are environment facts I cannot read from here):
MONGODB_URI and MONGODB_DATABASE_NAME exist in the tenant-management Vercel
project's Production scope. This is the console's first database read — its current
production deployment predates any DB use, so that connection has never been exercised there. A
missing MONGODB_URI 500s /tenants; a missing MONGODB_DATABASE_NAME is worse, silently
falling back to whatever the URI path names, which could list a different database's tenants.migrate-production is gated behind a required reviewer while
Vercel deploys on merge, so the console can serve /tenants before the indexes exist. Harmless
at current tenant volumes — a scan of a small collection — but worth knowing it is the window.console-scope.test.ts), not that Mongo honours those filters.
That is the honest boundary, not an oversight — see the build notes.05_ship/output/investor-update.mdWho it's for: The Sustentus team and vendor partners What shipped: A searchable tenant directory in the console, with a read-only view of each tenant. Why it matters: Partner-facing administration finally has a surface — a first step toward Establish Product-Market Fit with Vendor Partners under Scale the Bridge.
A partner sees only their own tenants; nothing reveals the rest exist.
Dig deeper: https://github.com/sustentus/sustentus/pull/887
05_ship/output/release.mda3b40f0 — settled via ci-status.sh after the docs/ship-note push. All seven
blocking checks pass (Quality Project, Project run labels, Audit database, both migrate
jobs, Review diff against CONVENTIONS.md, Vercel – web), plus Vercel – docs, which is the
factory's check that the new MDX compiles. The release.md commit on top is settled separately
before the merge — a file cannot name the commit that contains it.apps/docs/app/technical/applications/console/page.mdx — the console's
two audiences, the org-less Clerk marker and deny-by-default proxy that gate it, its routes, the
directory's in-query scoping, and its MONGODB_* requirement. Updated
technical/applications/page.mdx — six applications → seven, with the Console row and section.
This closes the gap Verify carried forward: apps/console shipped in tenant-app-access with no
page at all.business/roles and business/feature-role-matrix
describe the platform's six tenant-scoped roles and the permissions registry that governs
them. The console's audiences (sustentus, partner) are neither — they are org-less Clerk users
gated by a publicMetadata marker, outside that registry entirely. Filing the console there would
put it under a mechanism that does not govern it.changelog-entry admits only the six platform persona ids. Neither console audience
is one of them, so an entry there would announce to platform users a surface none of them can
reach — the "a changelog line a user can't act on is noise" rule. The ship note carries it.ship-note.yaml fires on the merge that carries this file and emails
05_ship/output/investor-update.md to #product-update. Its Dig deeper line is filled (the PR
URL; no changelog URL, per the line above), so the placeholder guard has nothing to refuse.close-out.sh tenant-directory archives this run to
apps/docs/archive/pipeline-runs/tenant-directory/. The tenant-management-app epic does not
archive with it — sibling stubs remain, and tenant-app-access's run folder is still live in
.icm/runs/ (it merged without going through Verify or Ship, so close-out never ran for it).Each line says how it was established. Verify's DoD split agent-traced criteria from operator-demonstrated ones; the operator half was never returned, and ticking Ready to merge is what authorised the merge over that gap rather than closing it.
listForConsole
(agent); not demonstrated signed-inconsole-scope.test.ts (escaped, case-insensitive); not demonstrated signed-inpartnerId is their Clerk user id; an empty assigned set
shows the explanatory empty state — pinned by unit test; not demonstrated signed-in, and note
that until partner-assignment writes partnerId, every partner's directory is legitimately
emptybuildConsoleTenantIdFilter and unit-tested; the HTTP behaviour is unprovenfindPaginated, 25/page, on the indexes that serve the sort (agent)partnerId declared and indexed with a matching migration, nothing writes it — traced: no
write path exists in the diff (agent)console-scope.ts
rather than that Mongo honours those filters. That is the honest boundary; see the build
notes.findPaginated's count/soft-delete mismatch is repo-wide. Fixed for this surface only;
repairing the shared helper would move every other list's totals. Worth its own chore.apps/console/.env.example still lacks MONGODB_URI. This session's permission settings deny
that path for read and write. Nothing functional depends on it — the variable is in turbo.json
→ globalEnv and set in Vercel — but it is a real one-line gap.migrate-production sits behind a required reviewer while Vercel
deploys on merge, so the console can serve /tenants before the indexes exist. Harmless at
current tenant volumes — a scan of a small collection — but that is the window.MONGODB_URI and
MONGODB_DATABASE_NAME are set in the tenant-management project's Production scope. This
matters because connectDB throws on a missing MONGODB_URI but passes dbName: undefined
through silently, letting the driver fall back to whatever the URI path names.