ADR-279: A2A code sits in the agent module and calls out guarded
- Status
-
Accepted
- Date
-
2026-11-10
- Authors
-
Netresearch DTT GmbH
Context
Tests/ enforces the module seams of
ADR-090. Service\, Service\ and
Service\ form the tools module; Controller and Widgets
the backend module. Its rule test forbids
every namespace that is not a listed module (and not on its list of named
command and form exceptions) to depend on the tools module, and
test forbids every namespace outside the
backend to depend on Controller. A2A needs the agent runtime
(Agent), the run persistence and the address guard, all
in the tools module; a new top-level namespace such as Netresearch\
would be "core" and could import none of them. The streaming helper the backend
uses, Ndjson, is a Controller class.
Outbound, nr-llm has two HTTP paths. Mcp sends through
nr-vault's Vault but is bound to an
Mcp, the MCP envelope and the Mcp- header.
The address guard of ADR-202, External with
External, checks scheme, own-site hosts, deny and
allow lists, ports and proxies, resolves the host, requires every address to
be public and returns a DNS pin; the factory builds its client from nr-vault's
Secure. External refuses any tool
group whose egress scope is not EXTERNAL_. Egress is decided per
group by Egress, fail-closed (ADR-061).
Decision
All A2A code lives under Netresearch\. That is
inside the tools module, so the seam test applies unchanged: it may use the
runtime, the persistence, the tool registry and the address guard, and must not
use Controller or Specialized. This includes the frontend PSR-15
middleware that answers inbound requests and the SSE writer of stage 5a, which
does not reuse Ndjson. Module is not extended; a
future nr_ package would carry A2A with it, which is where it
belongs, since A2A is a way to run agents. Registration files
(Configuration/, Configuration/)
are configuration, not namespaces, and are unaffected.
Outbound requests go through the address guard, never through
Mcp. Every request to a remote agent, a card import, a
push webhook (stage 5b) and a url part fetch (stage 5c) is checked by the
guard and sent with the pin it returns; a redirect is not followed by the
client but checked as a new target. Two egress groups are added to
Egress: a2a_ for calls to a registered remote
agent's selected interface, and a2a_ for URLs that arrive from
outside (webhooks, url parts). Both are EXTERNAL_: an interface
URL comes from the remote's card, not from the operator, so it gets the same
checks as any outside URL. The guard's allow and deny lists for these groups
are settings of their own, not the fetch lists of the web tool.
The credential is added per request. The remote agent's credential is an
nr-vault identifier on its record, injected for the one request; on the command
line inside Worker as worker.
(ADR-234).
The sender is a Vault pinned to the guard's addresses.
The stage-0 spike (2026-11-10, nr-vault 1.4.2) measured both candidates.
Vault is PSR-18, but send has no place
for the addresses the guard checked, and it buffers: the first SSE frame
arrived only when the stream ended. A Vault built with a
Secure whose DNS resolver returns exactly the guard's
addresses reached a host that does not resolve on both send paths (the control
with the default resolver failed with "Could not resolve host"), so it pins.
Its send delivered each SSE frame when its last byte arrived
(first frame after 1 to 3 ms) and reassembled frames split inside a line, a
multi-byte character and the terminating blank line. Both send paths returned a
307 without touching the target and sent a 503 exactly once. So:
- unary calls go through
send, streams throughRequest () send, both on that client;Streaming () - a 3xx is refused explicitly, never followed; a redirect target is checked by the guard as a new target;
- the client retries nothing; whether to try again is the caller's decision (an unknown outcome is never resent, ADR-281);
- the credential goes in through the client's own authentication injection, so the vault audit row is kept.
nr-vault must offer this as a supported API. Building the client with a
per-request resolver was measured, not confirmed with nr-vault as intended use:
the default resolver memoises answers for five seconds and also serves
is. nr-vault, which Netresearch maintains, gets a supported
way to send with pre-resolved addresses before stage 1 relies on it.
SSE needs nr-vault 1.1 or later. nr-llm allows ^0.;
nr-vault 0.16.0 has no streaming interface. The client role's streaming (stage
5a) is offered only with nr-vault ^1.1, and the release that adds it raises the
floor or switches streaming off below it.
Consequences
- ●● No seam test changes and no new exception list entry.
- ● One guard for every outbound A2A URL; its tests already cover rebinding, numeric hosts and redirects.
- ✕ A remote agent on a private address cannot be reached. The guard's lists only narrow; no allow-list entry admits a private, loopback, link-local or reserved address. An installation that runs an agent in its own network needs a further decision that amends ADR-202. The interop and TCK jobs reach their private Docker network through an address classifier that is registered in the Testing context only.
- ◑ A frontend middleware class in a
Servicenamespace is unusual for TYPO3; it is the price of the seam rule. - ✕ The SSE writer exists twice, once for the backend (
Ndjson) and once here.Stream
Net Score: +2
Alternatives considered
A new Netresearch\ namespace with a seam exception.
Rejected: it would add a fifth module to the split plan without a package to
split it into, and every new A2A class would need an entry in the exception
list.
Send through Vault. Rejected on measurement:
it cannot carry the guard's addresses and buffers streams.
Hand the client from External to an SDK.
Rejected on measurement: with a Guzzle client the SDK under test followed a
307 to its target without the guard seeing it.
Reuse Mcp. Rejected: it is bound to the MCP record,
envelope and session, and does not run the address guard.
Egress scope CONFIGURED_ for the remote agent. Rejected:
the operator enters a card URL, but the interface URL that requests go to is
read from the card, so it is not operator-declared.