Skip to Content

← All archived runs

Run: agentic-tenant-context

run.md

Run: agentic-tenant-context

  • branch: claude/agentic-tenant-context-pipeline-1dzc2u
  • pr: #902

02_define/output/spec.md

Spec: Tenant, role and data access for the agentic app

  • slug: agentic-tenant-context
  • personas: admin, csm, sdm, expert, vendor, customer
  • touches: apps/agent, packages/services/src/shared, packages/services/src/auth, packages/services/src/ai/assistant, packages/services/src/db/services/tenant, apps/web/lib, apps/docs/app/technical/applications
  • complexity: complex

Problem

apps/agent is where every new AI surface in the AI-platform-enablement batch is meant to land, and it cannot host one. Its gate is "authenticated Clerk user and nothing finer": no tenant, no role, and a services boundary that admits only the client-safe /ai subpath (apps/agent/AGENTS.md). The agentic-app-foundation batch deferred the auth-core lift deliberately — "a decision for the batch that introduces data access" — and this is that batch.

That deferral is now a hard blocker on the objective this batch serves (Scale the bridge / Q2-2026 Objective 2 — Build Repeatable Lead Generation Pipeline). Proposal scores must reach the customer and their managers and never a competing expert; an SDM shortlist is meaningless without knowing who the SDM is. Stubs 3–5 of this batch (ai-proposal-scoring, expert-matching-agent, expert-recruiter-agent) have nowhere legitimate to render until the agentic app can answer the same three questions apps/web answers on every request — which tenant, which user, what they may see — and read data on the strength of those answers.

Two populations, not one. Tenant users hold a Clerk org and a platform role. Sustentus staff and invited partners hold neither: they are org-less Clerk users carrying a publicMetadata console-access marker (packages/services/src/shared/console-access.ts), and today apps/web sends them to the console because /no-role is a dead end for them. They need the agent too, and with more reach than any tenant user — staff work across every tenant, partners across the tenants assigned to them. An agentic app that admits only org holders would lock out the people who run the platform.

Proposed change

Lift the tenant and role resolution that apps/web already has into @sustentus/services, have apps/agent resolve a caller's context on every request — tenant user or console audience — extend its deny-by-default gate accordingly, and bind the assistant's tools to whichever single tenant the caller is entitled to and currently working in.

It is largely a move, not a rewrite. The logic exists and works; the run relocates it so one definition serves both apps, and adds the agentic app's use of it.

The shared halves

The split follows the existing services boundary rather than inventing one:

  • Pure half → @sustentus/services/shared (packages/services/src/shared/platform-auth.ts): the claim-reading role helpers moved verbatim from apps/web/lib/auth.tsmapClerkRoleToSystemRole, deriveGrantedRoles, resolveEffectiveRole, and the GRANTED_ROLES_CLAIM / ACTIVE_ROLE_COOKIE / GRANTABLE_ROLES constants they read. No Clerk SDK and no next/headers, so either app's middleware can call them — the precedent is console-access.ts, and USER_ROLES already lives here.
  • Server half → @sustentus/services/server (packages/services/src/auth/request-context.ts): getTenant / getTenantOrNull moved verbatim from apps/web/lib/tenant.ts (Clerk auth() + clerkClient() + tenantService.upsertFromClerk, react.cache memoisation, the expected-failure classifier and its sanitised logging), plus the new resolvePlatformContext() described below.

route-policies.ts, View As, ROLE_HOME and the DB-backed permission resolver do not move (Q1 settled: the minimum — tenant, role, audience and a fail-closed membership check). The agentic app has one route; a policy table follows when it has routes worth policing.

One resolver, two kinds of caller

resolvePlatformContext() returns a discriminated union, or null when the caller is neither:

  • { kind: "tenant", clerkUserId, tenant, tenantId, role } — a signed-in user with a Clerk org and a recognised platform role, exactly as apps/web resolves them.
  • { kind: "console", clerkUserId, audience, scope, tenant, tenantId } — an org-less user carrying the console-access marker. scope is the existing ConsoleTenantScope (packages/services/src/db/services/tenant/console-scope.ts): { audience: "sustentus" } reaches every tenant, { audience: "partner", partnerId } only the tenants carrying their Clerk user id. That discriminated union is already the single home of partner authorisation in the console, and it is reused rather than restated. tenant/tenantId are the tenant they are currently working in, or null when they have not chosen one.

The two kinds are deliberately separate types: there is no way to build a console context without the scope that bounds it, and no way for a missing partnerId to silently widen a partner to the whole collection — the same reasoning that shaped ConsoleTenantScope.

Staff and partners pick a tenant; the binding never widens

Every AI tool is bound by createTenantContext(tenantId) to exactly one tenant, and a tool's execute never receives a tenant id — that construction is what makes "a tool cannot be pointed at another tenant" structurally true rather than merely intended. This run does not weaken it.

Instead, a console user chooses which tenant they are working in, and the session binds to that one tenant through the unchanged primitive. Staff may choose any tenant; a partner only theirs. Their extra privilege is reach, not power: more tenants available, one at a time, and nothing extra inside the one they pick.

The selection lives in an httpOnly cookie and is never trusted on its own — the discipline apps/web already applies to ACTIVE_ROLE_COOKIE and VIEW_AS_COOKIE. On every request the selected id is re-authorised against the caller's ConsoleTenantScope via the scope-bounded read; anything that does not come back — a partner naming an unassigned tenant, a forged id, a stale cookie for a since-deleted or since-unassigned tenant — resolves to "no selection", never to an error that would confirm the tenant exists. Revocation therefore takes effect on the next request rather than at the next sign-in.

The gate

apps/agent/proxy.ts stays deny-by-default and covers every route, and now admits exactly two kinds of caller:

  • an org holder with a recognised platform role, decided from Clerk session claims alone — no database and no Clerk API call on the hot path;
  • an org-less user carrying the console-access marker, read live from Clerk exactly as apps/console/proxy.ts reads it and for the same stated reason: reading it live is what makes a revocation deny the next request. The read happens only on the org-less branch, so a tenant user's request costs nothing extra.

Everyone else is refused: 403 on /api/**, and page routes fall through to an honest "no workspace" state rather than a crash or another tenant's data. /sign-in and the @vercel/firewall rate-limit callback remain the only public paths, and adding a route still adds nothing to the file unless it is public.

The surfaces

app/page.tsx renders one of three states from the resolved context: the assistant (tenant user, or console user with a valid selection); a tenant picker (console user with none, listed through tenantService.listForConsole(scope) so the list is bounded by the same clause that bounds everything else, with the current tenant always visible and switchable); or the "no workspace" state. app/api/chat/route.ts resolves the context server-side, answers 403 without one and 409 when a console user has not chosen a tenant, and otherwise streams with a toolset bound to the resolved tenant.

The tool binding is proved, not asserted

packages/services/src/ai/tools/catalogue.ts already carries a real tenant-scoped read-only tool (buildCatalogueTools, exported from /server) built on defineTenantTool. This run binds that existing tool into the assistant using the tenant resolved from the session, and adds the tools option to buildAssistantAgent to carry it. Nothing is invented to demonstrate the mechanism, and stubs 3–5 add their tools the same way.

Boundary and docs move with the code

apps/agent/AGENTS.md states that /server, /db and /shared are outside the app's boundary and that it reads no tenant data; the apps/docs applications page says the same. Both are updated in this PR to record the new terms — /shared anywhere, /server in server-only files (route handlers and server components) and never from a client component — and the app's two audiences.

apps/web gets a shim, not a sweep

apps/web/lib/auth.ts and apps/web/lib/tenant.ts become thin re-exports of the shared implementation, exactly as auth.ts already re-exports USER_ROLES from @sustentus/services/shared. No call site changes and no behaviour changes, so "the platform keeps working exactly as before" is reviewable rather than asserted. Repointing the ~hundreds of @/lib/auth importers is a follow-up chore lane run, cut after this merges.

Acceptance criteria

  • mapClerkRoleToSystemRole, deriveGrantedRoles, resolveEffectiveRole, GRANTED_ROLES_CLAIM, ACTIVE_ROLE_COOKIE and GRANTABLE_ROLES live in @sustentus/services/shared, with their unit tests moved alongside them; apps/web/lib/auth.ts re-exports them and defines none of them itself.
  • getTenant and getTenantOrNull live in @sustentus/services/server with their expected-failure classification and sanitised logging intact; apps/web/lib/tenant.ts re-exports them and defines neither itself.
  • resolvePlatformContext() returns a kind: "tenant" context for a signed-in user with an org and a recognised platform role, a kind: "console" context carrying the audience and its ConsoleTenantScope for an org-less user with the console-access marker, and null for everyone else — it never throws at the caller.
  • A signed-in user who is neither (no org, no marker) sees an honest "no workspace" state on /: no crash, no stack trace, and no other tenant's data.
  • Sustentus staff can reach the agent with no Clerk org, choose any live tenant, and hold a conversation whose tools read that tenant.
  • An invited partner can reach the agent and choose only tenants carrying their Clerk user id; tenants not assigned to them are absent from the picker and refused if their id is supplied directly, with the same "no selection" outcome as a stale cookie rather than a response that confirms the tenant exists.
  • The selected-tenant cookie is re-authorised against the caller's scope on every request: a forged id, a revoked assignment, or a since-deleted tenant resolves to no selection, and the next request after a revocation is already denied.
  • Being staff grants reach, not power: inside a chosen tenant a console caller gets the same tools and the same data as a tenant user there — no additional capability, no cross-tenant tool.
  • apps/agent/proxy.ts stays deny-by-default and refuses on tenant/role/marker as well as session. Tenant users are decided from session claims with no database or Clerk API call; the live Clerk marker read happens only on the org-less branch. /api/** is refused with 403; /sign-in and the @vercel/firewall rate-limit callback remain the only public paths, and adding a route still adds nothing to the file unless it is public.
  • POST /api/chat answers 403 for a caller with no resolvable context, 409 for a console caller who has not chosen a tenant, and otherwise streams with a toolset built by buildCatalogueTools(createTenantContext(ctx.tenantId)).
  • A test asserts the chat route's tool context is taken from the resolved session and not from the request body — a tenantId supplied anywhere in the conversation payload does not reach the tool, for a tenant user and a console caller alike.
  • buildAssistantAgent accepts an optional tools map and passes it to defineAgent; AssistantAgent (the toolless default export) still builds and is unchanged for callers that pass nothing.
  • apps/agent/AGENTS.md records the new services boundary — /shared anywhere, /server in server-only files only, never from a client component — its two audiences, and no longer states that the app reads no tenant data.
  • The apps/docs Technical › Applications page's Agent section matches the shipped boundary and audiences.
  • No behaviour change in apps/web: no call site is repointed, no route's access changes, its org-less console redirect is untouched, and its existing auth and tenant tests pass unmodified.
  • The agentic-interface Vercel project has MONGODB_URI (and MONGODB_DATABASE_NAME where the platform sets one) configured for preview and production — verified on the preview deploy, since the app now opens a database connection. No turbo.json change is needed; both are already in globalEnv.

Out of scope

  • Persona registers, generated views, the claim primitive and the levers from the agentic architecture report — later batches. This is authorisation plumbing, not the agentic model.
  • Cross-tenant tools of any kind. A console caller works in one tenant at a time, through the unchanged createTenantContext. Tools that read or compare across tenants would need a second authorisation primitive beside the tenant binding, and that is a decision for the batch that has a use for one.
  • A staff privilege tier. The permission registry is tenant-scoped and has no staff level; staff get reach, not extra actions. Granting platform-admin capability, or treating staff as a tenant admin, is deliberately not inherited here — it would hand every admin action in every tenant to staff with no separate audit trail.
  • View As, role switching, the active-role cookie, role-aware navigation and any role-specific landing surface in apps/agent. Tenant users are read at their primary org role only.
  • route-policies.ts and the DB-backed permission resolver (resolveEffectivePermissions). No permission-gated tool ships here: the catalogue tool needs tenant membership and nothing finer, and the first tool that needs a permission check brings the resolver with it (stub 3).
  • Repointing apps/web's @/lib/auth and @/lib/tenant call sites at @sustentus/services — a follow-up chore lane run once this merges.
  • Changing where apps/web sends a marked org-less user. It still redirects them to the console; the agent admits them independently.
  • Any other change to, redirection of, or removal of an apps/web surface, and any change to the platform's Clerk org roles, session-token template or webhook sync.
  • Conversation persistence, history or resume — still ephemeral, including the tenant a console caller last worked in beyond the selection cookie itself.

Open questions

  • none. The stub's Q1 (how much of the auth core moves) is settled above as its recommended minimum; Q2 (where the shared code lives) as the existing /shared + /server entry points; Q3 (re-home this stub into agentic-app-foundation) resolves to no — that epic closed out and was archived before this run opened, so there is no cut to re-home into. agentic-app-foundation/agentic-chat-interface has shipped, so the conversation surface this run gates already exists.

Context budget: over — the Inputs table allows targeted greps, and settling the stub's Q1/Q2 plus the staff-access requirement needed the four apps/web/lib auth files, packages/services/src/{shared,permissions,ai} entry points, the console's scope filter and edge gate, and the whole of apps/agent read in full. The batch also has no scope.md (recorded in its breakdown.md: cut from Jamie's 2026-08-17 platform-audit notes, not from a Scope run), so the stub stands in as the settled scope; the org-less-audience requirement arrived from Jamie in conversation after the spec was first written and is recorded here rather than in a scope document.

03_build/output/notes.md

Build notes: agentic-tenant-context

  • commits: 3ff5ea1 — the lift, the agent gate + surfaces, the tool binding, docs
  • ci: GREEN on 3ff5ea1 (first push; Quality Project green, so format/lint/typecheck/tests all pass, and the agentic-interface and web previews both built)

What changed

  • packages/services/src/shared/platform-auth.ts (new): the claim-reading half of the role model, moved verbatim from apps/web/lib/auth.tsmapClerkRoleToSystemRole, deriveGrantedRoles, resolveEffectiveRole and the three constants they read. Pure and isomorphic, so the agent's edge gate can call it. Its unit tests moved alongside it.
  • packages/services/src/auth/request-context.ts (new, exported from /server): getTenant and getTenantOrNull moved verbatim from apps/web/lib/tenant.ts with their expected-failure classifier and sanitised logging intact, plus resolvePlatformContext() — a discriminated union over the two populations, reusing ConsoleTenantScope as the single home of partner authorisation.
  • apps/web/lib/auth.ts, apps/web/lib/tenant.ts: now thin re-exports of the shared implementation. No call site changed; View As, ROLE_HOME and the cookie layers stay here.
  • apps/agent/lib/agent-context.ts (new): reads the selection cookie and hands it to the resolver, which re-authorises it. apps/agent/app/select-tenant.ts records a choice.
  • apps/agent/proxy.ts: the gate now admits an org holder with a recognised role (claims only) or an org-less caller with the console marker (live Clerk read, org-less branch only). /api/** refuses with 403; pages fall through to the "no workspace" state.
  • apps/agent/app/page.tsx + components/tenant-picker.tsx: three states — assistant, tenant picker (bounded by listForConsole(scope)), "no workspace".
  • apps/agent/app/api/chat/route.ts: 403 without a context, 409 for a console caller with no tenant, otherwise streams with buildCatalogueTools(createTenantContext(ctx.tenantId)).
  • packages/services/src/ai/assistant/agent.ts: buildAssistantAgent takes an optional tools map; AssistantAgent still builds toolless.
  • apps/agent/AGENTS.md + the apps/docs applications page: the new boundary and the two audiences.

Acceptance criteria status

  • Pure role helpers live in /shared with their tests — apps/web/lib/auth.ts re-exports and defines none of them.
  • getTenant/getTenantOrNull live in /server with classification and logging intact — apps/web/lib/tenant.ts re-exports and defines neither.
  • resolvePlatformContext() returns the tenant/console union and null for everyone else; every failure path returns rather than throws.
  • A signed-in user with no org and no marker gets the "no workspace" state on /.
  • Staff reach the agent with no Clerk org, choose any live tenant, and hold a conversation whose tools read that tenant.
  • A partner sees and may select only tenants carrying their Clerk user id — listForConsole bounds the picker and findForConsole refuses a direct id, both through the same scope clause, and an unassigned id is indistinguishable from a missing one.
  • The selection cookie is re-authorised against the caller's scope on every request; a forged, revoked or deleted tenant resolves to no selection.
  • Reach, not power: a console caller gets the same toolset as a tenant user in the chosen tenant. No cross-tenant tool exists.
  • proxy.ts stays deny-by-default and refuses on tenant/role/marker; tenant users cost no DB or Clerk call; the marker read is org-less-only; /api/** → 403; public paths unchanged.
  • POST /api/chat → 403 / 409 / stream with the bound toolset.
  • A test asserts the tool context comes from the session, not the body, for both kinds of caller.
  • buildAssistantAgent accepts tools; AssistantAgent unchanged for callers passing nothing.
  • apps/agent/AGENTS.md records the boundary, the two audiences, and no longer says the app reads no tenant data.
  • The apps/docs applications page matches the shipped boundary and audiences.
  • No behaviour change in apps/web — no call site repointed; all 82 @/lib/tenant importers and every @/lib/auth importer resolve through the shims; the View As tests stay green where they are.
  • agentic-interface has MONGODB_URI configured — ops, not code. See Notes for Verify.

Notes for Verify

  • The one criterion Build cannot satisfy: the Vercel env var. agentic-interface now opens a database connection, so it needs MONGODB_URI (and MONGODB_DATABASE_NAME where the platform sets one) in the project's preview and production environments. No turbo.json change is needed — both are already in globalEnv. Until someone sets them on the project, the preview will build but every context resolution will fail closed to "no workspace". Worth confirming on the preview before Verify signs off the staff and partner criteria.
  • Where to look hardest. The selection cookie is the only caller-supplied input in the authorisation path. resolvePlatformContext is its single check; the server action deliberately does not re-check, so the two cannot drift. Confirm you agree that is the right trade.
  • The apps/web half is a move: git diff on apps/web/lib/auth.ts should read as deletions plus an import/re-export block, and tenant.ts as a four-line shim. Anything else there is a bug.
  • apps/agent/vitest.config.ts gained a @sustentus/services/server alias, following the note the scaffold left for exactly this case.

04_verify/output/verify.md

Verify: agentic-tenant-context

  • ci: GREEN on 7f5fb91, the stage's last code commit — settled via .icm/scripts/ci-status.sh, with all eight Vercel projects built on it. Every commit after it is this record itself, under .icm/** only; each was pushed and settled GREEN in turn, and the exact head handed to the gate is named in the handover message rather than written into the file it would immediately invalidate. On those docs-only heads turbo-ignore correctly skips seven of the eight Vercel projects — recorded as skipped, never quoted as a green preview, which is why the code lines below reason about the 7f5fb91 build.
  • previews smoked: agentic-interface on 7f5fb91 (https://agentic-interface-git-claude-agentic-tenant-co-6e8c9b-sustentus.vercel.app) — but it is behind Vercel Deployment Protection and the agent cannot reach it (see the blocker below). web · demo · docs · help-centre · marketing · storybook · tenant-management also all built on 7f5fb91.
  • production-readiness: run (diff touches auth and the services dependency graph) — 4 findings: 1 blocker and 2 defects fixed on branch, 1 handed to the operator
  • code-review: high (spec complexity complex) — 5 findings, all 5 fixed on branch
  • security-review: run (diff touches auth and route policies) — assessed inline against origin/main...HEAD, as the remove-dead-user-fields run did for a diff this size. No HIGH or MEDIUM finding survived; 1 LOW accepted, 1 pre-existing pattern noted. Detail below.
  • playwright: TODO — manual DoD smoke performed instead

A baseline correction, again

git diff main...HEAD in this checkout reports 237 files / +18864 — local main was stale at a commit predating the whole tenant-management console. The true merge base is origin/main (722c13e) and the real diff is 24 files / +1101 −396. Every pass in this stage was run against origin/main...HEAD. Anyone re-running them must do the same, or they will review a dozen unrelated shipped features. This is the second consecutive run to hit it (see agentic-chat-interface's verify record) — worth a git fetch origin main in the stage preamble.

Blocker on the agent-run half of the smoke

Unchanged from the agentic-chat-interface and ai-gateway-billing-unlock runs: the agentic-interface preview sits behind Vercel Deployment Protection, so every unauthenticated request is answered by Vercel SSO before it reaches the app. Measured on this head:

GET  /          → 401  {"error":{"code":"401","message":"Protected deployment"}, "protection":{"vercel_auth_enabled":true,…}}
GET  /sign-in   → 401  (same Vercel SSO envelope)
POST /api/chat  → 401  (same)

This is a live trap for exactly this stage. POST /api/chat signed out returns 401, which is not our route's answer at all — our route answers 403 for an unresolvable context. Nothing reached the app. mcp__Vercel__web_fetch_vercel_url again failed to mint a bypass ("Unable to create shareable URL").

So the agent's share of the smoke below is code-path tracing plus the unit suite, and every line that needs the running preview is marked as outstanding for the operator — never as agent-demonstrated.

DoD smoke (each line says who verified it, and how)

Acceptance criteria, in spec order.

  • Role helpers (mapClerkRoleToSystemRole, deriveGrantedRoles, resolveEffectiveRole, GRANTED_ROLES_CLAIM, ACTIVE_ROLE_COOKIE, GRANTABLE_ROLES) live in @sustentus/services/shared with their tests — verified by normalised body-diffing each symbol against origin/main:apps/web/lib/auth.ts: all six are byte-identical after comment and whitespace stripping, so the move changed no behaviour. apps/web/lib/auth.ts defines none of them and re-exports all six. (agent)
  • getTenant / getTenantOrNull live in @sustentus/services/server with the expected-failure classifier and sanitised logging intact — same normalised diff against origin/main:apps/web/lib/tenant.ts: IDENTICAL for both. apps/web/lib/tenant.ts is a four-line re-export, and a parse of all 82 @/lib/tenant importers confirms they import exactly getTenant and getTenantOrNull and nothing else. (agent)
  • resolvePlatformContext() returns the three shapes — traced in packages/services/src/auth/request-context.ts: kind: "tenant" on the org branch once a role maps and the tenant loads, kind: "console" carrying audience + ConsoleTenantScope on the org-less marker branch, null on every other path. Every await inside it is guarded, so it cannot throw at the caller. (agent — code path)
  • A signed-in user who is neither sees an honest "no workspace" state on / — traced (page.tsx renders the empty state when context === null), needs the preview. (operator)
  • Sustentus staff reach the agent with no org, choose any live tenant, and hold a conversation whose tools read that tenant — needs the preview and a staff account. (operator)
  • An invited partner sees only tenants carrying their Clerk user id, and a directly-supplied unassigned id is refused indistinguishably from a missing one — the mechanism is buildConsoleTenantIdFilter, which returns null for a malformed id and ANDs consoleScopeClause for a partner, so all three cases end at the same not-found; it is unit tested in console-scope.test.ts. The end-to-end demonstration needs the preview and a partner account. (operator)
  • The selected-tenant cookie is re-authorised on every request — verified there is exactly one authorisation home: resolvePlatformContext calls tenantService.findForConsole(scope, id) on every read. select-tenant.ts deliberately authorises nothing about the tenant; its OBJECT_ID_RE and its refusal of an unrecognised caller are hygiene, not the gate. Because readConsoleAudience reads the Clerk marker live rather than off the session token, a revocation denies the next request rather than the next sign-in. (agent — code path)
  • Reach, not power — verified structurally: both context kinds reach buildCatalogueTools(createTenantContext(context.tenantId)), the identical toolset. There is no audience-conditional branch anywhere between the context and the tools, and no cross-tenant tool exists to reach. (agent)
  • proxy.ts stays deny-by-default — two isPublicRoute entries only (/sign-in, the @vercel/firewall callback); everything else falls through to isRecognisedCaller, which refuses on role and marker as well as session and fails closed on a Clerk read error. Tenant users are decided from orgRole/sessionClaims with no DB and no Clerk call; the live marker read is on the org-less branch only. /api/** → 403, pages fall through. Adding a route adds nothing to the file unless it is public. (agent)
  • POST /api/chat answers 403 / 409 / streams-with-bound-toolset — asserted by apps/agent/app/api/chat/route.test.ts ("refuses a caller the platform does not recognise with 403", "answers 409 when a console caller has not chosen a tenant", "binds the toolset to the tenant resolved from the session", "binds a console caller's toolset to the tenant they selected"), green in the Run tests step on 7f5fb91. (agent — unit suite)
  • A test asserts the tool context comes from the session, not the body — "ignores a tenantId planted in the conversation payload", covering a tenant user and a console caller. (agent)
  • buildAssistantAgent takes an optional tools map and AssistantAgent is unchanged for callers passing nothing — tools?: Record<string, Tool> on the config, forwarded to defineAgent; the default export still calls buildAssistantAgent() with no argument. (agent)
  • apps/agent/AGENTS.md records the services boundary, the two audiences, and no longer claims the app reads no tenant data. (agent)
  • The apps/docs Technical › Applications Agent section matches the shipped boundary. (agent)
  • No behaviour change in apps/web — no call site repointed (both shims keep the existing @/lib/auth and @/lib/tenant paths; a parse of all 77 @/lib/auth importers found every symbol they import still exported), no route's access changed, the org-less console redirect untouched, and its auth/tenant tests pass unmodified in the green suite. (agent)
  • agentic-interface has MONGODB_URI (and MONGODB_DATABASE_NAME where set) for preview and production — not verifiable by the agent: these are runtime project variables, invisible to the build (which passed regardless) and unreachable behind deployment protection. This is the one criterion that can silently fail in production, so it needs an explicit check. (operator)
  • auth (Clerk): a tenant persona and a Sustentus staff account both still sign in and reach their surface — needs the preview. (operator)
  • payments: not touched — no payment path, provider call or price is in the diff. (agent)
  • notifications: none expected — the diff sends no email and writes no notification; no notify, Resend or Ably call is added. (agent)

Security review

Run because the diff touches auth and route policies. Assessed inline against origin/main...HEAD. What was checked and what it found:

Holds up:

  • No cross-tenant reach is structurally possible. defineTenantTool's input schemas carry no tenantId and execute closes over a frozen TenantContext; createTenantContext rejects anything that is not a 24-char ObjectId. The route builds the toolset from context.tenantId alone. A tenantId planted anywhere in the conversation has no path to a tool — tested.
  • The system-message injection defence survives the rewrite. isConversationalMessage still filters non user/assistant roles before createAgentUIStreamResponse, so a caller cannot curl in their own system prompt and walk around prompt.ts.
  • The selection cookie has one authorisation home. httpOnly, sameSite: "lax", secure in production, and re-authorised through the scope clause on every read. A forged id, a revoked partner assignment and a deleted tenant are indistinguishable to the caller.
  • Partner scope is the console's own clause, not a restatement. scopeFor in request-context.ts produces the same ConsoleTenantScope as apps/console's scopeOf, and consoleScopeClause remains the single expression of partner authorisation.
  • Fail-closed marker reads. parseConsoleAccess returns null for anything that is not exactly a known audience, and both the edge gate and readConsoleAudience catch and deny on a Clerk error.
  • Two gates, not one. The edge refuses at proxy.ts; the route refuses again via getAgentContext. An edge-matcher regression does not by itself open a paid model.

Fixed during this stage (both would have been real exposure):

  • Suspended tenants sailed through. resolvePlatformContext gated on neither branch, so a workspace apps/web sends to /workspace-unavailable could still be read through the agent's tools. isTenantReachable was lifted from apps/web/lib/tenant-availability.ts into @sustentus/services and both branches now gate on it — one predicate, so a future third lifecycle status cannot default to "allowed" in one app and not the other. (a65fc0b)
  • Unguarded database reads in a resolver contracted never to throw. The console branch called findForConsole/findById bare, so an unset MONGODB_URI threw out of context resolution instead of resolving to "no selection". Both are now guarded and logged. (a65fc0b)

Accepted:

  • (LOW) scopeFor and apps/console's scopeOf state the same staff/partner mapping in two places. Not a live defect — both derive partnerId from the Clerk user id and both are the only construction sites — but it is a drift surface. Folding it into @sustentus/services/shared is worth doing; widening this PR to do it is not.
  • (pre-existing) The org-less branch costs one live Clerk getUser per request, and it runs before the rate-limit check, so an authenticated caller can spend Clerk API quota faster than they can spend gateway credit. The same shape already exists at this app's edge and throughout apps/console; changing it means caching a revocation, which is the trade this design deliberately refuses.

Findings & cleanup

Production readiness:

  1. BLOCKER — @clerk/nextjs major-version split. packages/services pinned ^6.36.10 (6.39.3 installed) while all four apps pin ^7.0.0 (7.3.1). git grep @clerk/nextjs 779b2aa -- packages/services/src returns nothing, so request-context.ts was the package's first importer: getTenant() would have read v7 middleware context through a v6 auth(), at ~82 apps/web call sites. Fixed on branch — the dependency became a peerDependency at ^7.0.0 plus a matching devDependency, and pnpm install confirms packages/services now resolves 7.3.1. (a65fc0b)
  2. Turbo dev-task graph was stale. @sustentus/agent#dev still declared "reads no tenant data, so no services dependency"; pnpm agent:dev would have resolved /server against an absent dist. Fixed — dependsOn now includes @sustentus/services#build, and transpilePackages includes @sustentus/services. (a65fc0b)
  3. No new environment variables, so turbo.json's globalEnv needed no change — recorded rather than skipped, since the stage checks for it. The two variables the app now needs at runtime (MONGODB_URI, MONGODB_DATABASE_NAME) are already listed there; globalEnv is build-time only, which is exactly why the Vercel-project check is an operator line above.
  4. apps/agent/.env.example was not written. Writing it is refused in this environment by a permission deny rule covering env files (both Write and a shell heredoc). Recorded honestly rather than faked: operator item — copy apps/console/.env.example's Mongo block into apps/agent/.env.example.

Code review (high effort, per spec complexity: complex) — all five fixed on branch:

  1. The tenant picker was a one-way door. It rendered only while tenantId was null and was handed a hardcoded currentTenantId={null}, so once a staff member or partner picked a tenant they could never pick another. Fixed: ?switch=1 reopens it, a header affordance links there, the real currentTenantId is passed, and the pager carries the flag. (a65fc0b)
  2. listForConsole was unguarded on the one page a console caller can reach, so an unreachable database rendered a 500 — precisely the state a fresh deployment with no MONGODB_URI is in. Now renders an honest "cannot list your tenants". (a65fc0b)
  3. Pagination defect made tenants unselectable. The page was fixed at 1 and the suspended-tenant filter ran after pagination, so tenant 26 of 30 could not be chosen at all. The page now comes from the page search param and the pager walks the whole scope. (a65fc0b)
  4. "No workspace yet — ask your administrator to add you" misdescribed a suspended tenant, which resolves to the same null context. Copy now covers both. (a65fc0b)
  5. Unused boundTenantId export removed from agent-context.ts. (a65fc0b)

CI:

  1. @sustentus/agent#lint failed on 'React' is not defined — the picker's Shell sub-component typed its children React.ReactNode with no namespace in scope. Fixed by importing the type directly. (7f5fb91, the head handed over)

Judgement calls surfaced rather than acted on:

  1. The scopeFor/scopeOf duplication above — deliberate non-action, reasoning recorded.
  2. Local main being stale enough to make git diff main...HEAD useless is now a repeat occurrence across three runs. Not this run's to fix; worth a line in the stage preamble.

Context budget: within the Inputs table. Read outside it: apps/console/lib/console-tenants.ts, packages/services/src/shared/console-access.ts, packages/services/src/ai/core/tenant-tools.ts and .../ai/tools/catalogue.ts for the security review's structural claims, and the archived agentic-chat-interface verify record to confirm the deployment-protection blocker was the same one, not a new regression.

05_ship/output/changelog.md


title: The assistant now knows which organisation you are in date: 2026-08-27T22:00:00Z personas: [customer, expert, csm, sdm, admin, vendor] slug: agentic-tenant-context pr: https://github.com/sustentus/sustentus/pull/902

The assistant now knows which organisation you are in

When the Sustentus assistant launched it could talk and nothing more — ask it for a figure from your account and it would tell you it could not reach one. It can now reach your organisation's own records. Sign in as you normally do and ask it what your organisation offers, and it will read your product and service catalogue and answer from it.

It reads your organisation and only ever your organisation. The assistant is bound to the workspace your session is in before the conversation starts, and nothing you type can point it somewhere else — asking it about another organisation's data will not get you another organisation's data. If your workspace has been suspended it reads nothing at all.

Sustentus staff and our invited partners can use the assistant too. They belong to no single organisation, so they pick one to work in and can switch between them; a partner sees only the organisations assigned to them. Working inside your organisation, they see exactly what you would see there — the same answers from the same records, with no extra reach into it.

The conversation is still not saved, so refreshing the page starts a fresh one.

05_ship/output/investor-update.md

The assistant can now read your organisation's own records

Who it's for: all six personas, plus Sustentus staff and partners What shipped: the assistant answers from your organisation's catalogue — and only ever yours. Why it matters: Scale the Bridge calls for autonomous AI agents; one that cannot reach your data cannot act on it.

Staff and partners pick an organisation and get no extra reach inside it.

Dig deeper: https://github.com/sustentus/sustentus/pull/902 · https://help.sustentus.com/changelog/2026-08-27-agentic-tenant-context

05_ship/output/release.md

Ship: agentic-tenant-context

  • pr: #902 · merge: authorised — Ready to merge ticked by Jamie; this commit rides the squash
  • CI: GREEN on 88b24cf after Verify's last push; re-settled via ci-status.sh on the head this stage merges, after the docs + release-notes push. One Quality Project failure earlier in the run ('React' is not defined in the tenant picker) was fixed in 7f5fb91.
  • technical docs: technical/deployment (the agent's two Mongo variables, with the same runtime-not-build-time caveat the gateway key carries) · technical/packages/services (a Request identity section for resolvePlatformContext, the re-authorised selection cookie, reach-not-power, and platform-auth in /shared; auth/ added to the source tree; the entrypoint rule now names apps/agent too) · technical/architecture/system-diagram (the agent is no longer a "Clerk-gated shell" — it draws request context from ./db and its bound toolset from ./ai) · technical/applications (the Agent section, updated during Build)
  • business docs: no business docs impact — the agentic app is not a documented surface in business/**. No persona's access, feature reach, or step in the service journey changes: the six personas see the same platform, and the new population (org-less staff and partners) is the console audience business/** already does not describe.
  • release notes: both
  • sent: ship note queued for #product-update by the merge (ship-note.yaml)
  • close-out: archive the run to apps/docs/archive/pipeline-runs/agentic-tenant-context/. The ai-platform-enablement epic keeps two stubs in flight (ai-gateway-billing-unlock and agentic-tenant-context are its _done/ entries; ai-proposal-scoring and expert-matching-agent remain — expert-recruiter-agent was dropped from the batch in #903, which landed on main mid-merge), so the epic is not archived here.

Acceptance check (vs spec)

  • Role helpers live in @sustentus/services/shared with their tests; apps/web/lib/auth.ts re-exports and defines none — verified in Verify by normalised body-diffing all six symbols against origin/main: byte-identical after comment/whitespace stripping.
  • getTenant / getTenantOrNull live in /server with classifier and sanitised logging intact; apps/web/lib/tenant.ts re-exports both — same diff, IDENTICAL; all 82 @/lib/tenant importers parsed and import only those two.
  • resolvePlatformContext() returns the tenant / console / null shapes and never throws — traced in packages/services/src/auth/request-context.ts; every await inside it guarded.
  • The "no workspace" state for a signed-in user who is neither — traced in page.tsx; needs the preview (operator).
  • Staff reach the agent with no org, choose any live tenant, and hold a conversation whose tools read it — needs the preview and a staff account (operator).
  • A partner sees only their assigned tenants and a directly-supplied unassigned id is refused indistinguishably — the mechanism is buildConsoleTenantIdFilter, unit tested in console-scope.test.ts; the end-to-end demonstration needs the preview (operator).
  • The selection cookie is re-authorised every request — one authorisation home (findForConsole inside resolvePlatformContext); the live Clerk marker read is what makes a revocation deny the next request.
  • Reach, not power — both context kinds reach the identical buildCatalogueTools(createTenantContext(...)); no audience-conditional branch exists between context and tools.
  • proxy.ts stays deny-by-default and refuses on tenant/role/marker — two public entries only; /api/** → 403; tenant users decided from claims with no DB or Clerk call.
  • POST /api/chat answers 403 / 409 / streams with the bound toolset — asserted by four tests in route.test.ts, green in the Run tests step.
  • A test asserts the tool context comes from the session, not the body — "ignores a tenantId planted in the conversation payload", for both caller kinds.
  • buildAssistantAgent takes an optional tools map; AssistantAgent unchanged for callers passing nothing.
  • apps/agent/AGENTS.md records the services boundary and the two audiences.
  • The apps/docs Technical › Applications Agent section matches the shipped boundary.
  • No behaviour change in apps/web — no call site repointed (both shims keep the existing import paths; all 77 @/lib/auth importers parsed, every symbol still exported), no route's access changed, the org-less console redirect untouched, its tests unmodified and green.
  • agentic-interface has MONGODB_URI / MONGODB_DATABASE_NAME for preview and production — not verifiable by the agent and unticked at merge. These are runtime project variables: invisible to the build (which passed regardless) and unreachable behind the preview's deployment protection. Ship documented them on technical/deployment rather than claiming them. This is the one criterion that can fail silently in production — and it fails quietly by design, as honest empty states rather than a crash. Jamie authorised the merge with it outstanding, having read the same finding in verify.md.

Notes

  • Three criteria are the operator's, not open defects. The agentic-interface preview sits behind Vercel Deployment Protection, so every unauthenticated request is answered by Vercel SSO before reaching the app — the agent's share of the smoke was code-path tracing plus the unit tier, recorded that way in verify.md rather than self-certified. Third consecutive run to hit this; the standing TODO is the Playwright tier.
  • apps/agent/.env.example was not written. Env files are covered by a permission deny rule in this environment, for both the Write tool and a shell heredoc. Recorded rather than faked: copy apps/console/.env.example's Mongo block across, now joined by the two variables this stage documented on the deployment page.
  • Pre-existing, not touched: the system diagram's Clerk -->|user + tenant + role sync (webhooks + JIT)| SvcDB edge fails Mermaid parsing — unquoted parentheses in an edge label. It is already on main and predates this change; quoting it would edit a line this run has no business in. Raised separately.
  • main was merged into the branch twice: once before the docs edits, so the three pages were changed against their current text rather than a fork four commits stale, and once when GitHub refused the first merge attempt with 405 Pull Request has merge conflicts — #903 had landed in the interval. No textual conflict either time; the branch was simply behind.

Context budget: within the Inputs table. Read outside it: business/initiatives/scale-the-bridge (the ship note's tie-in, quoted from the page) and the archived agentic-chat-interface changelog entry, to keep this run's entry continuous with the one it follows.