A turn runs on the provider its session row names, and a scripted rebind exits 0 (M4, M16) - #220
Merged
Merged
Conversation
Gate B only looked at the session row when the CLASSIFICATION demanded a repair. A row rewritten by anything outside this agent's process — the documented `biorouter session --resume … --provider …`, a second daemon, a schedule — therefore moved the row and the composer while the live agent, bound once at resume by `restore_provider_from_session`, went on serving what it had. Measured on merged main: `token_events` id 3662 recorded `model_id = gpt-5.5-2026-04-24` for a turn whose `sessions.model_config_json` said `gpt-4.1-2025-04-14`. The visible symptom was the composer chip going A -> B -> A inside 3.8s of one Send, but the flicker is downstream: the turn's own frame and the post-turn row re-read disagreed because the turn really did run somewhere the row did not name. Both endpoints were `versa_azure` here, so nothing crossed a tier; a row rewritten ACROSS tiers is the same mechanism with a disclosure at the end of it. `Agent::reply` now compares the row's `provider_name` and its model NAME against the live binding and, when they differ, rebinds from the row through the same construction path Gate B's repair uses — so Gate A still applies, a row naming a provider its own tier forbids is refused exactly as before, and the test seam still works. The pin the turn reports is then the row's by construction. Three properties are load-bearing and each has a test that fails without it: - Conditional on an actual difference. `restore_provider_from_session` is unconditional, which is precisely why resume is forbidden to call it (`resume_only_restores_a_provider_when_the_live_agent_is_missing_one`: it "discards its provider-local session"). Rebuilding a provider that already matches throws away a live Codex or Claude Code child for nothing; rebinding only on a disagreement cannot. - Provider name and model NAME, never the whole `ModelConfig`. A lead/worker composite re-serialises its routing state on every `get_model_config()`, so a wider comparison would report drift on any turn that advanced lead->worker and rebuild the composite from a stale snapshot, every turn. - An un-honourable row keeps the legal binding rather than refusing the turn. Refusing there would stop safe work on a chat somebody else broke. `rebind_from_row` now takes DR-15's master switch from the one read at the seam instead of applying its tier check unconditionally: Gate A's own statement admits a public provider onto a private row when the switch is off, so that state is producible, and a rebind that refused it would ignore a binding the user had just chosen with the barrier turned off. No call site was added to `privacy::floor(` or to the ratchet needles; the repo-grep censuses are unchanged (privacy_guard_wiring 3 passed, privacy_capability 4 passed). Tests, all with BIOROUTER_DISABLE_KEYRING=true: agents::agent::gate 38 passed (baseline 33, +5) privacy:: 232 passed subagent 197 passed -p biorouter --lib 3775 passed, 0 failed, 2 ignored Falsified both ways before being trusted: with the drift predicate wired to `false`, the two adoption tests fail with `left: (0, 1) right: (1, 0)` — the stale provider serving the turn, which is the runtime symptom verbatim; with it wired to `true`, the two no-churn tests fail instead.
`refreshSessionBinding` is async, adopts the row it reads OVER the turn-reported pin, and since #211 has two callers that can overlap — the end of a turn, and the `/sessions/changes` nudge. It applied its answer with no ordering token and no check that the row was even about this chat, so an older request landing last would overwrite a newer fact. That is the second half of the A -> B -> A the composer was measured doing across one Send. Two guards, each failing exactly one test when removed: - A generation token, bumped by a PIN that actually moves and by the START of a refresh. The rule that falls out is the one the store needs: a row replaces the pin only if it was read AFTER that pin was reported. A read that began earlier is not a disagreement, it is an older photograph. An unchanged pin deliberately does NOT bump it — "the daemon re-reported the same binding" supersedes nothing, and dropping an in-flight read there would cost freshness for no ordering gain. - An identity check on the response. Nothing downstream re-checks the id: every patch writes into this controller's snapshot and into its entry in the shared session list, so a mismatched payload would silently relabel this chat with another one's binding and classification. `sessionBindingSync`'s module doc opened by asserting that a chat's row is what it runs on. That was half true, and the missing half was this finding: the row was what the chat ran on the last time an agent was built for it. The premise is corrected rather than deleted, and points at the daemon-side change that makes it true again. chatStreamStore.binding.test.tsx 19 passed (16 before, +3) targeted binding/pinnedModel/sync/privacy suites 138 passed npm run test:run 440 files, 4920 passed, 1 skipped, 0 errors npm run lint:check clean; prettier clean on all 3 changed files Falsified: removing the generation guard fails "drops an answer that was requested before the pin it would overwrite" with `expected { provider: 'codex' } to deeply equal { provider: 'versa_azure' }`; removing the identity guard fails "drops a row that is about a different chat" with `expected 'versa_azure' to be 'codex'`.
`--interactive` says a person MAY be at the keyboard, never that one is. `biorouter session --resume --session-id … --provider … </dev/null` is the documented way to script a rebind and it takes the interactive branch of the working-directory prompt with no terminal attached, where cliclack returns `NotConnected` from its own `is_term()` check. The `.expect` there turned that into panicked at builder.rs:932: Failed to get user input: Kind(NotConnected) and rc=101 — AFTER the rebind had already been persisted, so a caller that checks an exit status read finished work as a failure. Every one of ~20 rebinds in the 2026-09-10 test drive ended that way. EOF is now the same answer this code already gives when it knows nobody is there: warn and stay in the current directory. Anything else would be a guess about a directory change on behalf of someone who cannot see the question, and `initial_value(true)` means the guess would be to MOVE. `Interrupted` is deliberately excluded and the reason is written next to the predicate: cliclack maps `State::Cancel` — a Ctrl-C at a real terminal — onto that kind, and a person pressing Ctrl-C has answered. -p biorouter-cli 397 passed, 0 failed (isolated HOME, literal CARGO_HOME/RUSTUP_HOME)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes M4 (MEDIUM-HIGH) and M16 (LOW) from the 2026-09-10 test drive of merged
main.Why
M4, as measured:
biorouter session --resume --session-id <id> --provider versa_azure --model gpt-4.1-2025-04-14 </dev/nullrewrote a chat's row from a sibling process; the composer chip showed the new model at rest, flipped to the old one 38 ms into the next turn, and back 32 ms after that turn ended. A → B → A inside 3.8 s of one Send.The flicker is the symptom. The defect is that the turn really did run somewhere the row does not name.
restore_provider_from_sessionbinds the row once, at resume. The desktop's own model switch callsAgent::update_provider, which writes the row and rebinds. The CLI writes the row directly, in another process — so the live agent is never told. Gate B looked at the row only when the CLASSIFICATION demanded a repair, and here it did not: both endpoints areversa_azure, both Private, sobind_allowedpasses and the stale binding serves the turn.Not a disclosure in this instance. A row rewritten across tiers is the same mechanism with one at the end of it.
What changed
1. Daemon — the authoritative half
At the top of
Agent::reply, before the ratchet and before the frame is built, the row'sprovider_nameand model name are compared against the live binding. When they differ, the turn rebinds from the row through the same construction path Gate B's repair already uses — so Gate A still applies, theoverride_rebind_providertest seam still works, and a row naming a provider its own tier forbids is refused exactly as before. The turn's first frame then reports the row's model, so pin and row agree by construction and the flicker has nothing left to flicker between.Three properties are load-bearing, and each has a test that fails without it:
restore_provider_from_sessionis unconditional, which is exactly why resume is forbidden to call it —resume_only_restores_a_provider_when_the_live_agent_is_missing_onesays an unconditional rebind "discards its provider-local session", i.e. a live Codex or Claude Code child. Rebuilding a provider that already matches throws that away for nothing; rebinding only on a disagreement cannot, because the child belonged to a binding the row no longer names.ModelConfig. A lead/worker composite'sget_model_config()re-serialises its routing state on every call, so a wider comparison would report drift on any turn that advanced lead→worker and rebuild the composite from a stale snapshot — every turn, forever.model_namethere is the lead's and does not move.rebind_from_rownow takes DR-15's master switch from the one read at the seam rather than applying its tier check unconditionally. Gate A's own statement admits a public provider onto a private row when the switch is off, so that state is producible, and a rebind that refused it would ignore a binding the user chose with the barrier deliberately turned off. The existing caller is already gated on the switch, so nothing changes for it — and threading rather than re-reading is what keeps the seam's "one sample per turn" invariant.No call site was added to
privacy::floor(or to the ratchet needles. The repo-grep censuses are unchanged.2. Renderer — the defensive half
refreshSessionBindingisasync, adopts the row it reads over the turn-reported pin, and since #211 has two callers that can overlap. It applied its answer with no ordering token and no check that the row was even about this chat.sessionBindingSync's module doc opened by asserting the row is what a chat runs on. That was half true, and the missing half was this finding. The premise is corrected rather than deleted.3. M16 — a scripted
--resumeno longer panics at EOF--interactivesays a person may be at the keyboard, never that one is. The working-directory prompt's.expectturned a closed stdin intopanicked at 'Failed to get user input: Kind(NotConnected)'and rc=101 after the rebind had already been persisted — every one of ~20 scripted rebinds in the test drive. EOF now gets the same answer this code already gives when it knows nobody is there: warn, and stay put.Interruptedis deliberately excluded, because a person pressing Ctrl-C has answered.Tests
All Rust runs under
BIOROUTER_DISABLE_KEYRING=true.cargo test -p biorouter --lib -- agents::agent::gatecargo test -p biorouter --lib -- privacy::cargo test -p biorouter --lib -- subagentcargo test -p biorouter --libcargo test -p biorouter --test privacy_guard_wiringcargo test -p biorouter --test privacy_capabilitycargo test -p biorouter --test privacy_toggle --test privacy_disclosure_togglecargo test -p biorouter-server --libcargo test -p biorouter-cli(isolatedHOME, literalCARGO_HOME/RUSTUP_HOME)cargo fmt --all -- --check./scripts/clippy-lint.shtoo_many_linesok, no banned TLS cratesnpx vitest run chatStreamStore.bindingnpm run test:runnpm run lint:checknpx prettier --checkon all 3 changedui/desktop/srcfilesEvery guard was falsified before it was trusted
falseleft: (0, 1) right: (1, 0)— the stale provider serving the turn, the runtime symptom verbatimtrueexpected { provider: 'codex' } to deeply equal { provider: 'versa_azure' }expected 'versa_azure' to be 'codex'One test was written, measured to be vacuous, and deleted
A turn-level case for the legacy row (a
provider_namewith a NULLmodel_config) passed with the drift predicate hard-wired totrue— the rebind it was meant to catch never happened, becauserebind_from_rowinvents a model fromConfig::global()there and that read simply fails in a test process. It would have stood as evidence for a property it could not observe. A note in its place records that, and the case is covered decisively one level down.Runtime verification
Own seam-built daemon (
strings … | grep -c TEST-AUTH= 1), sandboxed instancem4-rowbindon CDP 9371, realversa_azure/ GPT-5.5 turns. The report's repro, chip sampled every animation frame.⚠ The report's decisive measurement cannot decide this
M4 rested on
token_eventsid 3662 recordingmodel_id = gpt-5.5-2026-04-24while the row saidgpt-4.1-2025-04-14. That column isProviderUsage.model— what the gateway echoes — andversa_azure's Azure deployment is a compile-time constant (VERSA_AZURE_DEPLOYMENT = "gpt-5.5-2026-04-24", overridable only by config and never derived from--model). The persisted binding for the rebound row shows it plainly:model.model_name = "gpt-4.1-2025-04-14"besidedeployment = "gpt-5.5-2026-04-24".So on this provider every turn hits the same deployment and that column reads
gpt-5.5-2026-04-24in both the fixed and the unfixed state. The report's conclusion was right; the line it cited could not have established it. Measured here and reported rather than quietly worked around.The instrument that can decide is the turn's own
PrivacyProviderPinnedframe, which is built from the live binding after Gate B — the same source, on both arms.A/B, same instance, same chat, same direction, one variable
Row rebound from the CLI to
gpt-5.5-2026-04-24while the live agent was bound at resume togpt-4.1-2025-04-14; Send 700 ms later.gpt-4.1gpt-4.1gpt-5.5(row feed)gpt-5.5(row feed)gpt-5.5gpt-5.5gpt-4.1— the turn's own frame, naming the STALE bindinggpt-4.1gpt-5.5— the row re-read flips it backgpt-5.5Arm A is the report's finding reproduced: three transitions after one Send, B → A → B, at +47 ms and +27 ms either side of the turn (the report measured +38 ms and +32 ms). Arm B: zero transitions after the click — one value, the row's, across the whole Send.
The renderer guards were present in both arms, so the flicker in A is not something they could have fixed: the row read there genuinely began after the pin, so it legitimately applied. The binding was the cause, and the daemon is where it is fixed.
M16 at runtime: the same CLI invocation that panicked with rc=101 on merged
mainnow printsClosing session. Session ID: 20260910_1and exits 0, with the row correctly rewritten.Two traps met on the way, both known and both recorded
CONTEXT_WORKSPACE_SUMMARY: falsewas set in the sandbox: the sandbox's working dir is$HOME, and the workspace-summary walk blocks in~/Library/Group Containersbefore any provider call.npm cileftnode_modules/electron/distholding onlyLICENSES.chromium.html, so the launcher died withNo such file or directoryon the Electron binary. Restored from the main checkout.Housekeeping: the probe chat and its
token_eventsdeleted (select count(*) from sessions where date(created_at)=date('now')→ 0); the instance stopped and no process of mine survives; the one leftover Electron on this machine was attributed to another worktree (great-mayer-703dfc, started before this session) and left alone; the main checkout was never built in.Not done, deliberately
The optional session-meta nudge (having
/sessions/changespoke a live agent) is not included. The turn-start check makes the row authoritative at the only moment that decides where a turn goes, and a second mechanism aimed at the same fact is surface without a measured benefit.🤖 Generated with Claude Code