Skip to Content

← All archived runs

Run: partner-assignment

run.md

Run: partner-assignment

  • branch: claude/pipeline-partner-assignment-u8fkdj
  • pr: #888

02_define/output/spec.md

Spec: Partner assignment on tenants

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

Problem

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

Proposed change

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:

  • A select of known partners, derived exactly as the Access page derives its roster — from the console's own invitation list, resolved to live Clerk users, keeping only those whose current marker reads 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.
  • An assign-by-email fallback, for a partner granted directly onto an existing Sustentus account, who leaves no invitation behind and so never appears in that roster (the same blind spot the Access page documents). The console looks the address up in Clerk and assigns that user.

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.

Where the code goes

  • One shared write function, 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.
  • Authorization stays at the caller, deliberately. Unlike the scoped reads, whose whole point is that no call site can widen them, this function must serve two callers with different rights: staff assigning anyone, and (next stub) a partner being assigned to the tenant they just created. So 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.
  • The update document is a pure builder beside the filter builders in 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.
  • No migration. The field and its { 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.
  • No new environment variable — the console already reads Mongo and Clerk with what it has.

Acceptance criteria

  • A Sustentus staff member on a tenant's detail view can assign a partner by choosing one from the partner select, and the tenant then shows that partner's name and email address.
  • The same staff member can assign a partner by email address for a partner who is not in the select, and can re-assign an already-assigned tenant to a different partner, replacing the previous one.
  • Clearing the assignment leaves the tenant with no partner, and the field is unset rather than set to an empty string.
  • Assigning a Clerk user who does not currently carry the partner marker — a staff member, an unmarked platform user — is refused with a notice and writes nothing; so is an email address with no Clerk account, and a blank or malformed input.
  • A tenant can never hold two partners at once: the single field makes double assignment structurally impossible.
  • A newly assigned partner sees that tenant in their directory and can open its detail view; a partner whose assignment was cleared or moved sees neither, getting the same not-found response as for a tenant that does not exist.
  • A signed-in partner sees no assignment panel, no assigned-partner display, and no control that changes an assignment anywhere in the console.
  • A partner posting the assignment action directly is refused by requireStaff() and no write occurs.
  • A tenant assigned to a Clerk account that no longer exists renders the detail view intact, showing the unresolved id with an explanatory note instead of failing.
  • Assigning a soft-deleted tenant, a tenant id that does not exist, and a malformed tenant id all fail without writing, indistinguishably.
  • The partner roster behind the select is read through one shared implementation used by both the Access page and the assignment panel — there is not a second copy.
  • Nothing in apps/web or any tenant-facing surface reads or writes partnerId, and this run adds no such path.
  • Unit tests cover the update builder (set writes $set, clear writes $unset) and the partner-marker validation (marked partner accepted; staff marker, absent marker and unknown account each refused).

Out of scope

  • Automatic assignment when a tenant is created — that arrives with tenant creation itself in tenant-lifecycle, calling the setPartner function this run introduces.
  • Notifying a partner that they gained or lost a tenant [Q-12].
  • Recording the assignment in the activity trail — tenant-activity-dashboard owns the trail and will hook the one shared write function this run creates.
  • A partner column, partner filter, or "unassigned only" view in the tenant directory; the stub settles visibility at the detail view.
  • A partner-firm entity — the assigned person is the partner in v1; firm-level assignment is a later round.
  • Bulk or multi-tenant assignment, and any assignment surface outside the tenant detail view.
  • Any change to the platform's org roles, session-token claims, Clerk webhook sync, or any surface in apps/web.
  • Letting a partner assign, re-assign, or clear anything, their own tenants included [Q-5].

Open questions

  • none — the one that affected what gets built (how staff choose the partner, given that Clerk cannot filter users by 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.md

Build notes: partner-assignment

  • commits: feat: partner-assignment — assign, re-assign and clear a tenant's partner
  • ci: GREEN on 1df81cc — every blocking check passed on the first push, Quality Project (format/lint/typecheck/test) and the tenant-management preview build included

What changed

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

Acceptance criteria status

  • Assign from the select — the panel's first form; the tenant then shows the partner's name and email address.
  • Assign by email address, and re-assign — the second form; $set replaces whatever was there.
  • Clearing unsets rather than writing an empty string — buildPartnerAssignmentUpdate(null), asserted in console-scope.test.ts.
  • A non-partner, an unknown address, and blank/malformed input are refused with a notice and write nothing — isConsolePartner on the live marker, plus the zod guards in actions.ts.
  • Never two partners at once — a single field; the builder emits $set, never $push/$addToSet (asserted).
  • A newly assigned partner sees the tenant; a cleared/moved one gets the same not-found — unchanged findForConsole/listForConsole scoping, now with a field that moves.
  • A partner sees no panel, no assigned-partner display, no control — the panel is behind isConsoleStaff, and the Clerk reads behind it are skipped for partners too.
  • A partner posting the action directly is refused — requireStaff() opens both actions.
  • A deleted Clerk account renders the detail view intact — readConsoleAccountById absorbs only not-found; AssignedPartner renders the unresolved id with an explanation.
  • Soft-deleted / absent / malformed tenant id all fail without writing, indistinguishably — buildConsoleTenantIdFilter carries LIVE_ONLY and returns null for a non-ObjectId.
  • One shared roster implementation — readConsoleRoster, consumed by both surfaces; the access page's copies are deleted rather than left behind.
  • Nothing in apps/web reads or writes partnerId — grepped: no occurrences at all.
  • Unit tests cover the update builder and the partner-marker validation.

Notes for Verify

  • Correction (made at Verify): the staff-only rule is enforced ONCE, not twice. This note originally claimed the proxy gated the route as well. It does not: 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.
  • Assign-by-email exists because Clerk cannot filter users by 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.
  • Not covered by unit tests, by design: the Clerk reads and the server actions — both need the integration tier that CONVENTIONS.md says is decided but unbuilt. The operator smoke pass on the preview owns them.

04_verify/output/verify.md

Verify: partner-assignment

  • ci: GREEN on f47eba9 — 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.
  • previews smoked: 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.)
  • production-readiness: run — 1 high (cross-PR merge-order risk), 1 doc-sync gap handed to Ship, 3 advisories, 2 nits. Two fixed on branch, rest recorded below. Its central finding contradicted a claim I had made in the build notes; verified against proxy.ts before acting on it.
  • code-review: CI Claude review (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.
  • security-review: run — no HIGH or MEDIUM findings at the reporting threshold. Privilege escalation, IDOR, NoSQL injection, open redirect, XSS and cross-boundary disclosure each traced to a closed path (detail in "Findings" below).
  • playwright: TODO — manual DoD smoke performed instead (E2E tier not specified; see 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.

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

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:

  • Console preview is deployed and serving for this branch — 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.
  • Nothing in apps/web or any tenant-facing surface reads or writes partnerId — grepped the whole app: zero occurrences (agent)
  • A tenant can never hold two partners at once — single scalar field; the update builder emits $set, never $push/$addToSet, asserted in console-scope.test.ts (agent)
  • Clearing unsets rather than writing an empty string — buildPartnerAssignmentUpdate(null){ $unset: { partnerId: 1 } }, asserted (agent)
  • Soft-deleted / absent / malformed tenant id all fail without writing, indistinguishably — buildConsoleTenantIdFilter carries LIVE_ONLY and returns null for a non-ObjectId, so setPartner returns false before any write (agent, traced)
  • A partner posting the action directly is refused — both actions open with requireStaff(), which redirect()s and therefore throws before the write (agent, traced)
  • One shared roster implementation — readConsoleRoster is the only roster reader; the access page's local copies are deleted, not duplicated (agent)
  • Unit tests cover the update builder and the partner-marker validation — 7 new assertions, green in the Quality workflow's Run tests step (agent)

Pending operator — not verified, not ticked (each needs a signed-in session on the preview):

  • Assign a partner from the select; the tenant then shows that partner's name and email (operator)
  • Assign by email address for a partner not in the select (operator)
  • Re-assign an already-assigned tenant to a different partner, replacing the previous one (operator)
  • Clear the assignment; the tenant reads as unassigned (operator)
  • Assigning a staff member / an unmarked user / an unknown address / blank input is each refused with a notice and writes nothing (operator)
  • A newly assigned partner sees the tenant in their directory and can open it; a cleared or moved partner gets the same not-found as for a tenant that does not exist (operator)
  • A signed-in partner sees no assignment panel, no assigned-partner display, and no control that changes an assignment anywhere in the console (operator) — the highest-value line
  • A tenant assigned to a deleted Clerk account still renders, showing the unresolved id with its note (operator)
  • auth: a Sustentus staff member and a partner each still sign in and reach their home (operator)
  • payments: not touched by this diff (agent)
  • notifications: none expected — notifying a partner of an assignment change is out of scope per Q-12, and this run adds no notification path (agent)
  • walkthrough clip posted to the feature thread in #build (operator — visibility, not a gate)

Findings & cleanup

Fixed on this branch:

  1. My build notes asserted a security property that does not hold. They said the staff-only rule was "enforced twice — 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.
  2. Notice lookup hardened?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):

  1. PR #889 (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):

  1. Three statements in 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 — "partnerIdNo 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:

  1. Rollback story, stated: cleanly revertible — no migration, no schema field, no index. A revert leaves any partnerId values already written in production, which is harmless: tenant-directory's scoped reads already handle the field and treat presence and absence alike.
  2. 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.
  3. Preview/head mismatch — raised, then resolved. Mid-stage the head was an .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.

Operational note — git push authentication failed mid-stage

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:

  • Those commits are authored by the GitHub App rather than signed by the session's commit key.
  • The local checkout is diverged and stale — it holds an equivalent local commit that was never pushed, and cannot fetch to reconcile. Anyone resuming this run should re-clone or re-fetch with working credentials; resolve-run.sh will also fail its fetch until git auth is restored.
  • Nothing was lost: the branch on GitHub is the authoritative, complete state, and it is what CI verified green.

05_ship/output/investor-update.md

Partners can now be given tenants to run

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

Ship: partner-assignment

  • pr: #888 · merge: authorised — Ready to merge ticked by Jamie; this commit rides the squash
  • CI: GREEN on 29465f4, 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.
  • technical docs: updated 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.
  • business docs: no business docs impact — for the same structural reason 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.
  • release notes: ship-note-only — no end-user note. 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.
  • 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 holds the PR URL and no changelog URL (per the line above), so the placeholder guard has nothing to refuse.
  • close-out: 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).

Acceptance check (vs spec)

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.

  • Staff can assign a partner from the select, and the tenant then shows that partner's name and email — traced through assignPartnerreadConsoleAccountByIdsetPartner and the AssignedPartner render (agent); not demonstrated signed-in
  • Assign by email for a partner not in the select; re-assign replaces the previous partner — traced: the email branch resolves through readConsoleAccountByEmail, and $set on a scalar field is replacement by construction (agent); not demonstrated signed-in
  • Clearing unsets rather than writing an empty string — buildPartnerAssignmentUpdate(null){ $unset: { partnerId: 1 } }, asserted in console-scope.test.ts (agent)
  • An unmarked Clerk user, an unknown address, and blank or malformed input are each refused with a notice and write nothing — the live isConsolePartner(account.access) check and the XOR/zod guards, all returning before the write (agent); not demonstrated signed-in
  • A tenant can never hold two partners at once — single scalar field; the builder emits $set, never $push/$addToSet, asserted (agent)
  • A newly assigned partner sees the tenant in their directory; a cleared or moved one gets the same not-found as a nonexistent tenant — inherited from tenant-directory's in-query scoping, which this run only supplies the value for (agent, traced); not demonstrated signed-in
  • A signed-in partner sees no assignment panel, no assigned-partner display, and no control that changes an assignment — traced: the panel and both reads are behind isConsoleStaff(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-merge
  • A partner posting the action directly is refused by requireStaff() and no write occurs — traced: both actions open with it, and it redirect()s, which throws (agent)
  • A tenant assigned to a deleted Clerk account renders intact, showing the unresolved id with a note — traced: readConsoleAccountById absorbs only 404 and the third render state exists (agent); not demonstrated signed-in
  • Soft-deleted, absent and malformed tenant ids all fail without writing, indistinguishably — buildConsoleTenantIdFilter carries LIVE_ONLY and returns null for a non-ObjectId, so setPartner returns false before any write (agent, traced)
  • One shared roster implementation, not a second copy — readConsoleRoster in apps/console/lib/console-roster.ts; the Access page's local copies are deleted (agent)
  • Nothing in apps/web or any tenant-facing surface reads or writes partnerId — grepped the whole app: zero occurrences (agent)
  • Unit tests cover the update builder and the partner-marker validation — 7 new assertions, green in the Quality workflow's Run tests step (agent)

Carried forward (not fixed in this run)

  • The #889 conflict resolution — the highest-stakes thing in this merge, and it happened here. Verify's recommendation was to merge this PR first and let 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 realapps/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.
  • The conflict resolution's own bug — a semantic conflict git cannot see. This run had lifted 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.
  • Three further conflicts, all resolved on merge. 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.
  • The operator DoD smoke was never performed. Nine signed-in criteria remain unverified by a human; see 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.