HLD: A2A Egress + Foreign-Agent Adapter + Webhook Resume
| Field | Value |
|---|---|
| Status | Draft — AWAITING FOUNDER APPROVAL (architectural direction) |
| Version | 1.0 |
| Date | 2026-06-19 |
| Author | Principal Technical Architect Agent |
| Issue | #1423 (T5) |
| Parent | #1418 (Runtime Revamp tracking) |
| Milestone | #20 — Runtime Revamp — Adopt & Wrap (ADR-0019) |
| Governing ADR | ADR-0015 (docs/adrs/ADR-0015-agent-to-agent-communication-model.md, PR #1226) |
| Related ADRs | ADR-0012 (adopt Google A2A envelope), ADR-0019 (adopt-and-wrap runtime) |
| Depends on | T4 (#1422) autonomous loop LIVE (PR #1460, DEV); ADR-0015 bus substrate (dormant) |
| Implementation gate | Founder 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:
- Enable the dormant bus. Flip
BUS_ENABLED/COORDINATOR_ENABLED/HARNESS_ENABLEDon per-role, and convert the peer/delegation handoff atinternal/runtime/subagent/tool.go:616(BusRoutedbranch →busDelegationResult) so an allow-pathdelegate_to_agentsuspends the parent turn and emits a QUERY onto the bus instead of spawning a pause-resident child. The Coordinator forwards the governed hop (theforward()path ininternal/coordinator/handle.goalready governs+audits+accrues every QUERY/REPLY). - A2A wire-protocol egress adapter at the Coordinator. A new
EndpointKindFOREIGNmakes a hop's destination a foreign agent. The Coordinator, after running its normalGovernor.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. - 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.
- Inbound webhook → resume. A2A push-notification → a new
/api/v1/a2a/callback/...inbound endpoint → correlatetaskId→child_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-tenantSecretStoreHMAC). 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)
| Concern | What exists today (merged, dormant unless noted) | File |
|---|---|---|
| Bus substrate | Redis-Streams dispatcher, envelope proto, endpoint kinds (TEAM/ROLE/COORDINATOR) | internal/bus/envelope.go, cmd/agent-orchestrator/bus.go |
| Coordinator | Task+step state machine, per-hop Governor.Check→audit→cost→next-hop, approval gate, resume | internal/coordinator/{handle,resume,ports,consumer}.go, cmd/.../a2acoord.go |
| Per-hop governed forward | forward() already governs+audits+accrues every QUERY/REPLY before publish; fail-closed on non-allow | internal/coordinator/handle.go:172 |
| Worker harness | Consume WORK, hydrate, execute, emit RESULT / suspend-resume sub-query | internal/runtime/harness/*, cmd/.../a2aharness.go |
| Delegation seam | BusRouted branch returns busDelegationResult (suspend-turn + QUERY directive) instead of spawning resident child | internal/runtime/subagent/tool.go:616, bus_delegation.go |
| Approval pause/resume | Coordinator parks step on approvalID; ResumeFromApproval advances/terminates idempotently | internal/coordinator/resume.go |
| Signed inbound callback | Unauthenticated 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
| Component | Responsibility | Owner | Scaling |
|---|---|---|---|
| Coordinator (extended) | Adds FOREIGN destination handling: stamp+audit+approve, then hand to egress adapter; correlation + resume on inbound | backend | always-on, HA (existing) |
| A2A Egress Adapter (new) | Agent Card discovery + cache; JSON-RPC tasks/send/tasks/get; Task-lifecycle tracking; outbound HTTP with egress allowlist | backend | stateless; 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 ceiling | backend | DB-backed (RLS), cache-fronted |
| A2A Inbound Webhook (new) | HMAC-verified /api/v1/a2a/callback; correlate taskId→bridge row; translate to bus RESULT/REPLY; idempotent | backend (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 hop | backend | Postgres, 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 seam | backend | per-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):
HARNESS_ENABLED=truefor one non-interactive role on one DEV tenant (workers consume but Coordinator still dispatches nothing).COORDINATOR_ENABLED=true+COORDINATOR_TENANTS=<dev-tenant>(control queue consumed; decisions resume via the bus).BUS_ENABLED=true(the substrate the other two ride on).- Flip
BusRouted=trueon 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 inhandle.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 sameGovernor.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:
Governor.Check(GovHop{ActionType:"a2a_egress", Target: agentKey, ...})— the cascade gets a new action type so policies can specifically gate external egress.- Audit row (
AuditHop{ActionType:"a2a_egress", InputHash: hash(payload)}) — written before the envelope leaves. - Cost accrue (placeholder until the foreign Task returns metered usage).
- Only on
VerdictAllowdoes the adapter run;VerdictRequiresApprovalparks the step on an approval gate (reusingApprovalGate.Open+ResumeFromApproval) — the envelope does not leave the perimeter until approved;VerdictDenyfails the step closed.
6.3 The egress adapter itself. On allow:
- Discover —
GET {base}/.well-known/agent-card.json(per-tenant registry suppliesbase+ 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). - 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 internaltask_id/step, and a callback URL with a per-tenant HMAC-signed token (§7) into the A2A message metadata /pushNotificationConfig. - Persist an
a2a_bridgerow:(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. - 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
ClaudeTransportthat 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'spushNotificationConfigat dispatch (§6.3), signed with the per-tenantSecretStoreHMAC secret, verified via the existinglinksigner.Verify. - Correlation: the URL path/
foreignTaskId→ look up thea2a_bridgerow → yieldsorg_id(so we need anOrgResolver-equivalent:BridgeOrgResolvermappingforeignTaskId→org_id, exactly likecallback.go:OrgResolverfor 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:
- Parse the A2A Task result (artifacts/messages) into our
ResultPayloadshape. - Build a bus RESULT (or REPLY for a sub-query) envelope addressed to the Coordinator,
task_id/stepfrom the bridge row, payload = the foreign output. EnqueueWorkit. The Coordinator's existinghandleResult/forwardpicks 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
FOREIGNdestination (agent_role-equivalent = registryagentKey). 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.Checkat 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.VerifyChaincovers 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):
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
org_id | text | RLS scope |
foreign_task_id | text | A2A Task id from the foreign agent; UNIQUE (org_id, foreign_task_id) |
internal_task_id | uuid | FK → our Task |
step_name | text | the parked step |
foreign_agent_key | text | FK → foreign-agent registry |
continuation_token | text | Context-Engine checkpoint handle for parent resume |
status | text | dispatched/completed/failed/timed_out |
created_at/updated_at | timestamptz |
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:
| Concern | Posture (proposed) | Gate |
|---|---|---|
| Egress allowlist | Outbound 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 auth | Per-foreign-agent credential stored in secret manager, referenced by handle only; never in DB/logs/workflow defs. | Founder |
| Inbound auth | HMAC token IS the auth (reuse approvals-callback); per-tenant secret, rotatable; idempotent on token hash; rate-limited at the gateway. | reuse-proven |
| Governed-every-hop | clearance-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 boundary | We 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/credential | Registering a Google ADK / Claude-bridge endpoint introduces a new egress + credential surface → supply-chain + data-exfil review. | Founder |
| Data residency | Payloads 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/getreconciliation 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)+ guardedCompleteStep(existinghandle.godouble-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,VerifyChainpasses, 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)
| # | Track | Title | Story pts | Depends on |
|---|---|---|---|---|
| T5.1 | BACKEND | Enable dormant bus per-role + convert tool.go:616 BusRouted allow-path to emit-QUERY + checkpoint + stand-down (suspend/resume) | 5 | ADR-0015 substrate |
| T5.2 | BACKEND | Add ENDPOINT_KIND_FOREIGN to bus proto + bus.ForeignEndpoint; route FOREIGN dest to new ForeignDispatcher port in Coordinator advance/forward (choke-point unchanged) | 3 | T5.1 |
| T5.3 | BACKEND | foreign_agent_registry + a2a_bridge tables (RLS, indexes) + stores | 3 | — |
| T5.4 | BACKEND | A2A egress adapter: Agent Card discovery+cache+validate, JSON-RPC tasks/send, clearance-floor stamp, bridge-row persist, ForeignTransport seam | 8 | T5.2, T5.3 |
| T5.5 | BACKEND | New a2a_egress governance action type + audit-before-leave + approval-gate-parks-before-egress wiring | 3 | T5.4 |
| T5.6 | BACKEND | Inbound /api/v1/a2a/callback/{taskId} webhook reusing callback.go HMAC/linksigner + BridgeOrgResolver + A2A-result→ResultPayload parser → bus RESULT/REPLY → resume | 5 | T5.3, T5.4 |
| T5.7 | BACKEND | ForeignTransport impls: Google ADK (A2A-native) + Claude MCP↔A2A bridge | 8 | T5.4 |
| T5.8 | BACKEND | tasks/get reconciliation sweep + per-Task deadline/timeout fail-closed | 3 | T5.4, T5.6 |
| T5.9 | DEVOPS | Egress allowlist enforcement: NetworkPolicy / egress gateway + secret-manager credential refs + A2A_EGRESS_ENABLED flag plumbing | 5 | Founder §10 |
| T5.10 | QA | E2E heterogeneous A→foreign B→C with stub A2A agent; per-edge audit + VerifyChain; SSRF + HMAC tamper/replay + approval-before-egress suite | 8 | T5.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)
- OQ-1 (Founder): approve the egress-allowlist + outbound-auth + data-residency posture (§10) before any environment flips
A2A_EGRESS_ENABLED. - OQ-2 (Founder): confirm Google ADK + Claude-bridge as the first two foreign providers (new SaaS/credential surface).
- OQ-3 (LLD): exact A2A spec version + JSON-RPC method names (
tasks/sendvsmessage/send) to pin against the Google A2A protocol revision we adopt (ADR-0012). - OQ-4 (LLD): whether
requires_approvalon 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). - OQ-5 (LLD): foreign cost metering — adopt the foreign agent's reported usage vs our elapsed-time estimate.