Part 18 built a routing floor for exactly two tool names, gmail_read and gmail_read_attachment: the moment either ran, the session pinned to a local model for the rest of its life, side-calls included. That was enough while Gmail was the only connector that could pull third-party content into context. It stopped being enough the moment I wanted a second connector I didn’t fully trust with a cloud model, because the floor only knew two hardcoded strings.
Why a suffix list doesn’t generalize
The obvious extension is “just add more suffixes.” That works for individual tools, and SensitiveTools.Suffixes still does exactly that. But a connector doesn’t expose one tool, it exposes several, all namespaced under its own name (gmail_search, gmail_read, gmail_read_attachment, and so on, from the connector work in Part 13). Marking a connector sensitive should mean all of its tools are sensitive, not whichever ones I remembered to type into a list by hand. Maintaining that list by hand is also exactly the kind of thing that goes stale: a connector grows a new tool, nobody updates the suffix list, and the new tool quietly ships without the floor that was supposed to cover its whole namespace.
So connectors got their own sensitive boolean in settings, and a separate match rule keyed off it:
// internal/brain/session/sensitive.go
type SensitiveTools struct {
Suffixes func(context.Context) []string
ConnectorNames func(context.Context) []string
Route func(context.Context) string
}
func (s *SensitiveTools) Matches(ctx context.Context, toolName string) bool {
if s == nil {
return false
}
for _, suffix := range s.Suffixes(ctx) {
if toolName == suffix || strings.HasSuffix(toolName, "_"+suffix) {
return true
}
}
if s.ConnectorNames != nil {
for _, name := range s.ConnectorNames(ctx) {
if strings.HasPrefix(toolName, name+"_") {
return true
}
}
}
return false
}
The two rules can’t share one check. Suffixes matches a tool’s own trailing name, useful for pinning one specific tool regardless of which connector wraps it. ConnectorNames matches a connector’s name as a prefix, because that name sits in front of every tool the connector serves. Folding a connector’s name into the suffix list would misfire: a floor entry like gmail_read would suffix-match an unrelated tool named something_gmail_read, which isn’t the same claim as “the gmail connector is sensitive.” Two rules, two directions, kept separate on purpose.
Both Suffixes and ConnectorNames are funcs, not static slices, for the same reason Route already was in Part 18: a settings toggle has to apply to the very next tool call, not the next restart. ConnectorNames reads the connector table fresh every time, filtering to rows that are both enabled and marked sensitive:
// internal/brain/connectors/manager.go
func (m *Manager) SensitiveNames(ctx context.Context) ([]string, error) {
rows, err := m.rows.List(ctx)
if err != nil {
return nil, err
}
var names []string
for _, c := range rows {
if c.Enabled && c.Sensitive {
names = append(names, c.Name)
}
}
return names, nil
}
Flip the toggle in Settings, and the very next call to any of that connector’s tools trips the floor. No env var, no restart, same shape as every other runtime setting in this system.
Shipped, and wrong for four hours
That went out as part of a bundle (commit a7d20c5, alongside a few unrelated fixes I’ll get to below). It looked complete: the settings toggle worked, the connector table had the new column, and SessionSensitive correctly pinned any session that had ever touched a sensitive connector’s tools, same as it already did for the Gmail suffixes.
It wasn’t complete, and the gap was exactly the kind Part 18 had already found and fixed once for the suffix case: the pin has two layers, a session-level one that decides which route the next turn starts on, and an in-turn one, Agent.SetForceRoute, that can flip the route mid-turn the instant a sensitive tool call lands, so that tool’s own results get summarized on the floor route rather than whatever route the turn happened to start on. Part 18 built both layers for the static suffixes. This bundle only built the session-level layer for connectors. Nobody had wired the new ConnectorNames check into the in-turn mechanism at all.
The practical effect: flag a connector sensitive, then run a turn that calls one of its tools for the first time. The session-level pin hadn’t fired yet, because as far as it knew, this session had never touched a sensitive tool before this exact call. The in-turn mechanism, the one thing that could have caught it, only knew about gmail_read and gmail_read_attachment. So the tool’s results, potentially the exact private content the flag was supposed to protect, still got summarized by whatever cloud model the turn started on. The pin caught up on the next turn. One turn too late for the content that had just been pulled into context, which is the only turn that actually mattered.
The bug wasn’t a logic error inside either mechanism. Both worked exactly as written. It was a wiring gap: two independent pinning paths existed, one already generalized in Part 18 for named tools, and the new connector-level check only got plugged into one of them. Writing the fix made it obvious in retrospect; writing the feature the first time, it wasn’t.
The fix: a second forced-route path, wired the same way
SetForceRouteByConnector mirrors SetForceRoute almost exactly, prefix match instead of suffix, connector names instead of tool names, but otherwise the same sticky, re-resolved-at-flip-time semantics:
// internal/brain/loop/agent.go
func (a *Agent) SetForceRouteByConnector(names func(context.Context) []string, route func(context.Context) string) {
a.forceRouteByConnectorNames = names
a.forceRouteByConnectorRoute = route
}
if a.forceRouteByConnectorNames != nil {
for _, name := range a.forceRouteByConnectorNames(ctx) {
if strings.HasPrefix(c.Name, name+"_") {
if forced := a.forceRouteByConnectorRoute(ctx); forced != "" {
route = forced
hint = ""
}
break
}
}
}
names and route are both funcs, re-resolved the moment a matching tool call is seen, not read once at turn start. That matters for the same reason it mattered in Part 18: a settings change has to apply to the tool call currently in flight, not the next turn. And the hint drops the same way it always has to, hint = "" in the same breath the route flips, because an exact model hint outranks a route at the gateway’s resolve step and would otherwise sail straight past the floor.
main.go wires it up right next to the existing static pin, both reading the same live connector list and the same route resolver the session-level check uses:
// cmd/brain/main.go
agent.SetForceRouteByConnector(sensitiveConnectorNames, sensitiveRoute)
Same source of truth, same resolver, now consulted from both layers instead of one.
The recurring shape of this mistake
This is the same species of bug the series keeps running into, just one level up: a mechanism gets generalized in one place and not the other place that does the equivalent job. Part 18 itself was a fix of this shape, turn-scoped pin generalized to session-scoped, then to side-calls. This time the generalization went into the session/side-call layer and skipped the in-turn layer entirely, because the two layers live in different packages (session and loop) and nothing forced them to be touched together. The fix, in both cases, is the same discipline: when a safety mechanism has more than one enforcement point, changing what it protects means changing it everywhere it’s enforced, not just the first place that came to mind.
The bug shipped and was fixed on the same day, four hours apart by the commit timestamps. I’d rather tell that honestly than pretend the first cut was right.
The smaller things bundled into the same day
A few unrelated fixes rode along with this work:
Permission alerts went global. A permission request parked mid-turn used to only surface on that session’s own Chat page; anywhere else, it silently sat until the ten-minute auto-deny. A new GET /v1/permissions/pending endpoint (scoped to turns that are actually still active, never a zombie flag left behind by a crashed one) plus a permission signal on the existing global event hub now drive a sidebar badge and a persistent toast with a jump-to-session action from any page.
A chime that autoplay policy was quietly eating. The permission toast can optionally play a sound, but a toast triggered by a background SSE signal has no user gesture of its own, and browser autoplay policy blocks audio without one. The first version created a fresh AudioContext per chime, which starts suspended and stays suspended without a gesture to unlock it, so every chime silently failed. The fix shares one AudioContext across every chime and resumes it from the app’s first real click or keydown, a one-time unlock rather than a per-play gamble:
// web/src/lib/alertSound.ts
export function unlockAudio(): void {
try {
getCtx()?.resume()
} catch {
// Unsupported/blocked, playAlertSound's own try/catch covers the
// actual chime attempt; this is best-effort priming only.
}
}
Also worth a line: the connector sensitivity toggle’s description originally read like a Gmail-specific setting, left over from the fact that Gmail was the motivating case. It got reworded once the mechanism actually covered any connector, not just email.
A debug log line for an old mystery. session.Store.Create now logs its immediate caller’s file and line. Two untitled sessions had shown up in the past with no request that obviously created them, and there was no way to trace which code path was responsible after the fact. This doesn’t fix anything; it just means the next time it happens, the log line says who did it.
CI split in two. The single go job, build, vet, unit tests, integration tests, vuln scan, all serialized, split into go-test and go-integration, which share no state and now run in parallel. govulncheck got pinned to a specific version instead of floating on @latest, which had been quietly defeating the module cache and running a different scanner version on every single run.
None of these four needed their own post. Bundling small, independent, low-risk changes into one PR when they don’t touch the same code is a reasonable trade against CI overhead; it’s a different trade than bundling a security-relevant mechanism and its bug fix, which is why those two got the full story above.
Next: a hand-built SVG analytics dashboard replacing recharts, and CI path-based job filtering.
Landed the same day as Part 21’s turn-lifetime fix, a7d20c5 and 67b531e on main.