Skip to main content

HLD: A2A Egress + Foreign-Agent Adapter + Webhook Resume

FieldValue
StatusDraft — AWAITING FOUNDER APPROVAL (architectural direction)
Version1.0
Date2026-06-19
AuthorPrincipal Technical Architect Agent
Issue#1423 (T5)
Parent#1418 (Runtime Revamp tracking)
Milestone#20 — Runtime Revamp — Adopt & Wrap (ADR-0019)
Governing ADRADR-0015 (docs/adrs/ADR-0015-agent-to-agent-communication-model.md, PR #1226)
Related ADRsADR-0012 (adopt Google A2A envelope), ADR-0019 (adopt-and-wrap runtime)
Depends onT4 (#1422) autonomous loop LIVE (PR #1460, DEV); ADR-0015 bus substrate (dormant)
Implementation gateFounder approval on issue #1423 before any code. New external egress surface — go-live blocked on egress-allowlist + auth sign-off (see §10).

1. Executive summary

ADR-0015 settled the intra-tenant model: agents never call agents directly — every hop flows through a role-keyed message bus mediated by the thin, deterministic Coordinator, which is the single governance + audit + cost + routing choke-point. That substrate is already built and dormant behind three flags (BUS_ENABLED, COORDINATOR_ENABLED, HARNESS_ENABLED, all default false). T4 made the in-process autonomous loop live; T5 turns the dormant bus on and extends the same choke-point outward so our agents can reach foreign agents (Google ADK A2A-native today; Claude via an MCP/A2A bridge) over the Google A2A wire protocol — without surrendering the governed-every-hop invariant.

The design has four code-anchored parts:

  1. Enable the dormant bus. Flip BUS_ENABLED/COORDINATOR_ENABLED/HARNESS_ENABLED on per-role, and convert the peer/delegation handoff at internal/runtime/subagent/tool.go:616 (BusRouted branch → busDelegationResult) so an allow-path delegate_to_agent suspends the parent turn and emits a QUERY onto the bus instead of spawning a pause-resident child. The Coordinator forwards the governed hop (the forward() path in internal/coordinator/handle.go already governs+audits+accrues every QUERY/REPLY).
  2. A2A wire-protocol egress adapter at the Coordinator. A new EndpointKind FOREIGN makes a hop's destination a foreign agent. The Coordinator, after running its normal Governor.Check → audit → cost choke-point, hands the envelope to an A2A egress adapter that does Agent Card discovery (/.well-known/agent-card.json), opens an A2A Task via JSON-RPC over HTTP, and tracks the Task lifecycle. The clearance-floor stamp + audit row + (if required) approval are written BEFORE any envelope leaves the perimeter — governed-every-hop holds at the egress boundary.
  3. Hub-and-spoke heterogeneous workflows (A → foreign B → C). The Coordinator owns every edge; foreign agents are leaf nodes, never routers. B's output returns to the Coordinator (via inbound webhook → RESULT), which governs the B→C hop exactly as it governs an internal hop. We govern the edges, never the foreign agent's interior.
  4. Inbound webhook → resume. A2A push-notification → a new /api/v1/a2a/callback/... inbound endpoint → correlate taskIdchild_session/bridge-row → translate to a bus RESULT/REPLY → Coordinator resumes the parent from its Context-Engine checkpoint. This reuses the merged signed approvals-callback pattern (internal/runtime/approval/channel/callback.go + linksigner + per-tenant SecretStore HMAC). Moderate effort: the verification, idempotency, and org-resolution machinery already exist.

This HLD is design-first and additive. Every new path is gated; nothing changes for an existing deployment until a flag is flipped, and cross-perimeter egress stays dark until the founder signs off the security posture in §10.


2. Where this sits in the merged substrate (verified by inspection)

ConcernWhat exists today (merged, dormant unless noted)File
Bus substrateRedis-Streams dispatcher, envelope proto, endpoint kinds (TEAM/ROLE/COORDINATOR)internal/bus/envelope.go, cmd/agent-orchestrator/bus.go
CoordinatorTask+step state machine, per-hop Governor.Check→audit→cost→next-hop, approval gate, resumeinternal/coordinator/{handle,resume,ports,consumer}.go, cmd/.../a2acoord.go
Per-hop governed forwardforward() already governs+audits+accrues every QUERY/REPLY before publish; fail-closed on non-allowinternal/coordinator/handle.go:172
Worker harnessConsume WORK, hydrate, execute, emit RESULT / suspend-resume sub-queryinternal/runtime/harness/*, cmd/.../a2aharness.go
Delegation seamBusRouted branch returns busDelegationResult (suspend-turn + QUERY directive) instead of spawning resident childinternal/runtime/subagent/tool.go:616, bus_delegation.go
Approval pause/resumeCoordinator parks step on approvalID; ResumeFromApproval advances/terminates idempotentlyinternal/coordinator/resume.go
Signed inbound callbackUnauthenticated HTTP endpoint where HMAC is the auth; per-tenant secret store + rotation; OrgResolver maps id→org; idempotent on sha256(token)internal/runtime/approval/channel/callback.go, email.go, secrets.go, linksigner/
Autonomous loop (T4)LIVE in DEV (real tools, WORKER_MCP_STUB_MODE=false)PR #1460

Key insight: the egress adapter and the inbound webhook are the same two seams that already exist for intra-tenant + approvals, projected one ring outward. We are not building a second governance engine — we are extending the destination set the existing Coordinator can route to, and adding a second inbound correlation handler shaped like the approvals callback.


3. Architecture diagram

Human ──live stream──▶ Quad ──create Task──▶ Coordinator (thin, always-on, deterministic)
│ per-hop: Governor.Check → audit(hash-chain) → cost accrue

┌────────────────────────────────────┼─────────────────────────────────────┐
│ (internal hop) │ (FOREIGN hop) │
▼ ▼ │
Role/Team WORK queue A2A Egress Adapter │
│ 1. resolve Agent Card │
Worker Harness /.well-known/agent-card.json (cached) │
(hydrate ▸ execute ▸ RESULT) 2. clearance-floor stamp + audit + approval │
│ BEFORE any byte leaves │
└───RESULT──▶ Coordinator 3. JSON-RPC tasks/send → outbound A2A Task │
4. persist a2a_bridge row (taskId↔child) │
│ │
▼ │
╔═══════════════════════╗ │
║ FOREIGN AGENT B ║ (Google ADK A2A-native│
║ (leaf node — never ║ / Claude via MCP-A2A │
║ a router) ║ bridge) │
╚═══════════╤═══════════╝ │
│ A2A push-notification (webhook) │
▼ │
POST /api/v1/a2a/callback/{taskId}?t=<HMAC> │
(HMAC verify ▸ OrgResolver ▸ idempotent on token hash) │
│ correlate taskId → a2a_bridge row │
▼ │
Coordinator.ResumeFromForeign ──────────governs──┘
(B→C hop is a NEW choke-point pass; C may be
internal worker or another foreign agent)

Foreign agents touch only the egress adapter (out) and the inbound webhook (in). They never see a bus queue, never choose a successor, never carry clearance forward. The Coordinator is the only router on every edge.


4. Service boundaries

ComponentResponsibilityOwnerScaling
Coordinator (extended)Adds FOREIGN destination handling: stamp+audit+approve, then hand to egress adapter; correlation + resume on inboundbackendalways-on, HA (existing)
A2A Egress Adapter (new)Agent Card discovery + cache; JSON-RPC tasks/send/tasks/get; Task-lifecycle tracking; outbound HTTP with egress allowlistbackendstateless; goroutine in orchestrator (same dormant pattern)
Foreign-Agent Registry (new)Per-tenant catalog of reachable foreign agents: base URL, auth credential ref, allowlist entry, capability/clearance ceilingbackendDB-backed (RLS), cache-fronted
A2A Inbound Webhook (new)HMAC-verified /api/v1/a2a/callback; correlate taskId→bridge row; translate to bus RESULT/REPLY; idempotentbackend (gateway mount)stateless, behind gateway
Bridge store (a2a_bridge) (new)Durable map foreign_taskId ↔ {org, task_id, step, child_session/continuation}; one row per outbound foreign hopbackendPostgres, RLS, indexed on (org_id, foreign_task_id)
MCP↔A2A bridge (foreign Claude)Present a non-A2A foreign agent (Claude) as an A2A endpoint, or speak its native protocol behind the adapter's ForeignTransport seambackendper-provider adapter

All new code runs inside cmd/agent-orchestrator as goroutines/handlers behind a new A2A_EGRESS_ENABLED flag (default false) — no new binary, mirroring the bus/coordinator/harness dormancy pattern.


5. Enabling the dormant bus (Part 1)

5.1 Flag flip is per-role, not global. ADR-0015 makes warm-vs-scale-to-zero a deployment knob; analogously, bus routing is enabled per role/team via HARNESS_ROLES/HARNESS_TEAMS + COORDINATOR_TENANTS. Go-live sequence (each step independently reversible):

  1. HARNESS_ENABLED=true for one non-interactive role on one DEV tenant (workers consume but Coordinator still dispatches nothing).
  2. COORDINATOR_ENABLED=true + COORDINATOR_TENANTS=<dev-tenant> (control queue consumed; decisions resume via the bus).
  3. BUS_ENABLED=true (the substrate the other two ride on).
  4. Flip BusRouted=true on the subagent tool dependency for that role.

5.2 The tool.go:616 conversion. Today busDelegationResult(in) returns a bus_delegation directive (the harness suspends the turn and emits a QUERY). For T5, when BusRouted is true the allow-path delegation:

  • writes the parent audit row (already done at line 594, before dispatch),
  • emits a QUERY envelope addressed to the Coordinator with x-a2a-dest-{kind,id} headers naming the real ROLE/TEAM destination (the harness contract in handle.go:278 destFromHeaders),
  • checkpoints the parent continuation to the Context Engine and stands the parent down (zero resident compute — ADR-0015 §2). The Coordinator's forward() governs the QUERY (the same Governor.Check→audit→accrue it already runs for internal sub-queries) and publishes to the destination. No new governance code — the choke-point is reused verbatim; T5 only adds the FOREIGN destination kind in §6.

5.3 Clearance-floor on the hop. The clearance precheck at tool.go:582 (reject if role's inherent clearance exceeds parent) stays. The Coordinator additionally clamps the hop's effective clearance floor (ComputeFloor semantics) into the GovHop.Clearance it checks. This same floor is what the egress adapter stamps onto a FOREIGN envelope (§6.3).


6. A2A wire-protocol egress adapter (Part 2)

6.1 New endpoint kind. Add ENDPOINT_KIND_FOREIGN to the bus endpoint proto, with Id = the foreign-agent registry key (not a raw URL — the URL/credential resolve from the registry, so a workflow definition never embeds an external URL). bus.ForeignEndpoint(agentKey) constructor mirrors RoleEndpoint/TeamEndpoint.

6.2 Routing a FOREIGN hop. When the Coordinator's advance()/forward() resolves a destination whose kind is FOREIGN, instead of pub.EnqueueWork it invokes a new ForeignDispatcher port (a sibling seam to Publisher in ports.go). The per-hop choke-point order is unchanged and mandatory:

  1. Governor.Check(GovHop{ActionType:"a2a_egress", Target: agentKey, ...}) — the cascade gets a new action type so policies can specifically gate external egress.
  2. Audit row (AuditHop{ActionType:"a2a_egress", InputHash: hash(payload)}) — written before the envelope leaves.
  3. Cost accrue (placeholder until the foreign Task returns metered usage).
  4. Only on VerdictAllow does the adapter run; VerdictRequiresApproval parks the step on an approval gate (reusing ApprovalGate.Open + ResumeFromApproval) — the envelope does not leave the perimeter until approved; VerdictDeny fails the step closed.

6.3 The egress adapter itself. On allow:

  1. DiscoverGET {base}/.well-known/agent-card.json (per-tenant registry supplies base + auth). Cache the Agent Card with a short TTL keyed (org, agentKey); re-fetch on capability mismatch or TTL expiry. Validate the card declares the required skill/capability before sending (fail closed on mismatch).
  2. Translate our QUERY/WORK payload into an A2A Message and open a Task via JSON-RPC tasks/send (method per A2A spec) over HTTPS. Stamp the clearance floor, org_id, our internal task_id/step, and a callback URL with a per-tenant HMAC-signed token (§7) into the A2A message metadata / pushNotificationConfig.
  3. Persist an a2a_bridge row: (org_id, foreign_task_id, internal_task_id, step_name, continuation_token, foreign_agent_key, status='dispatched'). This is the correlation anchor for the inbound webhook.
  4. Stand down — the parent step is parked exactly like an approval/sub-query suspend; no resident compute waits on the foreign agent.

6.4 Foreign provider transports. Behind a ForeignTransport interface:

  • Google ADK (A2A-native): speak A2A JSON-RPC directly.
  • Claude (not A2A-native): an MCP↔A2A bridge — either present Claude behind a small A2A shim we host, or implement a ClaudeTransport that maps the A2A Task lifecycle onto Anthropic's API. This is a per-provider adapter, not a change to the Coordinator. (Provider choice + any new SaaS credential surface is a founder go-live item — §10.)

6.5 Task lifecycle. A2A Tasks are long-running. We do not poll synchronously. Primary completion path is the inbound push-notification (§7); a bounded tasks/get reconciliation sweep (existing harness reclaim-timer pattern) backstops a missed webhook and DLQs a Task that exceeds its deadline, failing the parent step closed with an audited timeout.


7. Inbound webhook → resume (Part 3) — reuse the signed-callback pattern

7.1 Endpoint. Mount POST /api/v1/a2a/callback/{foreignTaskId} on the gateway, modeled line-for-line on internal/runtime/approval/channel/callback.go:

  • Unauthenticated transport; the HMAC token (?t=) IS the auth — same posture as the approvals callback. The token is the one we stamped into the foreign agent's pushNotificationConfig at dispatch (§6.3), signed with the per-tenant SecretStore HMAC secret, verified via the existing linksigner.Verify.
  • Correlation: the URL path/foreignTaskId → look up the a2a_bridge row → yields org_id (so we need an OrgResolver-equivalent: BridgeOrgResolver mapping foreignTaskIdorg_id, exactly like callback.go:OrgResolver for approvals).
  • Idempotency: idempotency_key = sha256(token) (identical to the approvals callback) so an A2A retry / duplicate push collapses to one resume.

7.2 Translate → resume. On verified callback:

  1. Parse the A2A Task result (artifacts/messages) into our ResultPayload shape.
  2. Build a bus RESULT (or REPLY for a sub-query) envelope addressed to the Coordinator, task_id/step from the bridge row, payload = the foreign output.
  3. EnqueueWork it. The Coordinator's existing handleResult/forward picks it up, governs/audits/accrues, and advances to the next step — for a heterogeneous A→B→C flow, the B→C hop is a fresh choke-point pass (§8). Parent rehydrates from its Context-Engine checkpoint on the next dispatch.

7.3 Why moderate effort. Verification, secret rotation (rotator.go), idempotency, org-resolution, and the resume-via-bus mechanics all exist. New work is: the bridge store + resolver, the A2A-result→ResultPayload parser, and the route mount. No new crypto, no new resume engine.


8. Hub-and-spoke heterogeneous-workflow enforcement

Invariant (founder-visible): we govern the edges, not the foreign agent's interior.

  • A workflow definition step may name a FOREIGN destination (agent_role-equivalent = registry agentKey). The Coordinator dispatches it via §6, parks the step, and resumes via §7.
  • A foreign agent cannot name a successor. The A2A result returns only artifacts/messages to us; the next hop is chosen solely by our workflow definition + Coordinator. There is no path by which B's response routes work to C without re-entering Governor.Check at the Coordinator.
  • Every edge — A→B (egress), B→Coordinator (inbound), Coordinator→C (next hop, internal or foreign) — produces exactly one hash-chained audit row. AuditService.VerifyChain covers the foreign hops the same as internal ones; the foreign agent's internal steps are opaque to us by design (and out of our trust boundary — §10).
  • Cross-tenant isolation holds because the bridge row, registry, and secret are all RLS-scoped to org_id; a callback for a foreign Task resolves to exactly one tenant.

9. Data architecture

New table a2a_bridge (RLS on org_id):

ColumnTypeNotes
iduuid PK
org_idtextRLS scope
foreign_task_idtextA2A Task id from the foreign agent; UNIQUE (org_id, foreign_task_id)
internal_task_iduuidFK → our Task
step_nametextthe parked step
foreign_agent_keytextFK → foreign-agent registry
continuation_tokentextContext-Engine checkpoint handle for parent resume
statustextdispatched/completed/failed/timed_out
created_at/updated_attimestamptz

Index: (org_id, foreign_task_id) (correlation hot path), (org_id, status) (reconciliation sweep).

New table foreign_agent_registry (RLS on org_id): agentKey, base_url, auth_credential_ref (secret-manager handle, never the raw secret), egress_allowlist_entry, capability_ceiling, clearance_ceiling, provider (google_adk/claude_bridge/...), enabled.

Migrations are additive; both tables are empty until a tenant registers a foreign agent (a founder-gated action — new egress surface).


10. Security architecture — NEW external egress surface (GATED GO-LIVE CONCERN)

This is the first time platform agents originate outbound traffic to systems outside our perimeter. Calling it out explicitly as a founder sign-off gate:

ConcernPosture (proposed)Gate
Egress allowlistOutbound A2A HTTP is restricted to an explicit per-tenant allowlist (foreign_agent_registry.egress_allowlist_entry) enforced at the adapter and at the network layer (k8s NetworkPolicy / egress gateway). No agent can reach an arbitrary URL. SSRF-hardened fetcher (reuse the Web-URL-RAG connector's hardening, internal/knowledge/*).Founder
Outbound authPer-foreign-agent credential stored in secret manager, referenced by handle only; never in DB/logs/workflow defs.Founder
Inbound authHMAC token IS the auth (reuse approvals-callback); per-tenant secret, rotatable; idempotent on token hash; rate-limited at the gateway.reuse-proven
Governed-every-hopclearance-floor stamp + audit + (optional) approval written before any envelope leaves; a2a_egress is a distinct governance action type so a policy can require approval on every external hop.core invariant
Trust boundaryWe govern edges only; the foreign agent's interior is untrusted. Foreign outputs are treated as untrusted input (validated/parsed defensively before they re-enter the Coordinator).core invariant
New SaaS/credentialRegistering a Google ADK / Claude-bridge endpoint introduces a new egress + credential surface → supply-chain + data-exfil review.Founder
Data residencyPayloads sent to foreign agents may leave our infra/region → per-tenant policy + PII-redaction review.Founder

Go-live is blocked on founder sign-off of the egress-allowlist + outbound-auth + data-residency posture, independent of code completion. The code can land dormant (A2A_EGRESS_ENABLED=false) under normal agent review; flipping the flag in any environment is a founder decision.


11. HA, fault tolerance, cost

  • Missed webhook: bounded tasks/get reconciliation sweep (harness reclaim pattern) + per-Task deadline → audited timeout, step fails closed.
  • Foreign agent down / 5xx: bounded retry with backoff at the adapter; exhaustion → DLQ + fail step closed; never silently hangs the parent (parent is checkpointed, not resident).
  • Duplicate inbound: idempotent on sha256(token) + guarded CompleteStep (existing handle.go double-dispatch defense).
  • Cost: foreign Task usage (if the agent reports it) accrues onto our Task via the existing CostAccruer; otherwise we record elapsed-time + a configurable per-call estimate. No change to the rollup seam (#1127).
  • Coordinator HA: unchanged (existing always-on requirement); the adapter is stateless behind it.

12. Testing strategy

  • Unit: egress adapter (Agent Card parse/validate, JSON-RPC translate, clearance stamp); inbound webhook (HMAC verify reuse, correlation, idempotency); FOREIGN routing in forward()/advance().
  • Integration: a stub A2A foreign agent (httptest server serving /.well-known/agent-card.json + tasks/send + emitting a push-notification) drives a full A→foreign B→C heterogeneous workflow; assert one audit row per edge, VerifyChain passes, B never routes to C.
  • Security: SSRF tests on the egress allowlist; HMAC tamper/expiry/replay on the inbound callback; approval-required-on-egress parks before any byte leaves.
  • Governance: deny-on-egress fails the step closed with an audited reason; requires-approval parks and resumes.

13. Task breakdown (implementable sub-issues)

#TrackTitleStory ptsDepends on
T5.1BACKENDEnable dormant bus per-role + convert tool.go:616 BusRouted allow-path to emit-QUERY + checkpoint + stand-down (suspend/resume)5ADR-0015 substrate
T5.2BACKENDAdd ENDPOINT_KIND_FOREIGN to bus proto + bus.ForeignEndpoint; route FOREIGN dest to new ForeignDispatcher port in Coordinator advance/forward (choke-point unchanged)3T5.1
T5.3BACKENDforeign_agent_registry + a2a_bridge tables (RLS, indexes) + stores3
T5.4BACKENDA2A egress adapter: Agent Card discovery+cache+validate, JSON-RPC tasks/send, clearance-floor stamp, bridge-row persist, ForeignTransport seam8T5.2, T5.3
T5.5BACKENDNew a2a_egress governance action type + audit-before-leave + approval-gate-parks-before-egress wiring3T5.4
T5.6BACKENDInbound /api/v1/a2a/callback/{taskId} webhook reusing callback.go HMAC/linksigner + BridgeOrgResolver + A2A-result→ResultPayload parser → bus RESULT/REPLY → resume5T5.3, T5.4
T5.7BACKENDForeignTransport impls: Google ADK (A2A-native) + Claude MCP↔A2A bridge8T5.4
T5.8BACKENDtasks/get reconciliation sweep + per-Task deadline/timeout fail-closed3T5.4, T5.6
T5.9DEVOPSEgress allowlist enforcement: NetworkPolicy / egress gateway + secret-manager credential refs + A2A_EGRESS_ENABLED flag plumbing5Founder §10
T5.10QAE2E heterogeneous A→foreign B→C with stub A2A agent; per-edge audit + VerifyChain; SSRF + HMAC tamper/replay + approval-before-egress suite8T5.4–T5.8

Dependency summary: T5.1 → T5.2 → T5.4 is the spine; T5.3 feeds T5.4/T5.6; T5.5 gates egress on governance; T5.6 closes the loop; T5.7 adds providers; T5.9 (DevOps) + the §10 posture are the founder-gated go-live items; T5.10 verifies.


14. Open items (deferred to LLD / founder)

  1. OQ-1 (Founder): approve the egress-allowlist + outbound-auth + data-residency posture (§10) before any environment flips A2A_EGRESS_ENABLED.
  2. OQ-2 (Founder): confirm Google ADK + Claude-bridge as the first two foreign providers (new SaaS/credential surface).
  3. OQ-3 (LLD): exact A2A spec version + JSON-RPC method names (tasks/send vs message/send) to pin against the Google A2A protocol revision we adopt (ADR-0012).
  4. OQ-4 (LLD): whether requires_approval on a mid-workflow foreign sub-query is park-able (it is for a step; ADR-0015 currently fails a mid-turn sub-query approval closed — confirm the foreign case follows the step-park path, not the sub-query fail-closed path).
  5. OQ-5 (LLD): foreign cost metering — adopt the foreign agent's reported usage vs our elapsed-time estimate.