Scope clarification
The observed 9-judge-call TB-Lite failure was specifically Harbor running Hermes with session_affinity = true and message_hash_fallback omitted/false. Hermes did not send a native session header. Harbor did generate a stable UUID per trial attempt, but sent it as proxy_x_session_id, which Switchyard consumed for routing statistics but not affinity.
If message_hash_fallback = true, affinity was already best-effort functional by hashing the first user message. PR #308 therefore adds a fourth fix that recognizes Harbor's exact per-attempt ID: it makes Harbor/Hermes sticky without the fallback and supersedes the heuristic when the fallback is enabled. The empty-ID safeguard and diagnostic changes are general rather than Harbor/Hermes-specific.
Correction to section 1 below: current main already trims empty bare HTTP header values after #269. The empty-ID libsy safeguard still protects structured metadata containing an empty nested session value and direct callers that construct Metadata { session_id: Some("") }.
Summary
Session affinity has two failure modes that are silent: it can key on an identity that
isn't one, and it can key on nothing at all. Both surface as a route that reports itself
as configured while behaving as if affinity were off -- or, in the first case, worse than
off. We hit the second while benchmarking TB-Lite and lost a while to it, then found the
first by reading the surrounding code.
1. An empty session id is treated as a real identity
AffinityRouter::affinity_key accepts whatever metadata.session_id holds:
// crates/libsy/src/algorithms/util/affinity.rs
if let Some(metadata) = request.metadata.as_ref()
&& let Some(session) = metadata.session_id.clone()
{
...
return Some(AffinityKey::Session(session));
}
Header parsing does not normalise empty values — resolve_path in
crates/protocol/src/metadata.rs returns Some(raw.clone()) verbatim — so a harness that
sends x-switchyard-session-id: with an empty value yields Some(""). Every request in
every task then shares AffinityKey::Session(""), the first task's model latches, and the
whole run inherits it. The message-hash fallback cannot save this: it lives in the else
branch that is now unreachable.
This is cross-task contamination that produces plausible-looking numbers, not an error.
The sibling consumer of the same field already guards it, which is what makes this look
like an oversight rather than intent:
// crates/libsy/src/algorithms/fall_through.rs
fn session_id(request: &Request) -> Option<String> {
request.metadata.as_ref()?
.session_id.as_deref()
.filter(|id| !id.is_empty()) // affinity.rs has no equivalent
.map(str::to_string)
}
nonempty_header in crates/switchyard-server/src/routing_log.rs applies the same guard.
No existing test covers an empty session id.
2. Affinity that can never key anything says nothing
message_hash_fallback defaults to false (#[serde(default)] on a bool). So this
configuration is silently inert for any harness that sends no session header:
session_affinity = true
# message_hash_fallback not set -> false
affinity_key returns None on every request, every turn is classified afresh, and there
is no warning anywhere — warn! appears nowhere in affinity.rs or in the server's
config.rs. The behaviour is documented in docs/reference/toml_schema.md and
docs/routing_algorithms/llm_classifier_routing.md, but nothing at runtime tells you it is
happening.
Concretely: benchmark/run-baseline.sh sends no session header, so any benchmark using
session_affinity = true without the fallback silently measures per-turn routing while
reporting a sticky configuration. We measured 9 judge calls across 9 turns of a single
task before spotting it.
3. Request logs cannot distinguish absent from empty
// crates/switchyard-server/src/lib.rs
session_id = self.session_id.as_deref().unwrap_or(""),
A missing session and an empty one both render as session_id=, which is precisely the
distinction you need when diagnosing either problem above.
Scope clarification
The observed 9-judge-call TB-Lite failure was specifically Harbor running Hermes with
session_affinity = trueandmessage_hash_fallbackomitted/false. Hermes did not send a native session header. Harbor did generate a stable UUID per trial attempt, but sent it asproxy_x_session_id, which Switchyard consumed for routing statistics but not affinity.If
message_hash_fallback = true, affinity was already best-effort functional by hashing the first user message. PR #308 therefore adds a fourth fix that recognizes Harbor's exact per-attempt ID: it makes Harbor/Hermes sticky without the fallback and supersedes the heuristic when the fallback is enabled. The empty-ID safeguard and diagnostic changes are general rather than Harbor/Hermes-specific.Correction to section 1 below: current
mainalready trims empty bare HTTP header values after #269. The empty-ID libsy safeguard still protects structured metadata containing an empty nested session value and direct callers that constructMetadata { session_id: Some("") }.Summary
Session affinity has two failure modes that are silent: it can key on an identity that
isn't one, and it can key on nothing at all. Both surface as a route that reports itself
as configured while behaving as if affinity were off -- or, in the first case, worse than
off. We hit the second while benchmarking TB-Lite and lost a while to it, then found the
first by reading the surrounding code.
1. An empty session id is treated as a real identity
AffinityRouter::affinity_keyaccepts whatevermetadata.session_idholds:Header parsing does not normalise empty values —
resolve_pathincrates/protocol/src/metadata.rsreturnsSome(raw.clone())verbatim — so a harness thatsends
x-switchyard-session-id:with an empty value yieldsSome(""). Every request inevery task then shares
AffinityKey::Session(""), the first task's model latches, and thewhole run inherits it. The message-hash fallback cannot save this: it lives in the
elsebranch that is now unreachable.
This is cross-task contamination that produces plausible-looking numbers, not an error.
The sibling consumer of the same field already guards it, which is what makes this look
like an oversight rather than intent:
nonempty_headerincrates/switchyard-server/src/routing_log.rsapplies the same guard.No existing test covers an empty session id.
2. Affinity that can never key anything says nothing
message_hash_fallbackdefaults tofalse(#[serde(default)]on abool). So thisconfiguration is silently inert for any harness that sends no session header:
affinity_keyreturnsNoneon every request, every turn is classified afresh, and thereis no warning anywhere —
warn!appears nowhere inaffinity.rsor in the server'sconfig.rs. The behaviour is documented indocs/reference/toml_schema.mdanddocs/routing_algorithms/llm_classifier_routing.md, but nothing at runtime tells you it ishappening.
Concretely:
benchmark/run-baseline.shsends no session header, so any benchmark usingsession_affinity = truewithout the fallback silently measures per-turn routing whilereporting a sticky configuration. We measured 9 judge calls across 9 turns of a single
task before spotting it.
3. Request logs cannot distinguish absent from empty
A missing session and an empty one both render as
session_id=, which is precisely thedistinction you need when diagnosing either problem above.