← Community
bugfixed

Local-first router mistakes a governance-denied LLM call for a successful answer and caches the refusal as real output

ShwetaShweta#137d ago · 16 views
affected: station-v0.82fixed in: station-v0.84

station-v0.82 ships a new "orchestration router" (route_planner.py,
route_executor.py, route_ledger.py) that offloads classify/extract/detect/
summarize-type work to a local model first, escalating to the cloud only if a
confidence check fails. It is wired into three real, reachable surfaces: a new
"model" workflow node kind, a new /api/model/offload HTTP endpoint, and a
new railcall_offload MCP tool description explicitly aimed at Claude Desktop
for bulk classification work. All three funnel through the same executor.

route_executor.py's confidence check, default_confidence(), decides whether a
local-model attempt is good enough to keep by checking that the result is a
dict, has no error field, and has non-empty text in text/content/reply.
station_llm.complete() -- the router's own default runner -- returns that exact
shape when the call is REFUSED by the station's egress governance (a manifest
destination check or an operator's egress_policy.json rule): {"ok": false,
"denied": true, "reply": "[station.llm refused - <reason>]"}. There is no
"error" key on a governance refusal, and the refusal message itself is
non-empty text, so default_confidence() reads it as a well-formed answer and
returns ok=True with confidence 0.85. The executor never escalates to the
cloud and never surfaces the refusal -- it treats the denial as the model's
real output.

This directly contradicts the router's own stated design rule ("Escalate on
doubt, NEVER silently degrade... Mis-routing a unit that needed reasoning DOWN
to local code is the one failure that matters") -- here the router doesn't
even reach the point of choosing wrong, it silently converts a REFUSAL into a
false-positive success.

Consequences, all confirmed against the real code:

  1. The refusal string becomes the value returned to the caller as the "answer"

-- a workflow "model" node's output, or the answer field of
/api/model/offload and the railcall_offload MCP tool response, both of
which return ok:true with no indication anything was denied.

  1. The run's own receipt records served_from:"local_model", escalated:false,

confidence:0.85 -- misrepresenting a refused call as a normal, successful,
non-escalated local execution.

  1. Because the task type is extractive, the refusal gets written to the

on-disk cache (route_cache.json, 24h TTL) under the (task_type, input) key.
Every later call with the same input -- from a different workflow run, a
different node, potentially a different caller entirely -- is served the
cached refusal directly from route_executor.execute()'s cache-hit path,
which returns BEFORE station_llm.complete() is ever called again. The
manifest check, the PII/PHI classifier, and the policy engine are not
re-consulted at all on a cache hit.

The realistic trigger is exactly the scenario the router markets itself for:
bulk classification of real user data. Any operator who configures an
egress_policy.json deny rule for sensitive content -- the "credit cards" rule
is the literal first example in egress_policy.py's own module docstring -- has
that protection silently defeated for every workflow "model" node, every
/api/model/offload call, and every railcall_offload MCP call that happens to
touch matching content: instead of failing loudly or escalating to a cloud
model, the caller gets back the refusal text disguised as a real
classification/extraction answer.

Reproduction steps:

  1. Extract a clean station-v0.82 tarball; cd into workbench/.
  2. Write WS/egress_policy.json with a single rule:

{"version": 1, "default": "allow",
"rules": [{"name": "credit cards", "if": {"has_category": "card_like"},
"then": "denied"}]}
(this is egress_policy.py's own documented example rule, unmodified).

  1. Force the local-model probe to report available (no real Ollama needed):

set route_planner._LM_CACHE to a fresh, "available": true entry.

  1. Call route_executor.execute() with task_type="classify" and a message

containing a card number (e.g. "4111 1111 1111 1111"), using the
executor's real default runner (station_llm.complete, unmodified).

  1. Call it a second time with the same input from a different

workflow_id/step_id.

Expected: step 4 either raises/reports a denial to the caller, or escalates to
the configured cloud fallback and receipts that escalation; step 5 re-runs the
same governance checks as step 4 (or at minimum inherits the same refusal
status, not silent success).

Actual: step 4 returns {"served_from": "local_model", "escalated": false,
"confidence": 0.85} with the result's "reply" holding the literal refusal
string "[station.llm refused - policy: credit cards ... -> 'denied']" -- a
real, Ed25519-signed "denied" egress receipt is written to disk, and the
executor reports success anyway. Step 5 never calls station_llm.complete() at
all -- "served_from": "cache", governance untouched.

Root cause: route_executor.py default_confidence() (around line 103) checks
only result.get("error") and non-empty text/content/reply; it never
checks result.get("ok") or result.get("denied"), both of which
station_llm.complete() sets on every governance refusal (station_llm.py, the
"denied" return blocks under both the manifest/policy-deny branch and the
human-approval-denied branch).

Suggested fix: in default_confidence() (and/or in execute() before calling
confidence_fn), explicitly check result.get("denied") / `result.get("ok") is
False` first and treat it as an automatic escalation trigger, never a
cacheable success -- the same way a provider error is already handled.

3 pts

0 replies

Sign in to reply.