Repository navigation
v0.15.0 — alpha
Pre-releaseAlpha release. Twenty-first tagged Sunrise release. MINOR bump. It
completes the multi-tenancy phase: the external plane (§109), per-org quotas
(§110), the org-admin console (§111), within-org owner isolation (§115) and
vendor support access (§121). It also carries an erasure programme (account
deletion that is recorded first and finished in the background, and an
erased person kept out of deliveries, configs and runs), split input and
output prices on Model Matrix rows, URL secrets kept out of logs, traces,
audit rows and analytics, and Next 16.4. Measured againstv0.14.0: 62 PRs
merged, no direct commits, 53 issues closed; the suite is 1,373 files and
29,100 tests.
TENANCY_MODE=singlestays the default. Entries that apply only at
multisay so. None of the five features has been checked against a
deployedmultiinstall: §109's check was waived by an owner ruling, §110
and §115 leave it to a fork's firstmultiinstall, and §111 and §121
record none. Their behaviour is proved by their tests and the tenancy smoke
only.What a fork has to do
Seventeen migrations, then
npm run db:seed. Eight take locks that
scale with table size, so apply them off-peak on a large install. Four are
on the two delivery tables,ai_webhook_deliveryand
ai_event_hook_delivery:delivery_destinationre-creates their parent
foreign keys, which scans both to validate;delivery_destination_index
builds two indexes onai_webhook_deliveryanddelivery_payload_indexa
GIN index on each table, all withoutCONCURRENTLY; and
delivery_stopped_by_erasurebackfills both with aLIKEscan. The other
four index tables that grow with use, also withoutCONCURRENTLY:
ai_cost_log_org_created_at_indexonai_cost_log;
audit_log_support_grantonai_admin_audit_log, kept for ever by default,
where it also validates a new foreign key by scanning; and
user_pending_erasureanduser_pending_erasure_indexonuser, blocking
sign-ups and profile updates while each builds. All seventeen, in order:20261008120000_delivery_destination,
20261011120000_delivery_destination_index,
20261013120000_ai_cost_log_org_created_at_index,
20261013120000_delivery_payload_index,
20261014120000_provider_model_split_prices,
20261014130000_provider_model_price_pair,
20261015120000_ai_agent_binding_owner,
20261015150000_delivery_stopped_by_erasure,
20261015160000_delivery_payload_gin_no_fastupdate,
20261016120000_org_support_grant,
20261017120000_audit_log_support_grant,
20261017130000_webhook_delivery_send_generation,
20261017140000_user_signup_record,
20261017150000_webhook_subscription_outlives_creator,
20261017160000_erasure_receipt_email_hash_index,
20261019120000_user_pending_erasureand
20261019230000_user_pending_erasure_index. The seed's new
022-delivery-destinationsunit fills each delivery'sdestinationfrom its
current subscription or hook, where that still exists. The seed also
rewrites the fourteen document-cleanup capabilities'functionDefinition,
gives split prices to the seeded hosted chat models an admin has not
customised, updates the model auditor's capability definitions, and
publishes a new version of the provider-model audit workflow.Single-tenant installs that invited people into another org: run
npm run db:tenancy:install-flooronce (it refuses atmulti).At
TENANCY_MODE=multi:
- Org admins now run their org's sections of the admin console under
Sunrise's default policy, and can invite people into their org. A fork's
own routes and pages stay platform-only until it declares them
tier: 'org'or lists them throughlib/app/admin-tier.ts. If your
fork replacedcanAdminister, gate it onscope.tier: a policy that
admits onviewer.orgRolealone now admits org admins to every admin
surface, the layout included.- A customer org's configs may name only env vars starting
ORG_<ORG ID>_. A binding that names any other env var stops working;
move its value and update the binding.- Storage. Agent uploads land under
orgs/{orgId}/and are private by
default. On S3 setS3_OBJECTS_PRIVATE_BY_DEFAULT=trueor
S3_USE_ACL=true; on Vercel Blob such a binding is refused until it sets
public: true.- Outbound fetches made for a customer org (knowledge URL import,
webhooks,external_call,call_external_api) refuse a host that resolves
to a private, loopback or link-local address.- A memberless user with no signup record is no longer healed into the
install org at sign-in; a platform admin adds them.activeOrgForSession()can return anull
org.- Backup export and import leave the shared part out unless
shared=true
and the caller is in the install org.globalMonthlyBudgetUsdis again one ceiling across every org, where it
had been acting as a per-org cap.Your type-check and
validatewill flag:RouteErrorBoundary's
resetprop, nowretry; the newly requiredorgTierPagesprop on
<AdminHeader>andviewerTieronAdminSidebar; and hand-built router
mocks (npm run check:router-mocks), to be rebuilt with
createMockRouter(). They will not flag a missing<AdminTierGate>,
which is new: a fork that keeps its ownapp/admin/layout.tsxmust add
<AdminTierGate viewerTier orgTierPages>as Sunrise's layout does.Changes a build will not catch:
- The owner layer (§115). For a guarded caller the policy narrows to
one user (under the default policy, anyone but a platform admin or an
adminAPI key), in every mode: a list, count, aggregate or returning
write of memories, conversations, messages, workflow runs, evaluations or
datasets without an owner filter throwsOwnerReadRefusedError(a 404),
and an unfilteredfindFirstis silently narrowed to the caller's own
rows. Confine the query with the visibility helpers or
session.subjectFilter, or wrap a deliberate cross-owner read in
runAcrossOwners(reason, fn).- Account deletion. A route that deletes an account should call
requestUserErasure(). BothDELETEuser routes may now answer
202 { pending: true }or409. Erasure hooks,cleanupExternal
included, may run more than once, in another process: make them
idempotent.- Spend caps now hold every paid AI call, not only chat, so an install
with a global cap will see workflows, evaluations and ingestion stop at it.
Chat'sbudget_exceededis now the agent's own cap only, beside new
org_budget_exceededandglobal_budget_exceeded.- Seams. An
initApp*registration seam that returns a promise is
refused as if it had thrown. An erasure hook registered frominitApp()
now actually runs.- Behaviour.
- A webhook
send_notificationstep now POSTs to itswebhookUrl, whose
host must be inORCHESTRATION_ALLOWED_HOSTS.- A required text field holding only spaces is now a
400.- A unique violation other than the user email answers
400 VALIDATION_ERROR, notEMAIL_TAKEN.- Protected and auth routes match on whole path segments.
- An experiment variant with
agentVersionIdis refused.- An
adminAPI key stops working while its owner is not a platform
admin.- Delivery
subscription/hookandAiWebhookSubscription.createdBy
can benull.getStorageClient()returns a guarded wrapper, andorgs/is a
reserved key root in every mode.- Costs. Embeddings on Google, Mistral and Cohere models are costed
instead of recorded at $0, and a Model Matrix row with split prices
records chat turns at its input and output rates.- Logs and analytics. The logger reduces every outbound URL it writes to
origin and collapsed path, and Plausible now loadsscript.manual.js.- Overlays animate now that
tw-animate-cssis installed.- CI test jobs now time out (30 minutes per
test-fullshard, 60 for
test-changed). If yours pass slower than that, raise
CI_TEST_TIMEOUT_MINUTES.Dependencies. Next 16.4.2, vitest 5.0.5, vite 8.3.5, Prisma 7.10.1,
mermaid 12.2.0 and the rest of October's minor and patch bumps;undici7
is now a direct dependency, and@sentry/nextjs11.6 brings@sentry/cloudflarewith it.
Next 16.4'snext devkeeps its agent rules inAGENTS.md, no longer in
CLAUDE.md. Sunrise now commitsAGENTS.mdand endsCLAUDE.mdwith an
@AGENTS.mdimport in place of the old block. If yourCLAUDE.mdstill has
that block, take Sunrise's ending and commit theAGENTS.mdyournext dev
writes. Theprisma→mysql2override from 0.14.0 is gone: Prisma pins a
patchedmysql2itself, so a fork that copied the override can drop it.
Prisma 7.10.1 also fixes thedeepmerge-tsalert that 0.12.0 said only
Prisma 8 would fix.Dependency alerts accepted for this release:
postcss-selector-parser(moderate), under@tailwindcss/typography.
The fix is in a new major version the plugin does not yet accept, and the
plugin parses only the app's own CSS, at build time.sprintf-js(moderate), undermammoth→argparse. No patched
release exists, and only mammoth's command-line tool uses argparse, which
Sunrise never runs.braces/micromatch/fast-glob(high), under
@next/eslint-plugin-next. Lint-only, with no fixed release in the
plugin's range.
Added
-
Account deletion is recorded before it starts, and finished if the
request is not (t-842).requestUserErasure()in
lib/privacy/erasure-requests.tsrecords the deletion on theUserrow and
disables the account in one transaction (sessions deleted,AiApiKeys
revoked, sign-in refused), then runseraseUser(). If the erasure fails or
the request is killed, the newpendingErasuresmaintenance job
(processPendingErasures()) finishes it.Usergains five columns
(erasureRequestedAt,erasureRequestedBy,erasureReason,
erasureAttempts,erasureNextAttemptAt; migrations
20261019120000_user_pending_erasureand
20261019230000_user_pending_erasure_index). An API key whose owner is
being deleted is refused.exportUserData()leaves out
erasureRequestedBy,erasureAttemptsanderasureNextAttemptAt, and
eraseUser()clears the person's id fromerasureRequestedByon others'
pending erasures. Also exported:erasureBackoffMs(),
ERASURE_LEASE_MS,ERASURE_MAX_BACKOFF_MS,ERASURES_PER_RUN, and
refuseIfErasurePending()fromlib/auth/config.ts. BothDELETEuser
routes now answer409when another attempt already holds the deletion, and
the tick'sbackgroundTaskslist gainspendingErasures. See
.context/privacy/data-erasure.md→ "Pending erasures". Forks: a route
of yours that deletes an account should callrequestUserErasure()rather
thaneraseUser(). Your erasure hooks may now run again in a later
attempt, so make them idempotent. -
An account deletion answers within a request budget, and
202 pending
when the erasure needs longer (t-845).eraseUser()takes an optional
deadline: it stops at the next safe point once that passes and throws the
newErasureDeferredError(exported fromlib/privacy/erase-user.tsand
lib/privacy/erasure-deadline.ts); without one it behaves as before.
requestUserErasure()erases withinERASURE_REQUEST_BUDGET_MS(8 s,
overridable with theERASURE_REQUEST_BUDGET_MSenv var) and otherwise
returns{ status: 'pending', requestedAt }, leaving the maintenance tick to
finish it; the retrier has no deadline and runs each attempt to the end.
Only a timeout after the deadline is a deferral (isTimeoutError()
decides); any other error is a failure. A deferral does not count toward
erasureAttempts.boundLockWaits()takes an optional{ statementsToo }
that capsstatement_timeoutas well. BothDELETEuser routes answer
202with{ pending: true, requestedAt }in that case.GET /api/v1/users
andUserListItemgainerasureRequestedAt(optional on the type).
waitForSubjectLocks()takes{ maxWaitMs },
preRedactSubjectFromDeliveries()a statement runner and
preRedactSubjectFromRuns()a deadline, all optional. See
.context/privacy/data-erasure.md→ "Pending erasures". Forks: a client
of the delete routes should treat202as success. YourcleanupExternal
hooks may run more than once, in another process, so make them idempotent. -
lib/api/server-load.ts:requireServerData()andloadServerData(),
for reading internal API routes from a server component (t-861, #518).
requireServerData()returns a page's main data or throws a
ServerLoadErrorto the route's error boundary; with{ notFound: true }a
404, or a 400 whose validation error names only theid, renders the
not-found page, and no other failure does.
loadServerData()never throws and returns a typed failure, for a panel the
page can live without; render<LoadFailedNotice>from
components/admin/load-failed-notice.tsxin its place. Its
{ expected: [403] }option returns a status the caller handles itself
without logging it.requireAllServerData()reads every page of a list route
(or throws), for options a form must offer in full. All three log every
other failure, and let Next's own control-flow errors through. See.context/api/examples.md→ "Loading a page's data".
Forks: use these in your own admin pages rather than a try/catch that
returns[]. -
lib/app/erasure.ts→initAppErasureHooks(), a seam for erasure cleanup
hooks (t-854, #691).getErasureCleanupHooks()runs it once before its
first read, throughcreateAppInitGate, so a throw rolls its hooks back and
is logged. The registry is now held onglobalThistoo. Before, it was a
plain moduleMap, andinstrumentation.tsloads in a different module graph
from the route handlers that calleraseUser(): a hook registered from
initApp(), as.context/privacy/data-erasure.mdsaid to, was never run,
and nothing was logged. Forks: a hook registered frominitApp()now
runs with no change on your side; moving it intoinitAppErasureHooks()is
the documented place and adds the rollback. -
GET /api/v1/admin/orchestration/maintenance/tick, for Vercel Cron
(t-844). Vercel Cron only sends aGETwithAuthorization: Bearer $CRON_SECRET; setCRON_SECRETto an admin API key and it drives the tick
with no proxy. TheGETaccepts an admin API key only and refuses a browser
session with403, so a cross-site link cannot make a signed-in admin fire a
tick.HEADanswers405rather than running theGET.POSTis
unchanged. See.context/deployment/platforms/vercel.md. -
A user whose signup's membership write failed is healed into the org
that invited them (t-838).UsergainssignupOrgIdandsignupOrgRole
(enumOrgRole, both nullable, no FK): the membership the signup decided,
written byuserCreateBeforeHookin the same INSERT as the user and cleared
in the transaction that writes the membership (new
writeNewUserMemberships()inlib/tenancy/membership.ts). The sign-in
self-heal (activeOrgForSession) heals from it in either mode: an invitee
lands in the inviting org, an uninvited signup in the install org. At
TENANCY_MODE=multia user with no record (created by hand, or left with
no org byeraseOrgor a removal) is still refused until a platform admin
adds them. Migration20261017140000_user_signup_record. Both fields are
better-authadditionalFieldswithinput: falseandreturned: false, so
a sign-up or update body carrying either is a 400. Any other membership
write pays a surviving record first and clears it (healSignupRecord(),
called byaddMember,createOrgandsyncInstallMembershipRole), so the
invitation is neither lost nor healed back after a removal;
db:tenancy:install-floorheals a surviving record the same way.
Forks: a fork that creates users without better-auth's hooks writes no
record, and its users heal as before; one that writesOrgMembershiprows
directly should callhealSignupRecord()first. -
npm run db:tenancy:install-floor(t-835) gives every user without an
install-org membership one, by the platform-role rule. #872's floor is
written at signup only, so a user invited into another org before it
shipped still loses everything when that org is suspended. Single-tenant
installs that had such invitations should run it once. It writes nothing
on a second run and refuses atTENANCY_MODE=multi. New
backfillInstallMemberships()inlib/tenancy/membership.ts. -
Every admin audit row made in a support session names the grant and the
org (§121 t-825).AiAdminAuditLoggainssupportGrantId(FK to
OrgSupportGrant,onDelete: SetNull) andsupportOrgId(no FK, so an
erased org's support rows stay marked in the vendor's log). Migration
20261017120000_audit_log_support_grant.logAdminAction()fills both from
the tenant context, so no caller changes;AdminAuditEntrygains an optional
supportfor a caller acting on a session from outside one (the switch
route'ssupport.enter).GET /api/v1/admin/orchestration/audit-logrows
gainsupportOrgIdandsupportGrant(with its org), and the route takes
support=only|noneandsupportGrantId; the Audit Log page marks and
filters support rows. NewGET /api/v1/orgs/[id]/support-grants/activity
(listSupportActivity()inlib/tenancy/support-grants.ts) lists the rows
made under the org's own grants, and the Support access page shows them.
Forks: an audit row written straight toprisma.aiAdminAuditLog
rather than throughlogAdminAction()is not stamped. -
A platform admin enters a customer org under its open support grant
(§121 t-824). Atmulti,enterSessionOrgadmits a platform admin who is
not a member to an org whose grantverifiedSupportGrant()(new, in
lib/tenancy/entry.ts) finds: open, its org active, not the install org,
and its granter still an OWNER of the org. It is re-read on every request,
for the resolver header andactiveOrgIdalike; API keys never take it.
The entry is a new source,'support'(onOrgEntryand
TenantContextSource), with roleADMINand a newsupportfield
(SupportEntry:grantId,level,expiresAt) on the entry and the
tenant context. The support principal presents as an org ADMIN who is not
a platform admin: the guards andsessionPrincipalreplace the platform
role with the default user role on the principal and on the handler's
session (newlib/auth/support-principal.ts), so a fork policy or handler
that readsisPlatformAdminanswers false. AREAD_ONLYgrant refuses
every write in both guards before the handler runs.POST /api/v1/orgs/switchaccepts an org with an open grant for a platform admin,
writes asupport.enteraudit row, and its response gainssupport
(nullfor a member's switch). NewrequestSupportSession()in
lib/auth/admin-page-viewer.tsfeeds a banner across the admin layout, with
an ended state once the grant ends. The Organisations page offers Enter as
support, andGET /api/v1/admin/orgslists a grant only while its granter
is still an OWNER.DELETE /api/v1/users/merefuses inside a support
session, and so doesorgForMint(): no credential (embed token, invite
token, MCP key, API key) is minted under a grant. The maintenance bypass
asks about the platform admin's own role inside a support session.
Forks: a handler that must know it is serving support reads
getTenantContext()?.source === 'support'; the principal deliberately does
not say. -
An org's OWNER grants and revokes vendor support access (§121 t-823). New
modelOrgSupportGrantwith enumSupportGrantLevel(ADMIN, the default,
orREAD_ONLY), a system model besideOrgMembership: a reason, an expiry
(24 hours by default, 7 days at most) and who granted and revoked it. New
routesGET/POST /api/v1/orgs/[id]/support-grantsand
DELETE /api/v1/orgs/[id]/support-grants/[grantId], with the rules in the
newlib/tenancy/support-grants.ts: only an OWNER of the org, acting in it,
may grant (platform standing never counts); a new grant replaces the open
one; refused atsingle, for the install org and for a suspended org. New
org-tier page/admin/support-access(nav atmultionly;CORE_ADMIN_SECTIONS
gainssupport-access).GET /api/v1/admin/orgsrows gainsupportGrant.
Both privacy manifests list the model. Entry under a grant is t-824's, above.
Forks: run the migration; a fork page may not sit under
/admin/support-access. -
A fork lists its own admin pages as org-tier (t-817): the new
lib/app/admin-tier.tsseam (initAppOrgTierAdminPages(), ships empty)
callsregisterOrgTierAdminPage(pattern)fromlib/auth/admin-tier.ts, and
orgTierAdminPages()in the newlib/auth/org-tier-admin-pages.tsreturns
core'sORG_TIER_ADMIN_PAGESplus the fork's, so an org admin may open a fork
page without an edit to a core file. The fork's init runs on the server
only; the admin layout hands the list to<AdminTierGate>and
<AdminHeader>, which take a new requiredorgTierPagesprop. A throw
opens none of the fork's pages. The registrar refuses a pattern the matcher
cannot read, a dynamic first segment, and any pattern inside one of core's
sections (newCORE_ADMIN_SECTIONS), so a fork's list cannot reach a core
page.adminPageTier()'spagesargument is now required rather than
defaulting toORG_TIER_ADMIN_PAGES, which silently left the fork's pages
out. The roster test now also fails when a listed[x]pattern names an
unlisted literal page beside it, and when the fork's init threw.
/admin/orchestration/executions/live, a redirect to the executions page,
is now listed as org-tier for that reason. Forks:
tests/unit/lib/app/defaults.test.tshas a new row for the seam — pin your
pages there when you fill it. -
AiWebhookDelivery.stoppedByErasureandAiEventHookDelivery.stoppedByErasure
record that an erasure stopped the row (t-809; migration
20261015150000_delivery_stopped_by_erasure, which backfills rows already
stopped). A manual retry and a DLQ replay now refuse on this column instead
of on the"(not retried: subject erased)"wording at the end of
lastError, so a laterlastErrorwrite can no longer clear the stop, and a
receiver's error text that ends in the same words no longer looks stopped.
The migration marks existing rows by that wording once, so a row from before
it whose text merely ended in those words is marked too.
The wording stays onlastErrorfor people to read.isStoppedByErasure()
is gone fromlib/orchestration/webhooks/not-retried.ts. Forks: a job
or route of yours that retries or replays a delivery should refuse a row with
stoppedByErasure: true, and one that writeslastErrorno longer needs to
keep the suffix. -
A fork pairs its own capability seeds with their classes (#646): the new
lib/app/capability-seed-pairs.tsseam (APP_CAPABILITY_PAIRS, ships
empty) lists which class goes with which slug, and is spread into
capability-class-seed-parity'sPAIRS, so a fork's capability seeds are
held to their classes without editing a Sunrise test. That test now runs
every seed against a strict recording mock client instead of reading seed source, so it checks every capability a seed writes,
however the seed is written. Forks: a capability seed that the old
source reader could not see (a hoisted const, a definition taken from the
class) now fails the test until you pair it here. A seed must also run
under the mock: one that calls a client method it does not model, uses the
app's own client, or writes a capability through a nested write fails it,
unless you list it inAPP_UNCHECKED_SEEDS(same file, ships{}) with the
reason it writes no capability. -
AiAgent.bindingOwnerrecords who decides a platform agent's provider
and model (t-798; migration20261015120000_ai_agent_binding_owner). It
applies only to a platform agent whose definition picks its binding
(defaultBinding, with no starting provider or model of its own); every
other agent keepsnull. It is'platform'while the agent follows the
platform's pick,'org'once an admin sets either field, ornulluntil
classified. Agents from before this change are classified on their next
reconcile, partly from their version history.PATCH /api/v1/admin/orchestration/agents/:idsets'org'when it changes
provider or model, and so does version restore. The PATCH body also accepts
bindingOwner:'platform'hands the binding back and reconciles the org at
once,'org'takes it, and an unchanged ornullecho is ignored. Forks: a
route of yours that writes such an agent's provider or model should call
bindingOwnerAfterWrite(lib/orchestration/agents/binding-owner.ts, with
picksOwnBindingfromplatform-agent-guard.ts), or the platform will keep
moving a binding your admin chose. The column is not agent config: the field
registry lists it as a non-config scalar, so the agent form, agent export
and backup bundles, and version snapshots leave it out. The org data export,
which takes whole rows, includes it. -
An org's OWNER/ADMIN can invite people into their org (§111 t-813).
NewPOST /api/v1/orgs/[id]/invitations, with the same admission as the
members routes. It writes an invitation into the URL's org as a platform
USER, and has no platformroleororgIdin its body. New
checkInvitationInto()inlib/tenancy/lifecycle.tsholds the rules: an
OWNERinvitation needs owner standing, and the install org and a suspended
org are refused. A pending invitation into another org is refused, not
re-targeted. Unlike the platform route, it never returns the invitation
link: the invitee gets it by email only.OrgLifecycleErrorCodegainsORG_NOT_ACTIVE. A new org-tier
Members page (/admin/members, nav shown atmultionly) lists, invites,
re-roles and removes the entered org's members. Atmultithe admin header
names the org being acted in.POST /api/v1/users/inviteis unchanged and
stays the platform's. Forks: aswitchoverOrgLifecycleErrorCode
meets the new code. See Org endpoints. -
Admin tiers: a surface can declare it is the org's (§111 t-810). New
tieroption onwithAdminAuth()({ tier: 'org' }), passed to the policy
asscope.tier. Newlib/auth/admin-tier.ts:AdminTier, the page roster
ORG_TIER_ADMIN_PAGES, the route rosterORG_TIER_ADMIN_ROUTES, and
adminPageTier(). New optionalNavItem.tier. The default policy's
canAdministernow admits an org OWNER/ADMIN of the entered org to a
surface that declarestier: 'org', as a session and atmultionly.
Undeclared surfaces stay platform-only, fork surfaces included. Which core
surfaces declare it is t-811's entry under Changed. Forks: acanAdministeryou replaced
receivesscope.tier. Ignoring it keeps org admins out (the safe failure),
and a policy that admits onviewer.orgRolealone admits them everywhere,
so gate on the tier.AdminSidebarnow requires aviewerTierprop.
proxy.tsnow sets anx-sunrise-pathnamerequest header. See
Authorization → Admin tiers. -
A fork classifies its own models for the owner layer (§115 t-807): the
newlib/app/owner-classification.tsseam (initAppOwnerClassification(),
ships empty) and its primitiveregisterAppOwnerClassification()in
lib/db/owner-classification.ts. A fork marks a model inapp.prismaor a
framework-*.prismafilepersonal(optionallyenforced, so the owner
layer checks it) ornot-personalwith a reason, using core's row shape and
without editingOWNER_CLASSIFICATION.ownerClassificationOf(),
personalModels()andenforcedModels()now return core's rows plus the
fork's; newallOwnerClassifications()/appOwnerClassifications()read
them. A fork cannot reclassify a core model, and a fork row may not carry
alsoVisibleorownerless(both are core's per-model rules). With nothing registered the
readers return exactly core's list. Forks: the classification test now
audits your schema files too, so an unclassified model linked to a user or to
a personal model fails it until you classify it in the seam. See
Authorization → The subject context and the owner classification. -
The owner chokepoint (§115 t-801):
lib/db/owner-extension.ts
(withOwnerChecks()), a second$extendslayer over the tenancy client.
For a caller the policy narrowed to their own rows, a list, count or
aggregate of an enforced personal model must carry awherethat confines
it to rows the caller may read, afindUniqueis checked on the row it
returns, an unconfinedfindFirstis narrowed to the caller's own rows, a
write that returns its row (update,upsert,delete,
updateManyAndReturn) must be confined, and anincludeof one from any
root must be confined too. A
refusal throws the newOwnerReadRefusedError, whichhandleAPIError
answers as a 404. A classification entry'senforced: true(and
enforcedModels()) says which models it checks:AiUserMemory,
AiConversationandAiMessagein this release (t-802 adds the second two:
a message is checked through its conversation, a live share admits another
member's conversation on awithAdminAuthrequest only — the surface a share
consents to, where the read is audit-logged — and a checked read may follow the ownership relation
without awhereof its own; t-803 addsAiWorkflowExecutionand the six
evaluation and dataset models, and checks a to-one relation to a model whose
owner column decides its row — a run'sdataset— on the row it returns, as
afindUniqueis). A platform admin, a job, and any install with no narrowed caller
get byte-identical queries.SubjectEntry.readsShared(absent meansfalse)
andOwnerReader.readsSharedcarry the surface. See
Authorization → The owner chokepoint. -
The subject context and the owner classification (§115 t-800), the two
inputs of the within-org owner check.lib/auth/subject-context.ts:
withAuth/withAdminAuthnow run every handler insiderunAsSubject(),
andgetSubjectReadScope()tells code below the route whose rows the caller
may read (the policy'ssubjectScope, asked lazily and at most once per
request).runAcrossOwners(reason, fn)marks a deliberate read across owners
and logs its reason.lib/db/owner-classification.ts(OWNER_CLASSIFICATION,
ownerClassificationOf(),personalModels()) says which models are private
to one person and how each row's owner is reached, enforced against the core
schema by test. The check that reads them is the owner chokepoint, below. See
Authorization → The subject context. -
GET /admin/orchestration/hooks/deliveriesandGET /admin/orchestration/webhooks/deliveries(§109 t-739). Deliveries across
every hook, or every subscription the admin may see, in any status,
filterable bystatus, the parent's id andorphaned=true— the routes that
reach a delivery whose hook or subscription was deleted, now that such
deliveries are kept (below). The per-parent lists 404 once the parent is
gone, and the dead-letter list shows onlyexhaustedrows. -
'webhookDelivery'joinsUNATTRIBUTED_READ_KINDS(§109 t-739), so
session.unattributedReadsandUnattributedReadsgain awebhookDelivery
key. A fork that builds that record by hand (in a test fixture, say) must add
it; a fork policy'scanRead'unattributed'arm is now also asked about
webhook deliveries whose subscription was deleted, and decides who sees them
in the dead-letter queue (lib/orchestration/access/webhook-delivery-access.ts). -
lib/app/org-export.ts→initAppOrgSources(), and
registerAppOrgSources()inlib/privacy/org-source-registry.ts(§109
t-737). A fork's ownorgIdtables can now go into the org export. Each
source carries its ownfetch, runs as the org after core's sources, and
lands under a newappkey inexportOrgData()'s bundle, with a summary in
meta.app. Fork exclusions are appended tometa.excluded. With nothing
registered, the bundle is unchanged (noappkey and nometa.app), so
OrgExport.appandOrgExportMeta.appare optional. A fork source reads withownedBy(orgId), now exported from
lib/privacy/org-sources.ts, to match core's handling ofNULL-org rows.
If the fork's init
throws, or registers a row the registry rejects that leaves its model
uncovered,exportOrgData()throws
AppOrgDeclarationsUnavailableErrorfor every org; afetchthat does not resolve to an array throws
AppOrgSourceInvalidError. The org-manifest coverage test unions the
registry into its accounted set, so a fork'sorgIdmodel passes once
registered instead of failing. The same test now also fails on anyorgId
model whoseOrgrelation has noonDelete: CascadeorSetNull, a fork's
included, because org erasure relies on the cascade. -
A per-org monthly AI budget cap (§110 t-788).
Org.settingsgains a
quotasslice holdingmonthlyBudgetUsd. A platform admin writes it with
PATCH /api/v1/admin/orgs/[id]{ settings: { quotas } }(nullremoves
it). Atmultithat write is refused with a 403
(details.reason: shared_settings_install_org_only) unless it comes from
the install org or an unbound admin API key, so an org's admins cannot raise
their own cap. Every caps write is recorded in the admin audit log as
org.quotas.update, with the caps it replaced. The cap holds every paid AI
call made in the org, not only chat (see the spend gate under Changed).
checkBudget()reports it asorgCapExceeded/org: { limit, spent },
and the agents list's_budgetcarriesorgCapExceeded: true. A local
model is never stopped, so a cap of0means "no paid calls". With no
quotasset, nothing changes. Exported for forks' billing or plan code:
readOrgQuotas()/loadOrgQuotas()/writeOrgQuotas()and
ORG_QUOTAS_KEYfromlib/tenancy/org-settings.ts, andorgQuotasSchema/
OrgQuotasfromlib/validations/tenancy.ts. The spend gate caches each
org's cap for 30 seconds: code callingwriteOrgQuotas()directly calls
forgetOrgCap(orgId)fromlib/orchestration/llm/spend-caps.tsafter it
so its own process enforces the new cap at once. Migration
20261013120000_ai_cost_log_org_created_at_indexadds an
(orgId, createdAt)index onAiCostLog. Known limits: the budget lock is
per agent, so two agents in one org can each overrun the org cap by up to
one turn, and the month-to-date totals are cached for 10 seconds per
process, so another process's spend is seen within that window. See
Costs → Per-org monthly budget cap. -
A per-org API request cap (§110 t-789).
Org.settings.quotasgains
requestsPerMinute(a whole number, 1–100,000), written exactly as
monthlyBudgetUsdis and under the same install-org-only rule. Atmulti,
applyRateLimitcounts every request from a caller acting for an org
against that org's cap, in a second counter that
runs after the matched per-caller rule; both must pass. The org is the one
the guard will enter (a fork's tenant resolver's answer, else the session's
activeOrgId) or the org column of the API or MCP key row, and a user, or a
user key's owner, is counted only while an active member of it. The
x-sunrise-orgheader is never used to choose it. Embed-widget traffic,
/api/v1/admin/**,/api/v1/orgsand/api/v1/orgs/switchare not
counted. A user API key on a session-keyed route counts against its org. Nothing changes atsingle, or for an
org with norequestsPerMinute. Each org's cap is cached for 30 seconds:
code callingwriteOrgQuotas()directly callsforgetOrgRequestCap(orgId)
fromlib/security/org-request-cap.tsafter it. Known limits: another
instance can enforce an old cap for up to 30 seconds, and each instance
counts separately, as the section tiers do.'org'is deliberately not
a built-in rate-limit key, so a fork that registered its own'org'
resolver from the old recipe still boots. See
Rate limiting → Per-org request cap. -
GET /api/v1/admin/orchestration/costs/orgs(§110 t-790). Each org's
AI spend, call count and tokens for one UTC calendar month (period=YYYY-MM,
defaulting to the current one;orgIdnarrows it to one org), beside its
quotas.monthlyBudgetUsdand the share of it spent, plus adetachedbucket
for rows with no org. The usage data a fork's plans and invoices are built
from, viagetOrgUsage()inlib/orchestration/llm/cost-reports.ts. At
multionly the install org or an unbound admin API key may call it (403,
details.reason: shared_settings_install_org_only, for anyone else), since
the answer is every tenant's spend. Atsingleit returns one install-org
row. The costs page shows it as a per-org table when there is more than one
org, and the org detail page shows the org's month-to-date spend against its
cap. See
Costs → Per-org usage. -
Separate input and output prices on Model Matrix rows (t-774).
AiProviderModelgains nullableinputCostPerMillionTokensand
outputCostPerMillionTokens(migration
20261014120000_provider_model_split_prices). On a chat or reasoning row
(hasSplitPricing)dbModelToModelInfouses the pair when set and falls
back to the blendedcostPerMillionTokensotherwise; an embedding row keeps
its single rate whatever split it carries, and a row with no rate is
pricingUnknown. The pricing rules shared by every writer are in
lib/validations/model-pricing.ts. The blended column stays: an
embedding model has one rate, and a row without the split still uses it.
Written by the admin create, PATCH and bulk routes, the provider-model form
(a new Chat Pricing section), the seed, and the provider-model audit (below).
Every writer takes them as a pair: setting exactly one is refused, including
a PATCH that would leave the saved row with one, and migration
20261014130000_provider_model_price_pairadds a CHECK constraint
(ai_provider_model_price_pair, drift probe A10) so the database refuses it
too. A fork writing these columns directly must write both or neither. The
discover-models dialog now saves a chat model's prices from the provider's
catalogue when it has both. A positive price from the
built-in list or OpenRouter still wins over a matrix row for the same
provider (t-769), so this prices matrix-only models exactly; it is not a new
override for known ones. -
The provider-model audit proposes input and output prices (t-774).
apply_audit_changesgains both fields inAUDITABLE_FIELDS, and
add_provider_modelsaccepts them on a new model. A price must be backed by
aweb_search,knowledge_baseorexternal_callsource with a reference
(theaudit-proposalsschema rejectstraining_knowledgealone), be 0–10,000
per 1M tokens, and come as a pair. On approval the pair applies together or
not at all, drift-checked and written in one transaction; a new model whose
prices are half-set or invalid is created without them, and the result says
why. The
provider-model-auditoragent's instructions and the audit template's
prompts and approval screen are updated; the next seed publishes a new
version of the system audit workflow. -
A shared mode for the erasure locks (t-841).
lockSubjects()and
findErasedSubjects()inlib/privacy/erased-subjects.tstake an optional
{ shared: true }: the lock then excludes an erasure, which takes it
exclusively, but not other shared holders. Core's workflow notification step
and approval pause use it, so two sends to one address do not queue on each
other. Without the option the lock is exclusive, as before.sendEmail()
(lib/email/send.ts) takes an optionalsignalandidempotencyKey, passed
to Resend. -
CI_TEST_TIMEOUT_MINUTES— a repo variable capping the CI test jobs
(t-867, #843).test-full(each shard) now stops after 30 minutes and
test-changed, which can run the whole suite unsharded, after 60, instead of
GitHub's six hours. A vitest worker can deadlock before any test starts, for
example on an import cycle closed through avi.mockasync factory, and no
vitest timeout catches that. A timed-out job reportscancelled, which
ci-statusalready fails. Forks: if your suite is slow enough to hit
these while passing, raise them with
gh variable set CI_TEST_TIMEOUT_MINUTES --body 90, which sets both jobs.
See.context/architecture/ci.mdKnob 5. -
CI_NEXT_CACHE_MAX_MB— a repo variable bounding the CI Next.js build
cache (t-868, #856). The Build job's.next/cachegrew with every build
until the lockfile changed. A new step beforenpm run builddiscards the
restored cache when it is over the limit (default 2048MB on disk) and logs
the size either way, so the build starts cold and saves a fresh one.
Forks: a fresh Sunrise cache is 674MB on disk; if yours is already near
2048MB, raise the limit withgh variable set CI_NEXT_CACHE_MAX_MB --body 4096, or every build will start cold. See.context/architecture/ci.md
Knob 6.
Changed
-
npm run check:router-mocks:/pre-prstep 4m is a tested script, and
npm run validateand CI now run it (#936, t-863). The old 4m was a Python
block insidepre-pr.mdthat matched text. In a fork it reported CLEAN over
16 complete router literals and 17 casts: it could not count a shorthand member
({ push, refresh }), could not find a literal whoseprefetchwas shorthand,
and only grepped foras unknown as ReturnType<typeof useRouter>, while every
real offender wroteReturnType<typeof vi.fn>. The rules now live in
scripts/ci/router-mocks.ts(scanRouterMocks) and parse with the TypeScript
compiler. The cast rule fires when the thing cast isuseRouter, when the
target type names the router (includingAppRouterInstanceandMockRouter),
and when a cast value is handed to a mockeduseRouter. Sunrise's own suite
is clean. A fork'svalidatemay now fail where the old check passed:
build those routers withcreateMockRouter(). A file that mocks only
next/routeris skipped, and a genuine false positive takes
// router-mocks-ignore: <why>on the line above, reason required. -
RouteErrorBoundarytakesretry, notreset(t-861). Next's
reset()re-renders the segment without re-fetching it, so "Try again"
after a failed server-side load failed again the same way. Every Sunrise
boundary now passes Next 16.3'sretry(), which re-fetches first.
Forks: anerror.tsxthat rendersRouteErrorBoundarymust rename the
prop it receives and passes fromresettoretry; the type-check flags
each one. -
npm run type-checkruns under the same heap cap asnpm run lint
(t-855, #693). It goes throughscripts/run-capped.mjs, soNODE_HEAP_MB
now applies to it. It was left out on a 1.64-1.75 GiB peak, but that was a
warm run; cold, base Sunrise peaks at 2.74 GiB and a ~1.9x fork aborted at
4.40 GB, takingvalidatedown before lint ran. CI is unchanged: the
workflow'sNODE_OPTIONSstill wins. The wrapper also no longer hands out
more than was asked for under an inherited cap (t-856, #711). Forks: if
your own scripts calltscdirectly, they do not get the cap. -
An
initApp*registration seam that returns a promise is refused, as if
it had thrown (t-851, #739).createAppInitGateused to log and report
'ok', so an asyncinitAppAuthorizationPolicythat rejected left the
install on the default policy instead of safe mode, and an async
initAppSubjectSourcesnever madeexportUserData()refuse. The gate now
rolls the registry back, settles'failed'and runsonFailure.
Forks: an async seam that happened to work is now disabled at its first
read, with a log line saying it must be synchronous. Drop theasyncand do
the async work elsewhere.initAppinlib/app/bootstrap.tsis the boot
hook and may stay async. See.context/architecture/fork-init-seams.md. -
The global settings refuse keeping executions for ever while cost logs
expire (t-852, #839).PATCH /api/v1/admin/orchestration/settings, its
schema and the settings form used to skip the retention pair check when
either side wasnull, soexecutionRetentionDays: nullwith a finite
costLogRetentionDayswas accepted, and the cost breakdown emptied out under
executions still on file. They now answer 400 for it, the same rule the org
route already applied. Cost logs kept for ever (null) still pass against any
execution window, and a PATCH that names neither window is not checked, so a
pair saved under the old rule does not block unrelated saves. Forks: a client that sets one window alone may now be
refused against the stored other half; setcostLogRetentionDays: nulltoo,
or give executions a finite window. -
The site follows the device's light or dark setting until a visitor picks
one (t-853, #756). The root layout's no-flash script andThemeProvider
used to save the device setting tolocalStorageon the first page load, as
though the visitor had chosen it, so a device that later switched was never
followed. Now onlysetThemewrites the stored theme, and with nothing
stored the provider followsprefers-color-schemechanges. Both resolve
throughlib/theme.ts. Visitors who already have a stored value keep it, so
the change shows for new visitors only. -
A copy read before an erasure can no longer write the person back
(t-805). The settings PATCH (escalation list), the workflow draft save,
publish and rollback,createInitialVersion, webhook subscription create
and update, and schedule create and update now answer 400 (a
ValidationErrornaming the field) when what they would write names an
erased person: an approver id with an erasure receipt, or an address whose
receipt says erasure matched it as verified — unless a live, verified
account holds that address again. The backup importer androllback(whose
source snapshot cannot be edited) strip such recipients instead, with a
warning or anerasedRecipientsRemovedaudit flag. Each writer's
transaction runs withWRITER_TRANSACTION_OPTIONS(70 s) so one queued
behind an erasure is refused rather than timed out, and a new index on
DataErasureReceipt.subjectEmailHash(migration
20261017160000_erasure_receipt_email_hash_index) serves the lookup. In a
workflow draft or version an erased approver id is stripped rather than
refused, since the builder cannot show an id whose account is gone; an
erased address is still refused. Erasure and each writer share a
transaction-scoped advisory lock per address and user id, so neither can
slip between the other's read and write. NewrefuseErasedSubjects(),
findErasedSubjects(),lockSubjects()andsubjectEmailHash()in
lib/privacy/erased-subjects.ts, anddefinitionRecipients()in
lib/privacy/config-recipients.ts.DataErasureReceipt.metadatanow
recordsaddressVerified. Forks: a writer of your own that stores an
address or user id it notifies should callrefuseErasedSubjects()inside
its write transaction. -
A webhook subscription outlives the admin who created it (t-782).
AiWebhookSubscription.createdByis now nullable withonDelete: SetNull
(it wasCascade), so erasing an admin no longer stops the integrations they
set up. Migration20261017150000_webhook_subscription_outlives_creator. A
subscription with no creator is an ownerless row: a new kind,
'webhookSubscription', joinsUNATTRIBUTED_READ_KINDS/
UnattributedReads, and the default policy lets platform admins see, edit,
test, switch off and delete it. Every webhook route scopes through the new
webhookSubscriptionVisibilityWhere()/
webhookSubscriptionAccessBasis()(lib/orchestration/access/), and a
delivery's visibility nests its subscription's. The list and detail
responses gaincreatedBy(null once erased), and the list marks such a row
Creator erased. Erasure step 7 now also clears the subject's own email
subscription that emails them, verified or not. Forks: a policy that answers the
ownerless kinds one by one should answerwebhookSubscription; one that
wants an org's admins to inherit a departed colleague's integrations widens
it there. Code readingcreatedByas a string must allow null. -
At
TENANCY_MODE=multi, a user with no org membership is no longer
healed into the install org at sign-in (t-836). The install org is the
vendor's own, and a customer org's invitee whose signup failed could land
there.activeOrgForSession()now returns{ orgId: null, healed: false }
for a memberless user atmultiand writes nothing. The guard refuses them,
and a platform admin adds them throughPOST /api/v1/orgs/[id]/members.
This also covers an uninvited signup whose membership write failed, and a
member left with no org by an org erasure. A fork calling
activeOrgForSession()must handle anullorg.singleis unchanged.
An invited signup whose recorded decision is lost now lands in the org that
invited them (plus the install floor atsingle) rather than only in the
install org, andundecidedMembershipsForNewUser()now answers in both
modes and never returnsnull. -
POST /api/v1/users/inviterefuses anorgRolefor the install org
(t-831, #871). It answers 400VALIDATION_ERRORwhenorgRoleis sent
without anorgId, or withorgId: 'install'. An install-org role
follows the platformrole, as the members screen already enforces.
membershipForNewUserignores such anorgRoleon an invitation that is
already pending, and a resend drops it. AnorgRolefor any other org is
unchanged. -
eraseUser()redacts a person's delivery payloads before its
transaction (t-806; owner ruling 2026-10-15). Once its callers' checks
have passed, it redacts every webhook and event-hook delivery naming the
person in committed 5,000-row chunks (preRedactSubjectFromDeliveriesin
lib/privacy/delivery-payloads.ts), then runs the transaction, whose own
pass writes only rows that still need it. A heavy history used to time the
60 s transaction out (around 600,000 rows), and an erasure that times out
erases nothing. What changes for a caller: if the transaction fails after
that step, the person's delivery payloads are already anonymised and their
retries stopped while the account still exists; a retried erasure completes
the rest. Run your own refusal checks before callingeraseUser(), as the
core routes do. The delivery payload GIN indexes now carry
fastupdate = off(migration20261015160000_delivery_payload_gin_no_fastupdate,
probed bydb:drift-checkas A11 and A12), so the transaction's match does
not slow down until a vacuum. -
Email delivery sends are bounded at 15 s (t-808).
DISPATCH_TIMEOUT_MS
inlib/orchestration/webhooks/not-retried.tsholds every channel's send
timeout, and a test keeps each below the retry claim lease. An email send
that times out fails as retriable. Forks: a delivery channel you add
needs an entry there. -
An org admin now runs their own org's sections of the admin console
(§111 t-811). Atmulti, an org OWNER/ADMIN reaches agents, workflows,
triggers, knowledge, conversations, evaluations (with datasets and runs),
experiments, event subscriptions, MCP keys and approvals. That covers their
pages, their nav items, and every API route those pages call, all listed in
ORG_TIER_ADMIN_PAGES/ORG_TIER_ADMIN_ROUTES, plus one execution's
detail page, which the approval queue and a workflow run link to. They also
read the capability, agent-profile, provider, provider-model,
embedding-model, grader and tag lists, but cannot write any of them. Their
provider list omitsbaseUrl,apiKeyEnvVarandmetadata, the
usage counts on those lists cover their own org only (another tenant's
usage is a platform admin's delete check), and a
rateLimitOverridethey set on an MCP key may lower the server's limit but
not raise it (400above it). Each opened route now declares an
ownership. NewactsForPlatform()inlib/tenancy/context.tsanswers
whether the call stack acts for the platform;canChangeSharedSettingsnow
delegates to it. The provider test routes (providers/[id]/test,
test-model) refuse an org admin a provider their org is not approved for
(error: 'provider_not_approved') and run their probe in that org. New
administersPlatform()inlib/auth/admin-viewer.tstells a platform admin
from an org admin inside an org-tier route. Platform admins, and every
install atsingle, see no change. Forks: a route of yours that one of
these pages calls stays platform-only until you declare ittier: 'org'.
An org admin reaches the MCP keys page from the MCP landing (t-812). See Authorization → Admin tiers. -
An org admin now sees their org's half of the mixed admin pages
(§111 t-812). The orchestration dashboard, costs, analytics, the
executions list and the MCP landing joinORG_TIER_ADMIN_PAGES, and their
nav items aretier: 'org'. An org OWNER/ADMIN sees their org's figures
there and no platform-ops panel: no setup wizard or quarantines, no per-org
usage or budget-settings link, no live-engine cards, lease inspector or
force-fail, and only the API Keys card on the MCP landing. The routes behind
their half are now org-tier:costs,costs/summary,costs/alerts,
models,observability/dashboard-statsand the fiveanalytics/*
routes. Two answer an org admin differently:costs/alertsreturns
globalCap.capasnull(asspentalready was outside the install org),
andmodels?refresh=truereturns403./adminnow sends an org admin to
/admin/orchestration; the overview stays platform-only. New
adminPageViewerTier()inlib/auth/admin-page-viewer.tstells a page
which viewer it is rendering for;CostsView,ExecutionsListViewand
McpDashboardtake an optionalviewerTier, andExecutionsTablean
optionalengineControls, each defaulting to the platform's view.
Forks: a page you add that pairs a platform panel with org data should
askadminPageViewerTier()before you list it in the roster. -
A platform agent's
defaultBindingis now asked on every maintenance
pass, and its answer moves the agent (t-798).PlatformAgentDefinition .defaultBindingused to be asked only when an instance was created, or
while its provider and model were empty. Now theplatformAgentsjob asks
it every 15 minutes for each org whose agent's binding is the platform's,
and moves the agent whenever the answer changes;nullmoves it to
inherit. A fork that implements one should make it deterministic, with a
total order and no locale-dependent comparison, or the agent will move
back and forth between passes. It should also be cheap, and should throw
when it cannot answer rather than returnnull, which would move the agent
to inherit until the next pass. Core's eligibility and credential seams
still fail closed to "nothing eligible", so a fork resolver that is down
during a pass does move the clean-up agent to inherit for that pass
(t-819). -
The admin layout and the maintenance bypass ask the policy with the
guards' verified org facts (§111 t-810). Both used to passorgIdstraight
from the session's active org, with noorgRole. They now share
sessionPrincipal()(lib/auth/session-principal.ts), which enters the org
aswithAdminAuthdoes (enterSessionOrg, resolver header first) and passes
orgIdandorgRolefrom that entry. Where a guard refuses a session whose
entry is refused, these two ask with no org facts instead, so a platform
admin can still reach the console to switch org. Under Sunrise's default
policy nothing changes for a platform admin. Forks: acanAdminister
that readsviewer.orgRolenow gets it at the layout and the bypass too, so
an org admin your policy admits now reaches the page shells, where before the
layout sent them away. -
handleAPIErroranswersVALIDATION_ERROR, notEMAIL_TAKEN, for a
unique violation on any constraint but the user email (t-796). It
answered every P2002 withEMAIL_TAKENand "Field already exists", which
clients render as "This email address is already registered", for a slug
clash too. The status stays400; the message is "A record with that value
already exists", with nodetails. A duplicate email
(user_email_key) still answers400 EMAIL_TAKEN, now with
details.field: 'email'. -
Four events name the person whose conversation or run they report
(t-792).chat_budget_exceeded_per_turncarriesuserId(nullplus
embedVisitorIdfor an embed visitor);workflow_budget_exceededcarries
the run owner'suserId(nullfor a system-owned run); the force-failworkflow.failedandexecution.force_failed
hooks carry it besideactorUserId, the admin. Additive for receivers.
Erasure and subject access now find these deliveries after the
conversation or run is deleted; rows written before this change still
name the person only through it. -
escalationConfigSchemareads an emptyemailAddresseslist
(t-791);escalationConfigWriteSchemastill requires one address. Erasing
the list's only recipient leaves it empty with the webhook kept, and a value
the read schema rejected would vanish from the settings API and form. The
settings form now refuses to save escalation as enabled with no address
rather than savingnull, which would have dropped the webhook; untick
Enable escalation notifications to turn it off. A fork reading
parseEscalationConfig()may now seeemailAddresses: []. -
runAcrossOwnerslogs once per reason per request, and only when it
crossed something (§115 t-801) — the first read inside it the owner layer
would have checked, for a narrowed caller — not on every entry; and core declares its own crossings with it: the subject-data
export when an admin exports someone else (exportUserData), the org export
(exportOrgData), andresumeApprovedExecution, which runs the execution's
owner's workflow rather than reading narrowed to whoever approved it. A
decidedBy: 'policy'route whose personal reads the owner layer checked is
no longer reported as an ownership gap for not reading
session.subjectFilter, unless it also read a personal model the layer does
not check yet. -
The org and global monthly budget caps hold every paid AI call, not only
chat (§110 t-788). Only the streaming chat handler checked them, so
workflow steps, the orchestrator, evaluations, judges, knowledge ingestion
and scheduled or webhook runs kept spending after a cap was reached. The
provider manager now runs a spend gate (assertWithinSpendCapsin
lib/orchestration/llm/spend-caps.ts) before every vendor call, beside the
eligibility gate. A refused call throwsSpendCapExceededError, a
ProviderErrorwith codeorg_budget_exceededorglobal_budget_exceeded.
The codes are request faults (isRequestFault): not retried, and no circuit
breaker failure. Local providers are never refused, so provider selection
passes over a refused paid candidate to a local fallback:
getProviderWithFallbacksdoes, and so does chat's mid-stream failover.
When no candidate is left,getProviderWithFallbacksthrows the cap's
SpendCapExceededErrorinstead of the retriableall_providers_exhausted.
spendCapRefusal()answers without throwing. A chat agent whose primary is
local now answers on it after a cap, its own included, is reached. The turn
runs insidewithPaidCallHold, and the gate refuses every paid call that
turn makes (a fallback, embeddings, the summarizer, tools), sobudget_exceeded
is now a spend-cap code and a request fault too. An install with a global
cap set will see non-chat work stop at that cap where it used to carry on. -
Chat turns stopped by the org or global budget get their own error
codes (§110 t-788). The streaming handler emittedbudget_exceeded, with
"This agent has reached its monthly budget", for every cap. It now emits
org_budget_exceededorglobal_budget_exceededwhen one of those caps
stopped the turn.budget_exceedednow means the agent's own cap only. A
client that matchedbudget_exceededto catch every budget stop must also
match the two new codes. Thebudget_exceededwebhook still fires for all
three, including a turn the provider gate stops part way, and gains a
cap: 'agent' | 'org' | 'global'field. The message for each is the error
registry's message and action for the code, so the agent's no longer names
its limit (it read "…its monthly budget of $10.00"). -
GET /api/v1/admin/orchestration/costs/alertsreturnsglobalCap.spent
only to the install org (§110 t-788). The global total sums every org's
spend, so atmultian admin in a customer org getsspent: null, with
capandexceededas before.GlobalCapStatus.spentin
lib/orchestration/llm/cost-reports.tsis nownumber | null. -
Backup export and import run in one named org, and the shared part moves
only for the install org (§109 t-738). Both
POST /api/v1/admin/orchestration/backup/exportand…/importtake
?orgId=(defaulting to the active org; an unbound admin API key must name
one atmulti; an import naming a suspended org gets 409) and
?shared=true|false. The shared part is capabilities, knowledge tags and
theglobalorchestration settings. It is exported or written only when the
caller is in the install org, or is an unbound admin key. A caller in a
customer org that asks is refused with 403,details.reason: shared_settings_install_org_only. Left unset,shareddefaults to included
atsingle, so single-tenant backups are unchanged, and to left out at
multi. On import, a skipped shared part is reported in a warning and in a
newImportResult.sharedSkipped. The import route no longer declares
writesSharedSettings, so a customer org can now restore its own agents and
workflows; it was refused outright under t-751. A workflow whose slug
another org holds is imported under a free slug with a warning, instead of
failing the whole import. The renamed row is marked
metadata.importedFromSlug, so a repeat import updates it rather than
adding a copy. The audit entries'entityIdis now the target org instead
of'full'. Tag grants also resolve against existing tags.
exportOrchestrationConfig(scope)andimportOrchestrationConfig(raw, userId, scope)now take aBackupScopefrom the newresolveBackupScope()
inlib/orchestration/backup/scope.ts. The bundle format is unchanged
(schemaVersion: 3); a bundle without the shared part carries those fields
empty. -
A webhook or event-hook delivery records where it was sent, and outlives
the subscription or hook that sent it (§109 t-739).AiWebhookDeliveryand
AiEventHookDeliverygaindestination(the URL's origin only, so it holds
no URL secret, or the email address, normalised),destinationFingerprint(a
keyed HMAC of the full destination;fingerprintDestination()in
lib/orchestration/webhooks/destination.tschecks a URL against it) and
previousDestinations(the earlier ones, when a retry followed an edited
URL). Their parent FK,subscriptionId/hookId, is now nullable with
ON DELETE SET NULL: deleting a subscription or hook, or erasing the admin
who created a subscription, keeps its deliveries. Code that reads
delivery.subscriptionordelivery.hookmust handle null. A retry of such a
delivery is refused with409on both retry routes (the hook route used to
answer404for a delivery it could not retry), and a retry the maintenance
tick abandons keeps its last real error with(not retried: …)appended. The
webhook DLQ routes show such a delivery to the admins the authorization
policy permits an unattributed read (platform admins by default), and
retention prunes it by age as before. A delivery emailed to a person is
theirs to see and to have erased: the subject-access export gains a
webhookDeliveriessection (when, which event, the outcome, and their
own address — never another inbox a re-pointed row also names), and
eraseUser()redacts the address on every such delivery to[erased]and
stops any of them still retrying — both for a verified address only, matched
exactly on the normalised form.GET /webhooks/dlqno longer returns the
subscription'surl(which can carry a credential), and the
webhook_delivery.deleteaudit entry names the delivery's origin rather than
the subscription's full URL, and no email address.
Migrations20261008120000_delivery_destinationand
20261011120000_delivery_destination_index(two indexes on
ai_webhook_delivery, built withoutCONCURRENTLY, so apply off-peak on a
large install); then run
npm run db:seedso the022-delivery-destinationsunit fills existing rows
from their current subscription or hook (the best available value, not a
record). Rows whose parent is deleted before it runs stay unrecorded. -
At
TENANCY_MODE=multi, an org's stored files live underorgs/{orgId}/,
and agent uploads are private by default (§109 t-736). The
upload_to_storagecapability writesorgs/{orgId}/{keyPrefix}{uuid}.{ext}
(the org prefix wraps a binding's ownkeyPrefix), through
getStorageClient({ orgId }), a new optional argument that returns the
provider wrapped to refuse any key outside that org on upload, read, delete,
prefix delete and signing (lib/storage/org-scoped.ts). A binding that sets
neitherpublicnorsignedUrlTtlSecondsnow uploads privately and
returns a signed URL valid for 24 hours; setpublic: trueto keep a public
link. On Vercel Blob, which cannot sign, such a binding is refused with
signed_url_not_supporteduntil it opts into public; on S3, set
S3_OBJECTS_PRIVATE_BY_DEFAULT=true(bucket blocks public access) or
S3_USE_ACL=true, or such a binding is refused with
private_objects_not_supported. A dispatch with no org
in its tenant context is refused withno_org_context.orgs/is now a
reserved root:getStorageClient()withoutorgIdnow refuses any key
underorgs/(in any letter case), so an org's files are reachable only
through that org's client, and atsinglea binding whosekeyPrefix
starts with it is refused withinvalid_binding.getStorageClient()
therefore returns a guarded wrapper, not the provider instance: it exposes
theStorageProviderinterface only, so a fork that reached a provider's own
extra methods through it must keep its own reference to the provider.
validateStorageKey()now rejects.and empty path segments (a
trailing/on a prefix is fine; a full blob URL is judged by its path):
the local provider resolves keys as paths, so./orgs/b/xreached another
org's directory. Local storage access
tokens gain an optionalorgclaim:generateStorageAccessToken()and
buildStorageAccessUrl()take{ orgId }, refuse an org-owned key signed
for any other org (or for none), and the read route answers403 TOKEN_ORG_MISMATCHwhen the claim does not match the key, or403 ORG_UNAVAILABLEonce the claimed org is erased or suspended.StorageProvider.getSignedUrl
gains an optional thirdoptionsargument (SignedUrlOptions); a fork's
custom provider that ignores it still compiles, but one that mints
local-style tokens must forward it, or signing an org key fails.eraseOrg()
deletes the org's prefix after its transaction commits, best-effort, and
reportsstorageSwept(also onDELETE /api/v1/admin/orgs/[id]);
sweepErasedOrgStorage(orgId)re-runs that sweep for an org already erased. Unchanged atsingle: no prefix, and
the public default stays. Avatars and files uploaded before this stay where
they are, and org erasure does not reach the older ones. See
.context/storage/tenancy.md. -
Recorded costs change for models only the Model Matrix prices
(t-774). Once a matrix row has split prices, its chat turns are recorded at
the input and output rates instead of the blended(input + output) / 2
for both, so an input-heavy turn (long prompt, short reply) records about
half what it did. The seed now carries split prices for its hosted chat
rows, so seed-managed rows change on the next seed; rows an admin has edited
keep the blended rate until their prices are set. Models in the built-in
price list are unaffected.
Deprecated
EraseUserParams.userEmail(t-797). Ignored: the erasure receipt now
hashes the address stored on the user row, read inside the erasure
transaction, rather than the caller's copy, which a session cached across an
email change could hold stale. The parameter is optional and still accepted
so existing callers compile; stop passing it. It will be removed at the next
MAJOR.
Fixed
-
A running workflow no longer writes a person erased while it runs back
into its run (t-843). The engine's checkpoint, approval pause and final
write each run in a transaction that, after the write, checks for erasure
receipts since the run started and takes whoever the run names among them
out ofexecutionTrace,inputData,outputDataanderrorMessage, and
out of the step outputs later steps read.eraseUser()also makes one more
pass over recently written runs after it commits. New exports:
findErasedSinceRunStarted()andredactSubjectFromRunsSince()in
lib/privacy/run-recipients.ts, andmatchErasedSubjects()in
lib/privacy/erased-subjects.ts(findErasedSubjects()without the locks).
Each run write now opens a transaction, about 2 ms more per step on a local
Postgres. See.context/privacy/data-erasure.md→ "And what it writes back". -
Operators can set how long failed webhook deliveries are kept (t-884,
#838).webhookDlqRetentionDaysonAiOrchestrationSettingswas read by
the retention prune but nothing could write it globally, so dead-lettered
deliveries were always pruned on the webhook window.PATCH /api/v1/admin/orchestration/settingsnow accepts it (a positive integer up
to 365, ornullto usewebhookRetentionDays),GETand
OrchestrationSettingsreturn it, the Settings → Retention card has a
"Failed webhook retention" field, and backup export / import carry it,
held to the same 1–365 bound. Restoring a backup made before this leaves
the current value as it is. -
The API response helpers no longer invite a
publicCache-Control
override (t-881, #751). ThesuccessResponse()docblock and
.context/api/headers.mdsaid a route could replaceprivate, no-cache
with apublicdirective. That was never safe:checkConditional()sends
the default on its 304, so the first revalidation reverts it, and the proxy
can attach the per-visitor cookie to a first visit's API response, which a
shared cache would then replay to other visitors. The docs now say a
publicdirective ors-maxageis unsupported, and that a route may only
tighten the default. No code changed. Forks: if one of your routes sets
apublicdirective on an API response, remove it, or serve that data from
a page or a static asset. -
An agent export that includes built-in agents imports again (t-879,
#721).POST /api/v1/admin/orchestration/agents/exportnow leaves out
system agents (isSystem: true), as the backup export already does. Before,
a built-in agent's emptyprovider/modelmade the whole bundle fail
import validation. The import would have skipped those agents anyway. A
selection of only built-in agents now returns400, and a mixed one reports
how many it left out in a newX-Skipped-System-Agentsresponse header,
which the agents table shows. Forks: an export
file made before this change that contains a built-in agent still fails to
import; delete that agent's entry from the file and import again. -
Dialogs, menus, popovers, selects and tooltips animate again (t-875,
#682). Thecomponents/ui/overlays useanimate-in,fade-in-0,
zoom-in-95andslide-in-from-*classes, but the library that defines
them was never installed, so every overlay appeared and vanished instantly.
tw-animate-cssis now a dev dependency, imported fromapp/globals.css;
no component changed. Forks: your overlays will start animating after
this merge. If you had worked around the missing animations, or want them
off, remove the classes from your copies or override them. -
Correction to v0.11.2's coverage note: the widening covers
.js,.jsx,
.cjsand.mjs, not only.mjs(t-870, #698). Item 3 of the v0.11.2
notes and its### Fixedbullet said.mjsfiles had joined the per-file 80%
coverage floor. The scoped runner'scoverageTargetsactually admits every
[cm]?[jt]sx?file, so a changed.js,.jsxor.cjsfile under a path
coverage does not exclude is held to the floor too. Forks: if you checked
only your.mjsfiles after taking v0.11.2, check those as well — a design
prototype's.jsxis the case that has already failed a fork's unrelated PR.
Nothing changed in the runner; the wording was wrong. -
An experiment can no longer pin a variant to a saved agent version, which
it could not honour (t-864, #857). The run path loads only the live agent,
so pinned variants were scored against the live config and a version A/B
named a winner between identical setups.POST /api/v1/admin/orchestration/experimentsnow answers400for any variant
withagentVersionId, andPOST /experiments/:id/runanswers400for an
experiment already stored with one, before writing any run or session.
Unpinned experiments are unchanged; every variant runs the live agent, and
the create form and.context/orchestration/experiments.mdnow say so.
Forks: a client that sentagentVersionIdmust drop it. -
Admin pages say when their data failed to load, instead of showing an
empty list (t-861, #518). Every admin page that reads through
serverFetchcaught any failure and returned[]ornull, so a 500, a
timeout or a wrong base URL showed "No users found" against a full
database, and a detail page's 500 showed as a 404. Five pages logged
nothing. A page whose main data fails now reaches the admin error boundary,
whose "Try again" re-fetches; a detail page 404s only on a real 404; a
dashboard or summary panel that fails shows "Couldn't load …" in its place
while the rest of the page works..env.exampleno longer says a local
reverse proxy never needsINTERNAL_API_URL: a local production build
behind one does. -
Required text fields reject whitespace-only input instead of saving it
blank (t-860, #713). Zod runs a string chain's checks in the order written,
and 88 schemas called.trim()after.min(),.max()or.regex(). So
" "passed.min(1)and was stored as"". Sign-up'sname, the
contact form, profile and invitation fields, the MCP settings and most
orchestration admin forms were affected. Every chain now trims first, and
tests/unit/lib/validations/trim-order.test.tsfails on any new chain that
does not. Forks: a request whose required field is only spaces is now a
400with the field's "required" message. A padded value is measured after
trimming, so a value within.max()once trimmed now passes. If the guard
fails on your own schemas, move.trim()to straight afterz.string(). -
Approval history shows decisions on scheduled and inbound runs (t-849,
#773).GET /api/v1/admin/orchestration/approvals/historyread only runs the
caller owned, so every decision on a run nobody owns was missing, including
the caller's own. It now usesexecutionVisibilityWhere, the executions
list's rule: on a default install every admin sees decisions on those runs.
A fork whose authorization policy refuses unattributed reads narrows this
page too, where before the page ignored the policy. It now scans only runs
whose trace holds an approval step, so frequent scheduled runs cannot crowd
decisions out of its 1000-run window. Theknown-gapentry leaves
OWNERLESS_SURFACE_EXCEPTIONS. -
An evaluation run where no grader scored a single case is now
failed
(t-847, #921). When the subject ran but no grader returned a score (every
grader or judge threw, as with a judge that has no provider, or skipped), no
case carried anerrorCode, so the run endedcompletedwith nothing
scored, and the cost estimator sampled it as a good run. It now ends
failedwithsummary.note = 'no_scores_recorded', pluscasesFailedand
dominantErrorCodewhen some cases also errored. A run where any metric
scored at least one case still completes, andall_cases_failedis
unchanged. -
The maintenance tick's background tasks keep running after the route
responds (t-844). The route left them as a floating promise, which a
serverless host can freeze once the202is sent, silently stopping
schedules recovery, retries, the reaper and retention. They now run under
Next'safter(), and the route setsmaxDuration = 300(Vercel's Fluid
default and the Hobby ceiling) for the sweep and the chain together.runMaintenanceTick()returns the chain asbackground;
a fork calling it from its own request handler must pass that toafter(). -
eraseUser()reaches schedules and runs that name the person (t-804).
A verified address held in a schedule'sinputTemplateis blanked (the
step it feeds then fails loudly, as for an emptiedto; the schedule stays
enabled) and audited asworkflow_schedule.recipient_erased. Other
people's runs —inputDataandexecutionTrace, including a run paused at
an approval, whose approvers and notification target the approval routes
read — lose the person's id and address. New
lib/privacy/run-recipients.ts; runs are redacted in committed pages before
the erasure transaction (preRedactSubjectFromRuns()), as deliveries are,
so a large run table cannot time it out.dispatchApprovalNotificationno longer
logs the target address (hasChannelTargetinstead). -
eraseUser()no longer times out behind a backup import that names the
person (t-840). The importer holds the erasure locks for everyone its
bundle names for the whole import, up to 130 s (now
IMPORT_TRANSACTION_TIMEOUT_MSinlib/orchestration/backup/importer.ts),
and an erasure that waited for one inside its 60 s transaction failed. The
erasure transaction now only tries the locks. On a miss it rolls back,
eraseUser()waits outside it, and it runs again. The last of
SUBJECT_LOCK_TRIES(3, exported fromlib/privacy/erase-user.ts) waits
inside, as before. New inlib/privacy/erased-subjects.ts:
tryLockSubjects(),waitForSubjectLocks(),SubjectLocksBusyErrorand
SUBJECT_LOCK_WAIT_MS(150 s per lock).tryLockSubjects()returns false
on a held lock, and a caller must then roll back: the keys it took before
that one stay held until the transaction ends. An import holding a row the erasure
needs, rather than the person's lock, can still time it out. Forks: an
erasure queued behind a writer now takes as long as that writer, up to
150 s per lock waited for, where before it failed at 60 s. A route that calls
eraseUser()on a host with a short request limit may want to run it in the
background. -
LogoutButtonsigns out with a full page load, as the account menu
does (t-829, #707). It usedrouter.push()androuter.refresh(), which
keep the Router Cache, so pages rendered for the signed-in user could still
show after sign-out. Nothing in Sunrise mounts it. A fork that does gets the
same sign-out asUserButton. -
The user-created seam reports a Google or GitHub invitation acceptance as
an invitation (t-828, #688).viaInvitationwasfalseon every OAuth
signup, so a fork'slib/app/user-created.tshook could not tell an invited
colleague from a stranger. It is nowtrueon both ways of accepting, as
the seam's contract already said.lib/auth/pending-signup.tsgains
setInvitationAccepted()andwasInvitationAccepted(), which carry it
between the hooks on one request. -
On a single-tenant install, a user invited into another org is a member
of the install org too (t-827, #872). Before, they got only that org's
membership, so suspending it locked them out of everything. Their
install-org role follows the platform role the invitation grants. The new
membershipsForNewUser()inlib/tenancy/membership.tsdecides both
memberships, andPendingSignupgains an optionalinstallMembership.
Atmultinothing changes: the install org is the vendor's, and a customer
org's admin must not be able to add people to it. Their first session still
starts in the invited org. Later sessions follow the existing active-org
rule, which prefers the install org. Users invited before this release are
not backfilled. -
Protected and auth routes match on whole path segments (t-826, #758).
AnappProtectedRoutesentry, and the core/dashboard,/settingsand
/profile, now cover the route itself and the paths beneath it, not every
path that starts with the same letters. A public/profilesor
/settings-guideno longer redirects signed-out visitors to/login, and
the same rule applies to the auth-page and invite-only/signupredirects.
A fork that counted on an entry covering a longer sibling (/app
protecting/apply) lists that sibling itself. -
A
send_notificationstep on the webhook channel now POSTs to its
webhookUrl(t-794). Before, it sent nothing and still reported
sent: true: it broadcast aworkflow_notificationevent that no
subscription could select. Existing workflows with a webhook notification
step start sending when they take this release. The JSON body is
{ text, workflowId, workflowName, executionId, stepId }, sent through the
guarded outbound fetch, so the URL's host must be in
ORCHESTRATION_ALLOWED_HOSTSor the step fails.outputgainsstatus
(the response status). Theworkflow_notificationevent is no longer
dispatched. -
Fourteen orchestration admin routes answer "already exists" only for a
collision on their own unique constraint (t-814): agents, agent
capabilities, workflows (create, update, save as template), capabilities,
providers, provider models, knowledge tags and agent profiles. Before, any
unique violation in their writes was reported as a taken slug. A collision
on any other constraint now reaches the shared handler's400 VALIDATION_ERROR, except a provider model whose provider already has that
model id, which says so. Creating or re-slugging an org answers a slug
taken in a race past its pre-check with409 SLUG_TAKEN, like the
pre-check, not400. -
Restoring an agent version saved on inherit succeeds (t-820).
POST …/agents/:id/versions/:versionId/restorerefused any snapshot with an empty
providerormodelwith400 "Invalid version snapshot data". It now
restores them, putting the agent back on inherit. The PATCH body still
refuses an empty provider or model. -
An email-channel webhook delivery that timed out after Resend accepted it
is not sent again by the automatic retry (t-822). Each send carries a
Resend idempotency key.AiWebhookDeliverygainssendGeneration, bumped
by a manual retry or DLQ replay so those still send again. The email shows
the delivery'screatedAt, not the time of the attempt. -
The 14 document-cleanup capabilities now seed the schema their classes
accept (#646):prisma/seeds/019-cleanup-capabilities.tswrote a
hand-copiedfunctionDefinitionthat had drifted from every class
(different tool and parameter descriptions), and the parity test never saw
it. The seed now takes each definition from the class instance, so the next
deploy rewrites those rows'functionDefinitionto the class's. Admin-owned
name,description,rateLimitandisActiveare untouched. -
The clean-up agent's provider follows what the org can reach (t-798).
AtTENANCY_MODE=multia new org's platform agents are created before it
has any provider grant, so the clean-up agent started unpinned and ran on
the default chat model until a registry change triggered a reconcile; a pin
to a provider later revoked was refused on every call. While the agent's
provider and model are the platform's (the newAiAgent.bindingOwner, see
Added), theplatformAgentsmaintenance job re-picks them every pass and
moves them when the pick changes: a grant, a revocation (back to inherit if
nothing qualifies), a provider switched on, a key set, a strong model added.
PUT /api/v1/admin/orgs/[id]/providersalso reconciles at once on a policy
change (tryReconcilePlatformAgents, which never fails the grant). A
provider or model an org admin set is never moved. The pick's ranking now ends on the provider slug, so two providers
offering one model no longer trade places between passes. -
Code that handles a particular unique violation recognises it again
(t-796). Under Prisma 7's driver adapter a P2002 carries nometa.target,
so every reader of it missed. An inbound trigger replay answered500
instead of200 { deduped: true }(no second run was created), agent PATCH
answered a slug clash with "concurrent change, please retry" (409) instead
of400, a knowledge upload that lost a same-content race failed instead of
returning the winner, and a revision-number collision was not retried.
Agent PATCH now answers "please retry" only for a version-number race;
another unique violation goes to the shared handler's400. Agent clone
retries a new slug only on a slug collision, and answers its own409
"Could not generate a unique slug" when every suffix is taken.
isUniqueViolationOn(err, UNIQUE_CONSTRAINTS.<name>)in
lib/db/unique-violation.tsreads the collided index name; a test checks
each named constraint against the migrations. -
An erasure's stop on a delivery is no longer undone (t-793). An attempt
in flight when the erasure commits writes its outcome only to a row still
waiting to send, and arms no further retry. The manual retry routes answer
409for a row an erasure stopped, and a DLQ replay skips it; the erasure
marks every delivery about the person's activity, delivered or exhausted
ones included, so none of them is sent again. A due retry is claimed before
it is sent and the claimed row's payload is the one sent, so a stale timer
no longer double-sends a row an admin retried by hand, the in-process timer
and the maintenance tick no longer both send one retry, and neither sends a
copy read before an erasure redacted it. Both retry routes also answer
409, not404, for a delivery that exists but changed before the retry
could reset it. -
Erasing a person removes them from the escalation list and from workflow
notification and approval steps (t-791). Their verified address left
escalationConfig.emailAddressesandsend_notificationsteps'to, and
their idhuman_approvalsteps'approverUserIds(and their address an
approval step'snotificationChannel.target), so escalations and workflows
kept emailing, or naming, an erased person.eraseUser()now
removes them in the workflow draft and every published version (erasure
is the one writer allowed to change a version'ssnapshot), audited as
settings.escalation_recipient_erasedandworkflow.recipient_erased. A
list they leave empty stays empty: a notification step with no recipient
fails when it runs (its error strategy decides what follows, so askip
step is skipped) until an admin adds one. Unverified addresses are left
alone. See
Data erasure, step 8. -
The erasure receipt hashes the stored address (t-797), not the
userEmailthe caller passed, so it cannot name an address the account no
longer held. -
Embeddings from Google, Mistral and Cohere models are costed instead of
recorded as free (t-775).calculateEmbeddingCost()read only its own
rate table, which covers OpenAI and Voyage, sotext-embedding-004,
mistral-embedand the Cohereembed-*-v3.0models were logged at $0 —
knowledge ingestion and the query embedding behind every knowledge search
included — although the Model Matrix prices them. A model missing from the
table is now priced from the registry; one neither prices is logged with
metadata.pricing = 'unknown', as chat turns are. Embedding costs for these
models rise from $0 from this release. -
Cost estimates no longer call a free model "pricing unknown" (t-776).
GET /workflows/:id/cost-estimateand the evaluation estimate set
pricingKnown: falsefor any model whose rate was 0, including local models
and Model Matrix rows priced at an explicit 0, while runtime records those
turns as free and priced.pricingKnownis nowfalseonly for a model the
registry lacks or one whose matrix row has no cost. -
Bulk agent actions no longer reach deleted agents (t-795).
POST /api/v1/admin/orchestration/agents/bulkfiltered out system agents
but not ones deleted throughDELETE /agents/:id, so a deleted agent's id
in the selection made it active again and gave it a new version. Deleted
agents are now excluded from every action, and the response gains
skipped: the requested ids the action could not reach (system, deleted,
or unknown). -
The global monthly budget cap totals every org's spend again at
multi(§110 t-788).checkBudget()summed month-to-date spend inside
the acting org's scope, where theorg_isolationpolicy hides other orgs'
rows. SoglobalMonthlyBudgetUsdacted as the same cap applied to each org,
and the install had no ceiling on its shared provider keys. The total is now
summed in the system scope, detachedNULL-org rows included, and the
agents list andGET /costs/alertsread the same figure
(getGlobalCapState()inlib/orchestration/llm/cost-tracker.ts). At
singlenothing changes. -
S3 and Vercel Blob
deletePrefixnow delete past the first page
(§109 t-736). Both read only the first listing page (1,000 objects on S3),
so a prefix holding more kept the rest while reporting success; S3 also
reported success whenDeleteObjectsreturned per-keyErrors. S3 now
follows the listing to the end, Vercel Blob re-lists until the prefix is
empty, and an S3 per-key failure returns{ success: false }.eraseUser()'s avatar sweep anderaseOrg()'s org
sweep both go through it. -
Erasure reaches what a delivery's event says about a person, and
colleagues' subscriptions that email them (t-783, t-786). A webhook or
event-hook delivery whose payload names the erased person — their id as
userId,actorUserIdor an approver, or a conversation or workflow run of
theirs — is redacted, not deleted: an event about their own activity keeps
only which event it was (agent, workflow, ids, costs) and loses its free
text and any field a fork added; one about a change they made keeps the
change and loses only them; one still retrying about their activity stops
— and a retry timer armed before the erasure no longer undoes that: the
in-process retry timers for webhook and event-hook deliveries now skip a row
that has moved on since they were armed, which also holds the stop §109
t-739 applies to a delivery emailed to an erased person. An attempt already
in flight when the erasure commits no longer overwrites it either (t-793).
Another admin's email subscription to the person's verified address has the
address cleared and is deactivated, with a
webhook_subscription.recipient_erasedaudit entry that names the
subscription, never the address. Subject access returns the same deliveries:
webhookDeliveriesrows gainabout(which event, and what it said about
them — never other people, nor org configuration such as a notification
step'swebhookUrl) besidesentTo, and a neweventHookDeliveriessection
carries event-hook deliveries. A migration adds a GIN index on each delivery
table'spayload(notCONCURRENTLY; both tables are bounded only once
webhookRetentionDaysis set). A fork
whose own events name a person under another key clears it with a
scrubInTransactionhook; see.context/privacy/data-erasure.md. -
Editing a chat model in the provider-model form no longer unprices it
(t-774). The form clearedcostPerMillionTokenswhenever a model without
the embedding capability was saved, but that column is also a chat model's
blended price, so every edited chat model went to "pricing unknown" and its
turns were recorded at $0.
Security
-
Only a platform admin can change a platform role through
PATCH /api/v1/users/[id](t-837). The route wrote the body'srole
for whoever passedwithAdminAuth, so a fork whosecanAdministeradmits
an org's OWNER/ADMIN let them promote anyone, themselves included, to
platformADMIN. Changingrolenow needs the caller to be a platform
admin, whatever the policy said, and is a 403 otherwise. A support session
counts as not being one. Sending the role the user already has, and
editing the other fields, are unchanged. Stock Sunrise admits only
platform admins to the route, so nothing changes there. -
Only a platform admin can set a platform role through
POST /api/v1/users/invite(t-833). The route wrote the body'srole
for whoever passedwithAdminAuth, so a fork whosecanAdministeradmits
an org's OWNER/ADMIN also let them invite someone as a platformADMIN. A
roleother than the default now needs the caller to be a platform admin,
whatever the policy said, and is a 403 otherwise; a support session counts
as not one. So does re-sending (?resend=true) a pending invitation whose
role is not the default, since a resend rewrites its role and org. Stock Sunrise admits only platform admins to the route, so
nothing changes there. Inviting at the default role is unchanged. -
An
admin-scoped API key stops working when its owner stops being a
platform admin (t-830, #746; owner ruling 2026-10-17). Before, demoting
an admin left their admin keys with the whole admin surface, and on any
other route they ran with no org scope.withAdminAuth,withAuth(through
enterApiKeyOrg, which gains the refusaladmin-key-owner-not-admin) and
the default policy's platform-admin check now also require the owner's
current role to beADMIN. Re-promoting the owner restores the keys.
Demotion does not revoke them. A fork whose own policy readsviewer.scopes
foradminshould add the same owner check. -
A URL a customer org chooses can no longer reach the platform's own
network (§111 t-811). With the knowledge URL import and webhook
subscriptions open to org admins,checkSafeProviderUrlwas not enough:
it reads the URL's text, so a hostname resolving inside the network
(db,169.254.169.254.nip.io, an attacker's A record) passed, and the
imported page could be read back. Atmulti, a fetch made for a
non-install org (knowledge URL import, webhook delivery, webhook test, and the allowlisted calls behindexternal_call/call_external_api) now
connects throughwithOutboundDispatcher()(new
lib/security/safe-dispatcher.ts). It refuses the connection if the host
resolved to any loopback, link-local, private, CGNAT or unique-local
address, and connects to the address it checked, so DNS rebinding and
redirect hops are covered too. NewcheckSafeAddress()in
lib/security/safe-url.tsapplies the URL rules to a resolved address.
checkSafeProviderUrlnow drops a trailing dot before its denylist (metadata.google.internal.was let through), and both checks unwrap the IPv4 address inside a NAT64 (64:ff9b::/96) or 6to4 (2002::/16) address and refuse0.0.0.0/8and site-localfec0::/10. A refusal names the host, never the address it resolved to, and for a customer org a refused host and one that does not exist read the same, so a webhook test cannot map the internal DNS; a webhook delivery refused this way is terminal rather than retried. For the allowlisted calls, where no URL check runs first, an IP literal the address rules refuse is refused for a customer org before the fetch (a literal is never looked up). The install org, a system scope andsingleinstalls connect as before; a call stack with no tenant context atmultiis pinned. Addsundici(7.x, the
major Node 24'sfetchruns on) as a direct dependency. -
A customer org's configs can no longer read the platform's env vars
(§111 t-811). Opening the agent, workflow and chat routes to org admins let
them author the configs that name env vars:${env:NAME}templates in
call_external_api'sforcedUrl/forcedHeadersandexternal_call's
url/headers,auth.secret/authSecret, and the Twilio and WhatsApp
*Envfields. Naming the session secret or a provider key there, then
calling a host they could read, would have handed them the operator's
secret. Atmulti, a config acting for a non-install org may now name only
env vars startingORG_<ORG ID>_(orgEnvVarPrefix()in
lib/orchestration/env-template.ts). The check runs where the value is
read, and before the variable is looked up. A refusal isinvalid_binding
oncall_external_api, newenv_var_not_availableonexternal_call, and
new HTTP error codeauth_secret_not_available. The install org, a system
scope, andsingleinstalls are unchanged; a call stack with no tenant
context atmultinames no env var at all. The binding-save warning lists a
refused name apart from an unset one (meta.warnings.unavailableEnvVars,
withenvVarPrefix). Operators: a customer org
binding that names an unprefixed env var stops working atmulti. Move its
value toORG_<ORG ID>_…and update the binding. See
External calls. -
A narrowed caller's read of another member's memory is now refused at the
query (§115 t-801). Inside one org, an org OWNER, ADMIN or MEMBER (anyone
but a platform admin or anadminAPI key) could read another member's
AiUserMemoryrows through any library function that forgot to filter by
owner; nothing below the route checked. Now the data layer refuses it, in
every environment, with a 404. Upgrading: fork code that reads
AiUserMemoryfor a narrowed caller without filtering on the owner will
throw. Confine the query withsession.subjectFilter, or wrap a deliberate
read across owners inrunAcrossOwners(reason, fn). -
…and of another member's conversation or message (§115 t-802).
AiConversationandAiMessageare enforced: a narrowed caller (an org
OWNER or ADMIN included) reads only their own threads, ones shared with them
while the share is live, and ownerless ones where the policy admits it. Core's
reads are narrowed to match. Upgrading: fork code listing or counting
conversations or messages for a narrowed caller without
conversationVisibilityWhere(or an owner filter) will throw; a message read
byconversationIdalone needsconversation: …beside it. Behaviour
change: for a narrowed caller, the analytics that quote message text
(topics, unanswered questions, content gaps, recent negative feedback) now
draw only on threads that caller could open; the figures stay
deployment-wide. A platform admin sees what they saw before. -
…and of another member's workflow runs, evaluations and datasets (§115
t-803).AiWorkflowExecution,AiEvaluationSession,AiEvaluationLog,
AiDataset,AiDatasetCase,AiEvaluationRunandAiEvaluationCaseResult
are enforced: a narrowed caller reads their own, and ownerless runs and
datasets where the policy admits it. A named approver still approves, rejects
or cancels a run they cannot otherwise see (acrossOwnersForApprovalin
execution-access.ts), and every maintenance job now runs across owners, so
a tick fired from an admin's session is not narrowed to that admin's rows.
Upgrading: fork code listing or counting runs for a narrowed caller
withoutexecutionVisibilityWhere(or an owner filter) will throw, as will
evaluation logs, dataset cases or case results read by their parent's id
alone (name the parent's owner beside it:session: { userId },
dataset: datasetVisibilityWhere(session),run: { userId }), and an
includeof a case result'sdatasetCase(read the cases separately).
Behaviour change: a run whose orphaned dataset another admin has since
claimed keeps its scores and outputs, but its cases' inputs come back as
datasetCase: nullfromGET /evaluations/runs/:id/cases, and pairwise
verdicts mark those casesmissing_dataset_case.executionVisibilityWhere
now takes only{ user: { id }, unattributedReads }, as
conversationVisibilityWheredoes (a full session still fits). -
The
provider-model-auditoragent no longer has write tools bound
(t-774). It heldapply_audit_changes,add_provider_modelsand
deactivate_provider_models, so the audit'sdiscover_new_modelsturn,
whose prompt carries web search results, could change the Model Matrix
before the workflow's approval step. The workflow's own write steps run
under the workflow and never needed the binding. The reconcile removes the
three bindings from existing instances. An admin who chatted with the
auditor to apply changes directly now applies them through the audit's
approval step or the Model Matrix. -
The logger reduces every outbound URL it writes, whatever the key
(t-778).Logger's sanitiser now passes each string in a log entry (the
message, an error's message and stack, any field) through the new
loggableUrlsInText()inlib/logging/redact-path.ts: an absolute
http(s) URL keeps its origin and collapsed path and loses its query,
fragment and userinfo. Log lines that carried a full outbound URL on
purpose now show the reduced form.collapseDynamicSegmentsalso judges a
segment containing:part by part, so a Telegram-style
bot<id>:<secret>segment collapses to[param]. -
Admin trace surfaces show webhook and API URLs reduced (t-777). The
executions list, the execution detail and live routes,report.md(each
withinputData/outputData) and the run streams (execute,
execute-stream, rerun) applywithDisplayUrls()to values under keys
ending inurlorurls, and to anexternal_callsource's
reference;runSupervisorAssessmentdoes the same before prompting the
model, for the supervisor step and the review route. The approver's
GET /orchestration/approvals/:id/statusreduces each step'sinput
only: the chat approval cards send the last step's output back to the
agent as the workflow's result. The persisted trace keeps working URLs, so
{{steps.<id>.output.url}}and resume are unchanged. -
Webhook subscription audit entries record the URL origin, never the
full URL or an email address (t-785), including the update'schanges
diff; the knowledge fetch-url entry records the reducedsourceUrl
(t-777). Existing audit rows are not rewritten. -
Analytics page views keep only origin, collapsed path and attribution
query parameters (t-780).url,path,searchandreferreron a
page view, the providers'window.locationfallbacks, GA4's
page_location/page_referrer, PostHog's own$current_url-style
properties (viabefore_send) and server-side tracking's page context
are reduced bylib/analytics/page-location.ts; the query keeps only
ATTRIBUTION_QUERY_PARAMS(utm_*,gclid,fbclid). Plausible now
loadsscript.manual.js, so it records page views only through
PlausibleProvider.page(): a fork relying on Plausible's automatic page
views or its script extensions must check its setup. -
Prisma 7.10.1 clears the
deepmerge-tsalert (GHSA-ggr8-5vv4-36mx, high),
and mermaid 12.2.0 clears thekatexone (GHSA-238p-pmpm-9mq7, low)
(t-914).@prisma/config7.10.1 depends ondeepmerge-ts8.0.2, so the
0.12.0 note that only Prisma 8 would fix it no longer holds; mermaid 12.2.0
depends onkatex^0.18. Prisma 7.10.1 also pinsmysql2to 3.24.4, which
meets the removal condition of theprisma→mysql2override added in
0.14.0 (t-781), so that override and itsoverrideReasonsentry are gone. A
fork that merged the entry into its ownoverridescan remove it too.