partner-assignmentrun.md02_define/output/spec.mdPartner scoping is the heart of the console — "partners administer tenants assigned/referred to
them" — but nothing writes the field that scoping reads. tenant-directory (#887) declared
partnerId on the Tenant document, indexed it, and scoped every console read by it, but
deliberately shipped no write path: today every partner's directory is permanently empty and
every tenant is permanently unassigned. Until Sustentus staff can put a partner on a tenant, the
partner half of the console does nothing at all, and tenant-lifecycle (which auto-assigns a
partner-created tenant to its creator) has no assignment mechanism to call.
This is the write that turns the console from a staff-only viewer into the partner-facing surface Scale the bridge / Establish product-market fit with vendor partners (2026-Q2, Objective 1) needs.
An Assigned partner panel on the console's tenant detail view (/tenants/:id), visible and
operable to Sustentus staff only, from which they set, change, or clear the tenant's one partner.
The assignment is a field, not a membership [Q-3]. It is the existing optional partnerId on
the Tenant document, holding the assigned partner's Clerk user id. Setting it assigns; setting it
to a different id re-assigns; clearing it unassigns. At most one partner per tenant is the
field's cardinality — double assignment is structurally impossible, not policed. A tenant may
have none, and absence (not an empty string) is how "unassigned" is stored.
Choosing the partner. The panel offers two ways, decided with the author 2026-08-26:
audience: "partner". That roster read is factored out of the Access page into
apps/console/lib/ so both surfaces use one implementation rather than two that drift.Both paths validate the same thing before writing: the chosen Clerk user must currently carry
the console partner marker, read live via parseConsoleAccess. Assigning a staff member, an
unmarked platform user, or an unknown address is refused with a plain notice and no write. That
check is what keeps partnerId meaningful — without it the field would accept any Clerk user id
and quietly scope nothing.
Clearing unsets the field rather than writing an empty string, so an unassigned tenant reads the same whether it was never assigned or was cleared.
Displaying it. The detail view shows the assigned partner's name and email address, read live from Clerk. Three states are real and all three are rendered: unassigned; assigned to a partner Clerk can resolve; and assigned to an id Clerk no longer knows (a deleted account) — which shows the raw id with a note that the account no longer exists, rather than taking the page down. A revoked partner is deliberately not a fourth state: revoking console access does not clear an assignment, it stops the person signing in, and the field survives so re-granting access restores their scope.
Partners see none of this. The panel renders only for audience: "sustentus" and every action
behind it opens with requireStaff(), so a partner can neither read nor write an assignment —
their own included — from either the page or a hand-posted request.
tenantService.setPartner(tenantId, partnerId | null), in
packages/services/src/db/services/tenant/index.ts, so tenant-lifecycle's auto-assignment
path (partner-created tenant → that partner) calls the same code rather than a second copy
[Q-3, Q-4]. It resolves the tenant through the existing
buildConsoleTenantIdFilter({ audience: "sustentus" }, tenantId), which gives it the live-only
guard and the malformed-id → null answer for free; a soft-deleted or absent tenant is not
assignable.requireStaff() lives on the console server action, proxy.ts gates the
route, and the service function does not consult Clerk at all. Build must not push a staff
check down into it.console-scope.ts — { $set: { partnerId } } or { $unset: { partnerId: 1 } } — so the
set/clear asymmetry is unit-testable without a database, matching how the filter builders are
tested today.ConsoleTenantDetail gains partnerId?: string and findForConsole projects it; the
Clerk resolution of that id to a person happens in the console, not the services package.{ partnerId: 1, name: 1 } index already exist from
tenant-directory's two index migrations; this run adds no schema field and no index, so
there is nothing to keep in lockstep. The partnerId doc comment in
db/models/tenant.ts, which currently says the field is read-only and that
partner-assignment introduces the writes, is corrected to describe them.requireStaff() and no write
occurs.apps/web or any tenant-facing surface reads or writes partnerId, and this run
adds no such path.$set, clear writes $unset) and the
partner-marker validation (marked partner accepted; staff marker, absent marker and unknown
account each refused).tenant-lifecycle, calling the setPartner function this run introduces.tenant-activity-dashboard owns the trail and
will hook the one shared write function this run creates.apps/web.publicMetadata) was put to the author and settled on 2026-08-26:
a select of invitation-derived partners plus an assign-by-email fallback.Context budget: over — beyond the stub, scope.md and the knowledge map, this run read the
console's own source (lib/console-access.ts, lib/console-tenants.ts, the access actions and
page, the tenant detail page, services/tenant/{index,console-scope}.ts, the partnerId region
of the tenant model) to establish that the field, its index and the scoped reads already exist and
that Clerk cannot filter by metadata. The initiative and objective were taken from the stub rather
than re-read from business/.
03_build/output/notes.mdfeat: partner-assignment — assign, re-assign and clear a tenant's partnerQuality Project
(format/lint/typecheck/test) and the tenant-management preview build includedpackages/services/src/shared/console-access.ts: isConsolePartner, the exact mirror of
isConsoleStaff. Separate question, separate predicate: one asks "may this viewer
administer?", the other "may this account be recorded as a tenant's partner?".packages/services/src/db/services/tenant/console-scope.ts: buildPartnerAssignmentUpdate,
a pure builder beside the existing filter builders. Clearing $unsets rather than writing an
empty string, so a cleared tenant reads identically to one never assigned and no third state
enters the { partnerId, name } index.packages/services/src/db/services/tenant/index.ts: setPartner — the one shared write —
plus partnerId on ConsoleTenantDetail and its projection. The write resolves the tenant
through the existing buildConsoleTenantIdFilter({ audience: "sustentus" }, …), inheriting the
live-only guard and the malformed-id refusal for free.packages/services/src/db/models/tenant.ts: corrected the partnerId doc comment, which still
claimed the field was read-only pending this run.apps/console/lib/console-roster.ts (new): the invitation-derived roster and the two account
lookups, lifted out of the access page so the access page and the assignment select read one
implementation.apps/console/lib/clerk-errors.ts (new): isClerkNotFound, lifted from its private home in
console-tenants.ts because a second reader now needs it — one home rather than two copies.apps/console/app/(console)/tenants/[tenantId]/actions.ts (new): assignPartner and
clearPartner, both opening with requireStaff().apps/console/app/(console)/tenants/[tenantId]/page.tsx: the staff-only assignment panel,
the three assigned states, and the notice banner.apps/console/app/(console)/access/page.tsx: consumes the shared roster; its local copies deleted.No migration and no new environment variable. tenant-directory already declared partnerId
and created { partnerId: 1, name: 1 }; this run adds no schema field and no index.
$set replaces whatever was there.buildPartnerAssignmentUpdate(null),
asserted in console-scope.test.ts.isConsolePartner on the live marker, plus the zod guards in actions.ts.$set, never $push/$addToSet
(asserted).findForConsole/listForConsole scoping, now with a field that moves.isConsoleStaff, and the Clerk reads behind it are skipped for partners too.requireStaff() opens both actions.readConsoleAccountById absorbs
only not-found; AssignedPartner renders the unresolved id with an explanation.buildConsoleTenantIdFilter carries LIVE_ONLY and returns null for a non-ObjectId.readConsoleRoster, consumed by both surfaces; the
access page's copies are deleted rather than left behind.apps/web reads or writes partnerId — grepped: no occurrences at all.apps/console/proxy.ts
staff-matches /access(.*) only, so /tenants/:id is viewer-gated — correctly, since partners
must read their assigned tenants there. requireStaff() inside each server action is therefore
the whole boundary. It fails closed and is correct, but it is single-layered, and the actions now
carry a load-bearing comment saying so. Worth confirming on the preview that a partner viewing
their own assigned tenant sees no trace of the assignment — the criterion most likely to regress
in a later run.setPartner deliberately does not check authorization, unlike the scoped reads. It is shared
with tenant-lifecycle, whose auto-assignment path runs as a partner assigning their own new
tenant; a requireStaff pushed down into it would break that. This is the one place the package's
"authorization lives in the service" habit is intentionally not followed, and the reason is in the
method's doc comment.isConsolePartner is read live, not off the roster that drew the form. A partner whose access
was revoked between page render and submit is refused. That is the intended behaviour, not a race
to fix.publicMetadata. The roster is
derived from this console's own invitations, so anyone granted directly onto an existing account
never appears in the select. Same blind spot the access page already documents.CONVENTIONS.md says is decided but unbuilt. The operator smoke pass on the
preview owns them.04_verify/output/verify.mdf47eba9 — settled via ci-status.sh after the last code push of this stage. The
only commit after it is this evidence file plus the build-notes correction; that head was
re-verified green and its SHA is named in the Verify handoff.tenant-management (console) built for f47eba9 — every Vercel project
built at this head, none skipped. (An earlier head in this stage had no console preview because
its commit was .icm/**-only; that mismatch is gone.)proxy.ts before acting on it.Review diff against CONVENTIONS.md) enabled and passing —
zero inline comments posted, zero reviews. Triaged as nothing to action. Per the Verify
contract this replaces a separate /code-review pass at standard → medium effort.CONVENTIONS.md → Testing).Context budget: within the Inputs table. One deliberate overrun: PR #889's file list was read to confirm the add/add collision below, because the finding is only actionable if the collision is real.
The agent could demonstrate nothing on the preview. The console deployment sits behind Vercel
SSO (GET / → 302 to vercel.com/sso-api), so it is reachable and serving but not enterable
without Vercel credentials the agent does not hold. Every signed-in criterion is therefore the
operator's, and none of them is ticked here.
Agent-verified:
GET / and GET /tenants both
return 302 to Vercel SSO, i.e. the app is up and protected, not 404/500 (agent). The agent
cannot enter it: Vercel deployment protection stops it before Clerk sign-in, which is why
every signed-in line below is the operator's.apps/web or any tenant-facing surface reads or writes partnerId — grepped the
whole app: zero occurrences (agent)$set, never $push/$addToSet, asserted in console-scope.test.ts (agent)buildPartnerAssignmentUpdate(null) →
{ $unset: { partnerId: 1 } }, asserted (agent)buildConsoleTenantIdFilter carries LIVE_ONLY and returns null for a non-ObjectId, so
setPartner returns false before any write (agent, traced)requireStaff(),
which redirect()s and therefore throws before the write (agent, traced)readConsoleRoster is the only roster reader; the access
page's local copies are deleted, not duplicated (agent)Quality workflow's Run tests step (agent)Pending operator — not verified, not ticked (each needs a signed-in session on the preview):
#build (operator — visibility, not a gate)Fixed on this branch:
proxy.ts gates the route, and the actions call requireStaff()".
It is enforced once. apps/console/proxy.ts staff-matches /access(.*) only, so
/tenants/:id is viewer-gated — correctly, because partners must read their assigned tenants
there. requireStaff() inside each action is the whole boundary. It fails closed and is
correct, but it is single-layered and was documented as double-layered. Notes corrected; both
actions now carry a load-bearing comment stating that they are the only gate.?notice=constructor was truthy against a bare object index and
rendered an empty alert. Now guarded with an own-property check. Not injectable (React escapes),
so cosmetic, but it is new code of mine and the fix is one line.
This fix went red first, and that is worth recording: the first version used Object.hasOwn,
which needs the es2022 lib while apps/console targets lower. It failed
@sustentus/console#typecheck and took the console preview build down with it — CI caught it,
I read the job log, and replaced it with Object.prototype.hasOwnProperty.call, which is
target-agnostic. A cosmetic fix pushed without thinking about the app's tsconfig cost a full CI
cycle; the lesson is the Build contract's own rule, which I did not apply to a change I judged
trivial.Needs a decision from Jamie — merge order (highest severity):
tenant-user-management) creates the same file with the opposite gate, and it is
already ticked Ready to merge. It adds
apps/console/app/(console)/tenants/[tenantId]/actions.ts — the same new path this run creates
— whose actions resolve through requireViewer() (confirmed: 4 requireViewer() vs 2
requireStaff() in its diff), deliberately, because user management is a power both audiences
hold. Whichever PR merges second hits a guaranteed add/add conflict on that file, and a
resolution that harmonises everything on the file's then-dominant requireViewer() hands
partners the ability to assign themselves any tenant. There is no proxy rule to catch it and
apps/console has no test tier to fail (#889 is what introduces vitest there).
Mitigated here by the load-bearing comment, but the ordering is a call only Jamie can make:
merge #888 first, then resolve #889 onto it, and once #889's test tier exists add a
regression test asserting a partner viewer is refused by both assignment actions.Handed to Ship (docs are Ship's stage, via docs-sync — not silently skipped):
apps/docs/app/technical/applications/console/page.mdx are false as of
this merge and must be corrected in this PR at Ship::78-79 — "partnerId … No code path writes it yet — assignment is its own change."
This PR is that change.:53 — the route table calls /tenants/:id "Read-only tenant detail". No longer true
for staff.:41 — "Staff-only routes are enforced twice — in the proxy and again in requireStaff."
True of /access, false as a general rule, and it is precisely the sentence that would
justify a bad #889 conflict resolution. Should say the proxy gates console access, and that
staff-only actions carry their own requireStaff() because action dispatch is not
route-scoped.Accepted, with reasons:
partnerId values already written in production, which is harmless:
tenant-directory's scoped reads already handle the field and treat presence and absence alike.readConsoleAccountByEmail matches any email record on an account, including unverified
ones — a theoretical mis-assignment vector between two already-marked partners. Accepted: the
panel immediately renders the resolved name and email, so staff see the wrong person at once,
and the action is staff-only..icm/**-only
commit, so Vercel correctly skipped the console build and there was no preview at the head to
smoke. The Verify contract calls that mismatch a finding in its own right, so it was recorded
rather than papered over. It resolved itself: the Object.hasOwn fix put code back in the head
commit, and every Vercel project — tenant-management included — built at f47eba9.Partway through Verify, all authenticated git remote operations in this session began failing with
could not read Username for 'https://github.com' — fetch and push alike, after four backoff
retries and with no credential helper, http.extraheader or reachable proxy to repair. Earlier
pushes in the same session had worked, so this is a credential expiry in the environment, not a
repository problem.
The commits from this stage were therefore pushed through the GitHub MCP (push_files), which
authenticates independently. Consequences worth knowing:
resolve-run.sh will also fail its fetch until git auth is restored.05_ship/output/investor-update.mdWho it's for: The Sustentus team and its vendor partners What shipped: Staff can assign, re-assign or clear a tenant's partner from the console's tenant detail view. Why it matters: Without this write the partner half of the console was inert — it is what Scale the Bridge needs to establish product-market fit with vendor partners.
Dig deeper: https://github.com/sustentus/sustentus/pull/888
05_ship/output/release.md29465f4, settled via ci-status.sh after the docs + ship-output push — six
blocking check runs plus Vercel – docs and Vercel – web, none failed. That verdict was then
invalidated by a conflict resolution, not by a code change of ours: PR #889 merged to main
at 07:50:08 UTC while this run was waiting for CI to settle, and the merge attempt came back
405 Pull Request has merge conflicts. main was merged into this branch and four conflicts were
resolved (below). That resolution went red first, and it was a real defect, not a flake:
08efdbe failed Vercel – tenant-management and Quality Project with
Type error: Cannot find name 'isNotFound' — one root cause, two red checks. Fixed in 6abd8c4,
which settled GREEN with all seven Vercel previews built and every blocking check passing. The
commit carrying this file is settled separately, since a file cannot name the commit that contains
it.apps/docs/app/technical/applications/console/page.mdx — the three
statements Verify found false as of this merge. (1) "Staff-only routes are enforced twice"
replaced: the proxy gates console access and staff-matches /access(.*) alone, so a staff-only
action on a viewer-gated route carries its own requireStaff(), which is the whole boundary —
server actions dispatch by action id and can be posted from any path. (2) The route table's
"Read-only tenant detail" for /tenants/:id, no longer true for staff. (3) "No code path writes
partnerId yet", which this PR is the change that ends. Added a Partner assignment section
covering the field's cardinality, the unauthorized-by-design shared write, the two ways a partner
is chosen, and the single roster implementation.tenant-directory (#887)
recorded. business/roles and business/feature-role-matrix describe the platform's six
tenant-scoped roles and the permissions registry governing them. The console's audiences
(sustentus, partner) are org-less Clerk users gated by a publicMetadata marker, outside that
registry entirely. platform-overview's "Partner" is a pricing tier for white-label partners, a
different thing again.changelog-entry admits only the six
canonical platform persona ids, and neither console audience is one; the help centre serves the
tenant-facing platform, so an entry there would announce a surface no reader can reach. Verified
against the skill rather than inherited from #887's precedent.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 holds the PR URL
and no changelog URL (per the line above), so the placeholder guard has nothing to refuse.close-out.sh partner-assignment archives this run to
apps/docs/archive/pipeline-runs/partner-assignment/. The tenant-management-app epic does
not archive with it — three stubs remain in flight (tenant-activity-dashboard,
tenant-lifecycle, tenant-plan-visibility).Verify split the Definition of Done into what the agent could trace and what needs a signed-in session on the preview. The operator half was never returned — the console preview sits behind Vercel deployment protection, which stops the agent before Clerk sign-in. Ticking Ready to merge is what authorised the merge over that gap rather than closing it. Each line below says which it is.
assignPartner → readConsoleAccountById → setPartner and the
AssignedPartner render (agent); not demonstrated signed-inreadConsoleAccountByEmail, and $set on a scalar
field is replacement by construction (agent); not demonstrated signed-inbuildPartnerAssignmentUpdate(null) →
{ $unset: { partnerId: 1 } }, asserted in console-scope.test.ts (agent)isConsolePartner(account.access) check and the
XOR/zod guards, all returning before the write (agent); not demonstrated signed-in$set,
never $push/$addToSet, asserted (agent)tenant-directory's in-query scoping,
which this run only supplies the value for (agent, traced); not demonstrated signed-inisConsoleStaff(viewer.access) (agent). Verify called this the highest-value line and it
was never demonstrated signed-in — the single most valuable thing to smoke post-mergerequireStaff() and no write occurs —
traced: both actions open with it, and it redirect()s, which throws (agent)readConsoleAccountById absorbs only 404 and the third render state exists
(agent); not demonstrated signed-inbuildConsoleTenantIdFilter carries LIVE_ONLY and returns null for a non-ObjectId, so
setPartner returns false before any write (agent, traced)readConsoleRoster in
apps/console/lib/console-roster.ts; the Access page's local copies are deleted (agent)apps/web or any tenant-facing surface reads or writes partnerId — grepped the
whole app: zero occurrences (agent)Quality workflow's Run tests step (agent)tenant-user-management (#889) resolve
onto it. The opposite happened: #889 was merged to main by a human at 07:50:08 UTC while
this run was waiting on CI, so the add/add conflict on
apps/console/app/(console)/tenants/[tenantId]/actions.ts fell to this PR to resolve — exactly
the resolution Verify flagged as the danger, because the file's other four actions are
requireViewer() on purpose and harmonising them all would hand partners the assignment write.
It was resolved as a union that keeps both gates, and the file now opens with a header saying
so in as many words: assignPartner/clearPartner keep requireStaff(); invite / revoke /
remove / re-role keep requireViewer() via resolveTarget. Verified after resolving: two
requireStaff() call sites, one requireViewer(), and the six exported actions map onto them as
intended. The remaining gap is unchanged and still real — apps/console has a vitest tier
now that #889 introduced it, and nothing yet asserts that a partner viewer is refused by the two
assignment actions. That regression test is the single most valuable follow-up from this run.isNotFound out of console-tenants.ts into lib/clerk-errors.ts as isClerkNotFound, deleting
the module-local copy and updating its one call site. #889 independently added three more call
sites of the old local name, in regions that did not conflict textually. Git correctly applied
both the deletion and the new references, and the result named a function that no longer existed.
Both predicates were byte-identical, so the three sites now call the shared helper. The lesson is
the same one the Object.hasOwn slip taught at Verify and it was not applied: a conflict
resolution has to be checked against what each side did to the file's symbols, not just its
text. Diffing the resolved file against both parents — which is what found the remaining files
clean — should have come before the push, not after the red.page.tsx was rebuilt on main's version with
this run's additions re-applied, because #889 had restructured the Users card; its notice lookup
now uses main's Map, which solves the prototype-inheritance problem better than the
Object.prototype.hasOwnProperty.call guard this run had pushed, so that guard is gone. Both
runs had claimed the notice code invalid-input with different meanings, so assignment's is
renamed assign-invalid-input. console-tenants.ts took both imports. The console docs page's
route-table row now names all three capabilities.backToDirectory's notice code is inert. /tenants renders no notice banner, so the code it
carries is dropped on the floor. Pre-existing from Build, not caused by the merge, and left
alone rather than widened into this PR.04_verify/output/verify.md. They are now post-merge checks against production.readConsoleAccountByEmail matches any email record on an account, verified or not — accepted
at Verify: a theoretical mis-assignment between two already-marked partners, made visible
immediately because the panel renders the resolved name and email, and reachable by staff only.Context budget: within the Inputs table.