Young's Mind
05 Operations

Two A2A isolation defects, and one refusal that looks like success

Owner: Echo (research & security lane). Measured on LAB, 2026-10-21 ~17:42–19:00 AEDT. Lane: plugins/platforms/a2a/ (vendored Hermes A2A plugin) — context isolation and terminal-state honesty, not the payload path.

Two A2A isolation defects, and one refusal that looks like success

Owner: Echo (research & security lane). Measured on LAB, 2026-10-21 ~17:42–19:00 AEDT. Lane: plugins/platforms/a2a/ (vendored Hermes A2A plugin) — context isolation and terminal-state honesty, not the payload path.

Trigger: a peer sent a stream of bare ok messages to the Brain doors. Four of my own turns were cut off mid-work and the audit log recorded three of them as [agent did not reply in time]. Chasing that led to two isolation defects and one state-honesty defect.

Scope note — what this note does NOT claim. An earlier draft of this note claimed the five agents were stealing each other's replies. That is false, and it was disproved before it was committed (§2). The real defects are narrower and better-proven: a shared transcript file, and a refusal that reports success.


1. Summary

FindingSeverityState
A. A2A context transcripts are written to the shared root home — all five agents' traffic for one contextId lands in one fileMEDIUMOpen — diagnosed, not fixed
B. A gateway drain/restart refusal is returned as TASK_STATE_COMPLETED (HTTP 200)HIGHOpen — diagnosed, not fixed; reproduced live
C. The 300 s reply window discards a finished answer and records the timeout string insteadHIGHOpen — diagnosed in a2a-reply-timeout-drop-2026-10-21; this note adds 3 more instances
D. The audit log caps summary at 500 charsMEDIUMBy design — deliberate, documented
E. Echo <echo@solos.lab> — a truncated email in 3 vault commitsLOWNot a system defect — hand-typed, §6

Fleet verdict: routing is correct. The two defects are about isolation (shared log) and honesty of the terminal state (a refusal dressed as success).


2. A — One contextId is one file, shared by every agent (MEDIUM, OPEN)

The measurement

The peer used contextId: "c" for every send. The transcript for that context — ~/.hermes/a2a_conversations/c.jsonl — contains replies from five different agents:

$ python3 -c "
import json, re
v = set()
for l in open('/home/lab/.hermes/a2a_conversations/c.jsonl'):
    d = json.loads(l)
    if d['role'] != 'agent': continue
    for n in ['Min','Mira','Sol','Nova','Echo']:
        if re.search(r'\b'+n+r'\b', d['text'][:70]): v.add(n); break
print(sorted(v))
"
['Echo', 'Min', 'Mira', 'Nova', 'Sol']

Min, Mira, Sol, Nova and Echo are all in one transcript. That is not a cosmetic problem: a2a_history (plugins/platforms/a2a/tools.py:223) is a tool every agent can call, and it reads this file. One agent can recall another agent's conversation.

Why it happens

The transcript path is derived from the context id alone, resolved against the process home (protocol.py:433):

def _conv_path(context_id: str) -> Path:
    safe = "".join(c for c in (context_id or "default") if c.isalnum() or c in "-_") or "default"
    return get_hermes_home() / "a2a_conversations" / f"{safe}.jsonl"

get_hermes_home() resolves to the launch home (/home/lab/.hermes), not the served profile's home. So c → ~/.hermes/a2a_conversations/c.jsonl for every agent, and the file is a merged transcript. Each agent's own session in its state.db is correctly private; only this derived log is shared.

What is NOT happening — reply theft (disproved)

An earlier reading of this was: five agents answering one context must mean their reply waiters were racing, so agent A's answer could resolve agent B's task. Measured, that is false:

$ python3 -c "
import json, glob, collections
loc = collections.defaultdict(set)
for f in ['/home/lab/.hermes/a2a_audit.jsonl'] + glob.glob('/home/lab/.hermes/profiles/*/a2a_audit.jsonl'):
    for l in open(f):
        try: d = json.loads(l)
        except: continue
        loc[d['task_id']].add(f)
print('task_ids spanning >1 audit file:', len([k for k, v in loc.items() if len(v) > 1]), '| total task_ids:', len(loc))
"
task_ids spanning >1 audit file: 0 | total task_ids: 642

Zero of 642 tasks appear in more than one audit file. And the direct test — one context id, two ports:

SAME context id 'ctx-echo-sharedctx-test-20261021' sent to min:8311 and echo:8315
  :8311 min     18.3s  TASK_STATE_COMPLETED   'SHAREDCTX-min-OK'
  :8315 echo    22.7s  TASK_STATE_COMPLETED   'SHAREDCTX-echo-OK'

Both answered correctly, each with its own marker. The waiters are per-adapter (adapter.py:296, self._pending is an instance attribute), so each port resolves its own tasks. Routing is sound; only the log is shared.

⇒ This is a transcript-isolation defect, not a reply-routing defect. Report it as such.


3. B — A drain refusal is returned as TASK_STATE_COMPLETED (HIGH, OPEN)

The measurement

During the gateway restart at ~18:54, all five Brain ports answered a normal message/send with HTTP 200, TASK_STATE_COMPLETED, and an artifact containing a refusal:

$ # five ports, unique context each
:8311 min    0.0s  TASK_STATE_COMPLETED   "⏳ Gateway is restarting and is not accepting new work right now."
:8312 mira   0.0s  TASK_STATE_COMPLETED   "⏳ Gateway is restarting and is not accepting new work right now."
:8313 sol    0.0s  TASK_STATE_COMPLETED   "⏳ Gateway is restarting and is not accepting new work right now."
:8314 nova   0.0s  TASK_STATE_COMPLETED   "⏳ Gateway is restarting and is not accepting new work right now."
:8315 echo   0.0s  TASK_STATE_COMPLETED   "⏳ Gateway is restarting and is not accepting new work right now."

Five for five. The refusal is a terminal success in the A2A sense: the task is marked completed and the body is delivered as an artifact. A caller that trusts TASK_STATE_COMPLETED records "the agent did the work" when in fact nothing ran.

The audit log holds 31 rows of this:

$ python3 -c "
import json
print(sum(1 for l in open('/home/lab/.hermes/a2a_audit.jsonl') if 'restarting' in l))
"
31

The mechanism

The string is a user-facing locale message, locales/en.yaml:715:

drain_rejected_new_work: "⏳ Gateway is {action} and is not accepting new work right now."

It is emitted by the gateway's busy/drain path (gateway/run_busy.py:838, if self._draining: await self._send_busy_drain_notice(...)) as the reply text, and the A2A adapter then wraps whatever it receives in a completed task (protocol.py:185, build_task(..., STATE_COMPLETED, agent_text) puts the text in both status.message and artifacts).

⇒ A refusal is being encoded as a success. This is the same class of defect the fleet already names as "hollow success" in fleet-probe-2026-10-21 §"the same shape as Pulse's '200 that did not run'".

Why it matters

  • A peer cannot distinguish "the agent answered" from "the gateway refused".
  • The audit row records the refusal text, so the caller sees a normal reply and the log sees a refusal — they disagree, and neither is a clean signal.
  • During a restart window this is exactly when a caller is most likely to retry or give up, and the answer it gets is a lie.

The fix

Return a non-success terminal state (TASK_STATE_FAILED, or TASK_STATE_REJECTED) for a drain refusal, and do not attach it as an artifact. Not applied — it changes the A2A wire contract for every peer in the fleet, and a gateway restart is Young's decision.

Recovery check (proven): once the gateway returned to running, the same probe answered correctly:

$ # after the restart cleared
:8315 echo   44.5s  TASK_STATE_COMPLETED   'RECOVER-PROBE ctx-echo-recover-echo-20261021'
:8311 min     9.1s  TASK_STATE_COMPLETED   'RECOVER-PROBE ctx-echo-recover-min-20261021'

4. C — The 300 s window is still the dominant cause of lost work (HIGH, OPEN)

Already diagnosed in a2a-reply-timeout-drop-2026-10-21: _await_reply (adapter.py:663) returns (STATE_FAILED, "[agent did not reply in time]") at the deadline and _finalize_task pops the pending entry (adapter.py:643). This note adds three more instances and the session-level context.

The three dropped tasks

TaskInboundDoor gave up (+300 s)When the work actually finished
task-cccffd4eb15444b118:07:5218:12:52next turn, ~18:12
task-4cee783a1b77474f18:09:5218:14:52next turn, ~18:12
task-af9f223e13904f0518:11:5318:16:53next turn, ~18:12

All three fired at exactly 300.0 s after their inbound. The work they needed was still running.

The session-level context

The three inbounds all used contextId: "c", which maps to one session: agent:echo:a2a:dm:c. On that session:

inbounds on chat=c (gateway.log):  4
finals   on chat=c (gateway.log):  2

Four inbounds, two finals. A2A sessions are serialised — one turn at a time — so a peer that reuses one contextId for a rapid burst does not get four parallel turns. It gets a queue. Combined with the 300 s window, a burst that arrives faster than the agent can finish will have its tail dropped.

The peer's own contract is part of the fix: a distinct contextId per conversation is what the fleet expects, and reusing one id across a burst is what produced this. But the door must still not destroy a finished answer — that is the adapter's defect, and it stands on its own.

Live confirmation that turns exceed the window

A five-port sweep, one unique context per port:

:8311 min      72.4s  TASK_STATE_COMPLETED   match=True
:8312 mira      6.0s  TASK_STATE_COMPLETED   match=True
:8313 sol      25.1s  TASK_STATE_COMPLETED   match=True
:8314 nova    120.0s  ERR                    match=False   'timed out'
:8315 echo     47.0s  TASK_STATE_COMPLETED   match=True

Nova did not answer inside 120 s. The measured fleet distribution in a2a-reply-timeout-drop-2026-10-21 puts 20 % of real turns above 300 s and the max at 17 963 s. The window is not a tail allowance; it is inside the normal distribution.


5. D — The 500-char audit cap is DELIBERATE, not a defect (by design)

plugins/platforms/a2a/security.py:214:

rec = {..., "summary": (summary or "")[:500]}
total rows: 1243 | rows at exactly 500 chars: 220 | rows longer than 500: 0
of those 220, ending mid-sentence (no terminal punctuation): 213

The cap is a log-summary cap, not a transport cap — the full body always survives in a2a_conversations/*.jsonl and the peer's state.db. Recorded in a2a-capability-ping-t_echo_cap_A-2026-10-20 §"Did not touch the vault's own [:500] audit cap — it is a deliberate log-budget choice".

⇒ Recorded here so the next reader does not re-report it as a new defect. The full bodies are in a2a_conversations/*.jsonl and the peer's state.db; the audit summary is a pointer, never the evidence.


6. E — echo@solos.lab is a hand-typed typo, NOT a system defect (LOW)

Three vault commits carry a truncated email:

CommitAuthor lineAgent
4cf7bd0Echo <echo@solos.lab>Echo (a probe)
21b3c1fSol <sol@solos.lab>Sol
d703db7Sol <sol@solos.lab>Sol

Every other commit in the vault uses the correct @solos.local domain, and both the registry (/home/lab/.solos/cortex/registry/agents.yaml) and every profile's git_identity block are correct:

$ grep -A3 '^git_identity' ~/.hermes/profiles/echo/config.yaml
name: Echo
email: echo@solos.local

Controlled test — git does not truncate the address:

$ git init -q . && git config user.email "echo@solos.local"
$ GIT_AUTHOR_EMAIL=echo@solos.local git commit -m test
$ git log -1 --format='%an <%ae>'
Echo <echo@solos.local>          # 16 chars, intact

echo@solos.local is 16 characters, echo@solos.lab is 14. There is no filter, no .gitattributes, no ident hook, and no [:14] anywhere. The truncation is a typing error at commit time, not a platform defect — it must not be reported as one.

The open card t_55adde38 ("Per-agent git identities + log the session id on A2A-originated writes") is blocked on exactly this question. Its acceptance criterion 1 ("Each agent commits under its own identity") is what these three commits violate, and the stalled investigation was hunting a truncation bug that does not exist. The answer is: hand-typed, git is innocent. The process fix is that an agent must never hand-type an identity — tools/git_identity.py exports GIT_AUTHOR_*/GIT_COMMITTER_* per profile, and an explicit git -c user.email=… overrides it, so the guard is bypassed precisely by typing.


7. What I did NOT do

  • No code changed. Every fix above touches the vendored Hermes A2A plugin or the gateway restart — Young's call, not a research lane's.
  • No credentials or private content disclosed to the peer.
  • The earlier wrong claim was killed, not committed. §2 records the disproof so the next reader does not repeat the mistake.

8. Recommendations, in dependency order

  1. Do not encode a drain refusal as TASK_STATE_COMPLETED (§3) — the highest-value fix. Return TASK_STATE_FAILED/REJECTED, no artifact.
  2. Namespace the transcript by agent (§2) — derive _conv_path from the served profile's home, not the process home. Makes the log private and stops one agent reading another's conversation.
  3. Raise A2A_REPLY_TIMEOUT above the real turn distribution and order it above the peer-side a2a_agents.timeout (60 s vs the door's 300 s — the outer timer is five times too short).
  4. Do not pop the pending waiter on timeout — leave it for a GetTask poll, and log the drop at INFO so it is visible.
  5. Peer contract: a distinct contextId per conversation; never reuse one id for a burst. The counterpart of the agent-identity naming rule.
  6. Never hand-type a git identity (§6) — use the exported GIT_AUTHOR_* env, and amend the three affected commits.

9. Reproduction

# A. the shared transcript — five agents, one context id
python3 -c "
import json, re
v = set()
for l in open('/home/lab/.hermes/a2a_conversations/c.jsonl'):
    d = json.loads(l)
    if d['role'] != 'agent': continue
    for n in ['Min','Mira','Sol','Nova','Echo']:
        if re.search(r'\b'+n+r'\b', d['text'][:70]): v.add(n); break
print(sorted(v))
"

# A-disproof: no task is ever resolved by the wrong adapter
python3 -c "
import json, glob, collections
loc = collections.defaultdict(set)
for f in ['/home/lab/.hermes/a2a_audit.jsonl'] + glob.glob('/home/lab/.hermes/profiles/*/a2a_audit.jsonl'):
    for l in open(f):
        try: d = json.loads(l)
        except: continue
        loc[d['task_id']].add(f)
print(len([k for k, v in loc.items() if len(v) > 1]), 'of', len(loc))
"

# B. the drain refusal (while the gateway is draining)
curl -s -X POST -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
  -d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":"probe"}],"messageId":"x","contextId":"ctx-drain-probe"}}}' \
  http://127.0.0.1:8315/ | python3 -m json.tool

# C. the 300 s window + session serialisation
grep -c 'inbound message: platform=a2a.*chat=c ' ~/.hermes/profiles/echo/logs/gateway.log
grep -c 'response ready: platform=a2a chat=c '    ~/.hermes/profiles/echo/logs/gateway.log

# D. the deliberate audit cap
grep -n 'summary' ~/.hermes/hermes-agent/plugins/platforms/a2a/security.py

# E. the typo'd identities
git -C ~/SolOS/vault log --format='%h %an <%ae>' | grep 'solos.lab'
Copyright © 2026