Repository navigation
v0.14.0 — alpha
Pre-releaseAlpha release. Twentieth tagged Sunrise release. MINOR bump. It
carries two multi-tenancy features, platform-owned system agents (§116) and
per-org provider policy (§120), plus the install-org rules for shared
settings (§107 t-751–t-753) and a security batch on logging, exports and
outbound messages. Measured againstv0.13.0: 50 PRs merged, no direct
commits, 25 issues closed; the suite is 1,261 files and 25,968 tests.
TENANCY_MODE=singlestays the default. Entries that apply only at
multisay so. §120 shipped without its check against a deployedmulti
install (an owner ruling); its behaviour is proved by its tests only.What a fork has to do
Six migrations. None rewrites rows, but two take locks that scale with
table size, so apply them off-peak on a large install:
chunk_key_per_orgbuilds a unique index onai_knowledge_chunk(not
CONCURRENTLY), andconversation_owner_exclusivevalidates a CHECK
constraint by scanningai_conversation. All six:
20260929180000_chunk_key_per_org,20260930120000_retire_builtin_template_rows,
20261001100000_provider_jurisdiction,20261006120000_embed_visitor_conversations,
20261006130000_conversation_owner_exclusiveand
20261006140000_execution_reply_conversation. Check each deployed database
before applyingretire_builtin_template_rows. It switches off the twelve
seededtpl-*workflow rows, so a schedule wired to one is skipped and a run
waiting on approval fails when approved. The entry under Changed gives a
query that lists what is wired to each.Before you deploy: clone any system agent whose prompt you customised.
Where the maintenance tick is scheduled, itsplatformAgentstask reconciles
each org within 15 minutes of the new code running; the firstdb:seeddoes
too, whichever comes first. Both write each platform agent's behavioural
fields back to Sunrise's definition, and the admin API now refuses those
edits (the §116 entries under Changed).
Provider, model, budgets, rate limit and retention stay yours. The
agent-only seed units and the built-in template seeds are removed (see
Removed), so a fork that edited one will see a modify/delete conflict.
Carry an agent edit into a replacement registered from
lib/app/platform-agents.ts. A template seed edit has nothing to carry over.Already at
TENANCY_MODE=multiwith customer orgs? From the moment the
new code is live, every org but the install org is refused all AI calls
(provider_not_permitted) until it is granted providers. The route that
grants them,PUT /api/v1/admin/orgs/[id]/providers, arrives with the same
deploy, so script the grants as an immediate post-deploy step. Erasure
hooks andcollectAppSubjectData()now run as the system scope atmulti,
sorequireOrgId()throws inside them and a row they create needs an
explicitorgId. Backup import runs from the install org only, until §109
t-738.Changes a build will not catch:
- Sentry 11 collects request bodies, headers, cookies and gen-AI inputs
by default. A fork with Sentry on should setdataCollection, and add the
scrubSentrySpan/scrubSentryEventhooks (both in Security).- dotenv 18's
-r dotenv/configpreload ignoresdotenv_config_path=.
SetDOTENV_CONFIG_PATHinstead.- Logs.
urlis gone from the request log context,endpointcollapses
ids and tokens to[param], and a dozen log keys are renamed. Dashboards
and alerts on the old keys need updating.- Behaviour.
- A
judge_callwith athresholdnow fails the step when the judge
returns no score, finds its criterion not applicable, or scores outside
0–1. A custom judge scoring 1–10 or 0–100, or a gate on
eval-judge-context-precision, fails on every run.- A
tool_callstep now interpolates itsargs.- Workflows can no longer send outreach on a conversation they were not
handling: passreplyConversationIdonly for a genuine reply.- Recorded costs change: they use the model's real rates, and unpriced
turns are flagged.- Reverts, imports and bulk agent actions now add agent versions, and
those routes return a retryable409on a version clash.- Embed visitors. Hook and webhook payloads for an embed visitor carry
userId: nullplusembedVisitorId. CheckisEmbedUserIdbefore writing
a caller id into aUserforeign key.- Knowledge embedding. A keyless remote embedding row now fails with
missing_api_key: mark a self-hosted one Local. The bare
OPENAI_API_KEYembedding fallback is removed.Dependency alerts accepted for this release:
deepmerge-ts(high), under the Prisma CLI.postcss-selector-parser(moderate), under@tailwindcss/typography.
Both have a fix only in a new major version under their parents.katex(low), under mermaid.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. Witheslint-config-nextand the
plugin itself, this chain accounts for 5 of the 8 highsnpm audit
reports.
Added
-
ComputedCost.unpriced,ModelInfo.pricingUnknownand
isMatrixOwnedFigure(id, field)(#813, t-769).calculateCostreturns
unpriced: truewhen no rate was available, so a caller can tell "not
priced" from "free";pricingUnknownmarks a registry entry that only a
null-cost Model Matrix row supplied;isMatrixOwnedFiguresays whether a
registry figure follows its matrix row. See Fixed. -
Platform agents: Sunrise's own agents, defined in code, with one instance
per org (multi-tenancy §116 t-724). The sixteen agents seeds used to write
once, as the install org's rows, are now definitions in
lib/orchestration/agents/platform-agents.ts(CORE_PLATFORM_AGENTS,
PlatformAgentDefinition,PLATFORM_AGENT_BASELINE,listPlatformAgents(),
platformAgentsForOrg(),registerPlatformAgent()). They are materialised
as ordinaryisSystemagents in every org by
reconcilePlatformAgents(orgId)
(lib/orchestration/agents/reconcile-platform-agents.ts), which runs from
createOrg, from the new seed unit021-platform-agents, and from a new
per-org maintenance job. Atmultia new org's clean-up upload, MCP tool
calls, evaluation judges and case generator now work with no manual step.
provider-model-auditor,audit-report-writer,pattern-advisorand
quiz-masterstay in the install org only (the last two, the Learn page's,
help the install's app admins; t-733). Atsinglethe install org's agents are the same rows as before:
a freshdb:seedproduces the same sixteen, field for field (cleanup-agent
now also gets itsv1version, which the old seed order skipped). See
platform-agents.md. -
A new fork seam,
lib/app/platform-agents.ts→initAppPlatformAgents()
(§116 t-724). It registers a fork's own platform agent, or replaces a
Sunrise one by slug (logged at warn). Run once, lazily, through
createAppInitGate. It ships empty, with a new row in
tests/unit/lib/app/defaults.test.ts. -
platformAgent: 'code' | 'org'onAgentFieldDescriptor, and
platformAgentFieldNames()inagent-field-registry.ts(§116 t-724).
This is who owns a field on a platform agent. It is required on every core
field, so a newAiAgentcolumn must take a side and (if'code') a
baseline value. It is optional on a fork'sappAgentFields: a fork field
without it is ignored by the reconcile. -
A
platformAgentsbackground task on the maintenance tick (§116 t-724),
appended to thebackgroundTaskslist the tick route returns. Every 15
minutes, per org, it reconciles an org whose platform agents are behind
the running code, or still missing a capability or tag its agents declare
or its copy of the patterns knowledge (t-726).
Otherwise it is oneOrgread per org. It records its
progress in a new platform-ownedOrg.settings.platformAgentskey, beside
retention. Every other key insettingsis preserved. -
listActiveOrgIds(db?)takes an optional client (§116 t-724), for a
caller holding its own connection, such as the seed runner. -
GET /admin/orchestration/agents/:idreturnsplatformAgent(§116
t-725):{ lockedFields, tunableFields, bindingsLocked }on a system agent,
nullon an org's own. It is the split the API enforces, and the agent form
renders from it.getPlatformAgent(slug)inplatform-agents.tsreads
the registry by slug. -
getOrCreateDefaultKnowledgeBase(db?)takes an optional client (§116
t-726), aslistActiveOrgIdsdoes.materialisePatternsKnowledge(),
loadPatternsChunks()andparseSeedChunks()inseeder.ts, and
the constants in the newlib/orchestration/knowledge/patterns-knowledge.ts
(PATTERNS_DOCUMENT_SLUGamong them), are the patterns knowledge's per-org
seam. -
LlmProvider.embedMany?(texts, { model, dimensions?, inputType? }), with
theEmbedManyOptions/EmbedManyResulttypes (t-740). This is how
knowledge ingestion and search now reach an embedding vendor: through the
provider manager, counted by the in-flight Proxy like chat, instead of the
embedder's ownfetch. Implemented byVoyageProviderand
OpenAiCompatibleProvider, and classifiedtrackinMETHOD_DISPOSITION.
It is optional, so a fork's provider class written against the older contract
still compiles, but such a class cannot be used for knowledge embedding
(embedding_unsupported). Implement it to make it usable. It must return
plainnumber[]vectors in input order, senddimensionsonly when given
one, and refuse redirects.OpenAiCompatibleProviderasks for
encoding_format: 'float', because theopenaiSDK's base64 default decodes
toFloat32Array. -
A call-time provider gate (§120 t-741). Every vendor call through the
provider manager (chat,chatStream,embed,embedMany,transcribe,
transcribeStream) now asks the provider eligibility rule first, per call,
and a refused call throwsProviderCallRefusedError(aProviderError,
codeprovider_not_permitted, with areasonof'policy'or
'no_org_scope') fromprovider-eligibility.ts. Until now the
rule was only consulted where a provider was chosen, from a hand-maintained
list of sites; a site missing from it now chooses less well but cannot send
a call the rule refuses. To tell the rule where a provider came from,
getProvider(slug, context?)andgetProviderWithFallbacks(primary, fallbacks, provenance?)take a new optional argument, and
ResolvedAgentBindinggainsprovenance. A call made without one is
permitted only if the rule permits it both as'primary'and as
'explicit', the two things a lone provider fetched by name can be, so a fork
callinggetProviderdirectly should pass a context. AtTENANCY_MODE=multi
a vendor call outside any org scope, or insiderunAsSystem, is refused.
Atsinglewith no rule registered (the default), nothing changes.
listModelsandtestConnectionare not gated. See
llm-providers.md→ The call-time gate. -
A provider credential seam:
lib/app/provider-credentials.ts
(initAppProviderCredentials, primitiveregisterProviderCredentialResolver
inlib/orchestration/llm/provider-credentials.ts) (§120 t-744). Where a
provider row's API key comes from: by default the env var it names, as
before; a fork can resolve keys from a gateway, a vault reference or workload
federation, or give each org its own. A resolver returns the key and a
non-secretidentity, and is asked on everygetProviderwith the org in
context. Sunrise still stores no tenant's key. A resolver or registration
that throws refuses the credential (credential_unavailable) rather than
falling back to the environment. Also exported:hasProviderCredential
(can this row be called),hasProviderKey(does it have a key — the admin
apiKeyPresentflag),filterProvidersWithCredentialandreadEnvKeyfrom
the seam module;breakerKeyOfandgetProviderIfBreakerClosedfrom the
provider manager;getCircuitBreakerStatusForProvider/
resetBreakersForProviderfrom the circuit breaker. The
circuit_breaker_openedwebhook gainsperCredential, and its
providerSlugstays the plain slug. See
llm-providers.md→ Provider credentials. -
Per-org approved providers at
multi(§120 t-742). The install org may
use every provider. Every other org may use only the providers a platform
admin has granted it, and starts with none. The grant is the org's new
platform-ownedOrg.settings.providersslice,
{ approved: string[], jurisdictions?: string[] }, which stores provider row
ids, so a grant survives a rename and does not pass to a new provider created
under a deleted one's slug. It is enforced inside
resolveEligibleProviders, so it applies at every selection site and in the
call-time gate on every vendor call. A fork'sregisterProviderEligibility
rule now runs on what core's rule permitted, so it can narrow the org's set
and never widen it. Atsinglenothing changes. See
llm-providers.md→ Per-org approved providers. -
GET/PUT /api/v1/admin/orgs/[id]/providers(§120 t-742). Platform
admins read and replace an org's approved providers and jurisdictions. Each
replace writes anorg.providers.replaceaudit row. The install org has no
set and aPUTnaming it is refused. ThePUTnames providers by slug and
must statejurisdictions(nulllifts the restriction); an unknown slug
is a 400, and a clash with a concurrent settings write is a 409
(as it now is onPATCH /api/v1/admin/orgs/[id]). Both return each grant as
{ id, slug }. Also
exported:readOrgProviderPolicy/writeOrgProviderPolicy/
isSettingsWriteConflictfromlib/tenancy/org-settings.ts,
orgProviderPolicySchema/orgProviderPolicyInputSchema/
jurisdictionSchemafromlib/validations/tenancy.ts, and
lib/orchestration/llm/org-provider-policy.ts. -
AiProviderConfig.jurisdiction(§120 t-742; migration
20261001100000_provider_jurisdiction): a nullable, upper-cased code for
where the vendor processes data, writable through the provider create and
update APIs. An org held to jurisdictions is permitted only providers
recorded in one of them. A provider with none recorded matches no
restriction. -
Saving an agent or workflow step that names a provider the org is not
approved for is refused atmulti(§120 t-743). Agent create, update,
clone and version restore return 400VALIDATION_ERROR, with
details.unapprovedProvidersand a per-fielddetails.errorsentry; only
providers the write introduces are checked, so a stranded agent can still
be edited. A workflow stepmodelOverridewhose provider is not approved is
a new semantic error,PROVIDER_NOT_APPROVED: create, save-as-template,
publish and rollback refuse it (publish and rollback only for a provider the
new version introduces),/validatereports it, and execution does not
check it.
semanticValidateWorkflowtakes a new optional
{ approval: { held?, onUnreadable? } }argument for it; that check also
coverssupervisorsteps' overrides. Also exported:
findUnapprovedModelOverridesInandassertWorkflowProvidersApproved. Bundle and backup import keep such agents
and workflows and add a warning. New helpers:unapprovedProvidersin
org-provider-policy.ts,lib/orchestration/agents/provider-approval.ts, and
findUnapprovedModelOverridesinsemantic-validator.ts. Atsingle
nothing changes. See
llm-providers.md. -
An Organisations page, and approved providers shown in the agent UI
(§120 t-745)./admin/orgslists every org and/admin/orgs/[id]edits its
approved providers and jurisdictions (under Management in the sidebar);
rename, suspend and members stay API-only. The agent form offers a provider
the org is not approved for disabled, with the reason, and an agent stranded
by a later policy change shows it in the agents list and on its edit page.
New response fields:approvedForOrg: boolean | nullon each row of
GET /api/v1/admin/orchestration/providers, and
_unapprovedProviders: string[] | nullon each agent from
GET /agentsandGET /agents/:id(onAiAgentListItemtoo;nullwhen
that is unknown: no org in scope, or the policy could not be read). New
helpersstrandedAgentProvidersandrefusedProvidersOrUnknownin
lib/orchestration/agents/provider-approval.ts. Atsingleevery provider
is approved and nothing is disabled. -
Every-org usage on the shared-settings read API, and the scope that
counts it (§107 t-752). The new fields name the caller's org's agents and
count other orgs' without naming them. All are0atsingle.GET /provider-modelsandGET /providers/:id/modelsadd
otherOrgAgentCountper model.GET /capabilitiesadds_otherOrgAgentCountper item.GET /capabilities/:id/agentskeeps its array and adds
meta.otherOrgAgentCount.- The counts run in the new
runAsCrossOrgCount()
(lib/tenancy/context.ts). It is therunAsSystembypass logged at
debug, because the admin pages ask on every load. The always-run
tests/unit/lib/tenancy/cross-org-count-sites.test.tsconfines it to
lib/orchestration/admin/global-config-usage.ts.
-
Shared-settings pages are read-only outside the install org, at
multi
(§107 t-753). They say so before a write is refused (t-751): a notice with
a button that switches the session to the install org, and create, edit,
delete and toggle actions hidden or disabled. This covers providers, the
model matrix, capabilities, agent profiles, knowledge tags, the MCP pages,
feature flags, orchestration settings and its backup import, the setup
wizard and the provider-detection banner. The admin layout reads
getSharedSettingsAccess(session)(new,
lib/tenancy/shared-settings-access.ts) once per request and provides it;
the switch is offered only to a member of the install org. From the new
components/admin/shared-settings-access.tsx: components ask
useSharedSettingsReadOnly()(anduseIsInstallOrg()for install-only
actions), pages render<SharedSettingsReadOnlyNotice />, a server page
wraps its create link in<SharedSettingsEditOnly>, and a disabled save
gets<SharedSettingsSaveHint />. Outside the provider the components
are editable as before, butuseIsInstallOrg()answersfalse, so the
install-only model audit is hidden until a provider says otherwise.sessionActingOrgId()(new,lib/tenancy/entry.ts) is
the one derivation of a cookie session's org that this and
GET /api/v1/orgsshare. Nothing changes atsingle. Forks: an admin
component of yours that writes one of theGLOBAL_CONFIG_MODELSshould ask
the hook too; the server refuses the write either way.
Changed
-
Reverting an agent's instructions, importing an agent over it, restoring a
backup over it, and bulk activate / deactivate / delete now each add an agent
version (t-779).POST /admin/orchestration/agents/:id/instructions-revert,
POST /admin/orchestration/agents/import,POST /admin/orchestration/backup/import
andPOST /admin/orchestration/agents/bulkused to change an agent's
versioned config without one, so its newest version no longer matched what
it ran. Version history now shows each such change as a new vN, summarised by
the fields it changed; an agent created by an import gets a v1, and an agent
with no history keeps its prior config as v1 first. A write that leaves an
agent matching its newest version adds none. These routes, like PATCH, now
return a retryable409when a concurrent edit takes the version number. The
version-history diff also stops reporting an object field as changed when only
its key order differs. The workflowchat_turnstep reads an agent and its
newest version in one snapshot, so an edit committed mid-read cannot pin a
turn to a version it did not run. -
The Model Matrix no longer overrides the price or context window of a
model the registry already knows (#813, t-769). A positive figure from the
static map or OpenRouter now wins over the row's blended rate and context
bucket, at runtime and inGET /admin/orchestration/models. An operator who
relied on editing the matrix to re-price a known model must now do it
elsewhere; a model only the matrix knows is still priced from its row. -
Log keys renamed so word-based redaction leaves them readable (#951).
The logger now redacts any key containing a sensitive word (see Security),
which would also hide non-secret keys such astokenIdorhasApiKey, as
their snake_case forms already were. Sunrise's own such keys are renamed;
dashboards or alerts reading the old keys should switch:missingEnv(a list of unset variable names) replaces thehas*
booleans in the email, S3 and WhatsApp configuration warnings;deliveryStatus/messageIdreplaceemailStatus/emailIdin the
invitation and contact routes;deliveryEnabledreplacesemailEnabled;
verificationRequiredreplacesrequireEmailVerification;grantedKeyreplacestokenKey(storage token mismatch);recordId
replacestokenId(embed and invite token refusals, embed-token admin
routes);keyIdreplacesapiKeyId(unscoped MCP knowledge search);
usagereplacestokenUsage(evaluation completed).- The sensitive-field lists drop their
_spellings (api_key,
first_name, …): one letters-only spelling now covers every form.
-
A
tool_callstep interpolates itsargs(t-770). Every string in an
authoredargsobject now resolves{{input.…}},{{<stepId>.output}},
{{trigger.…}}and the rest, as every other step type's config already did;
objects and arrays are walked, with the same walker the admin trace viewer
uses. As in every step type, an interpolated value is a string: a reference
to a number or an object arrives as its text, so pass structured args with
argsFrom. Args taken fromargsFromor theinputDatafallback are data
and are passed through unchanged. For a fork: a
tool_callwhoseargsheld literal{{…}}text meant to reach the
capability verbatim now gets it interpolated (a missing reference becomes
the empty string). -
A
judge_callstep with athresholdnow fails when the judge does not
properly score (§77 t-747). It used to reportpassed: truewhenever the
judge returned no score — a vendor error, no provider, the org's provider
policy refusing it (common atmultisince §120), a reply that was not
{score, reasoning}JSON, or a platform judge finding its criterion not
applicable (for example faithfulness and citation judges on an answer with no
[N]markers, correctness, recall and answer-similarity judges with no
expected output, the brand-voice judge with no voice) — and compared an
out-of-range score as if it were valid. So a quality gate let through
anything the judge did not properly score. The step now throws, with the
judge'serrorCode,judge_not_applicableorjudge_score_out_of_range, and
itserrorStrategydecides: the defaultfailfails the run,retry
retries unless the cause cannot change,fallbackroutes to the author's
step. An existing workflow whose judge sometimes fails, or gates on a
criterion that does not always apply, will now fail where it used to pass
unjudged; so will a gate oneval-judge-context-precision, which this step
passes no citations, and one on a custom judge that scores outside 0–1
(1–10, 0–100), on every run. Without a threshold, nothing changes. -
@sentry/nextjs11, mermaid 12 and dotenv 18 (t-758). Core calls Sentry
only throughlib/errors/sentry.ts, whose calls are unchanged, and ships no
Sentry.init. A fork that configures Sentry: v11 collects request and
response bodies, headers, cookies, query and database parameters and gen-AI
inputs and outputs by default, and Sunrise's agents carry personal data
through all of them; setdataCollectionas
sentry-setup.mdshows, and import
withSentryConfigfrom@sentry/nextjs/config. Mermaid 12 lays diagrams out
with ELK and a new default look. dotenv 18 adds a CLI and an opt-in fast
parser, andconfig({ path })is unchanged, but its-r dotenv/config
preload no longer readsdotenv_config_path=from the command line: it
loads.envand ignores the argument. SetDOTENV_CONFIG_PATH=.env.local
in the environment instead (scripts/diagnose-eval-run.tsand
verify-eval-run.tsnow say so). -
At
multi, a fork's erasure hooks and subject-export collector run as the
system scope (§107 t-748).registerErasureCleanupHook's
cleanupExternalandscrubInTransaction, andcollectAppSubjectData()
(lib/app/data-export.ts), used to run in whatever org the caller had
entered: the session's active org, or none from an admin API key, where a
tenant-owned query threw. A person's rows can sit in several orgs, so they now
run asrunAsSystem. Filter every query on the subject you are handed, since
an unfiltered one reaches every org's rows. There is no org to ask for:
requireOrgId()throws, and a tenant-owned row a hook creates is not stamped,
so write itsorgIdexplicitly. Atsinglenothing changes. -
The browser-tab icons moved from
public/toapp/favicon.icoand
app/icon.svg(#640). Next links both from<head>itself, so the SVG
(vector, and the only kind that can followprefers-color-scheme) is now
actually served; before, nothing linkedpublic/favicon.svg. Rebranding is
replacing those two files, with noapp/layout.tsxedit. A fork that
replaced eitherpublic/icon merges cleanly: the files move unchanged
upstream, so git carries the fork's own icons toapp/favicon.icoand
app/icon.svg. If you wrote ametadata.iconsblock into
app/layout.tsx, drop it — a link to/favicon.svgnow 404s. Don't add
icons back topublic/:public/favicon.iconext toapp/favicon.icomakes
/favicon.icoa 500 in dev ("conflicting public file and page file"). -
At
TENANCY_MODE=multi, an org other than the install org can no longer
call any AI provider until it is granted one (§120 t-742). An install
that is already atmultiwith customer orgs must grant each one its
providers withPUT /api/v1/admin/orgs/[id]/providerswhen it takes this
release, or their chat, workflow, embedding and transcription calls are
refused withprovider_not_permitted. Single-tenant installs are
unaffected. -
Provider clients, circuit breakers and in-flight counts are keyed per
credential (§120 t-744), oncredentialKey(slug, identity): the bare slug
for the install's shared credential, so nothing changes until a credential
resolver returns another identity.getProviderWithFallbacksnow also
returnsbreakerKey; pass it (orbreakerKeyOf(provider)) togetBreaker
instead of the slug. The admin provider list and health route aggregate a
provider's breakers, and the live-engine dashboard's in-flightprovideris
that key. A provider's breaker is checked once it is fetched (that is what
says which credential applies), and a chat turn that fails over credits its
success to the provider that served it, not the primary it left — which had
cleared the failure just recorded. Reachability checks (auto-pick, the agent
form preview, the clean-up agent's pin, the admin model routes) and the admin
apiKeyPresentflag now ask the credential seam instead of reading
process.env. -
An eligibility rule now also decides operator-chosen providers, at call
time (§120 t-741). Selection still never reroutes an explicit
agent.provider, a step'smodelOverride, a pinned embedding default, or an
EVALUATION_*env var, but the call-time gate refuses one the rule refuses,
asking it withsource: 'explicit'andprimarySlugset to that provider
(the context keeps its contract:primarySlugisnullonly for
'primary'). So a fork rule that barred a provider only for'primary'/
'system'behaves as before, and one that barred it for'explicit'too
now stops agents that name it, where before it only removed it from their
fallback lists. The pinned audio default is now asked at selection, as
'explicit', and a refused pin falls through to the next permitted audio
row, as an unreachable pin already did. A refusal counts as a request fault:
chat does not fail over from it, a workflow step does not retry it and fails
with codeprovider_not_permitted(as a refused task default already did)
whichever executor wraps it, a fork's included —ExecutorErrornow takes
that code and a non-retriable verdict from a refusal within ten levels of
itscausechain (the engine'sexecutor_threwwrapper keeps its code and
only becomes non-retriable),
no circuit breaker records it, and the retroactive-review route answers 403
provider_not_permittedfor a refusedmodelOverrideor
EVALUATION_JUDGE_MODEL. -
getProviderprefers a slug match over a name match (§120 t-741). It
used one unorderedfindFirstover both, so a caller holding one row's slug
could be handed a different row whose name equalled it. A name lookup is no
longer cached under the name: it queries on each call, and reuses the slug's
cached instance rather than rebuilding it. -
Admin edits to a system agent's platform-owned fields no longer
survive (§116 t-724). This is the upgrade to look at. On the first
reconcile after deploying (the maintenance tick runs one within 15 minutes,
and so does the firstdb:seed, whichever comes first), and on every
reconcile after that, each
platform agent's name, description, instructions, temperature, max tokens,
knowledge settings, visibility, persona, guardrails and brand voice are
written back to the definition's. So are its capability bindings (stray ones removed, disabled ones re-enabled), exceptmcp-system's, which stay the org's so strict-mode grants survive, and its knowledge-tag grants
(document grants cleared). A version row records each change. Provider,
model, fallback providers, provider config, monthly budget, per-turn cap,
rate limit and retention are the org's, and are never touched. Before this, re-seeding overwrote the prompts (and descriptions) of the judges, the case generator andmcp-system, refreshedcleanup-agent's prompt only while it had never been edited, and left the others alone. An install that customised a system agent's
prompt should recreate it as its own agent (clone it) before upgrading.
The API now refuses these edits (§116 t-725, below). -
The admin API refuses an edit to what the platform owns on its agents
(§116 t-725). On a system agent,PATCH /agents/:idreturns 403 for a
change to any field the agent field registry marksplatformAgent: 'code',
naming the fields. That widens the old three (slug, instructions,
deactivation) to every behavioural field, the profile link and the grants,
andisActive: trueis now refused too, so a retired agent stays off. A
field sent unchanged passes, so a client echoing the whole agent back is
refused only for what it changed. The org's fields (provider, model,
fallbacks, provider config, budget, per-turn cap, rate limit, retention)
stay writable. The binding routes refuse attach, detach and switching a
binding on or off (itscustomConfigandcustomRateLimitstay writable,
since the reconcile never writes them;mcp-system's bindings stay the
org's).PATCH /agents/:id/widget-configrefuses a system agent. Version restore applies
only the org's fields and leaves the grants. A client that edited a
system agent's prompt or settings through the API now gets a 403 where it
used to get a 200 that the next reconcile undid. -
Platform slugs are reserved (§116 t-725). Creating or renaming an agent
to a registered platform slug (a Sunrise one, install-only included, or one
a fork registers) returns 400 onslug. Clone refuses one the caller chose
and skips past a generated one, andPOST /agents/importand the config
restore skip such an agent with a warning. An org that already has an agent
of its own under such a slug keeps it, but the clean-up andmcp-system
lookups, the patterns-tag grant, the quiz master on the Learn page and in
saved quiz scores, and every load by a platform slug in chat (the judges and
case generator), in workflowagent_call,chat_turnand orchestrator
steps, in consumer and admin chat, and in the validation and cost estimates
that check those slugs ahead of a run, now match
only system agents, so that org
gets "not found" instead of its own agent run in the platform's place. -
The agent form shows a platform agent's platform-owned fields read-only
and never sends them (§116 t-725). Its banner says what the org can change.
The capabilities tab keeps Configure (custom config and rate limit) but not
attach, detach or the switch, except onmcp-system; the embed tab keeps
its tokens but not the widget appearance, and the instructions history
drops Revert. Leaving the config or rate limit blank in Configure now
clears it (a blank used to leave the old value in place).
"Audit Models" shows in the install org only, and the
dashboard's agent count excludes system agents. -
AiKnowledgeChunk.chunkKeyis unique per org, and the patterns knowledge
follows its agents (§116 t-726, t-733). Migration
20260929180000_chunk_key_per_orgreplaces the globalUNIQUE (chunkKey)
withUNIQUE (orgId, chunkKey), sochunkKeyalone is no longer a
findUnique/upsertkey: a fork that looks a chunk up by it must add
the org. The platform-agent reconcile writes the "Agentic Design Patterns"
document, its 191 chunks and tag link into an org's own default knowledge
base, without embeddings, wherever an agent declares the patterns tag: in
core, the install org only. A fork agent that declares it takes a copy into
every org it is for.seedChunks()andPOST /knowledge/seedwrite into
the caller's org's default knowledge base, are idempotent by the
document's slug rather than its name, and no longer grant the tag to the
pattern advisor and quiz master themselves: the reconcile does. An
existing copy is never refreshed, as before: an org holding one from an
earlierchunks.jsonkeeps it, with a warning.npm run db:seed:embeddingsembeds the install org's chunks, and now runs at
multitoo. -
Seeds
005-pattern-advisor,008-mcp-serverand010-model-auditorno
longer create agents (§116 t-724). They still seed the pattern and cost
capabilities, the MCP server config and resources, and the audit
capabilities and workflow. Their content changed, so each re-runs once. -
The built-in workflow templates are served from code, to every org
(§116 t-727).GET /api/v1/admin/orchestration/workflows/templatesnow
returnsBUILTIN_WORKFLOW_TEMPLATESfrom code, then the calling org's own
template rows (lib/orchestration/workflows/template-catalogue.ts), and
the new-workflow and edit pages read their "Use template" list from it.
Atmultievery org's builder now offers all twelve. The endpoint's
response changed: each entry is{ source: 'builtin' | 'custom', slug, name, description, workflowDefinition, patternsUsed, metadata }; it is no
longer paginated (at most 100 of the org's own, withmeta.customTruncated
true when that cap cut the list), and thecategoryfilter
is gone.sourcestays, but a value other thanbuiltinorcustomis
now a 400 rather than ignored. -
The seeded built-in template rows are retired on upgrade (§116 t-727).
Migration20260930120000_retire_builtin_template_rowsswitches each of
the twelvetpl-*rows off and clears its template flag, keeping the row,
its versions and its executions. Check each deployed database first:
anything wired to one of those rows stops when it is switched off (a
schedule is skipped, and a run waiting on approval fails when approved).
This query lists what is wired to each:SELECT w.slug, w."isActive", (SELECT count(*) FROM ai_workflow_schedule s WHERE s."workflowId" = w.id) AS schedules, (SELECT count(*) FROM ai_workflow_trigger t WHERE t."workflowId" = w.id) AS triggers, (SELECT count(*) FROM ai_workflow_execution e WHERE e."workflowId" = w.id AND e.status IN ('pending', 'running', 'paused_for_approval')) AS open_runs, (SELECT count(*) FROM ai_agent_capability c WHERE c."customConfig" -> 'allowedWorkflowSlugs' ? w.slug) AS run_workflow_bindings FROM ai_workflow w WHERE w."isTemplate" AND NOT w."isSystem" AND w.slug IN ('tpl-customer-support', 'tpl-content-pipeline', 'tpl-saas-backend', 'tpl-research-agent', 'tpl-conversational-learning', 'tpl-data-pipeline', 'tpl-outreach-safety', 'tpl-code-review', 'tpl-autonomous-research', 'tpl-cited-knowledge-advisor', 'tpl-scheduled-source-monitor', 'tpl-inbound-conversation-handler') ORDER BY w.slug;
Also look for code or external callers naming a
tpl-*slug, such as a
seed creating a schedule, or a caller of
/api/v1/webhooks/trigger/tpl-…. For a row that is in use, either switch
it back on from the workflows list (it keeps its slug, schedules and
triggers, and runs as an ordinary workflow), or recreate it with Use
template and move its schedule or trigger over. Backup export and import
now skip a built-in slug carried as a template, so a backup taken before
the upgrade cannot bring a seeded row back; a row switched back on is an
ordinary workflow and is backed up as one.A fork that added its own templates to
BUILTIN_WORKFLOW_TEMPLATES
also had seed 004 write them as rows, and this migration names only the
twelve core slugs. Add a migration of your own with the sameUPDATEfor
your slugs; until then those rows stay active templates, hidden from the
"Use template" list (the code version is served instead) but listed on
the workflows page. -
The provider manager now SSRF-checks a configured Voyage
baseUrlat
build time (t-740), as it already did for OpenAI-compatible rows. Knowledge
text is posted there byVoyageProvider.embedMany, which honours the row's
baseUrlas the knowledge embedder did; the embedder used to run this check
itself. A Voyage row whosebaseUrlfails the guard now fails to build with
unsafe_base_url. As for OpenAI-compatible rows, loopback is allowed only
when the row is marked Local. -
Knowledge embedding now goes through the provider manager's build rules
(t-740), with three consequences for existing installs:- Keyless remote endpoints. An OpenAI-compatible embedding row that is
not marked Local and has no API key used to embed, because the embedder
sent noAuthorizationheader. The manager requires a key for a
non-local row, as it always has for chat, so that row now fails with
missing_api_key. For a keyless self-hosted server (TEI, vLLM), mark the
row Local, which also costs it at $0. For a hosted one, set its API key. - Timeouts. A batch of more than one text gets at least 5 minutes
(EMBEDDING_BATCH_TIMEOUT_MS), where the old embedder set none. A single
text, such as a search query inside a chat turn, keeps the row's own
timeout. - Test model. The admin Test model action for an embedding model
(POST /api/v1/admin/orchestration/providers/[id]/test-model) now calls
embedManywith the model under test. It used to callembed, which
ignored that model and, on Voyage, the row'sbaseUrl.
- Keyless remote endpoints. An OpenAI-compatible embedding row that is
-
listMcpResources()andresources/listno longer return templated
resources (#823). A row whose URI has a{param}expression (for example
fork://project/{slug}) used to appear in bothresources/listand
resources/templates/list, offering clients an entry that could only be read
with the literal{slug}. It now appears inresources/templates/list
only.listMcpResources(), exported from@/lib/orchestration/mcp, keeps its
name and type but returns only concrete rows, so a fork that called it to
enumerate every enabled row (templates included) should call
listMcpResourceTemplates()as well.isRegisteredMcpResourceUri()is
unchanged: it still matches a concrete URI against template rows. -
At
multi, shared settings change only from the install org (§107
t-751). Providers, provider models, capabilities, agent profiles, knowledge
tags, feature flags, MCP exposure and server config, and orchestration
settings are one row serving every org, so a change made by a platform
admin switched into a customer's org landed in every org. The rule is
canChangeSharedSettings()in the newlib/tenancy/shared-settings.ts:
the install org or a system scope only, and never a call stack that entered
no org.- Routes:
withAdminAuthtakes a new option,
{ writesSharedSettings: true }, and 34 write handlers declare it. At
multithe guard refuses a session entered into any org but the install
org with a 403 whosedetails.reasonis
shared_settings_install_org_only. An unbound admin API key, which
enters no org, is still allowed. Reads are unchanged. - The backup import (
POST …/backup/import) is one of them. Atmulti
it now runs from the install org only, until §109 t-738 splits a
backup's shared settings from the importing org's own data. - Capabilities:
BaseCapabilityhas a new declarative
writesSharedSettingsflag, set byadd_provider_models,
deactivate_provider_modelsandapply_audit_changes. The dispatcher
refuses a declared capability, with the error code
shared_settings_install_org_only, before approval or execution.
Forks: a route of yours that writes one of the
GLOBAL_CONFIG_MODELS
must declare the option, and a capability class that writes one must set
the flag. The always-runtests/unit/scripts/ci/shared-settings-writes.test.ts
names any that doesn't. Nothing changes atsingle. - Routes:
Deprecated
LlmProvider.embed(t-740). It cannot choose a model or dimension or
report usage, and knowledge embedding never used it. UseembedMany. It
will be removed at the next MAJOR. Until then it stays for fork provider
classes and the admin test-model route.
Removed
-
The agent-only seed units
006-quiz-master,016-evaluation-judges,
017-case-generator-agent,018-rag-evaluation-judgesand
020-cleanup-agent(§116 t-724), replaced by021-platform-agents. Their
prompts moved verbatim to
lib/orchestration/agents/platform-agent-definitions/. A fork that edited
one of these files will see a modify/delete conflict. Carry the edit into a
replacement registered fromlib/app/platform-agents.tsinstead. -
The seed units
004-builtin-templatesand005-backfill-step-descriptions
(§116 t-727). The built-in templates are no longer rows, so nothing mirrors
them. A fork that added a template toBUILTIN_WORKFLOW_TEMPLATEShas it
served from code with no change, but its old row needs retiring (see the
migration entry above). A fork that edited either seed will see a
modify/delete conflict; there is nothing to carry over. -
SYSTEM_AGENT_PROTECTED_FIELDSfromagent-field-registry.ts(§116
t-725). The three fields it named are now three of many:
platformAgentFieldNames('code')is the list the API and version restore
hold a system agent to. -
The bare
OPENAI_API_KEYembedding fallback, andUNCONFIGURED_OPENAI_SLUG
('env:openai') fromknowledge/embedder.ts(t-740). The embedder's last
resort sent documents toapi.openai.comoff the env var alone, with no
provider row an admin could see. It dated from before provider rows and
env-key detection existed. Upgrade check: your install is unaffected if
Settings has an embedding model selected, or an active OpenAI, Voyage or
local provider row exists. If neither, add OpenAI as a provider (the
Providers page detectsOPENAI_API_KEYand creates it in one step). It then
produces the same model and 1536-dimension vectors, so nothing needs
re-embedding. Until you do, a document upload fails with an error that names
the fix (the server logs awarnsaying the same), and Generate
Embeddings stays disabled behind the knowledge page's "Add an embedding
provider" banner. Existing embeddings are untouched. A fork eligibility rule namingenv:openainow matches nothing.
Fixed
-
Recorded AI costs use the model's real rates, and a turn nobody could
price is marked as such (#813, t-769). Recorded costs change. Three
defects, one fix each:- Once the Model Matrix had been loaded into the model registry, a matrix
row's single blended rate replaced the registry's separate input and output
rates (gpt-4o-mini cost its 0.375 blend on both sides), and its coarse
context bucket (high→ 200,000) replaced the model's real window, which
the chat handler uses as its history budget.registerModels()now keeps a
positive registry figure and lets a row fill only a zero. A model only the
matrix knows keeps its own row's figures, and an edit to that row,
clearing included, takes effect on the next hydrate. See Changed for what this
takes away from operators. - A model only the matrix knows (a dated snapshot id such as
gpt-4o-mini-2024-07-18, a discovered model) was costed at $0 on the chat
path and in the evaluation worker: neither ever loaded the matrix into the
registry.getProvider,resolveAgentProviderAndModel,runLlmCall,
keyword enrichment, the cleanup page's context window and the retroactive
execution review now do: one query a minute (every 10 s while it fails,
once one has landed). An OpenRouter refresh no longer
drops the matrix's models until the next hydrate. - A turn with no price (a model the registry still does not know, or a
matrix row whose cost is null) was stored at $0 exactly like a free one,
and a null-cost row did not even log the "unknown model" warning.
logCostnow stampsmetadata.pricing = 'unknown'on theAiCostLog
row (not on a local turn). An explicit cost of 0 is still a free model.
- Once the Model Matrix had been loaded into the model registry, a matrix
-
The SMS / WhatsApp inbound-reply template sends its reply (t-770). Its
send_replystep is atool_call, andtool_callwas the one step type
that did not interpolate its config, so{{trigger.conversationId}}and
{{respond_to_inbound.output}}reachedsend_message_to_channelas literal
text and every reply failed withconversation_not_found. See Changed. -
An embed widget visitor's first message now starts a conversation
(#705, t-765). It failed before any reply on every install: the handler
wrote the anonymousembed_<hash>visitor id intoAiConversation.userId,
a foreign key toUser. A visitor is deliberately not aUser(owner
ruling), so they stay out of the user lists and every user's export and
erasure. A visitor's conversation now has nouserId; the new nullable
AiConversation.embedVisitorIdcolumn (migration
20261006120000_embed_visitor_conversations) owns it, and continuing a
conversation matches on it. A CHECK constraint,
ai_conversation_owner_exclusive(migration
20261006130000_conversation_owner_exclusive, drift probe A9), keeps a row
from carrying both. The per-user conversation cap does not apply to a
visitor (owner ruling): a visitor cannot archive conversations and shares an
identity behind a NAT. Every built-in tool that would write the visitor id
into a user key now decides explicitly:read_user_memory,
write_user_memory,add_provider_modelsandsend_message_to_channel
refuse a visitor withanonymous_visitor, andrun_workflowruns the
sub-workflow unowned, as a scheduled run does (the document-cleanup tools
already refuse an embed conversation before writing anything). An embed
turn's cost rows are now written, unattributed. Theconversation.started,
message.createdandcapability.refused_not_advertisedhook events and the
conversation_escalatedwebhook name a visitor asembedVisitorId, with
userId: null; thebudget_exceededwebhook names them asembedVisitorId
with noactorUserId; and the guard-events seam'sGuardEventContextgains
an optionalembedVisitorId, set for a visitor (whoseuserIdthere is
still the visitor id). Engagement analytics count each visitor as their own
participant. When the server no longer recognises a conversation as the
visitor's (their IP changed), the widget starts afresh as New chat does and
hands back the message and attachments they just sent. The widget also could not send a message from a
partner site at all:proxy.ts's CSRF origin check refused every
cross-origin POST. The embed routes and the embed approval routes, which
authenticate with a token rather than a cookie and check their own origin
allowlist, are now exempt (CROSS_ORIGIN_TOKEN_ROUTES). For a fork: a
feature that writes a caller's id into aUserforeign key must check
isEmbedUserId(lib/embed/auth.ts) first, and decide what a visitor gets. -
At
multi, a person's data export holds their rows from every org
(§107 t-748).exportUserData()read inside whatever org the caller had
entered, so the self-service and admin exports returned only the session's
active org's conversations, memories and executions, and an admin API key's
export threw. Atmultiit now reads, one source at a time, as the audited
system scope. A fork's hook and collector change with it; see Changed. -
An evaluation run where every case errored is now
failed, notcompleted
(#801). A run with no scored result (no provider configured, a provider
outage) used to look the same as a clean one, so a fork polling runstatus
forcompletedread an empty evaluation as a result. It now endsfailed
withsummary.note = 'all_cases_failed',summary.casesFailedand
summary.dominantErrorCode; a run where only some cases failed stays
completed. The experiment compare page no longer reports a failed or
cancelled variant run as still queued. -
At
multi, the models matrix, a provider's model list and the
capabilities pages no longer call a shared model or capability unused while
another org uses it (§107 t-752). They counted only the entered org, so
from the install org the matrix offered to delete a model another org's
agents relied on.- Their counts, "has agent" filters, sort and the matrix's delete-disabled
state now add other orgs' agents (the new fields under Added). - So do the capability page's "used by" card and quarantine blast radius,
and the capabilities list's delete warning. - On these pages other orgs' rows are counted with
groupBy, never read.
With no org entered (an admin API key) nothing is the caller's, so every
agent is in the count. - The workflow-pin check reads only workflows whose JSON could pin the
model, and the knowledge-tag check makes three queries instead of five.
- Their counts, "has agent" filters, sort and the matrix's delete-disabled
-
Backup import no longer overwrites a system workflow (t-729). The
provider-model audit (tpl-provider-model-audit,isSystem: true) was
exported in every bundle and versioned over on import, so restoring an older
bundle republished its stale definition and could deactivate the workflow,
which PATCH refuses. The export now leaves system workflows out, as it does
system agents and capabilities. The import skips one by slug, using the new
SYSTEM_WORKFLOW_SLUGS/isSystemWorkflowSlug()in
lib/orchestration/workflows/template-catalogue.ts, or by an existing row's
isSystemflag, and reports the skip. A bundle exported before this fix
still carries the audit workflow; importing it reports the skip whether or
not the target has that row. A fork's own seeded system workflow is covered
by the row flag wherever the importing org can see the row (atmulti, a
row in another org is invisible, and the import fails on the slug as any
cross-org slug collision does today). The export leaves out the
same two, so a bundle no longer carries a row the import would refuse.
Cost: an admin's own edits to a system workflow are no longer in the
backup, so a restore to a fresh install brings back the seed's version.
If you restored an older bundle before this fix, check that the
provider-model audit workflow is active and is not marked as a template,
since that restore could have changed either. PATCH acceptsisActive: true,
so an inactive one can be re-activated through the API. It refuses any
template-status change on a system workflow, and the seed resets that only when
010-model-auditoror the audit template it hashes changes, so a wrongly-set flag needs a direct
database update. -
An org export at
multinow holds the exported org's data (t-735).
exportOrgData()read its sources as whichever org the caller was in. The
admin route's guard enters the admin's own active org, so exporting any
other org gave a bundle whose tenant sections were all empty, with no
error. With an admin API key, which enters no org, it threw. It now reads
every source insiderunAsOrg(orgId). Nothing changes atsingle.
The export also reads its sources one at a time now, not all 42 at once,
so atmultia large org's reads don't queue behind the connection pool
and time out.eraseOrg()needed no change. The two-org smoke now erases
an org holding knowledge documents, from inside another org and from no
org, and checks that afterwards no tenant-owned table holds a row carrying
that org (a cost row stays, with its org cleared, by design) (t-730). -
Save as template works at
multiwhen another org holds the slug
(t-728).AiWorkflow.slugis unique across the install, but the slug loop
inPOST /api/v1/admin/orchestration/workflows/:id/save-as-templateread
through the org's isolation policy. It called a slug free that another org
held, and the create was refused with a 400 "please try again" that could
never succeed. It now picks the slug with the newfindFreeWorkflowSlug()
inlib/orchestration/workflows/slug-availability.ts, which checks every
org under the system scope and returns only the free slug. The slug is also
cut to 100 characters (WORKFLOW_SLUG_MAX_LENGTH), the cap the workflow
API validates against. A longer one was saved, and every later save from
the builder then failed validation. That could happen atsingletoo,
for example by saving a template of a template.isWorkflowSlugTaken()in
the same module is for the backup importer, which still fails to import a
slug another org holds until t-738 moves it onto this check. -
Deleting global config at
multinow counts every org's usage
(t-731). Providers, provider models, knowledge tags and agent profiles are
shared by every org, but the in-use checks counted through the caller's
org only. SoDELETE /providers/:id?permanent=true,
DELETE /provider-models/:idandDELETE /knowledge/tags/:idcould remove
something another org's agents or workflows still used. An agent profile's
agentCount(list) anddetachedAgentCount(delete) were short too. The
checks now go through the newlib/orchestration/admin/global-config-usage.ts
(providerUsage,providerModelUsage,knowledgeTagUsage,
agentProfileUsage,knowledgeTagCounts), which counts under the system
scope atmulti. A refusal names only the caller's own rows. Other orgs'
usage is a count, in the newotherOrgAgentCount/otherOrgWorkflowCount
/otherOrgDocumentCountdetails fields and in the message. A forced tag
delete, which strips the tag from every org's documents, is now told how
many of them are another org's first. The read surfaces count every org
too:- the tag list's
documentCount/agentCount, so "unused" and the bulk
delete mean unused by any org; GET /knowledge/tags/:idandGET /agent-profiles/:id, which add
otherOrgDocumentCount/otherOrgAgentCountbeside the caller's own
lists.
The tag-delete dialog, the tag drill-down, the model-delete dialog and the
profile edit form show those counts. The model check now also counts a
model pinned through asupervisorstep, which it used to miss even at
single. Not yet moved: the provider-models matrix list and
GET /providers/:id/modelsstill count the caller's org (t-751, which
also makes shared settings editable only from the install org). Nothing
else changes atsingle. - the tag list's
-
npm run test:changed:coveragenow holds each changed file to 80%, as
documented (t-749). It passed--coverage.thresholds.perFile=true, which
vitest's CLI reads as the string"true", so per-file mode never switched on
and the floor applied to the average of the changed files. A file at 75%
branches passed beside a well-covered one. It now passes the bare flag. The
self-test checks the parsed value with vitest'sparseCLI, and an integration
test runs a real vitest over a thin file and a well-covered one. Fork
note: a PR whose changed files only cleared 80% on average now fails
/pre-pr, which is what the gate always claimed to do. The failure names the
file.
Security
-
The Prisma CLI's
mysql2is forced to^3.23.1(t-781), for
GHSA-3f6p-5ww8-9rcr (high) and GHSA-rgwj-5xj2-c3m3 (medium).prisma7.10 pins
mysql2to exactly 3.15.3 and only uses it to connect to a MySQL server, which
a Postgres install never does. The fix is a scopedoverridesentry
(prisma→mysql2) inpackage.json, with its removal condition in
overrideReasons; a fork's ownmysql2dependency is not affected. A fork
that keeps its ownoverridesshould merge the entry rather than drop it. -
Client error reports and Sentry events no longer carry the page URL's
query string, fragment or credential-shaped path segments (#952). The
global client error handler (lib/errors/handler.ts) sent
window.location.hrefasextra.url; it now sends the collapsed pathname
asextra.path(update any Sentry saved search or alert onextra.url),
and scrubs URLs in its context and in the error it reports.
lib/errors/sentry.tsregisters a newscrubSentryEventon Sentry's
global scope (client:initErrorTracking(); Node server:instrumentation.ts),
so error and transaction events, their breadcrumbs and stack frames are
scrubbed from then on without a fork change. Forks with Sentry on should add
beforeSendSpan: scrubSentrySpanto eachSentry.init(spans are
streamed past event processors in@sentry/nextjs11), and
beforeSend: scrubSentryEventtoinstrumentation-client.ts(the client
registration runs after hydration, so load-time errors would miss it) and
sentry.edge.config.ts. New helpers
scrubUrl(),scrubUrlsInText(),scrubUrlsDeep()and
scrubUrlsInError()live inlib/logging/redact-path.ts. See
sentry-setup.md. -
The
api-keyandembed-tokenrate-limit key strategies key on a
verified credential (#701). Both built the bucket from the header value
as presented, so a caller could open a new bucket per request and the cap
never engaged. The middleware now looks the presented key or token up
(lib/security/rate-limit-credentials.ts, cached 60s per verified
credential) and keys on the stored row's id; a value that names no live
credential gets the IP bucket. Lookups of unverified values are capped by a
separate per-IP lookup budget (30/min). The proxy now parses
Authorizationexactly as the routes do (case-sensitiveBearer). A
fork rule using either strategy (viaregisterRateLimitRule()) gets the
same behaviour, and the identifier segment changes from the raw value to
key:sk:<id>/key:mcp:<id>/embed:<id>:<ip>. The embed chat
stream's per-flowembedChatLimiternow runs after the token is resolved
and keys on the visitor id derived from the token row, not the raw header.
API_KEY_PREFIX(lib/auth/api-keys.ts) andMCP_API_KEY_PREFIX
(lib/orchestration/mcp/auth.ts) are exported. -
send_message_to_channelsends only within the conversation being
handled (t-770). It sent on whateverconversationIdits caller passed, so
a model steered by the person chatting, by an inbound message or by content
an MCP client was reading, or the starter of a workflow run (a model calling
run_workflowincluded), could have the operator's number message anyone
who had ever written to it, given that conversation's id. Now a workflow
step, fixed (tool_call) or AI-driven (agent_call, the orchestrator),
sends only on its run's newAiWorkflowExecution.replyConversationId
(migration20261006140000_execution_reply_conversation,onDelete: SetNull), which the inbound route sets and the rerun route copies through
the newExecuteOptions.replyConversationIdonly when the admin asks, with
the newresendReply(the re-run dialog's "Send the reply to the person
again" checkbox), and only for a finished original, so a re-run never texts
a real person on its own; an interactive chat only on
its own conversation; an MCP client never. Anything else is refused with
conversation_not_permitted, and logged, before the conversation is read.
For an operator: a workflow that texted a thread other than the one it
replies on (reminders, outreach), or an MCP client that sent outbound
messages, now gets that refusal; deliberate outreach is not supported yet.
For a fork: passreplyConversationIdtoengine.execute()only for a
conversation the run is genuinely replying on, never one from a request or a
model; it is what authorises the run to message that conversation. -
Route log lines no longer carry a credential or an email from the
request URL (#685). The logger redacts by key name only, and the request
context bound to every line a route logs held the URL verbatim, so a token
in a path (/api/v1/x/<token>), or a token or searched-for email in a query
string, reached stdout and the admin log buffer whatever the route's own
log fields said.- Breaking:
urlis removed from whatgetRequestContext(),
getFullContext()andgetRouteLogger()bind. A fork that read or
parsedcontext.urlin its logs should useendpointandmethod, and
log any query parameter it needs explicitly. endpoint(getEndpointPath()) now collapses id- and credential-shaped
path segments to[param]: UUIDs, cuids, email addresses, JWTs, and 20+
character hex, base64 or percent-encoded tokens; readable slugs stay
(collapseDynamicSegments()/loggablePath()in
lib/logging/redact-path.ts). Dashboards grouping on a resolved
endpointwill see the collapsed form.- New:
getRouteLogger(request, { endpoint })pins a route pattern,
logged verbatim, for a dynamic segment the heuristic cannot recognise (a
short token, a dotted or one-case secret).
DELETE /api/v1/admin/invitations/[email]uses it. - The other server-side lines that logged a request path collapse it the same way:
the proxy'shttp_accessline (LOG_HTTP_ACCESS=true), which covers page
routes such as a/s/<token>share link; the auth guards'pathon
their refusal and ownership lines; the auth catch-all'sauthPath
(better-auth's/reset-password/<token>); and the rate-limit
middleware's unknown-tier warning.
- Breaking:
-
Outbound webhook and document URLs are no longer logged verbatim
(#953). Hook and webhook-subscription deliveries, the escalation webhook,
the webhook create and test routes, the notification step and
knowledge-base URL fetches logged the target URL as configured, so a
credential carried in it (a Slack or Discord webhook path, a signed URL's
query, userinfo) reached stdout and the admin log buffer. They now log
loggableUrl()(new inlib/logging/redact-path.ts): origin plus the
collapsed path, with userinfo, query and fragment dropped, and
[non-http-url]for any other scheme.describeFetchFailure()
(lib/errors/fetch-error.ts) now reduces any URL quoted in the error it
describes, so a delivery'serrorfield and storedlastErrorno longer
carry one either, and a knowledge-base fetch error reaches the route's error
log reduced. The outbound HTTP client'sHTTP request: sendingline
collapses itspath, and itshost_not_allowederror, which reaches the
model, carries the reduced URL. A path secret the
collapseDynamicSegments()heuristic does not recognise (under 20
characters, or containing:) is still kept.
Not covered: the execution trace still shows a step's configured URL,
in itsinputand in asend_notificationstep'soutput.url, as the
workflow definition does;output.urlkeeps the working URL because later
steps and resumed runs read it. -
The logger redacts camelCase and kebab-case keys (#951). Key matching
lower-cased a key before looking for word boundaries, which erased the
camelCase boundary, souserPassword,accessToken,clientSecret,
userEmailand similar keys were written in the clear whilepasswordand
user_emailwere redacted. Keys are now split into words at every
non-letter and at camelCase and acronym boundaries, and a pattern matches a
run of consecutive words; every key redacted before is still redacted.
Production log fields that change from clear text to[REDACTED]/
[PII REDACTED]:- the sign-up hook's
userEmail, the OAuth invitation-mismatch line's
invitationEmailandoauthEmail, and the invitation-delete route's
deletedByEmail; sendEmail's recipients, now logged asrecipientEmailinstead ofto
on every send line, so invitation, contact and welcome mail no longer log
the address in production;clientIPon the inbound signature-failure and verification-handshake
lines and the webhook-trigger execution line (IP is in the PII list; set
LOG_SANITIZE_PII=falseto keep it);- PostHog's debug
apiKeyPrefix, andapiKeyEnvVarin the May 2026
provider migration scripts' skip warnings.
- the sign-up hook's
-
csvEscapequotes a lone CR, so free text can no longer start a CSV
record of its own (#768). It quoted on comma, quote and LF only; a CR after
the first character was emitted bare, a spreadsheet read it as a record break,
and the text after it opened a new cell past the leading-trigger prefix. Both
admin exports that use it are fixed for every free-text column:
/conversations/export(message content, conversation title) and
/approvals/history(notes, reason, workflow name, step label, approver
name). A fork that wrappedcsvEscapeto quote CR can drop the wrapper. -
A data export no longer hands the subject another person's contact-form
messages (#766).exportUserData()matchedContactSubmissionwith
mode: 'insensitive', which Prisma compiles to an unescapedILIKE, so_
or%in the subject's address matched other people's rows. It now matches
exactly on the address trimmed and lower-cased, which is how the contact route
stores it. A fork that copied the oldcollectAppSubjectData()example from
.context/privacy/data-export.mdintolib/app/data-export.ts, or used the
same match anywhere else keyed by email, should change it the same way. -
Erasing an account now deletes the contact-form messages sent from its
address, and both erasure and export attribute those messages only to a
verified address (t-767).ContactSubmissionhas no FK toUser, so the
erasure cascade never reached it:eraseUser()reported success and left the
person's name, address and messages behind, while the export already treated
those rows as theirs. They are now deleted inside the erasure transaction,
matched exactly on the account's stored address, trimmed and lower-cased,
throughcontactSubmissionsOf(), the matcher the export also uses. The
contact form proves nothing about who typed an address, so an account whose
emailVerifiedis false now matches none of them: its erasure leaves them,
and its export no longer includes them (it previously did, so with email
verification off an account opened under someone else's address received
their enquiries). Where verification is required (the production default)
this affects only sign-ups that never verified; when one leaves messages
behind, erasure logs a warning with the count so an operator can handle them
by hand. A fork with its own table keyed by email needs the same step in an
erasure hook'sscrubInTransaction, and the same verified-only
rule if a public form fills it;.context/privacy/data-erasure.mdshows how. -
The email-preview server's copy of Next is no longer in the
next/og
remote-code-execution range (t-758).@react-email/ui(dev-only) pins
next16.3.3; a scopedoverridesentry makes it use the app's ownnext,
with its removal condition inoverrideReasons. engine.io, undici,
brace-expansion and fast-uri move to fixed versions in range. The highs
npm auditstill reports are pre-existing: Prisma 7.10's CLI pins
deepmerge-ts, andbraces(lint-only) has no fixed release. Prisma's
mysql2pin is overridden by t-781, above.