Skip to content

A turn runs on the provider its session row names, and a scripted rebind exits 0 (M4, M16) - #220

Merged
Broccolito merged 3 commits into
mainfrom
claude/loving-jones-fb8627
Sep 10, 2026
Merged

A turn runs on the provider its session row names, and a scripted rebind exits 0 (M4, M16)#220
Broccolito merged 3 commits into
mainfrom
claude/loving-jones-fb8627

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

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/null rewrote 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_session binds the row once, at resume. The desktop's own model switch calls Agent::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 are versa_azure, both Private, so bind_allowed passes 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's provider_name and 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, the override_rebind_provider test 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:

  • Conditional on an actual difference. restore_provider_from_session is unconditional, which is exactly why resume is forbidden to call it — resume_only_restores_a_provider_when_the_live_agent_is_missing_one says 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.
  • Provider name and model NAME, never the whole ModelConfig. A lead/worker composite's get_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_name there is the lead's and does not move.
  • An un-honourable row keeps the legal binding rather than refusing the turn. Refusing would stop safe work on a chat whose row somebody else broke: a new failure mode invented by the fix for a different one.

rebind_from_row now 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

refreshSessionBinding is async, 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.

  • 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. An unchanged pin deliberately does not bump it — 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, so a mismatched payload would silently relabel this chat with another one's binding and tier.

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 --resume no longer panics at EOF

--interactive says a person may be at the keyboard, never that one is. The working-directory prompt's .expect turned a closed stdin into panicked 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. Interrupted is deliberately excluded, because a person pressing Ctrl-C has answered.

Tests

All Rust runs under BIOROUTER_DISABLE_KEYRING=true.

Gate Result
cargo test -p biorouter --lib -- agents::agent::gate 38 passed (baseline 33; +5)
cargo test -p biorouter --lib -- privacy:: 232 passed
cargo test -p biorouter --lib -- subagent 197 passed
cargo test -p biorouter --lib 3775 passed, 0 failed, 2 ignored
cargo test -p biorouter --test privacy_guard_wiring 3 passed
cargo test -p biorouter --test privacy_capability 4 passed
cargo test -p biorouter --test privacy_toggle --test privacy_disclosure_toggle 1 + 4 passed
cargo test -p biorouter-server --lib 581 passed
cargo test -p biorouter-cli (isolated HOME, literal CARGO_HOME/RUSTUP_HOME) 397 passed
cargo fmt --all -- --check clean
./scripts/clippy-lint.sh ✅ baseline, too_many_lines ok, no banned TLS crates
npx vitest run chatStreamStore.binding 19 passed (16 on #211; +3)
targeted binding / pinnedModel / sync / privacy suites 13 files, 138 passed
npm run test:run 440 files, 4920 passed, 1 skipped, 0 errors
npm run lint:check clean — tsc, eslint, 3 themes, 332 contrast assertions, token mirrors
npx prettier --check on all 3 changed ui/desktop/src files clean

Every guard was falsified before it was trusted

perturbation fails
drift predicate → false 3 tests, incl. left: (0, 1) right: (1, 0) — the stale provider serving the turn, the runtime symptom verbatim
drift predicate → true 2 tests (the no-churn ones), so the negatives are not vacuous
generation guard removed 1 test: expected { provider: 'codex' } to deeply equal { provider: 'versa_azure' }
identity guard removed 1 test: 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_name with a NULL model_config) passed with the drift predicate hard-wired to true — the rebind it was meant to catch never happened, because rebind_from_row invents a model from Config::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 instance m4-rowbind on CDP 9371, real versa_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_events id 3662 recording model_id = gpt-5.5-2026-04-24 while the row said gpt-4.1-2025-04-14. That column is ProviderUsage.model — what the gateway echoes — and versa_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" beside deployment = "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-24 in 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 PrivacyProviderPinned frame, 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-24 while the live agent was bound at resume to gpt-4.1-2025-04-14; Send 700 ms later.

Δt from Send A — daemon WITHOUT the drift check B — with it
−12.5 s / −11.6 s idle gpt-4.1 idle gpt-4.1
−5.5 s / −5.8 s idle gpt-5.5 (row feed) idle gpt-5.5 (row feed)
+96 / +89 ms RUN gpt-5.5 RUN gpt-5.5
+143 ms RUN gpt-4.1 — the turn's own frame, naming the STALE binding (no transition)
+3088 ms idle gpt-4.1
+3115 ms idle gpt-5.5 — the row re-read flips it back
+3951 ms idle gpt-5.5

Arm 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 main now prints Closing session. Session ID: 20260910_1 and exits 0, with the row correctly rewritten.

Two traps met on the way, both known and both recorded

  • Every turn hung indefinitely until CONTEXT_WORKSPACE_SUMMARY: false was set in the sandbox: the sandbox's working dir is $HOME, and the workspace-summary walk blocks in ~/Library/Group Containers before any provider call.
  • The worktree's npm ci left node_modules/electron/dist holding only LICENSES.chromium.html, so the launcher died with No such file or directory on the Electron binary. Restored from the main checkout.

Housekeeping: the probe chat and its token_events deleted (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/changes poke 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

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)
@Broccolito
Broccolito merged commit 8177ed3 into main Sep 10, 2026
16 checks passed
@Broccolito
Broccolito deleted the claude/loving-jones-fb8627 branch September 10, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant