agentic-tenant-contextrun.md02_define/output/spec.mdapps/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.
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 split follows the existing services boundary rather than inventing one:
@sustentus/services/shared (packages/services/src/shared/platform-auth.ts):
the claim-reading role helpers moved verbatim from apps/web/lib/auth.ts —
mapClerkRoleToSystemRole, 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.@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.
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.
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.
apps/agent/proxy.ts stays deny-by-default and covers every route, and now admits exactly two
kinds of caller:
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.
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.
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.
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 sweepapps/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.
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./: no crash, no stack trace, and no other tenant's data.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)).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.apps/docs Technical › Applications page's Agent section matches the shipped boundary and audiences.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.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.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.admin, is deliberately not inherited here — it would hand every admin action in every tenant to staff with no separate audit trail.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).apps/web's @/lib/auth and @/lib/tenant call sites at @sustentus/services — a follow-up chore lane run once this merges.apps/web sends a marked org-less user. It still redirects them to the console; the agent admits them independently.apps/web surface, and any change to the platform's Clerk org roles, session-token template or webhook sync./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.md3ff5ea1 — the lift, the agent gate + surfaces, the tool binding, docsQuality Project green, so format/lint/typecheck/tests all pass, and the agentic-interface and web previews both built)packages/services/src/shared/platform-auth.ts (new): the claim-reading half of the role model,
moved verbatim from apps/web/lib/auth.ts — mapClerkRoleToSystemRole, 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./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./.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.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.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.apps/docs applications page matches the shipped boundary and audiences.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.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.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.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.md7f5fb91, 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.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.complex) — 5 findings, all 5 fixed on branchorigin/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.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.
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.
Acceptance criteria, in spec order.
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)/ — traced
(page.tsx renders the empty state when context === null), needs the preview.
(operator)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)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)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)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)apps/docs Technical › Applications Agent section matches the shipped boundary. (agent)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)notify, Resend or Ably call is added. (agent)Run because the diff touches auth and route policies. Assessed inline against
origin/main...HEAD. What was checked and what it found:
Holds up:
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.isConversationalMessage still
filters non user/assistant roles before createAgentUIStreamResponse, so a caller cannot curl
in their own system prompt and walk around prompt.ts.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.scopeFor in
request-context.ts produces the same ConsoleTenantScope as apps/console's scopeOf, and
consoleScopeClause remains the single expression of partner authorisation.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.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):
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)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:
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.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.Production readiness:
@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)@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)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.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:
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)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)page search param and the pager walks the whole scope. (a65fc0b)a65fc0b)boundTenantId export removed from agent-context.ts. (a65fc0b)CI:
@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:
scopeFor/scopeOf duplication above — deliberate non-action, reasoning recorded.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.mdWhen 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.mdWho 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.md88b24cf 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/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/**. 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.ship-note.yaml)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.@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.page.tsx;
needs the preview (operator).buildConsoleTenantIdFilter, unit tested in
console-scope.test.ts; the end-to-end demonstration needs the preview (operator).findForConsole inside resolvePlatformContext); the live Clerk marker read is what makes
a revocation deny the next request.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.buildAssistantAgent takes an optional tools map; AssistantAgent unchanged for callers
passing nothing.apps/agent/AGENTS.md records the services boundary and the two audiences.apps/docs Technical › Applications Agent section matches the shipped boundary.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.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.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.