RCA: graph-os fleet-mount bookkeeping disagreed with the callable tool surface (D-OB-3)¶
CONCEPT:AU-OS.governance.truthful-state-invariant.
Status: root-caused and fixed on fix/mcp-tool-state-desync (worktree
${WORKTREES_ROOT}/au-mcp-tool-desync, commit 5f235f52), awaiting merge to
main. This document is the formal RCA the operator asked for; it also
generalizes the finding into a named invariant and surveys the codebase for
other places the same class of bug can hide — two of which are still open (see
Where else it hides and
Known gaps this RCA surfaces).
Symptom¶
load_tools(servers=["github-mcp"]) succeeded. multiplexer_status,
list_catalog, and find_tools all reported the gith__* tools mounted:
true with server state: "up". But every direct gith__* invocation
failed with "No such tool available", and pinning one through the
ask/act intent verbs was rejected with "Pinned capability is not allowed
for this intent verb." — a second, independent symptom traced to the same
investigation (see Bug 4).
Why it mattered: graph-os is the control plane every agent in this workspace
is told to query before doing anything else (AGENTS.md's "Delegate to the KG
+ graph-os"). A control plane that reports a capability as usable when it is
not forces agents back onto raw CLIs — defeating the MCP-driven operating
model the whole platform is built around.
Root cause: three independent truthfulness bugs, one shared mechanism¶
All three live in agent_utilities/mcp/multiplexer.py. None of them is a
logic error in the ordinary sense — each function was internally consistent
and passed its own unit tests. The defect is architectural: each status
surface re-derived "is this tool usable" against a different piece of
bookkeeping than the one the dispatch gate actually enforces, and the two
were allowed to drift apart.
Bug 1 — list_catalog's "mounted" measured the wrong layer¶
Two independent facts share one name:
- Process-level:
server in self.children— is the child process spawned at all. Set once, bymount_child. - Session-level: whether
SessionVisibilityMiddlewarewill actually let this calling session dispatch the tool — gated by_exposed(a live forwarder was registered) and_session_loaded(this session ranload_toolsfor it).
Before the fix, list_catalog reported the process-level fact under the
key "mounted" in three places:
- the single-server drilldown,
agent_utilities/mcp/multiplexer.py:2465(pre-fix) —"mounted": server in self.children - the same drilldown's
"mounted"per tool (no session-scoped equivalent existed at all) - the all-servers summary per-entry field,
multiplexer.py:2505(pre-fix) —"mounted": name in self.children - the all-servers summary's top-level field,
multiplexer.py:2519(pre-fix) —"mounted": sorted(self.children.keys())
find_tools (discover_tools) was already session-correct — it threads a
loaded: set[str] through and only list_catalog lied. So the same fleet
state, asked two different ways, gave two different answers: find_tools
correctly said "not yet callable, run load_tools"; list_catalog said
"mounted: true". A caller that reasonably reads list_catalog as the
authoritative browse surface — which is literally its stated purpose — was
told a tool was ready when a second/subagent session that had never run its
own load_tools will get "No such tool available" from the exact same
name.
Bug 2 — resolve_and_mount silently dropped unresolvable requested tools¶
resolve_and_mount (multiplexer.py:2524, pre-fix) computed
(mounted_servers, to_expose, failed). An explicitly requested tool name that
did not resolve — an unknown name, or one disabled by config/runtime policy
after its owning server mounted successfully — was left out of both
to_expose and failed. It vanished with no signal at all: not exposed,
not reported as failed, just absent. A caller diffing what it asked for
against newly_exposed had to notice a name was missing by omission — nothing
told it why, or even that something had gone wrong for that specific tool.
Bug 3 — _notify_tools_changed swallowed the client notification failure¶
_notify_tools_changed (multiplexer.py:2767, pre-fix) sends
notifications/tools/list_changed so an MCP client refreshes its own tool
list after a dynamic mount. On failure it logged a logger.warning — visible
only to the server operator — and returned nothing. load_session_tools
(the load_tools core) reported newly_exposed as an unconditional success
whenever the mount itself succeeded, in either case of whether the client was
ever actually told. A client whose notification silently failed to arrive
kept its stale tool list and will hit "no such tool" on the very name
load_tools had just reported as newly available — a THIRD way the same
symptom can occur, indistinguishable from Bug 1 at the calling agent's
vantage point.
Bug 4 — a mounted fleet tool was invisible to the intent-verb surface¶
A second, independently-discovered symptom from the same investigation:
pinning a dynamically-mounted fleet tool through ask/act/write/manage
(hints_json={"tool": ...}) failed with "Pinned capability is not allowed
for this intent verb." — phrased as a verb-specific policy denial. Root
cause: intent_tools._build_candidates() builds its candidate table
exclusively from graph-os's own CPD-backed REGISTERED_TOOLS; a dynamically
mounted fleet tool was never a candidate at all, independent of which verb
was requested. The error message named the wrong cause. Fixed alongside Bugs
1–3: dispatch_intent now distinguishes "known capability, wrong verb" from
"not part of the intent-verb surface at all" and names the tool + points at
calling it directly after load_tools.
The fix¶
MCPMultiplexer.tool_dispatchable(prefixed_name, session_key=None) -> bool is
now the single source of truth for "can THIS session dispatch this tool
right now" — the exact predicate SessionVisibilityMiddleware enforces at the
tools/call gate. Every status-reporting surface now derives its claim from
that one function instead of re-deriving the same logic against a second,
parallel structure:
list_catalog's per-tool field is renamed to reflect what it actually means: the process-level fact is nowprocess_running/servers_running, and"mounted"(drilldown) /"dispatchable_tools"(summary) are computed by callingtool_dispatchable()per tool — so they cannot claim a tool is usable when a call will actually be rejected, because they run the identical check the rejection path runs.SessionVisibilityMiddleware._visible()andon_call_tool()now delegate totool_dispatchable()too, deleting the middleware's own duplicated_gated/_visiblelogic — there is structurally one computation for "is this callable", not two that can drift.resolve_and_mountnow always reports an unresolved requested tool infailedwith a specific reason ("tool is not present in the fleet catalog"or"tool is not registered by its owning server (disabled by config or rejected by its runtime policy)") — never silently absent from bothto_exposeandfailed._notify_tools_changednow returns whether the push actually reached the client (Falsefor "no active request context" — an expected, silent no-op — vs.Falsefor a live client that rejected/dropped it, both distinguished fromTrue).load_tools/unload_toolssurface this as a"notified"field, so a caller can tell its OWN client's tool list may be stale instead of discovering it later via a dispatch failure.
Regression tests prove: tool_dispatchable is session-scoped and false for a
catalogued-but-never-mounted tool; list_catalog's per-tool "mounted" /
"dispatchable_tools" match the real dispatch outcome across two live FastMCP
Client sessions (reproducing the reported parent-vs-subagent asymmetry at
the state layer); resolve_and_mount reports an unknown/disabled requested
tool in failed, never silently; _notify_tools_changed's return value and
load_tools's "notified" field are honest about context-absence vs. send
failure; the two intent-verb-pinning messages are distinguished by tool
origin. 183 tests passed on the fix branch.
The generalized invariant¶
This is not the first time this exact shape of bug has appeared. Naming it:
The favorable-restatement anti-pattern. A status field is derived by a second layer instead of read through from the one component that actually performed (or can check) the operation. The restatement drifts toward "looks more done than it is" because the second layer encodes what the operation was supposed to mean, not what actually happened — and nothing forces the two to be re-checked against each other when either side changes.
The invariant this fix restores, stated as a rule: Reported state must be derived from the authoritative source at the moment of reporting — never restated, cached, or inferred by a second layer that only witnessed the operation's outcome secondhand. Concretely: if component A is the one that actually gates/performs/persists an operation, every surface that reports on that operation must call into A's own predicate (or A's own confirmed result) — never re-implement an equivalent-looking check against a different signal (a sibling flag, a process-level fact, an input parameter, a "should have succeeded" assumption).
Two structural tells that a piece of code is about to violate this invariant, both present in every instance found so far:
- Two names for what reads like one concept (
mountedprocess-level vs.mountedsession-level;activerequested vs.activeconfirmed;statusclaim vs.statusfact) — when a second field is added that can answer the same question a first field already answers, the two are now free to disagree, and nothing enforces that they don't. - A success value assigned before, or independent of, the fallible step it describes — set from an input parameter or an early branch, then never revisited even when a later step (that the field is supposed to describe) fails.
Other confirmed instances of the same class¶
This is at minimum the fifth time this shape of bug has been found in this codebase — cited here so this RCA generalizes rather than narrates one fix:
- N1 — the provenance layer recorded
succeededfor a failed operation. A failedgraph_queryreturnedclaims[0].status="failed"while the same response'srouting.decision_trace.result_provenance.statusread"succeeded". Root cause:intent_tools.py's_execution_succeeded()(agent_utilities/mcp/tools/intent_tools.py:1006) only recognizeddict/strresults;graph_query/graph_ask/nl_queryreturn a typedEvidenceBundle, which matched neither branch and fell through toreturn result is not None— true for any non-empty bundle in either case of its content. Fixed (51182953): dump the model first soEvidenceBundle.erroris honored as the one source of truth. The poisoned path was_record_dispatch_outcome(..., success=True), which fed the capability router's learned reward for that tool/task-class — a restated status corrupting a learning signal, not just a display field. - The catalog's specific-sounding "budget exceeded" attribution may not be
true of the specific server it names.
probe_fleet_catalog(agent_utilities/mcp/multiplexer.py:2186) bounds a fleet-wide probe pass withasyncio.wait(tasks, timeout=budget); every task stillpendingwhen the aggregate wait returns is stampedf"catalog discovery budget exceeded after {budget:g}s". The aggregate claim is true — the overall pass did exceed budget — but probes are admitted through a boundedasyncio.Semaphore(16), so a "pending" task may never have started running at all; it can have been queued behind 15 others the entire time. Every still-pending server is given the identical, specific-sounding reason in either case of which is true. This is a close cousin of the main invariant (a plausible-sounding cause substituted for an unverified one, rather than a favorable status substituted for an unfavorable one) — flagged here as still open, not fixed by this branch; see Known gaps. OntologyLifecycle.set_active()/load()can reportactive=Trueindependent of whether the engine actually loaded the axioms.set_active()(agent_utilities/knowledge_graph/ontology/lifecycle.py:579-582) writesrecord["active"] = bool(active)before callingself._load_axioms(...), and never revisits it against the result.load()(lifecycle.py:326) writes"active": bool(activate)— the caller's requested intent — while_load_axioms()(lifecycle.py:249-258) swallows anadd_triplesfailure into{"loaded_to_engine": False, "reason": str(exc)}and returns normally (never raises). So a caller can readactive: trueon a record whose nestedengine.loaded_to_engineisfalsein the very same response. D-OB-14 records this as fixed ("Nowactivereflects what the engine actually confirmed") — checked against currentmainwhile writing this RCA, that fix is not present here; it most likely lives on a still-unmerged sibling branch, the same "fixed but not yet landed" state this RCA's own fix is in. Recorded as a still-open, independently-checked instance in Known gaps rather than silently assumed closed.
Where else it hides¶
A grep-based checklist for auditing the rest of the codebase against the invariant — anywhere one of these shapes appears, check whether the two sides can independently drift:
- Any field that mirrors a boolean input parameter into a persisted/returned
"outcome" field (
"active": bool(activate),"success": requested,"enabled": flag) instead of the actual result of the fallible operation the field claims to describe. - Any place a process-/resource-level fact (
X in self.children, "is the connection open", "is the worker alive") is surfaced under a name that reads like a capability/dispatch-level claim ("mounted", "ready", "available") — the two are almost always independently gated (a process can be up with nothing dispatchable through it, e.g. mid-restart, or gated by a session/tenant/ACL layer the process-level check never consults)._server_level_fallback()(multiplexer.py:2202-2219, used bydiscover_tools's no-match branch atmultiplexer.py:2441) still returns"mounted": server in self.children— the exact Bug 1 shape, not touched by this fix because it is a separate code path fromlist_catalog. See Known gaps. - Any
except Exceptionthat returns a "handled" dict instead of re-raising or setting a caller-visible failure flag, when the surrounding success/status field was already decided before entering thetry(Bug 3's shape, and_load_axioms's shape above). - Any two independent code paths that compute what should be the same fact (a middleware's own gating check vs. a status tool's separate recomputation of "is this visible") — the fix's core lesson is to collapse these into one function, not keep them in sync by convention.
- Any error-message template applied uniformly across a batch ("budget exceeded", "timeout", "not found") to the whole batch after only the aggregate condition was checked, when per-item causes can differ (this RCA's item 2).
- Provenance/telemetry/reward-feeding status fields specifically — per
N1, a favorable restatement here doesn't just mislead a display, it can
poison a learned signal (
calibrated_outcome_reward, capability routing). Any new evidence/outcome-shaped field added to the AHE reward pipeline should be checked against this invariant before it ships.
Known gaps this RCA surfaces (not fixed here)¶
Recorded honestly rather than left implicit, per this RCA's own invariant:
- D-OBC-1 —
_server_level_fallback()(multiplexer.py:2202-2219) still reports process-level"mounted"for itsfind_tools-fallback results — the exact Bug 1 shape, outside the branch this RCA documents. - D-OBC-2 —
OntologyLifecycle.set_active()/load()(lifecycle.py:326,579-582) still writeactivefrom the requested flag, not the engine's confirmed result, on currentmain— despite D-OB-14 recording this as fixed elsewhere. - D-OBC-3 — the catalog's per-server "budget exceeded" attribution
(
multiplexer.py:2186) does not distinguish "this server's probe actually timed out" from "this server's probe was never dequeued from the semaphore in time."
These are filed in reports/deferred/lane-ob-closures.md for a
follow-up lane — deliberately not fixed inline here to keep this change
surgical to the RCA the operator asked for.
Verification¶
- Fix branch:
fix/mcp-tool-state-desync@5f235f52, worktree${WORKTREES_ROOT}/au-mcp-tool-desync. 183 tests passed. Awaiting merge. - 11 pre-existing, unrelated
ChildRuntime/circuit-breaker test failures are recorded separately asD-DESYNC-1(reports/deferred/lane-mcp-desync.md) — reproduced identically on the unmodified base commit, not introduced by this fix. - This RCA's own citations against current
main(64a41727) were checked by direct file read while writing this document, not copied from the fix branch's commit message — which is how the two still-open gaps above were found.