Repository navigation
v0.13.0 — alpha
Pre-releaseAlpha release. Nineteenth tagged Sunrise release. MINOR bump. It
carries three multi-tenancy features: org identity (§106), row and namespace
isolation (§107), and tenant-aware jobs and caches (§108). It also removes the
stateful MCP transport. Measured againstv0.12.1: 20 PRs merged, no direct
commits, two issues closed; the suite is 1,220 files and 24,767 tests.
TENANCY_MODE=singlestays the default, and every tenancy entry below
states its single-mode behaviour.multican now be switched on end to end;
multi-tenancy.md
gives the steps. The rest of the phase has not landed, so read
what you do not yet get
before turning it on. The system agents, for one, are still the install
org's rows.What a fork has to do: merge it in two stages
Six migrations. One of them,
20260919200000_tenant_owned_org_id, adds
orgIdto 38 tables and backfills every existing row: 38UPDATEs and 38
new indexes. That rewrite is the one to schedule on a large database. To
apply the identity half first, merge in two stages, deploying each.
Staging is recommended, not required; a fork with small tables can take the
tag in one merge.Stage 1: merge commit
7d511506(#815, the end of §106). Name the SHA,
not a branch. Make it a merge commit, never a squash. Then runprisma migrate deployand deploy before starting stage 2. If you merge twice and
deploy once, every migration runs together and the staging buys nothing.
At this commitlib/sunrise-version.tsstill reads0.12.1, which is an
ancestor, so the fork-sync ancestry check passes. Stage 1 is the §106
entries below. They carry these things to act on:
- Two migrations:
20260917120000_org_identity(theOrgand
OrgMembershiptables, the install org, a membership for every user,
nullableorgIdon the four credential tables, and
Session.activeOrgId, which is also a better-auth session
additionalFieldnow) and
20260918120000_credential_org_backfill. The second re-runs the first's
backfill; on a fork, where both run in one deploy, it updates nothing.- A new seam row,
lib/app/tenant-resolver.ts, in
tests/unit/lib/app/defaults.test.ts. It ships empty.- Docblock-only edits to
lib/app/authorization.ts. A fork that filled
that file keeps its code and takes the comment. If your policy compares
resource.orgId === viewer.orgId, guard the resource side: two
undefineds compare equal.- Type changes a build finds:
EmbedContextandMcpAuthContextrequire
orgId.- One change a build does NOT find:
runInvitedSignuptakes the
invitation as a second argument, and that argument defaults tonull. So
a fork's own call with one argument still compiles and still passes the
invite_onlygate. But the new user's org membership is then decided
without the invitation. An invited platform admin joins the install org as
MEMBER, notOWNER. An invitation naming another org is ignored. Pass
the invitation metadata at every call site you own.- Test changes: the role-literal guard now also polices
'OWNER'and
'MEMBER'.org-sources.test.tsfails on any model of yours that carries
orgIduntil it has a disposition inlib/privacy/org-sources.ts.- A dependency move:
@better-auth/coremoves todependenciesat the
same pin.Stage 2: merge
v0.13.0. It brings the other four migrations:
tenant_owned_org_id, the backfill above;org_isolation_policies, which creates the RLS policies but does not
enable them;org_scoped_slugs;remove_mcp_max_sessions_per_key.And these, each of which announces itself:
- Three new always-run tests name your models.
model-classification.test.tsfails on any model that neither carries
orgIdnor sits on an allowlist.policy-coverage.test.tsfails on a
tenant-owned table without itsorg_isolationpolicy.
org-scoped-slugs.test.tsfails on a tenant-owned slug that is still a
global@unique. The fix for each is in the §107 entries; never delete
an allowlist row to pass.where: { slug }onAiAgent,AiKnowledgeBaseor
AiKnowledgeDocumentno longer type-checks. UsefindFirstinside an
org context, ororgId_slug.prismais aTenancyClient. Only a parameter annotated exactly
PrismaClientneeds changing.- The stateful MCP transport is gone. The table under Removed lists
every symbol, route and env var.PATCH …/mcp/settingsis now strict: a
body carryingmaxSessionsPerKey, or any unknown key, gets a 400.- Retention's exported shapes moved.
RetentionResultand
RetentionWindowslose their audit-log fields.
POST …/maintenance/tickgains a ninthbackgroundTasksname,
auditLogRetention.- A test that mocks
@/lib/admin/logsmust now return
registerLogTenancy.- A docblock-only edit to
lib/app/db-drift.ts, handled the same way
as theauthorization.tsedit in stage 1.- Process-global state is now declared. A fork that adds a module-level
cache to a platform file underlib/meets the
lib/tenancy/process-state.tsscanner test.Three new optional env vars, all needed only to enable
multi:
MIGRATE_DATABASE_URL, plusTENANCY_APP_ROLE_PASSWORDand
TENANCY_APP_ROLE(defaults tosunrise_app) fordb:tenancy:role. DeleteMCP_SESSION_MODE
wherever you set it.Two behaviour changes at
single, both corrections.
AiApiKey.lastUsedAtis now written; it had beenNULLfor every key. And
POST /api/v1/user/api-keysrefuses anadminkey asked for while acting
in any org but the install org.Read the MCP entry under Security even though the code is gone. A fork
still on 0.12.x that runsMCP_SESSION_MODE=statefulis exposed to a
cross-key SSE hijack; the default (stateless) never was.
Added
-
runDetached(fn)onlib/tenancy/context.ts(multi-tenancy §108 t-715)
— runsfnoutside every tenant scope, for arming something whose lifetime
is the process's from inside a request. AnAsyncLocalStoragestore is
captured whensetIntervalis called, so a repeating timer armed inside
runAsOrgcarries that org for the life of the process: every line it logs
is attributed to whichever org made the first request after boot, and at
multievery query it makes is scoped to them. It has no caller in the
platform: the one it was written for,McpSessionManager's eviction sweep,
went with the stateful MCP transport later in this same release (see
Removed). It ships anyway, because the rule is the asset and the alternatives
at a call site are both wrong —runAsSystem(an audited database bypass) or a
re-derivedAsyncLocalStorage.exit. Use it only where the timer's
work belongs to the process rather than to one org — a timer belonging to
one org's work (a lease heartbeat, a delivery retry, afetchabort) must
KEEP the context it was armed in, because its callback writes that org's rows.
"Outlives the request" is not the test: a delivery retry does, and it still
belongs to the org that armed it. Nor is thisrunAsSystem, which logs a
reason on every entry and is the audited database bypass — a claim about what
the code may read that arming a timer is not making. -
registerLogTenancy(bridge)and theLogTenancytype on
lib/admin/logs.ts(multi-tenancy §108 t-714) — how the admin log buffer
learns which org a line was produced in, and whether the install runs more
than one.lib/tenancy/context.tsregisters it at module scope; nothing else
needs to call it. It exists as a registration rather than an import because
lib/admin/logs.tsmust reach no other module at runtime: the logger pulls
it in with a literalrequireand is itself imported by client components,
so an import here putslib/db/client.ts— andpg— in the browser
bundle. Forks: a test that doesvi.mock('@/lib/admin/logs', …)must now
returnregisterLogTenancy, or importing anything that loads the tenancy
module throws. -
Each org can set its own retention windows (multi-tenancy §108 t-713).
PATCH /api/v1/admin/orgs/[id]takes
settings: { retention: { … } }— the five windows the tenant retention
sweep reads (webhookRetentionDays,webhookDlqRetentionDays,
costLogRetentionDays,executionRetentionDays,evaluationRetentionDays),
each optional and nullable, stored onOrg.settings. A key omitted inherits
the globalAiOrchestrationSettingswindow;nullcarries whatevernull
means for that column globally (keep forever, except
webhookDlqRetentionDays, whose existing fallback is "use
webhookRetentionDays");{ "retention": null }or{}removes the slice.
A slice is stored and returned in both tenancy modes but applied at
multionly — no prune carries anorgId, so atsingle, where there are
no policies, one org's window would reach every org's rows. The write refuses
a slice whose effective cost-log window is shorter than the execution
window it would inherit,executionRetentionDays: nullincluded: executions
kept for ever outlive every finite cost-log window, so keeping them needs
costLogRetentionDays: nullwith it. The write replaces
theretentionkey and preserves every other key insettings, which is a
fork's.auditLogRetentionDaysis deliberately not settable: it prunes a
system table with no org. Both objects are strict, and an org's slice is
refused when the cost-log window it sets undercuts the execution window it
would inherit. Platform admin only until the org console (§111). Two new
modules —lib/tenancy/org-settings.ts(the slice: its schema, the
per-key read and the write-merge) and
lib/orchestration/retention-windows.ts(loadRetentionWindows, the new
loadEffectiveRetentionWindows(),readRetentionWindows()and
RETENTION_WINDOW_KEYS, moved out oflib/orchestration/retention.ts,
which re-exports them).readRetentionWindows()isloadRetentionWindows()
without the swallow-on-error, for callers that must not read "I could not
look" as "there is nothing to check". See
.context/orchestration/retention.md. -
A fork's recurring job declares whose rows it acts on (multi-tenancy
§108 t-711).AppJob(lib/orchestration/maintenance/app-jobs.ts, the
registerAppJobseam) gains an optionalscope?: JobScope—
'per-org' | { system: string }, the type re-exported from the same
module, withDEFAULT_APP_JOB_SCOPE = 'per-org'. A per-org job runs once
per active org inside that org's tenant context (its Prisma calls see and
stamp that org's rows only; atmultithe row-level policies enforce it);
a system job runs once under the audited bypass with the stated reason
logged. Additive: a registration with noscopeis per-org, which on a
single-tenant install is the install org — today's behaviour, now
explicit.lib/app/jobs.tsis unchanged. -
A CI job drives the platform as two orgs and proves neither can see the
other (multi-tenancy §107 t-709).smoke-multiinci.ymlruns the
operator's sequence on a fresh pgvector container — migrate and seed as
the owner,db:tenancy:role --create,db:tenancy:enable, the drift
check atTENANCY_MODE=multias the restricted role (93 probes) — then
the newnpm run smoke:tenancy-isolation
(scripts/smoke/tenancy-isolation.ts, throwaway databases only) as that
role: two orgs with equivalent rows, and as one org every read path —
vector search, cost reports, conversation semantic search, the message
embedder's rawINSERT, relation reach from a global root,forEachOrg,
the credential resolvers and the inbound route called as nobody — answers
its own rows and none of the other's.smoke:tenancynow also asserts at
singlethat RLS is neither enabled nor forced on any tenant-owned table.
New onlib/tenancy/context.ts:runAsCredentialLookup(credential, fn)
— the null-org system scope for the one read that learns which org a
credential belongs to, logged at debug;resolveApiKey,
resolveEmbedTokenandauthenticateMcpRequestrun their lookup (and the
lastUsedAttouch) inside it, which closes the known gap where every
credential-authenticated request threw atmulti, and the inbound and
approval routes use it in place ofrunAsSystem. One behaviour change
rides along:AiApiKey.lastUsedAtis now actually written on every
sk_request — the previousvoid prisma.aiApiKey.update(…)never ran,
aPrismaPromisebeing lazy until awaited, so the column has beenNULL
for every key since it was added; a failed touch is logged at warn. The conversation
semantic-search SQL moved from the admin route into
lib/orchestration/chat/conversation-semantic-search.ts
(searchConversationEmbeddings) so the harness drives the statement
itself; the route's behaviour is unchanged. -
Isolation policies ship dormant with the schema, and one command turns
them on or off (multi-tenancy §107, the database half of row isolation).
One raw-SQL migration,20260920120000_org_isolation_policies, creates an
org_isolationpolicy on every tenant-owned table (42) —USINGand
WITH CHECKboth"orgId" = NULLIF(current_setting('app.current_org', true), '')behind the bypass armcurrent_setting('app.bypass_rls', true) = 'on'— without enabling RLS, so amigrate deploycarries them while a
single-tenant install pays nothing. New commands:npm run db:tenancy:enable(refuses if any table lacks its policy; backfillsNULL
orgIdto the install org — a platformadminAPI key excepted, itsNULL
being the point, andAiCostLogskipped, a detached row being no org's — thenENABLE+FORCE ROW LEVEL SECURITYon every
tenant-owned table — derived from the generated client, no list) anddb:tenancy:disable(DISABLE+
NO FORCE, both flags), each idempotent by readingpg_classand
refusing to report success until the flags read back;npm run db:tenancy:role -- --create|--dropfor the requiredLOGIN NOBYPASSRLS
app role (TENANCY_APP_ROLE, password only via
TENANCY_APP_ROLE_PASSWORD, sent as a locally computed SCRAM-SHA-256
verifier) with its grants and default privileges — nothing on
_prisma_migrations— refusing to touch a superuser, aBYPASSRLSrole,
a table owner or the connecting role, and revoking before dropping (Neon
refusesDROP OWNED BY).prisma/seed.tsprefers the same owner DSN and
now runs through the tenancy chokepoint as the install org (SeedContext.prisma
is the extended client), so every seeded tenant-owned row is stamped;
the rawINSERTs that write tenant-owned rows (message embeddings,
knowledge chunks from the seeder, the document manager and the two dev
scripts) readorgIdoff the parent row in the same statement. A blank
MIGRATE_DATABASE_URLis unset in every reader,lib/env.tsincluded. New optional env
varMIGRATE_DATABASE_URL— the owner DSNprisma.config.tsand the
db:tenancy:*scripts prefer overDATABASE_URL, because a table's owner
(and anyBYPASSRLSrole, Neon'sneondb_ownerincluded) is never
subject to the policies.npm run db:drift-checkgains a derived T-series:
tenancyDriftProbes()(new export onlib/db/drift-probes.ts) yields one
policyExistsprobe per tenant-owned table always and an
enabled-and-forced probe per table atTENANCY_MODE=multi(9 + 42 probes
atsingle, 9 + 84 atmulti); the script now runs underrunAsSystem.
Newlib/tenancy/isolation.ts(side-effect-free):orgIsolationPolicySql,
planRlsSwitch,runTenancySwitch. Fork note: a new
tests/unit/lib/tenancy/policy-coverage.test.tsfails naming any
tenant-owned table — yours included — without exactly oneorg_isolation
policy in the migrations (appendorgIsolationPolicySql('<table>')to a new
migration), and any non-tenant table carrying one. Behaviour at
TENANCY_MODE=singleis unchanged: the policies are inert until enabled.
Docs: new.context/tenancy/isolation.md. -
Every tenant-owned row knows its org (multi-tenancy §107, first
schema task). One migration,20260919200000_tenant_owned_org_id, adds a
nullableorgId+org Org? @relation(onDelete: Cascade)(SetNullon
AiCostLog, a billing record) +@@indexto
the 38 tenant-owned published models that did not yet carry one — every
agent, conversation, message, knowledge, workflow, evaluation, experiment,
webhook and cost row, child tables included (no join-based policies; the
column is what everything derives from) — and backfills every existing row
to the install org.NOT NULLis a later staged migration.Orggains a
back-relation per model. Newlib/tenancy/classification.ts:
SYSTEM_MODELS,GLOBAL_CONFIG_MODELS,classifyModels()and
tenantOwnedModels(prisma)(model → table, derived from the client it is
given at runtime — the roster the row-isolation policies, drift probes and
enable script will read). Every one of the 38 has a disposition in
lib/privacy/org-sources.ts(36export— withholding the signing secrets on
AiWebhookSubscriptionandAiWorkflowTrigger, an execution'sleaseToken,
and an event hook's secret and custom header values viatoSafeHook;
AiMessageEmbeddingandAiWorkflowExecutionLeaseEventexcluded with
reasons), so an org export now carries 42 sections. Fork note: a new
tests/unit/lib/tenancy/model-classification.test.tsfails by name on any
model — yours included — that neither carriesorgIdnor sits on an
allowlist; add the column (the shape is in
.context/tenancy/identity.md) or classify it deliberately, never by
deleting from an allowlist. Behaviour atTENANCY_MODE=singleis
unchanged. The data-layer chokepoint (under Changed) stamps the column
on every create; a row created between this migration and that stamping
carriesNULL, which the org export treats as the install org's at
singleand strictly atmulti, wheredb:tenancy:enablewill backfill
before enforcing. -
Every install has an org, and every user belongs to one (multi-tenancy
§106, first task). Two published model interfaces in a new
prisma/schema/tenancy.prisma:Org(slug,name,status
ACTIVE|SUSPENDED,settings) andOrgMembership(role
OWNER|ADMIN|MEMBER, unique on(orgId, userId), both FKs cascade), with
the client-safe vocabulary inlib/tenancy/roles.ts(ORG_ROLES,
orgAdministers()) and the install org's fixed identity in
lib/tenancy/constants.ts(INSTALL_ORG_ID). One migration,
20260917120000_org_identity, creates the tables, inserts the install org,
makes every existing user a member — a real platform admin asOWNER,
everyone else (the SERVICE config-owner included) asMEMBER— and adds a
nullable, backfilledorgIdtoAiApiKey,AiAgentEmbedToken,
AiAgentInviteTokenandMcpApiKey(anadmin-scoped API key stays
NULL: it is a platform credential) plusSession.activeOrgId, wired by
the next bullet.
userCreateAfterHookgives every later user a membership (non-blocking,
logged at error on failure; the session path self-heals it in t-670), and
the001-system-ownerseed gives the config-owner one on a fresh install.
OrgMembershipis anexportsource andOrganattributionsource in
SUBJECT_DATA_SOURCES;npm run smoke:tenancyproves the invariant against
a real database. Behaviour atTENANCY_MODE=singleis unchanged (the
third bullet below says how the guards and the policy read these rows
without changing a single-tenant answer). Fork note: the role-literal guard
(tests/unit/auth-role-literals.test.ts) now also polices'OWNER'/
'MEMBER'outsidelib/tenancy/roles.ts; the org-role enum is closed —
product tiers belong beneath the org, on your side of the FK. Guide:
.context/tenancy/identity.md. -
A session knows which org it acts in, a user can switch between theirs,
and an invitation can name one (multi-tenancy §106, second task).
Session.activeOrgIdis now a better-auth sessionadditionalField
(input: false— server-written only; the public/update-session
refuses it) chosen by a newsessionCreateBeforeHookat every sign-in: the
user's only org, else the install org if they belong to it, else the most
recently joined — and a user with no membership at all is given the
install-org default right there (the self-heal t-669 promised). It reaches
AuthSession.session(lib/auth/guards.ts, optional so hand-built
sessions still compile), the inferred server type, anduseSession()on the
client. New endpointPOST /api/v1/orgs/switch{ orgId }(API.ORGS.SWITCH)
verifies membership, writes the row and re-issues the cookie cache; API-key
callers are refused.invitationMetadataSchemagains optionalorgId/
orgRoleandPOST /api/v1/users/inviteaccepts both (the org must exist
and be active; the authorization policy is askedcanAdministerabout it —
platform admins only today, sincewithAdminAuthadmits nobody else). The membership a new user
gets is one function,membershipForNewUser(user, invitation)in
lib/tenancy/membership.ts: the install org by the role rule on the role
the invitation grants (so an invited platform ADMIN now owns the install
org — the gap t-669 documented), or the named org with itsorgRole, where
the first member of a new org is itsOWNER.runInvitedSignuptakes
the invitation as a second argument.@better-auth/coremoves from
devDependencies to dependencies (same exact pin): the hooks share the
membership through its request state (lib/auth/pending-signup.ts).
Behaviour atTENANCY_MODE=singleis unchanged: with one org and no
invitation metadata every session flow writes the same rows plus one
populated column, and every pending invitation round-trips as before. -
A request knows which org it is acting for, and the admin decision can
read it (multi-tenancy §106, third task — the gating PR for the phase).
Three additions to the public surface:- The tenant context —
lib/tenancy/context.ts:getTenantContext(),
requireTenantContext()(throws atmultiwhen nothing entered a
context; answers the install org, markedimplicit, atsingle),
runAsOrg(orgId, fn),runAsSystem(reason, fn)(logged) and
forEachOrg(fn)(one scope per active org, sequential; uncalled in core
until §108). Both guards enter it for every request they admit — from
session.activeOrgId, the API key's org, or the resolver header below —
verifying membership and org status wherever a non-install org is named
(a refusal is a 403 that names nothing), and run the handler inside it.
The guard-less webhook trigger enters its key's org the same way. An
admin-scoped key is a platform credential and enters none.
getRequestContext()/getFullContext()now carryorgId(a new
LogContextfield) inside a scope. - A new fork seam,
lib/app/tenant-resolver.ts→
registerAppTenantResolver(), wired byproxy.tsat module scope, and a
new request-header contract: the proxy writesx-sunrise-orgfrom the
fork's resolver (registerTenantResolver()inlib/tenancy/resolver.ts,
Web-standard only) and strips any inbound copy when there is no
answer — the proxy is the header's sole writer, the visitor-id shape.
Ships empty;defaults.test.tsandfork-init-seams.test.ts(registrar
count 3→4,proxy.tsa consumer) enforce it. - The policy reads the org.
AuthorizationPrincipalgainsorgId?/
orgRole?(filled by the guard — told, not sniffed) and the guards pass
{ org }asscope.DEFAULT_AUTHORIZATION_POLICY.canAdministergrants
an orgOWNER/ADMINa resource that carries their org, and
canRead's'unattributed'arm admits them to thethis-rowof one —
answered before the once-per-kind diagnostic. Anullresource, or one
without anorgId, still grants nothing (platform-ops surfaces stay
platform-only); the capability question stays platform-only until §107
scopes ownerless reads by org.resolveApiKey()returns the key's
orgIdandownerAccountType(optional in the type — a test double
built before the org axis still compiles).
Behaviour at
TENANCY_MODE=singleis unchanged, by construction and by
sweep: the install org is entered with no membership read (its role is
the platform role projected by the same rule the migration and the signup
hook apply), no core route that predates the next bullet names an
org-carrying resource so the org arm cannot fire on any of them, and
authorization-org.test.tsasserts every principal × every question those
routes can ask answers identically with and without org facts, on both
policies. Fork note: a policy that comparesresource.orgId === viewer.orgIdmust guard the resource side — bothundefinedcompares
equal. Guide:.context/tenancy/context.md. - The tenant context —
-
An org can be created, suspended, exported and erased, and its members
managed, without touching its members' other orgs (multi-tenancy §106,
fourth task). Two documented API surfaces and two privacy entry points:- The org API (
.context/api/org-endpoints.md).
Member view,withAuth:GET /api/v1/orgs(my memberships, marks the
active one; like the switch it does not enter the session's org, so a
member of a suspended org can still find the way out),GET /api/v1/orgs/[id](any member),GET/POST /api/v1/orgs/[id]/membersand
PATCH/DELETE /api/v1/orgs/[id]/members/[userId]— admitted by the
policy's org arm (the org's ownOWNER/ADMINwhile acting in it, or
a platform admin; no role check in the routes), mutations browser-session
only. Platform view,withAdminAuth:GET/POST /api/v1/admin/orgs,
GET/PATCH/DELETE /api/v1/admin/orgs/[id](rename, re-slug, suspend,
reinstate; erase),GET /api/v1/admin/orgs/[id]/export. Constants under
API.ORGS/API.ADMIN. The rules live once, inlib/tenancy/lifecycle.ts,
and every refusal carries acode: the install org can be renamed but
never suspended, re-slugged or deleted (INSTALL_ORG_IMMUTABLE); an org
keeps at least oneOWNER(LAST_OWNER); only an OWNER — or a platform
admin — may grantOWNER, change an OWNER's role or remove an OWNER
(OWNER_STANDING, so an ADMIN cannot take the org from their appointer);
the install org's memberships follow the platform role and cannot be
edited or removed through the members API (INSTALL_ORG_MEMBERSHIP);
removing a member revokes their sessions acting in that org
(revokeUserSessionsgains an optionalactiveOrgIdfilter) and keeps
the rest. - The org arm is a session grant.
DEFAULT_AUTHORIZATION_POLICY's
org arm now refuses anapi-keyprincipal outright (t-671 admitted a
key by the org role the entry projected onto it — atsinglethat is
the key OWNER's platform role, so achatkey minted by a platform admin
would have read the install org's roster). A key's standing is its
scopes; anadminkey administers as before. - The ruling on role drift (a): the install org's
OWNERset now
follows the platform-admin set —PATCH /api/v1/users/[id]with a
roleupserts the install-org membership to the rule's answer in the
same transaction (syncInstallMembershipRole), so a demoted admin no
longer keepsOWNER. exportOrgData()(lib/privacy/export-org.ts) and the org manifest
ORG_DATA_SOURCES/ORG_EXCLUDED_SOURCES(lib/privacy/org-sources.ts):
the roster with member id/name/email, pending invitations into the org
(tokens omitted), and the four credential kinds as attribution (hashes and
scopes omitted). Every model carrying anorgIdcolumn must be declared
there —tests/unit/lib/privacy/org-sources.test.tsparses the schema
and fails until it is, the subject manifest's guard for an org subject;
row isolation will meet it on every model it addsorgIdto. Guide:
.context/privacy/org-export.md.eraseOrg()(lib/privacy/erase-org.ts): one transaction deleting
the pending invitations into the org, clearingSession.activeOrgIdon
every session still pointing at it, and the org row (memberships and
credentials cascade). Users are never deleted; the install org is
refused. Guide:.context/privacy/org-erasure.md.
Also:
ORG_STATUSES/OrgStatusjoinlib/tenancy/roles.ts;
ORG_ID_SHAPEis exported fromlib/tenancy/resolver.tsand shared with
the neworgIdSchema;INVITATION_IDENTIFIER_PREFIXis exported from
lib/utils/invitation-token.ts.npm run smoke:tenancynow walks the whole
lifecycle against a real database. Behaviour atTENANCY_MODE=singleis
unchanged: no existing endpoint changes its answer, and on the install org
the org arm admits exactly the platform admins the platform check already
admitted, because the install-org role is the platform role's projection and
is now kept so. - The org API (
-
A credential remembers the org it was minted in, and acts only there
(multi-tenancy §106, fifth and last task). The four long-lived credentials
— API keys, embed tokens, agent invite tokens, MCP keys — gained anorgId
column in the identity migration above that nothing wrote at mint; under tenancy each was a
credential that worked everywhere. Now every mint writes the org the
request was acting in (orgForMint()inlib/tenancy/entry.ts, one read
of the tenant context, never a body field) and every resolution enters it:
resolveEmbedTokenandauthenticateMcpRequestapply the read rule
themselves (resolveCredentialOrg(), the org's status read with the row —
a suspended org's embed tokens and MCP keys are refused, no extra
query) and the six guard-less handlers underapp/api/v1/embed/**and
app/api/v1/mcprun insiderunAsOrg(orgId, …)withsource: 'embed-token' | 'mcp-key'; an API key enters through the guards as
before. An agent invite token is a gate the session passes through, not a
credential that acts: new modulelib/orchestration/invite-tokens.ts
(resolveInviteToken(),consumeInviteToken(),InviteTokenOutcome) is
the one implementationPOST /api/v1/chat/streamand
POST /api/v1/chat/agents/[slug]/validate-tokenshare, comparing the
token's org with the org the guard entered — a token from another org
reads as one that does not exist.orgOfColumn()names the null-column
rule once (install org atsingle, no org atmulti). One data migration,
20260918120000_credential_org_backfill, re-runs the identity migration's
four backfillUPDATEs verbatim (a test holds them byte-equal) so the
credentials minted between that migration and this one —orgId = NULL, read
as the install org atsingle, refused atmulti— are bound before any
install switches modes; from here no mint writes a null org. Guides:
.context/tenancy/identity.md
and the org-binding sections of
api-keys.md,
embed.md,
agent-visibility.md
andmcp.md.
Removed
-
The stateful MCP transport, and every session-shaped surface with it
(§39 t-718). There is one MCP transport now and it holds nothing: every request
stands alone, noMcp-Session-Idis issued, one arriving is ignored, and
GET/DELETE /api/v1/mcpanswer405withAllow: POST.Breaking, and the parts a fork has to act on:
Gone What to do MCP_SESSION_MODEdelete it from every env file and deploy config; an unknown key is inert GET /api/v1/admin/orchestration/mcp/sessionsnothing to list DELETE /api/v1/admin/orchestration/mcp/sessions/:idnothing to terminate McpServerConfig.maxSessionsPerKey(migration drops the column)stop sending it to PATCH …/mcp/settings— the body is refused with a 400 naming the key, alone or alongside a live fieldMcpSession,McpLogLevel,MCP_LOG_LEVELS,McpLogLevelRank,JsonRpcNotificationontypes/mcp.tsdrop the imports JsonRpcErrorCode.SESSION_NOT_FOUND(-32002) and.STATELESS_UNSUPPORTED(-32005)nothing emits either McpSessionManager,createEphemeralSession,McpResourceAudience,NotificationSink— getMcpSessionManager()from@/lib/orchestration/mcp— broadcastMcpToolsChanged,broadcastMcpResourcesChanged,broadcastMcpPromptsChanged,broadcastMcpResourceUpdatedremove the calls; keep the clearMcp*Cache()beside themlib/orchestration/mcp/resource-update-hooks.ts(notifyMcpAgentsChanged,notifyMcpWorkflowsChanged,notifyMcpKnowledgeChanged)remove the calls lib/orchestration/mcp/log-emitter.ts(emitMcpLog) andprogress-tracker.ts(createProgressReporter,extractProgressToken)a fork using emitMcpLoghas no transport to send onresources/subscribe,resources/unsubscribe,logging/setLevelan attempt now answers METHOD_NOT_FOUNDthe JSON-RPC envelope GET /api/v1/mcpused to return with its 405stop parsing that 405's body — it is now EMPTY. The stateless GET answered 405with{jsonrpc, error: {code: -32005, message}}; a client or ops script doingJSON.parse(await res.text())on it now throws. Read the status andAllowinsteadadmin/orchestration/mcp/sessionspage,mcp-sessions-list.tsx,API.ADMIN.ORCHESTRATION.MCP_SESSIONSandmcpSessionById— PATCH /api/v1/admin/orchestration/mcp/settingsnow rejects an unknown key
rather than stripping it (updateMcpSettingsSchemais.strict()). This is
what makes the line above true: a plainz.objectstrips what it does not
declare, so a settings form PATCHing the whole object got a 200 and lost the
maxSessionsPerKeyvalue with no signal — the removal announcing itself only in
the one shape nobody sends (that key alone). Strict costs a fork one 400 at
upgrade time and names the offending key; the same reasoning made §108 t-713's
org retention slice strict. It also catches a typo in a field that IS settable,
which used to be accepted and ignored.lib/api/sse.tsis untouched. Six non-MCP routes stream through it —
grep -rn "from '@/lib/api/sse'" appis the list, rather than one copied here
that would go stale. The SSE bridge was never MCP's.Why now. MCP revision
2026-07-28
removes protocol-level sessions, the session headers, theinitializehandshake
and the GET stream; tells a server to answer405for GET and DELETE and to
ignore a legacyMcp-Session-Id; and moves server-push to
subscriptions/listen. Sunrise's default (stateless) was already most of that
shape. The other mode could not be run — it threw at module scope onVERCELor
AWS_LAMBDA_FUNCTION_NAME, which is the platform's own deployment target, and
it refused a current client outright, answering400 Missing Mcp-Session-Id headerto a request that correctly sends none. Keeping it meant maintaining two
transports where one was unusable and increasingly non-conforming.Server-push is not replaced yet.
subscriptions/listenis a different
design problem from the one that killedstateful— a long-lived stream on a
function-per-request platform, rather than state shared across processes — and
is tracked separately. Until it lands, an MCP client learns a list changed by
asking again; the 5-minute registry caches are cleared on every admin mutation
exactly as before, so the nexttools/listis correct.
mcp.md
carries the two decisions that survive the code: a push audience is decided by
what CHANGED rather than by who is subscribed, and a session id is not a
capability — the key is.
Security
-
A cross-key MCP SSE hijack is fixed, and then the surface it was on is
removed (multi-tenancy §108 t-716, then §39 t-718 — both in this release).
Read this one even though the code is gone, because a fork still on 0.12.x or
earlier is running it.GET /api/v1/mcpattached the SSE notification listener and was the only verb
not re-checking that the named session belonged to the authenticated key. A
caller holding any valid MCP key could open the stream with another key's —
atmulti, another org's —Mcp-Session-Idand receive that session's
notifications/message,resources/updatedandprogresspushes, while the
rightful owner silently stopped receiving them, because the sink registry was
keyed by session id and a second registration replaced the first. Polling that
endpoint with someone else's session id also refreshed it, so a foreign
caller could keep another org's session alive indefinitely.Reachable only under
MCP_SESSION_MODE=stateful, which is not the default and
throws on any platform announcing itself function-per-request. If you run the
default (stateless), you were never exposed. If you runstatefulon an
older release, upgrade or switch to the default — in this release the mode, the
GETstream and the whole session plane are removed, so there is no session for
a caller to name.
Changed
-
MCP sessions were scoped to the org whose key opened them, and are then gone
entirely (multi-tenancy §108 t-716, then §39 t-718 — both in this release).
t-716 stampedMcpSession.orgIdfrom the tenant context, filtered the admin
sessions list and the terminate route to the reading org, and made
broadcastMcpResourceUpdatedtake a required audience. t-718 removes the
transport all of that belonged to, so nothing in that paragraph is a surface
in this release — it is recorded inRemovedinstead, as one story rather
than a breaking change followed by a deletion.What it was fixing is still worth knowing if you run an older version at
multiwithMCP_SESSION_MODE=stateful: an org admin read every other org's
session ids,apiKeyIds and activity times and could terminate any of them,
and one org's agent edit told every other org that its agent list had changed. -
The admin Logs page shows only the reading org's lines (multi-tenancy
§108 t-714).LogEntry(types/admin.ts) gainsorgId?: string | null,
stamped byaddLogEntryfrom the tenant context, andgetLogEntries—
behindGET /api/v1/admin/logs— filters to the reader's org and counts
totalafter that filter. The buffer itself is unchanged: one process-wide
ring, scoped at the query. An entry produced outside any tenant scope (boot,
arunAsSystemjob, a request on a platform credential, which carries no org
in either mode, or a timer armed throughrunDetached) is stampednull. The scope rule applies atmulti
only: atsinglethe page shows the process's lines exactly as it always
has, so a single-tenant install is unchanged. Atmultian unstamped entry
is visible only to a reader who is also outside an org, and an org admin
never sees another org's lines —
which they previously did, messages,contextandmeta, searchable. A
platform operator has no cross-org view through this page until §111. -
Both org reads now carry the org's settings (multi-tenancy §108 t-713).
GET /api/v1/admin/orgs/[id]returns the wholeOrg.settingscolumn, and
createOrg/updateOrgreturn it onOrgRecord.GET /api/v1/orgs/[id]
gainssettings: { retention }only — the validated slice,nullwhen the
org has set none — because that route is readable by every MEMBER and the
platform cannot vouch for a key a fork keeps beside its own. An org's data
export (GET /api/v1/admin/orgs/[id]/export) carries the column too, since
it exports the org row whole — so a fork storing its own config in
Org.settingsnow sees it there. The retention sweep's incoherent-windows
warning now names the org it is about, and no longer reads
executionRetentionDays: nullas "nothing to couple to": executions kept for
ever are the longest window, not an absent one. -
The maintenance tick runs every platform job and the schedules sweep per
org (multi-tenancy §108 t-711). Every entry inPLATFORM_JOBSdeclares a
scope(required); all eight existing tasks andprocessDueSchedulesare
per-org, run throughforEachOrg; the idle-gate horizon read runs under
runAsSystem. AtTENANCY_MODE=multithe tick therefore does work again
(before, every task threwNo tenant contextand nothing ran) and a tick
fired from an admin's session no longer runs inside that admin's org. The
publishedbackgroundTaskslist onPOST /api/v1/admin/orchestration/maintenance/tick
gains a ninth, appended name,auditLogRetention: the admin and MCP
audit-log prunes move out ofenforceRetentionPolicies()(now
tenant-owned tables only, per org) intoenforceSystemRetentionPolicies()
(system scope, once), soRetentionResultlosesauditLogsDeleted/
mcpAuditLogsDeletedandSystemRetentionResultcarries them; the exported
RetentionWindows/loadRetentionWindows()loseauditLogRetentionDays
with them, since the tenant sweep no longer reads that column.
POST /api/v1/admin/orchestration/schedules/tickruns per org too and gains
a500 SCHEDULER_TICK_FAILEDfor a sweep that failed in every org —
with one org that case already answered 500 by propagating, and without it
adding a second org would have turned the same total failure into a 200. Atmulti
with more than one org, a per-org task's entry in the completion log line —
and the route'sschedulesfield — is the fold across orgs
({ orgs, …summed counters, orgErrors? }); a single-tenant install sees
no change in shape. -
Two orgs can each have an agent called
support(multi-tenancy §107
t-708).AiAgent,AiKnowledgeBaseandAiKnowledgeDocument— published
model interfaces — move fromslug @uniqueto@@unique([orgId, slug])
(20260921120000_org_scoped_slugs; the generated compound key is
orgId_slug).AiWorkflow.slugstays global: it is the unauthenticated
inbound/:channel/:slugURL segment. Breaking for a fork that keys a
Prisma call onwhere: { slug }for one of the three — the type no
longer admits it; usefindFirst({ where: { slug } })inside an org
context (the tenancy context scopes it atmulti, the same row at
single), orwhere: { orgId_slug: { orgId: requireOrgId(), slug } }
where the caller has the org (the seeds do;requireOrgId()is new on
lib/tenancy/context.ts, refusing the system scope). The two partial
uniques on the same tables that Prisma cannot model move with them, under
their existing names: the ready-document dedupe becomes(orgId, fileHash) WHERE status = 'ready'and "one default knowledge base"
becomes one per org —getOrCreateDefaultKnowledgeBase()now upserts
the caller's org's default, and only the install org's keeps the fixed id
kb_default.npm run db:drift-check's A5 and A7 probes assert the new
definitions (indexExistsgains an optionaldefinitionContains, the
constraintExistsshape). A new always-run guard,
tests/unit/lib/tenancy/org-scoped-slugs.test.ts, fails naming any
tenant-owned model whose slug is still a global@unique. The two routes
whose credential is a signed token naming a row — the inbound trigger
(/api/v1/inbound/:channel/:slug) and the HMAC approval routes — now read
that row underrunAsSystemand run insiderunAsOrgwith the new
TenantContextSourcevaluesinbound-triggerandapproval-token
(resolveCredentialOrgaccepts both); a workflow or execution whose org
is suspended, or carries none atmulti, is a 404. The smoke scripts run
theirmaininside the install org. -
A query runs only inside the org the request entered, and a forgotten
path fails loud instead of reading wide (multi-tenancy §107, the
data-layer chokepoint). The tenancy seam's contract changes:
TENANCY_MODE=multino longer throws at import. The client
lib/db/client.tsexports is now the basePrismaClientthrough
withTenancy()(newlib/db/tenancy-extension.ts, a Prisma$extends):
every create of a tenant-owned row — nested creates included, whatever the
root model — is stamped with the org the request entered (the install org
atsinglewhen nothing entered a context; as the scalarorgId, or as
org: { connect }on a row in Prisma's checked form; never overwriting an
explicitorgId; nothing underrunAsSystem), and atmultievery operation on a
tenant-owned model, every raw op and every write under a context runs as
$transaction([set_config('app.current_org', <org>, true), op])with one
setter at the top of an interactive or batch$transaction,runAsSystem
settingapp.bypass_rlsinstead, and an operation that needs an org and has
none throwing before any SQL; a read on a non-tenant model that reaches a
tenant-owned one through a relation at any depth (include/select/
_count, a relation filter ororderBy) is scoped the same way.runAsOrg/runAsSystem/forEachOrgnow
await their callback inside the scope, so a non-async callback returning a
lazyPrismaPromisekeeps its org. Type note for forks:prismais
typedOmit<PrismaClient, '$on'>(TenancyClient) — every call site,
Pick<PrismaClient, …>default andtypeof prisma.x.ycompiles unchanged;
only a parameter annotated exactlyPrismaClientneedsTenancyClient, and
$onwas never usable on an extended client.tenantOwnedModels()in
lib/tenancy/classification.tsnow takes the client (tenantOwnedModels(prisma))
so the module stays free of the client it helps build. Behaviour at
TENANCY_MODE=singleis unchanged apart from the stamped column — no
set_configis ever issued there, proven through the real Prisma runtime on
a recording adapter (tests/unit/lib/db/tenancy-extension.test.ts).multi
is correct only with the policies enabled and aNOBYPASSRLSapp role
(§107's next task ships both). -
Credential response shapes and resolver contexts carry
orgId(§106,
with the bullet above).POST/GET /api/v1/user/api-keys(nullfor an
adminkey),POST/GET …/agents/[id]/invite-tokens,POST/GET /api/v1/admin/orchestration/mcp/keysandPOST …/mcp/keys/[id]/rotate
return the org each credential is bound to (the embed-token create already
returned the whole row; itsorgIdis now written).EmbedContext
(lib/embed/auth.ts) andMcpAuthContext(types/mcp.ts) gain a
required, non-nullorgId— a fork constructing either by hand adds the
field; both resolvers now returnnullfor a credential whose org is
suspended or, atmulti, unbound. Two rules onadmin-scoped API keys
are now enforced rather than documented:POST /api/v1/user/api-keys
stores anadminkey with no org and refusesadminasked for while
acting in any org but the install org (400, naming no org), and
both guards refuse an API key that carries bothadminand an org —
withAdminAuthat its scope floor (any org-bound key, whatever its
scopes),withAuththroughenterApiKeyOrg(new refusal
bound-admin-key) — so such a row is admitted nowhere;
lib/app/authorization.tssays the rule now holds. And atmultia
request acting in no org — anadminkey — passes no invite-only gate:
the token admits members of its org, and the refusal is logged with its
own reason (no-request-org). Behaviour atTENANCY_MODE=singleis
unchanged for every honest row: an unbound or install-org credential
resolves to the install org exactly as before.
Fixed
- Four process-global caches stopped mixing orgs (multi-tenancy §108
t-712). All four are behaviour changes atTENANCY_MODE=multionly; at
singlethere is one org and none of them changes anything. The event-hook
cache (lib/orchestration/hooks/registry.ts) was one process-wide
Map<eventType, CachedHook[]>holding tenant-ownedAiEventHookrows, so
whichever org refreshed it had its hooks dispatched for every org for
the next 60 seconds — org B's event POSTed its payload to org A's URL,
signed with org A's secret, while B's own hooks never fired. It is now
keyed by org;invalidateHookCache()still clears every partition, and at
multianemitHookEventfrom a call stack that entered no org — or one
running as the audited system scope, where the RLS bypass would otherwise
fan a single event out to every org's webhook — logs and dispatches nothing
rather than reading wide (atsinglethe context is the install org, so
nothing changes). And the MCP per-key rate-limit override
cache (lib/orchestration/mcp/protocol-handler.ts) is keyed by API key id,
which is unique across orgs — but it was filled inside whichever org's
request triggered the refresh, andMcpApiKeyis tenant-owned, so every
other org'srateLimitOverridewas silently dropped for five minutes. The
read now runs under the audited system scope, behind an in-flight latch and
a failure backoff so a burst — or a failing database — cannot turn that
audit line into noise. And the chat prompt-context
cache (lib/orchestration/chat/context-builder.ts) keyed on
(type, id, userId), all three of which a request supplies — the built-in
patterntype keys by a pattern number over tenant-owned
AiKnowledgeChunkrows, and a fork's registered contributor is handed the
caller's owntype/id— so a user who belongs to two orgs could open
pattern:3in one and, inside the 60-second TTL, be served that org's
knowledge content in the other org's system prompt. The key now carries the
org first, and a call under the audited system scope is refused outright
rather than served a body merged from every org's rows.
invalidateContextbuilds the same key, so call it inside the org whose
entry you mean to drop. And the MCP system-agent cache
(lib/orchestration/mcp/tool-registry.ts) resolvedmcp-systemby slug,
which §107 t-708 made unique per org and therefore shared across them:
under the bypass that lookup answers from an arbitrary org, so it now
refuses the system scope rather than caching the answer under a sentinel.
An install atsingleis unaffected by any of the
four, and multi-tenancy remains the opt-in capability the
playbook's
what you do not yet get
describes; the new
lib/tenancy/process-state.tsmanifest
and its scanner test are what found them, and are what a fork editing a
platform module underlib/will meet if it adds process-global state.