Skip to Content

← All archived runs

Run: tenant-directory

run.md

Run: tenant-directory

  • branch: claude/tenant-directory-pipeline-hl243n
  • pr: #887

02_define/output/spec.md

Spec: Tenant directory and detail view

  • slug: tenant-directory
  • personas: Admin (Sustentus team), Partner
  • touches: apps/console, packages/services/src/db/models/tenant.ts, packages/services/src/db/services/tenant, packages/services/src/db/migrations
  • complexity: standard

Problem

tenant-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.

Proposed change

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].

Where the code goes

  • Scoping is a service, not a page concern. 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.
  • The Clerk membership read stays in the console (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.
  • Routes sit under the existing gated segment app/(console)/, so proxy.ts covers them with no change; each page still calls requireViewer(), per the app's double-lock rule.
  • The header gains a "Tenants" link and the home placeholder's "nothing to administer yet" copy is corrected to point at the directory. The landing dashboard itself remains tenant-activity-dashboard's.

No new environment variable. MONGODB_URI is already declared in turbo.jsonglobalEnv 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.

Acceptance criteria

  • A signed-in Sustentus staff member opening /tenants sees every live tenant, listed with name, status and setup state.
  • Typing part of a tenant's name into the directory's search narrows the list to matching tenants, case-insensitively, on a substring anywhere in the name.
  • A signed-in partner sees only tenants whose partnerId equals their Clerk user id; a partner with no assigned tenants sees an empty-state that says so, not an error.
  • Nothing on the partner's directory reveals that other tenants exist — no total count, no pagination beyond their own set, no cross-tenant link.
  • Opening a tenant shows its name, derived status, setup state, identity fields, and its user list with each member's name, email and organisation role.
  • A partner requesting /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.
  • Soft-deleted tenants appear in neither the directory nor the detail view, for either audience.
  • The directory is server-paginated and never loads the whole collection in one query.
  • partnerId is declared on the Tenant schema with a matching index migration, and no code path in this run writes it.
  • Unit tests cover the scoped read pair: staff sees all, partner sees only its own, an out-of-scope find resolves to null, and search matches case-insensitively.

Out of scope

  • Any mutation whatsoever — creating, suspending, deleting, editing, inviting, removing, or changing a role. Read-only [Q-5].
  • Writing partnerIdpartner-assignment owns assignment, re-assignment and clearing.
  • The plan / seats / subscription-status panel and any plan change — tenant-plan-visibility.
  • Money amounts, invoices and payment history — never shown to partners [Q-9].
  • A suspended status, the two-step delete, and any view of deleted or recoverable tenants — tenant-lifecycle.
  • The activity trail and the counts-and-activity landing dashboard — tenant-activity-dashboard.
  • Any change to the platform's org roles, session-token claims, Clerk webhook sync, or any surface in apps/web.

Open questions

  • none — the three that affected what gets built (plan panel, the meaning of "status", and the source of the user list) were put to the author and settled on 2026-08-26: defer the plan panel to tenant-plan-visibility; derive status from what exists today; read the user list live from Clerk organisation memberships.

03_build/output/notes.md

Build notes: tenant-directory

  • commits: feat: tenant-directory — scoped tenant reads + directory indexes, feat: tenant-directory — directory and detail views in the console
  • ci: GREEN on 0b193b0 — 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.

What changed

  • 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.

Acceptance criteria status

  • Staff see every live tenant with name, status and setup state — listForConsole adds no clause for the sustentus audience.
  • Search narrows by case-insensitive name substring — escaped regex via escapeRegex, per the list-surface convention.
  • A partner sees only tenants whose partnerId is their Clerk user id; an empty assigned set renders an explanatory empty state, not an error.
  • Nothing on the partner's directory reveals other tenants — the count and pager are computed from the scoped query, so both describe only their own set.
  • Detail view shows name, derived status, setup state, identity fields and the user list with each member's name, email and organisation role.
  • An unassigned tenant, a nonexistent id and a malformed id all end at the same notFound() — the scope rides in the query and a bad id resolves to null before any read.
  • Soft-deleted tenants appear in neither view — softDeletePlugin excludes them from every find, for both audiences.
  • The directory is server-paginated — findPaginated, 25 per page, with indexes that serve the sort.
  • partnerId declared and indexed with a matching migration, and nothing writes it.
  • [~] Unit tests cover the scoped read pair — see the note below; the scoping algebra is fully tested, the DB-level assertions are not reachable in the configured tier.

Fix after the first preview smoke

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.

Notes for Verify

  • Test tier, read this first. The criterion asks for tests that "staff sees all, partner sees only its own, an out-of-scope find resolves to 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.jsonglobalEnv and provisioned on the console's Vercel project, which is what makes the preview work. Worth a one-line follow-up.
  • The console now reads Mongo for the first time. Previously it only talked to Clerk. The preview is the real test of that — if the connection is missing, the directory is where it shows.
  • Two Clerk reads per detail view (the viewer's marker, then the tenant's memberships). Fine at this scale; worth remembering when the activity dashboard starts fanning out.
  • 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.md

Verify: tenant-directory

  • ci: GREEN on 30b6df3 — 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.
  • previews smoked: tenant-management (https://tenant-management-git-claude-tenant-directory-f05ff2-sustentus.vercel.app) · web built · demo/docs/help-centre/marketing/storybook skipped by turbo-ignore for this diff (correctly — the diff touches neither)
  • production-readiness: run — required (diff touches the database). Two blockers found and fixed on branch, two accepted as intake. See findings.
  • code-review: medium (spec complexity standard); the CI Claude review is off (Review diff against CONVENTIONS.md reports skippedENABLE_CLAUDE_REVIEW is not set), so it was run here rather than triaged. Three findings, all fixed on branch.
  • security-review: run — required (the diff introduces the partner/staff authorization boundary). No HIGH or MEDIUM findings.
  • playwright: TODO — manual DoD smoke performed instead

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

  • Staff see every live tenant with name, status and setup state — (operator)
  • Search narrows by case-insensitive name substring — (operator)
  • A partner sees only tenants whose 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)
  • Nothing on the partner's directory reveals other tenants exist — (operator)
  • Detail view shows name, derived status, setup state, identity fields and the user list — demonstrated: the operator hit this page on the preview; it rendered and then surfaced the Clerk 404 described below (agent traced the code path; operator exercised the page)
  • An unassigned tenant, a nonexistent id and a malformed id all return the same not-found — (operator; the equivalence is enforced in buildConsoleTenantIdFilter and unit-tested, but the HTTP behaviour is unproven)
  • Soft-deleted tenants appear in neither view — traced: softDeletePlugin excludes them from find, and the count now excludes them too (fix below) (agent)
  • The directory is server-paginated and never loads the whole collection — traced: 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)
  • auth: staff and partner sign-in + reach the console — (operator)
  • payments: not touched (agent)
  • notifications: none expected — the diff adds no notification path (agent)

Findings & cleanup

Fixed on branch:

  • Index collation — indexes could not serve the sort they exist for, and raced 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.
  • A repeated query param 500'd the directory. ?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.
  • An out-of-range ?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.
  • The users card silently truncated at 100. The Clerk membership read is capped and unpaged; a larger roster read as complete. It now says so when the cap is hit.

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.jsonglobalEnv 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):

  • Confirm 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.
  • Migration/deploy ordering. 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.

Notes for Ship

  • The last acceptance criterion stays unticked by design: only the unit tier is configured, so the tests pin the scoping algebra (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.md

Sustentus staff and partners can now find and open a tenant

Who 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.md

Ship: tenant-directory

  • pr: #887 · merge: authorised — Ready to merge ticked by Jamie; this commit rides the squash
  • CI: GREEN on a3b40f0 — 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.
  • technical docs: added 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 docs: no business docs impact. 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.
  • release notes: ship-note-only — no end-user note. The help centre serves the tenant-facing platform, and 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.
  • sent: queued — 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: 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).

Acceptance check (vs spec)

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.

  • Staff see every live tenant with name, status and setup state — traced in listForConsole (agent); not demonstrated signed-in
  • Search narrows by case-insensitive name substring — pinned by console-scope.test.ts (escaped, case-insensitive); not demonstrated signed-in
  • A partner sees only tenants whose partnerId 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 empty
  • Nothing on the partner's directory reveals other tenants exist — traced: no global count, no pagination past their own set (agent)
  • Detail view shows name, derived status, setup state, identity fields and the user list — the operator opened this page on the preview; it surfaced the Clerk 404 that was then fixed on branch (operator + agent)
  • An unassigned tenant, a nonexistent id and a malformed id all return the same not-found — enforced in buildConsoleTenantIdFilter and unit-tested; the HTTP behaviour is unproven
  • Soft-deleted tenants appear in neither view — traced, and the count now excludes them too (fixed at Verify) (agent)
  • The directory is server-paginated and never loads the whole collection — traced: findPaginated, 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)
  • Unit tests cover the scoped read pair — deliberately unticked. Only the unit tier is configured (node env, no DB), so the tests pin the scoping algebra in console-scope.ts rather than that Mongo honours those filters. That is the honest boundary; see the build notes.

Carried forward (not fixed in this run)

  • 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.jsonglobalEnv and set in Vercel — but it is a real one-line gap.
  • Migration/deploy ordering. 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.

Owner decision recorded

  • Production database env. Verify could not read Vercel env vars and flagged this as an owner decision before merge. Jamie confirmed in-session (2026-08-26) that both 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.