Skip to content

feat(a2a): A2A interoperability — inbound server (well-known + JSON-RPC/SSE) + control MCP tools (ent#157/#160)#1628

Open
dolho wants to merge 15 commits into
devfrom
feature/157-a2a-inbound-server
Open

feat(a2a): A2A interoperability — inbound server (well-known + JSON-RPC/SSE) + control MCP tools (ent#157/#160)#1628
dolho wants to merge 15 commits into
devfrom
feature/157-a2a-inbound-server

Conversation

@dolho

@dolho dolho commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

The complete public surface for A2A (agent-to-agent) protocol interoperability — one feature, one PR. Two halves:

  1. A2A inbound server (ent#157) — make an exposed Trinity agent genuinely reachable + taskable over the open A2A protocol by an external orchestrator (Google ADK, LangChain, AWS Bedrock AgentCore, another Trinity).
  2. A2A control MCP tools (ent#160) — drive the exposure/config plane (toggle exposure, read the card, manage the inbound allow-list, register outbound endpoints) over MCP.

The entitlement-gated enterprise backend ships in a separate repo (the private submodule): trinity-enterprise#161. It's the writer/management half; this PR is the reader/server + MCP half. (The submodule is a distinct git repo, so it can't share a PR with the public code.)

A2A inbound server (ent#157)

OSS a2a_exposed primitive (edition-agnostic, default OFF — safe by default):

  • agent_ownership.a2a_exposed column (schema.py + tables.py + two-track migration: db/migrations.py + Alembic 0024) + A2AExposureMixin accessors + surfaced on GET /api/agents (mirrors mcp_exposed). OSS owns the column + read/enforcement; the write is entitlement-gated by the enterprise setter (core-primitive + enterprise-knob, like users.suspended_at).

Public server (routers/a2a.py → new a2a_server_router in main.py):

  • GET /a2a/{name}/.well-known/agent-card.json — unauthenticated discovery; uniform 404 for non-exposed/non-existent (no enumeration oracle). Safe by default: the flag can't be set without the entitled setter → OSS-only builds always 404.
  • POST /a2a/{name}JSON-RPC 2.0: message/send (sync → execute_task(triggered_by="a2a")), message/stream (SSE), tasks/get, tasks/cancel. JSON-RPC error codes (-32700/-32600/-32601/-32602) + A2A task-not-found (-32001). Bearer = a Trinity MCP key, per-call get_current_user (fail-closed 401). Owner/shared + per-agent inbound allow-list via the new services/a2a_gate seam (OSS no-op; enterprise provider; fail-open). Trigger-boundary idempotency on messageId (Invariant Unified Executions Dashboard (EXEC-022) #18). Every task audit-logged (source="a2a"). taskId == execution_id.
  • Card honesty: protocolVersion "0.3.0", url → the JSON-RPC endpoint, preferredTransport=JSONRPC, Bearer scheme.
  • Phased (seams ready): tasks/resubscribe (-32004), incremental token streaming.

A2A control MCP tools (ent#160)

src/mcp-server/src/tools/a2a.ts — 7 tools: get_agent_a2a_config, set_agent_a2a_exposure, get_agent_a2a_card (proxies the OSS #737 served card), set_a2a_inbound_allowlist, register_a2a_endpoint, list_a2a_endpoints, remove_a2a_endpoint. Honest gating (unentitled-403 / OSS-404 → structured not_entitled/not_found, never silent success); mutations owner/admin + human-only; outbound credentials write-only (never echoed). a2a_exposed flows natively on list_agents/get_agent via the OSS column (no extra fetch).

Test Plan

  • Backend: tests/unit/test_157_a2a_inbound_server.py (16) + test_a2a_card_service.py updated for the honest card. Schema/Alembic parity guards green; pg-migrations, backend-boots-without-enterprise, non-root all green.
  • MCP: src/mcp-server/src/tools/a2a.test.ts (10); full suite 100 pass, tsc clean.
  • regression-diff green (updated the two feat: serve A2A Agent Cards from template.yaml for external discoverability #737 card assertions).

Companion PR

trinity-enterprise#161 — the entitlement-gated enterprise.backend.a2a module: the exposure setter (write-throughs to this OSS column — without it the flag stays OFF and every public route 404s, the intended safe default), curated-skills + inbound allow-list + outbound endpoint registry (AES-256-GCM creds), and the allow-list provider it plugs into a2a_gate.

Related to trinity-enterprise#157, trinity-enterprise#160

🤖 Generated with Claude Code


Update — A2A config UI (ent#158) added. An owner-only A2A tab on Agent Detail (components/A2aPanel.vue), gated on the a2a entitlement (never a blank tab in OSS): exposure toggle (→ the OSS a2a_exposed write-through), one-click-copy Agent Card URL, advertised skills (read-only), inbound allow-list add/remove, outbound endpoint registry (credentials write-only, never echoed). No new backend endpoints — proxies the surface above. This PR now covers the full public feature: inbound server (#157) + control MCP tools (#160) + config UI (#158).

@dolho dolho changed the title feat(a2a): A2A inbound server — public well-known card + JSON-RPC/SSE task endpoint (ent#157) feat(a2a): A2A interoperability — inbound server (well-known + JSON-RPC/SSE) + control MCP tools (ent#157/#160) Jul 15, 2026
@dolho

dolho commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

📖 A2A user guide + screenshots

Full guide added at docs/user-docs/integrations/a2a-protocol.md. Reproduced here for review, with the live UI screenshots.

Trinity speaks the open A2A protocol — an exposed agent is reachable by any A2A orchestrator (Google ADK, LangChain, Bedrock, another Trinity), and can call external A2A agents in turn.

Availability: exposing over A2A requires the A2A capability to be entitled. Otherwise the tab is hidden and public routes 404 — off + invisible by default.


Part 1 — Expose an agent (the new A2A tab)

A2A config panel

  1. Flip "Expose over A2A" on (OFF by default; while off the public routes 404).
  2. Copy the Agent Card URL — the public discovery URL, one click.
  3. Review advertised skills (from template.yaml).
  4. Inbound allow-list — restrict which caller identities may task the agent (empty = any owner/shared caller).
  5. Outbound endpoints — external A2A endpoints the agent may call; credentials stored encrypted, never echoed.

Agent Card URL section

Same controls are available as MCP tools (get_agent_a2a_config, set_agent_a2a_exposure, set_a2a_inbound_allowlist, register_a2a_endpoint, …). Exposure + credential ops are owner/admin and human-only.


Part 2 — Consume an exposed agent (external orchestrator)

1. Discover — fetch the card:

curl -s http://localhost:8001/a2a/new_cool_agent/.well-known/agent-card.json | jq
# → { "protocolVersion":"0.3.0", "url":".../a2a/new_cool_agent",
#     "preferredTransport":"JSONRPC", "securitySchemes":{"bearerAuth":{...}} }

2. Authenticate — a Trinity MCP API key (Settings → MCP Keys) as Authorization: Bearer trinity_mcp_…. Unauth → 401.

3. Task — JSON-RPC 2.0:

curl -s -X POST http://localhost:8001/a2a/new_cool_agent \
  -H "Authorization: Bearer trinity_mcp_YOUR_KEY" \
  -d '{"jsonrpc":"2.0","id":1,"method":"message/send",
       "params":{"message":{"messageId":"req-1",
         "parts":[{"kind":"text","text":"Summarize today's sales."}]}}}'
# → Task { status:{state:"completed"}, artifacts:[{parts:[{text:""}]}] }
  • message/stream for SSE (working → final completed); tasks/get / tasks/cancel take {"id":"<taskId>"}.
  • Re-sending the same messageId returns the original task without re-executing (idempotent).

Notes

  • Safe by default — exposure OFF per agent; non-exposed/non-existent → uniform 404 (no enumeration).
  • Front door — external clients hit your public URL; Trinity proxies /a2a/ to the backend (nginx prod / dev proxy). Set PUBLIC_CHAT_URL so the card's url is externally reachable.
  • Every inbound task is audit-logged (source=a2a).

JSON-RPC error codes: -32700 parse · -32600 bad envelope · -32601 method not found · -32602 invalid params · -32001 task not found.

@dolho
dolho requested a review from vybe July 15, 2026 13:01

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated via /validate-pr. Impressive scope and the fundamentals are solid — dual-track migrations are textbook (all four surfaces present, 0024 chains to the verified sole head 0023, matches the 0010_mcp_exposed house pattern byte-for-byte), Invariants #4/#14 clean, SQL is all bound-param Core, and the JSON-RPC path is fail-closed 401 with uniform 404s. The negative-path test coverage is genuinely good.

Three findings I'd want resolved before this lands. All verified against the code, not inferred.


1. CRITICAL — cross-caller response disclosure via messageId idempotency

routers/a2a.py:

message_id = message.get("messageId")
decision = idempotency_service.begin(f"a2a:{agent_name}", message_id)
if decision.replay and not decision.in_flight and decision.snapshot:
    return _rpc_result(rpc_id, decision.snapshot)

The scope carries no caller identity, the key is a peer-controlled protocol field, and the stored snapshot contains the agent's full response text (artifacts[0].parts[0].text).

Failure: users A and B both have shared access to agent bot. A sends messageId: "req-1". B sends messageId: "req-1"B receives A's agent output, and B's own task silently never executes.

This isn't hypothetical: A2A messageId is mandatory, auto-generated by SDKs, and the spec only requires per-client uniqueness. This PR's own guide uses "messageId": "req-1" as the worked example.

I know make_agent_scope(name) + caller-supplied key is the house shape (chat.py:205, fan_out.py:63) — the difference is Idempotency-Key there is opt-in and deliberately chosen, while messageId is automatic and collision-prone. Suggest folding the caller principal into the scope.

2. CRITICAL — unauthenticated well-known route has no rate limit

routers/a2a.pya2a_well_known_card_serve_card

Per request against an exposed agent: DB read → live uncached Docker API call (docker_service.get_agent_container) → HTTP call into the agent container (_fetch_template_data, 5s timeout). No throttle at either layer — services/rate_limiter isn't imported in the diff, and nginx.conf has no limit_req/limit_conn.

Every other unauthenticated route in the repo carries a per-IP limit: public.py:50 (PUBLIC_LINK_LOOKUP_RATE_LIMIT = 60, annotated "pentest 3.3.2"), files.py:44 (_DOWNLOAD_RATE_LIMIT = 60), webhooks.py:99 (added by #1424 for exactly this endpoint shape).

The card URL is published by design, so the agent name is public. An unauthenticated flood saturates the Docker API (called synchronously inside async def, so it stalls the event loop) and hammers the agent container → fleet-wide degradation from one URL.

Worth noting this PR is what makes the route public — the docs it replaces said "Trinity does not serve that route yet — it requires a separate access-policy decision per agent." That decision seems to have been made implicitly; the per-IP limit is the piece that didn't come with it.

3. CRITICAL — public seam file names a private enterprise table (standing rule; CI green by accident)

services/a2a_gate.py:13:

* Enterprise build → the provider consults ``enterprise_a2a_inbound_allowlist``:

enterprise_a2a_inbound_allowlist matches the guard's own pattern and is not allowlisted. The guard won't catch it.github/workflows/enterprise-docs-guard.yml greps docs/** CLAUDE.md plus SEAM_FILES='src/backend/main.py src/backend/services/entitlement_service.py'. a2a_gate.py is a new open-core seam the stale list doesn't cover.

This is exactly the #1461 class the guard's own header calls out: "a comment there is unguarded prose that can name the paid catalog just as a doc can." Fix is one comment edit (describe the mechanism, not the table) — and please add a2a_gate.py to SEAM_FILES so the next one is caught.

The docs/ side is clean.


Warnings

  1. tasks/cancel reports success unconditionally. terminate_execution_on_agent(...) returns a bool that's discarded; the handler always returns "canceled". No status=CANCELLED write, no slot release, no CAS precondition (contrast #1082 status-as-projection, and chat.py:2408's dedicated db.cancel_queued_execution branch). Cancelling a queued execution → registry 404s → helper returns True → client told "canceled" → the task drains minutes later, runs, bills, and performs side effects the caller believed cancelled.

  2. SSE disconnect wedges the messageId for 24h. _gen() wraps _run_a2a_task in except Exception, but a client disconnect raises asyncio.CancelledError — a BaseException since 3.8, so uncaught. Neither complete() nor fail() runs → row stays in_flight → every retry with that messageId gets -32603 "already in progress" for 24h.

  3. Allow-list identity contract mismatch. caller_identity = getattr(current_user, "email", None) or current_user.username — always email/username. But A2aPanel.vue placeholders "did:example, https://caller/…, or key id" and the guide says "a caller URL, client id, or key id". An operator entering a DID makes the list non-empty → every real caller (an email) is denied, or they think they've restricted access when they haven't.

  4. The access gate has zero coverage. test_157_a2a_inbound_server.py defines state["access"] = {"bot"} but no test ever varies it — the exposed-but-inaccessible → uniform 404 branch is never exercised.

  5. Allow-list fails open; docs present it as a hard boundary. check_inbound_allowed returns True on provider exception (deliberate, documented in code) — but the guide says "only listed identities are accepted; everyone else gets 403" with no mention that a policy-backend outage admits every owner/shared caller.

Suggestions

  • await request.json() is uncapped (contrast #1083's callback and #1424's webhook, which caps before parsing). Mitigated by nginx client_max_body_size 25m, but not if :8000 is reachable directly.
  • message/stream replay returns JSON, not SSE — a streaming SDK breaks on replay.
  • _a2a_state_for maps everything non-successfailed, so a CANCELLED execution reports failed, not canceled.
  • architecture.md not updated: line 784 still says "A2A v1.0" (now 0.3.0); the agent_ownership block lists mcp_exposed but not a2a_exposed; the two new public routes, services/a2a_gate.py, and triggered_by="a2a" are undocumented. Only docs/user-docs/ was touched.

Merge ordering

Companion enterprise PR abilityai/trinity-enterprise#161 imports from services import a2a_gate and calls get_a2a_exposed/set_a2a_exposed — none of which exist in OSS until this PR lands. I verified the API contracts match exactly on both sides, so it's purely sequencing: #1628 → dev first, then #161, and any submodule-pointer bump must land on a branch that already contains this PR.

dolho added a commit that referenced this pull request Jul 16, 2026
…ic card, degeneric the seam

Resolves the three criticals and the warnings from the #1628 review.

Criticals:

- messageId dedup was scoped per agent only, but messageId is a
  peer-controlled field that SDKs auto-generate and the spec only
  requires to be unique per client. Two callers colliding on "req-1"
  meant caller B received caller A's stored snapshot — the agent's full
  response text — and B's own task silently never ran. The scope now
  carries the caller principal, preferring mcp_key_id so agent-scoped
  keys resolving to one owner stay distinct.

- The unauthenticated well-known card route had no throttle, while each
  hit costs a DB read, a live Docker API call, and an HTTP call into the
  agent container. Since the URL is published by design, one address
  could stall the event loop and hammer the fleet. Adds a per-IP limit
  ahead of all that work, matching public.py/files.py/webhooks.py.

- services/a2a_gate.py named a private enterprise table in a comment.
  The comment now describes the mechanism, and the file joins SEAM_FILES
  so the guard covers this new seam (the #1461 class it was blind to).

Warnings:

- tasks/cancel discarded the bool from terminate_execution_on_agent and
  always reported "canceled". A queued task would drain later, run, and
  bill against a caller who believed it cancelled. Queued rows now cancel
  through the backlog CAS, terminal rows return TaskNotCancelable, and a
  failed terminate is reported honestly.

- An SSE client disconnect raises CancelledError, a BaseException that
  the generator's `except Exception` never caught, so the row stayed
  in_flight and wedged the messageId for the full 24h TTL.

- The allow-list UI and guide advertised DIDs and caller URLs while the
  code compares email-or-username, so a DID-only list denies everyone.
  Both now say email, and the guide states the gate fails open rather
  than presenting it as a hard boundary.

- The access gate had no coverage; adds cases for exposed-but-
  inaccessible, 404 indistinguishability, and the admit path.

Also: cancelled now maps to `canceled` rather than `failed`, the JSON-RPC
body is capped before parsing, stream replay returns SSE instead of JSON,
and architecture.md records the new routes, the a2a_exposed column, the
0.3.0 protocol version, and the seam service.

Every new test was verified to fail against the unfixed code.

Related to #1628

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dolho
dolho requested a review from AndriiPasternak31 as a code owner July 16, 2026 08:06
@dolho

dolho commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Re-validated via /validate-pr after the fix commit (3cff879c).

Disclosure: I authored 3cff879c, the commit this validates. A self-review is structurally weaker than an independent one — the three criticals below are confirmed by live behaviour and by tests I checked fail against the unfixed code, but the judgement that they're adequately fixed is not independent. Weight it accordingly.

The three criticals are resolved — verified live, not just by tests

The backend bind-mounts src/backend into /app, so this branch is what the running stack serves. All checks below are against the live instance on PostgreSQL (Alembic at 0024, applied cleanly from 0023).

1. Cross-caller disclosure via messageId — the scope now carries the caller principal (_a2a_idem_scope), preferring mcp_key_id over username because agent-scoped keys all resolve to one owner user; username alone would have collapsed two calling agents into a shared namespace and left a narrower version of the bug. Reverting to f"a2a:{agent_name}" makes the new end-to-end test fail with "caller B's task was skipped — B replayed A's cached result", so it reproduces the reported failure rather than just asserting the fix.

2. Unauthenticated well-known route had no rate limit — added per-IP (60/min), placed ahead of the DB/Docker/agent work, trusted-proxy aware via _get_client_ip (nginx sets X-Real-IP). Live flood of 75 requests: 57×200 then 18×429 with Retry-After: 50. A test asserts the limiter runs before any Docker/DB call — limiting after the expensive part wouldn't have fixed the flood.

3. Seam file named a private table — comment now describes the mechanism, and a2a_gate.py is added to SEAM_FILES. I verified the guard both passes now and catches the old wording when re-injected — it was previously blind to this file.

Warnings 4–8 addressed: tasks/cancel routes queued rows through the backlog CAS and returns TaskNotCancelable (-32002) rather than reporting success for a task that will later run and bill; SSE catches CancelledError (a BaseException, so the old except Exception never saw it) and releases the in-flight row; the allow-list UI/guide now say "email" and state the gate fails open; the access gate has coverage. Suggestions folded in: cancelledcanceled, body capped before parse (live: -32600 "Request body too large"), stream replay returns SSE.

Other live checks: non-exposed and nonexistent agents both return a uniform 404; JSON-RPC without auth is fail-closed 401; the card serves protocolVersion 0.3.0. Tests: 44 pass (was 24). Adjacent suites green (77 passed incl. test_186_enumeration_uniformity, test_models_centralized).

Validation findings

Category Status Notes
Base branch targets dev
PR size 34 files (under the 50 threshold)
Issue link Related to trinity-enterprise#157, #160 — both resolve, P2/type-feature/complexity labelled. "Related to" (not "Closes") is correct per the cross-tracker manual-close convention
Commit messages conventional prefixes, explain why
Architecture now records the routes, a2a_exposed, 0.3.0, and the seam service
Migrations (Inv #3) all four surfaces; 0024 chains to sole head 0023; verified applying cleanly on live PG
MCP surface (Inv #13) tools/a2a.ts + .test.ts + client/server wiring
Security sweep no keys, tokens, real emails, public IPs, .env, or credential files
Infrastructure nginx.conf /a2a/ proxy justified (front-door routing, SSE); client_max_body_size 25m applies
Build packaging Dockerfile uses a *.py glob — no gap (the skill's explicit-name check is stale and false-positives on database.py; confirmed present in the image)
Config packaging no new os.getenv() vars
Requirements see below
Feature flow ⚠️ see below

❌ Requirements not updated for a new capability

requirements/mcp.md §32 covers only #737 A2A Discoverability — §32.1 is the authenticated card endpoint, explicitly scoped "Phase 1". The inbound server is a distinct new capability (two public routes, a new DB column, exposure flag, allow-list gate, MCP control tools) with no requirement entry. CLAUDE.md Rule 1 wants the area file updated before implementing; Rule 4's tier for "new capability" is requirements + feature-flow. This is the one item I'd genuinely block on — everything else is either fixed or a judgement call.

⚠️ Stale docs the PR makes wrong

docs/user-docs/faq/collaboration.md:55 currently states:

"There is no public unauthenticated /.well-known route yet"

This PR ships exactly that route, so the FAQ becomes actively false on merge — and docs/user-docs/** auto-publishes to a public search index via sync-docs-to-vertex.yml. Same paragraph also describes the card as authenticated-only.

A2A v1.0 is stale in four more places (the card is 0.3.0): requirements/mcp.md:54,63, user-docs/integrations/mcp-server.md:123, user-docs/README.md:92, faq/collaboration.md:55. The architecture.md:784 instance is fixed; these are the same class.

⚠️ No feature flow

A vertical slice across router + seam + MCP tools + Vue panel + private module is exactly the shape feature-flows/ exists for — compare mcp-connector.md for the analogous #846/ent#46 feature. Not a merge blocker on its own, but it's the tier the methodology asks for.

Suggestion

proxy_read_timeout 86400 on location /a2a/ is inherited from the SSE pattern and is right for message/stream, but it also applies to the unauthenticated well-known card, where a 24h read timeout is more rope than that route needs.

Recommendation

REQUEST CHANGES — narrowly, on documentation:

  • Add a requirements entry for the A2A inbound server (requirements/mcp.md, alongside §32)
  • Fix faq/collaboration.md:55 — it will be false on merge and it auto-publishes publicly
  • Update the four stale A2A v1.00.3.0 references
  • Consider a feature-flows/a2a-inbound-server.md

The code findings from the previous review are resolved and hold up under live exercise. Merge ordering from that review still stands: #1628dev first, then trinity-enterprise#161, which imports a2a_gate and get_a2a_exposed/set_a2a_exposed — none of which exist in OSS until this lands.

dolho added a commit that referenced this pull request Jul 16, 2026
…t stale claims

Closes the documentation gaps from the /validate-pr re-review.

Requirements (requirements/mcp.md): the inbound server is a new
capability — two public routes, a new column, an exposure flag, the
allow-list seam, MCP control tools — but §32 covered only the #737
authenticated card, explicitly scoped "Phase 1". Adds §32.2 (inbound
server, ent#157) and §32.3 (control over MCP, ent#160), each recording
the decisions a future reader would otherwise have to reverse-engineer:
why the dedup scope carries the caller, why the public route is limited
before its own work, why cancel reports what happened, and that the
allow-list fails open.

Feature flow: adds feature-flows/a2a-inbound-server.md and indexes it.
A vertical slice across router, seam, MCP tools, Vue panel, and a private
module is the shape feature-flows exists for (cf. mcp-connector.md).

Stale claims — two user-facing docs asserted the opposite of what this
PR ships, and docs/user-docs/** auto-publishes to a public search index:

  - faq/collaboration.md said "There is no public unauthenticated
    /.well-known route yet"
  - faq/advanced-features.md said "the public /.well-known/agent-card.json
    route is not served yet"

Both now describe exposure, the two public routes, and that tasking still
requires a key.

"A2A v1.0" is corrected to 0.3.0 in eight places, including four in
a2a_card_service.py — the file that emits "0.3.0". The router docstrings
still described a single-endpoint Phase 1 surface and called public
serving a follow-up needing "a separate access-policy decision"; that
decision is the per-agent exposure flag, and it shipped here.

Related to #1628

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dolho

dolho commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Requested changes applied in aada0476. (Same disclosure as above — I wrote the commit I'm reporting on.)

  • Requirements entryrequirements/mcp.md §32.2 (inbound server, ent#157) + §32.3 (control over MCP, ent#160). Written as FR-N bullets in the §45 house style, recording the why behind each decision — the caller-scoped dedup, the limit-before-work ordering, honest cancel, and the fail-open gate — since those are what a future reader would otherwise reverse-engineer from the diff.
  • faq/collaboration.md — now describes exposure, both public routes, and that tasking still needs a key.
  • Stale A2A v1.0 — corrected in eight places, not the four I first counted. Four were in a2a_card_service.py, the file that emits "0.3.0".
  • Feature flowfeature-flows/a2a-inbound-server.md, indexed.

One finding I missed in the review above: faq/advanced-features.md:51 carried a second false claim — "the public /.well-known/agent-card.json route is not served yet". Same class as the collaboration.md one, same auto-publishing tree; my grep pattern just didn't reach it. Fixed here.

Also refreshed the router + card-service docstrings, which still described a single-endpoint Phase 1 surface and called public serving a follow-up needing "a separate access-policy decision" — that decision is the per-agent exposure flag, and it shipped in this PR.

Verification: 44 tests still green; enterprise-docs-guard passes against the new prose (it greps docs/**, and §32.2 describes the seam mechanism without naming the private table); live card still serves 0.3.0. I temporarily set a2a_exposed=1 on a scratch agent to re-check the live route and reverted it to 0.

The blocking documentation item is cleared from my side. The code findings were resolved in 3cff879c. Merge ordering unchanged: #1628dev, then trinity-enterprise#161.

Worth an independent pass before merge — two of the three criticals here were fixed and then validated by the same author, and the two false-doc findings show that a fresh reader looking at a different corner keeps finding things.

dolho added a commit that referenced this pull request Jul 16, 2026
…dex row

Two nits caught re-validating my own commit:

- §32.1 used "✅ Shipped", a label the legend doesn't define. The four
  canonical labels are ⏳ Not Started / 🚧 In Progress / ✅ Implemented /
  ❌ Removed, and "Implemented" is used 179 times elsewhere; "Shipped"
  appeared exactly once, introduced by me. (#737 is closed, so ✅ itself
  is right.)

- The requirements index row for mcp.md still said "A2A discoverability",
  which no longer routes a reader to the inbound server now living in the
  same area file.

Related to #1628

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dolho

dolho commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Re-validated via /validate-pr at a5c49eb6.

Disclosure, third time: I wrote every commit under review here (3cff879c, aada0476, a5c49eb6). This pass is only reliable as a check on what I introduced — it is not an independent judgement that the fixes are right. Treat the ✅ column below as "the mechanical checks pass", not as review.

Status

Category Status Notes
Base branch dev
PR size 42 files (was 34; under the 50 threshold)
Issue link Related to trinity-enterprise#157, #160 — both resolve, P2/type-feature
Commit messages conventional, explain why
Requirements was ❌requirements/mcp.md §32.2 (ent#157) + §32.3 (ent#160); §32.1's stale Behavior/Phase-2 text corrected
Feature flow was ⚠️feature-flows/a2a-inbound-server.md, indexed; all 4 sibling links resolve
Architecture routes, a2a_exposed, 0.3.0, seam service
Migrations (Inv #3) check_alembic_parity.py run with real SHAs: PASS — both tracks updated, 0024 paired with the SQLite DDL
Invariant guards test_models_centralized (#14) 4✅ · test_186_enumeration_uniformity (#8) 29✅ · test_1560_agent_redis_key_parity 4✅ · test_alembic_revision_id_length 27✅
MCP surface (Inv #13) tools/a2a.ts + .test.ts
Security sweep no keys, tokens, real emails, public IPs, .env, credential files
Enterprise guard passes over the enlarged docs tree — §32.2 describes the seam mechanism without naming the private table
Build/config packaging no new top-level module (glob COPY), no new os.getenv()
Feature tests 44 pass

Found in this pass (both mine, both fixed in a5c49eb6)

  1. Non-canonical status label. §32.1 said ✅ Shipped — a label the legend doesn't define. The four canonical values are ⏳ Not Started / 🚧 In Progress / ✅ Implemented / ❌ Removed; ✅ Implemented is used 179× across requirements/, ✅ Shipped exactly once, introduced by me. (feat: serve A2A Agent Cards from template.yaml for external discoverability #737 is closed, so ✅ itself was correct.)
  2. Index row stale. requirements.md still routed mcp.md as "A2A discoverability", which no longer points a reader at the inbound server now in that area file.

Nothing else new. Everything in the previous comment's checklist is cleared.

Recommendation

APPROVE — with the standing caveat that this needs a reviewer who isn't me.

The mechanical position is clean: the three criticals from vybe's review are fixed and exercised live, the documentation tier the methodology asks for is present, every invariant guard passes, and the dual-track migration is verified with real SHAs. What's missing is adversarial judgement. Across three passes I've now found two false doc claims and a bad status label in my own work — which is evidence the self-review loop still catches things, and equally evidence that it keeps finding them because one author's blind spots don't move.

Specifically worth an independent look:

  • _a2a_idem_scope's principal choice (mcp_key_id or username or "anonymous") — the "anonymous" fallback is unreachable through the authenticated route today, but it's a shared-namespace bucket if a future caller ever reaches it with neither field.
  • The allow-list fail-open decision. It's deliberate, documented, and consistent with Trinity's availability bias — but it's the kind of call worth a second opinion, not a self-affirmation.

Merge ordering unchanged: #1628dev, then trinity-enterprise#161.

@github-actions

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@dolho
dolho requested a review from vybe July 16, 2026 08:26
dolho added a commit that referenced this pull request Jul 17, 2026
Unblocks the PR (#1628 was CONFLICTING) and re-bases the A2A work on current
dev before the skills-exposure feature lands on top.

Sole conflict: `tests/registry.json` — both sides appended entries at the same
array position. Resolved as a union (the a2a entry plus dev's #1600/#1649/#1615/
ssh entries); verified programmatically that the merged set is exactly
|HEAD ∪ dev| = 82 with nothing dropped and no duplicates.
@dolho

dolho commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Two additions on this branch

1. Merge conflict resolved (cdc561ff). The PR was CONFLICTING; sole conflict was tests/registry.json, where both sides appended entries at the same array position. Resolved as a union and verified programmatically that the merged set is exactly |HEAD ∪ dev| = 82 — nothing dropped, no duplicates. (The nightly bot's skip comment should clear on its next run.)

Also worth noting for whoever re-reviews: @vybe's CHANGES_REQUESTED from 07-15 was addressed by 3cff879c the following day (messageId dedup scoped per (agent, caller principal), public card rate-limited, seam de-genericised). The review state is stale rather than outstanding.

2. Configurable exposed skills (trinity-enterprise#180) — a55b439a + ef6b8b80, requested by the maintainer.

An exposed agent's card advertises every template.yaml capabilities[] tag, and this PR's well-known route is unauthenticated by design — so that full list is world-readable. This lets an operator choose what the outside is told.

It extends this PR's own seam rather than adding a module: a2a_gate gains a second provider (exposed_skills(agent) -> Optional[List[str]]), and both card surfaces go through one helper (_card_with_exposed_skills) so they can't disagree and a future third surface inherits the filter. OSS registers no provider → identity function → cards byte-identical. The config/storage/UI live in the enterprise a2a module (where exposure and the inbound allow-list already are).

It is a disclosure control, and every layer says so. message/send dispatches free-form text via execute_task, so hiding a skill hides it — it does not make it unreachable. That sentence is in requirements §32.4, the seam docstring, the filter, the endpoint and the UI copy, because the risk here isn't a bug, it's an operator reading it as an access control. A real boundary would be allowed_tools/guardrails on triggered_by="a2a" — separate work, own threat model.

Requirements §32.4 landed before the code; public docs describe the seam mechanism only, per the standing enterprise-docs rule. 17 new OSS tests (110 green across the A2A suites), 6 enterprise tests (module suite 24).

Worth a look in review: writing the tests surfaced that a malformed provider return would have failed closed and invisibly — a str iterates into single characters, matches no id, and silently empties the card, which reads as "this agent has no capabilities" rather than "the provider is broken". Malformed returns now take the same fail-open path as a raised error.

@dolho
dolho force-pushed the feature/157-a2a-inbound-server branch from ef6b8b8 to 05fa025 Compare July 17, 2026 13:30
dolho added a commit that referenced this pull request Jul 17, 2026
…ic card, degeneric the seam

Resolves the three criticals and the warnings from the #1628 review.

Criticals:

- messageId dedup was scoped per agent only, but messageId is a
  peer-controlled field that SDKs auto-generate and the spec only
  requires to be unique per client. Two callers colliding on "req-1"
  meant caller B received caller A's stored snapshot — the agent's full
  response text — and B's own task silently never ran. The scope now
  carries the caller principal, preferring mcp_key_id so agent-scoped
  keys resolving to one owner stay distinct.

- The unauthenticated well-known card route had no throttle, while each
  hit costs a DB read, a live Docker API call, and an HTTP call into the
  agent container. Since the URL is published by design, one address
  could stall the event loop and hammer the fleet. Adds a per-IP limit
  ahead of all that work, matching public.py/files.py/webhooks.py.

- services/a2a_gate.py named a private enterprise table in a comment.
  The comment now describes the mechanism, and the file joins SEAM_FILES
  so the guard covers this new seam (the #1461 class it was blind to).

Warnings:

- tasks/cancel discarded the bool from terminate_execution_on_agent and
  always reported "canceled". A queued task would drain later, run, and
  bill against a caller who believed it cancelled. Queued rows now cancel
  through the backlog CAS, terminal rows return TaskNotCancelable, and a
  failed terminate is reported honestly.

- An SSE client disconnect raises CancelledError, a BaseException that
  the generator's `except Exception` never caught, so the row stayed
  in_flight and wedged the messageId for the full 24h TTL.

- The allow-list UI and guide advertised DIDs and caller URLs while the
  code compares email-or-username, so a DID-only list denies everyone.
  Both now say email, and the guide states the gate fails open rather
  than presenting it as a hard boundary.

- The access gate had no coverage; adds cases for exposed-but-
  inaccessible, 404 indistinguishability, and the admit path.

Also: cancelled now maps to `canceled` rather than `failed`, the JSON-RPC
body is capped before parsing, stream replay returns SSE instead of JSON,
and architecture.md records the new routes, the a2a_exposed column, the
0.3.0 protocol version, and the seam service.

Every new test was verified to fail against the unfixed code.

Related to #1628

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Jul 17, 2026
…t stale claims

Closes the documentation gaps from the /validate-pr re-review.

Requirements (requirements/mcp.md): the inbound server is a new
capability — two public routes, a new column, an exposure flag, the
allow-list seam, MCP control tools — but §32 covered only the #737
authenticated card, explicitly scoped "Phase 1". Adds §32.2 (inbound
server, ent#157) and §32.3 (control over MCP, ent#160), each recording
the decisions a future reader would otherwise have to reverse-engineer:
why the dedup scope carries the caller, why the public route is limited
before its own work, why cancel reports what happened, and that the
allow-list fails open.

Feature flow: adds feature-flows/a2a-inbound-server.md and indexes it.
A vertical slice across router, seam, MCP tools, Vue panel, and a private
module is the shape feature-flows exists for (cf. mcp-connector.md).

Stale claims — two user-facing docs asserted the opposite of what this
PR ships, and docs/user-docs/** auto-publishes to a public search index:

  - faq/collaboration.md said "There is no public unauthenticated
    /.well-known route yet"
  - faq/advanced-features.md said "the public /.well-known/agent-card.json
    route is not served yet"

Both now describe exposure, the two public routes, and that tasking still
requires a key.

"A2A v1.0" is corrected to 0.3.0 in eight places, including four in
a2a_card_service.py — the file that emits "0.3.0". The router docstrings
still described a single-endpoint Phase 1 surface and called public
serving a follow-up needing "a separate access-policy decision"; that
decision is the per-agent exposure flag, and it shipped here.

Related to #1628

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Jul 17, 2026
…dex row

Two nits caught re-validating my own commit:

- §32.1 used "✅ Shipped", a label the legend doesn't define. The four
  canonical labels are ⏳ Not Started / 🚧 In Progress / ✅ Implemented /
  ❌ Removed, and "Implemented" is used 179 times elsewhere; "Shipped"
  appeared exactly once, introduced by me. (#737 is closed, so ✅ itself
  is right.)

- The requirements index row for mcp.md still said "A2A discoverability",
  which no longer routes a reader to the inbound server now living in the
  same area file.

Related to #1628

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dolho and others added 10 commits July 17, 2026 17:01
…k endpoint (ent#157)

Make an exposed Trinity agent reachable + taskable over the open A2A protocol by
an external orchestrator (Google ADK, LangChain, Bedrock, another Trinity).

OSS `a2a_exposed` primitive (edition-agnostic, default OFF):
- agent_ownership.a2a_exposed column (schema.py + tables.py + two-track
  migration: db/migrations.py + Alembic 0024) + A2AExposureMixin accessors +
  surfacing on GET /api/agents (mirrors mcp_exposed). OSS owns the column + the
  read/enforcement; the WRITE is entitlement-gated by the enterprise A2A setter
  (core-primitive + enterprise-knob, like users.suspended_at).

Public A2A server (routers/a2a.py, new a2a_server_router mounted in main.py):
- GET /a2a/{name}/.well-known/agent-card.json — unauthenticated discovery;
  uniform 404 for non-exposed/non-existent (no enumeration oracle; safe by
  default since the flag can't be set without the entitled setter).
- POST /a2a/{name} — JSON-RPC 2.0: message/send (sync bridge to
  execute_task(triggered_by="a2a")), message/stream (SSE), tasks/get,
  tasks/cancel; proper JSON-RPC error codes (-32700/-32600/-32601/-32602) +
  A2A task-not-found (-32001). Bearer = a Trinity MCP key, validated per call by
  get_current_user (fail-closed 401). Owner/shared access + per-agent inbound
  allow-list via the new services/a2a_gate seam (OSS no-op; enterprise provider,
  fail-open). Trigger-boundary idempotency on messageId (Invariant #18) so
  at-least-once re-delivery can't double-execute. Every task audit-logged
  (source="a2a"). taskId == Trinity execution_id.
- Card honesty: protocolVersion pinned "0.3.0", url → the JSON-RPC endpoint
  (not the old chat placeholder), preferredTransport=JSONRPC, Bearer scheme.

Phased (seam ready): tasks/resubscribe → -32004; message/stream is
non-incremental (the agent turn is atomic) but spec-shaped so a streaming client
attaches and receives the terminal task.

Tests: tests/unit/test_157_a2a_inbound_server.py (16) — card honesty, well-known
gating, JSON-RPC dispatch + error codes, message/send bridge (triggered_by=a2a),
tasks/get + tasks/cancel, idempotent replay, allow-list allow/deny + fail-open.
Schema + Alembic parity guards green.

Related to trinity-enterprise#157

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…160)

The MCP surface for the A2A interoperability management plane — the third
surface (Invariant #13) over the entitlement-gated enterprise backend
(trinity-enterprise#160, /api/enterprise/a2a/*). Distinct from the runtime
call_a2a_agent (#736).

New src/mcp-server/src/tools/a2a.ts (7 tools):
- get_agent_a2a_config, set_agent_a2a_exposure, get_agent_a2a_card (proxies the
  OSS #737 served-card endpoint), set_a2a_inbound_allowlist,
  register_a2a_endpoint, list_a2a_endpoints, remove_a2a_endpoint.
- Honest gating: an unentitled 403 ("not licensed") / OSS-only 404 return a
  structured { not_entitled | not_found } — never a silent success. Mutations
  are owner/admin + human-only, enforced at the backend (agent-scoped key → 403
  human_only). Outbound credentials are write-only — the backend returns only
  has_credentials, so no tool echoes a secret.

client.ts: 8 A2A methods (getA2AExposedMap swallows OSS-404/unentitled-403 → {}).
agents.ts: list_agents/get_agent best-effort merge a2a_exposed (mirrors
mcp_exposed, #846) — omitted in editions without A2A, no OSS↔enterprise coupling.
server.ts: register the tool group (connector-denied visibility, like the rest).

Tests: src/mcp-server/src/tools/a2a.test.ts (10) — proxy contract, credentials
never echoed, entitlement/human-only/404 gating. Full suite 100 pass; tsc clean.

Related to #160

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/api/agents (ent#157)

With ent#157 surfacing agent_ownership.a2a_exposed natively on GET /api/agents,
list_agents/get_agent already carry the field. Remove the belt-and-suspenders
enrichA2AExposed helper + getA2AExposedMap client method (both read the same OSS
column). Fewer round-trips, no behaviour change. MCP suite 100 pass, tsc clean.
The card generator now pins protocolVersion "0.3.0", points url at the real
JSON-RPC endpoint (/a2a/{name}) with a documentationUrl, and declares
preferredTransport=JSONRPC. Update the two #737 shape assertions
(test_card_full_template_shape, test_card_label_fallback_shape) to match —
fixes the regression-diff CI failure.
…uth, soft-delete guard

Broaden the A2A inbound-server suite (16→28) and add the OSS exposure-mixin DB
suite (11), plus registry entries:
- message/stream SSE (working → final completed task)
- in-flight messageId idempotency → retryable -32603
- tasks/get state mapping across all Trinity statuses; multipart concat; empty-text invalid
- exposed-but-stopped agent still serves the card (label fallback)
- unauthenticated JSON-RPC POST → 401 (fail-closed, before dispatch)
- A2AExposureMixin: default/set/get, per-agent isolation, missing-agent, and the
  deleted_at guard (soft-deleted agent can never be flipped/read exposed)

Backend a2a+adjacent 142 pass, enterprise 21, MCP 100.
…ot a dict (live-caught)

Live end-to-end testing surfaced a 500 on tasks/get: db.get_execution returns a
ScheduleExecution MODEL, but the handler used row.get(...) (dict API) →
AttributeError. Add _exec_field(row, name) that works for both a model
(attribute) and a dict, and use it in tasks/get + tasks/cancel. Regression test
returns an object-shaped row so CI catches this shape mismatch.

Verified live against a running instance: expose → well-known 200 (honest card),
message/send → real agent execution (state=completed), tasks/get → 200,
messageId idempotency replay (no re-exec), triggered_by="a2a" on the row,
allow-list enforcement → 403, credentials never echoed, unexpose → 404.
…ist, endpoints (ent#158)

Adds an owner-only "A2A" tab on Agent Detail (components/A2aPanel.vue), gated on
the enterprise `a2a` entitlement (sessionsStore.a2aAvailable ← enterprise_features)
so it never renders in OSS/unentitled builds — no blank tab. Enterprise Vue ships
in the OSS bundle, gated by the feature flag (standard open-core seam).

The panel:
- Expose-over-A2A toggle (default OFF) → the enterprise setter that write-throughs
  to the OSS a2a_exposed column; not-exposed shows an explainer + CTA (no dead state).
- One-click-copy public Agent Card URL (the ent#157 well-known discovery route).
- Advertised skills (read-only, from the #737 served card).
- Inbound allow-list add/remove.
- Outbound endpoint registry add/remove; credentials are write-only (password
  input, never echoed back — the API returns has_credentials only).

Store methods in stores/agents.js (getA2aConfig/setA2aExposure/updateA2aAllowlist/
registerA2aEndpoint/removeA2aEndpoint/getA2aCard); feature flag in stores/sessions.js;
tab wired through OverflowTabs so overflow + ?tab= deep-linking keep working.
No new backend endpoints — proxies the ent#157/#160 surface (all live-verified).

Related to trinity-enterprise#158

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (ent#157)

The A2A public routes (well-known Agent Card + JSON-RPC/SSE task endpoint) live
on the backend under /a2a/, but neither the prod nginx nor the vite dev proxy
forwarded /a2a/ — so hitting the card URL on the frontend origin fell through
try_files → index.html (prod) / the SPA (dev) and redirected. External A2A
orchestrators reach Trinity through this front door, not :8000 directly, so the
card was unreachable.

- nginx.conf: add `location /a2a/` proxying to trinity-backend:8000 (SSE-friendly:
  proxy_buffering off, mirrors /api/).
- vite.config.js: add `/a2a/` to the dev proxy. changeOrigin:false preserves the
  browser Host so the backend renders the card's `url`/documentationUrl against the
  real front-door origin (dev has no PUBLIC_CHAT_URL; prod nginx uses `Host $host`).

Verified live on :8001: card served (200 JSON, no redirect) with a self-consistent
url (http://localhost:8001/a2a/new_cool_agent).
…enshots (ent#157/#158/#160)

Rewrite the stale #737 A2A doc into a full how-to for the shipped feature:
- Enable exposure via the new A2A tab (with screenshots) or the MCP tools.
- Consume an exposed agent as an external orchestrator: discover the well-known
  card → authenticate with a Trinity MCP key → task over JSON-RPC (message/send,
  message/stream SSE, tasks/get, tasks/cancel), using the real live-verified curl flow.
- Inbound allow-list, outbound endpoint registry, security/behavior notes, and a
  reference (public routes, JSON-RPC methods, error codes).

Screenshots (headless-captured against the live UI): the full A2A config panel and
the Agent Card URL section. Describes only the generic A2A capability + entitlement
seam — no enterprise schema/module internals (enterprise-docs guard clean).

Related to trinity-enterprise#158

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ic card, degeneric the seam

Resolves the three criticals and the warnings from the #1628 review.

Criticals:

- messageId dedup was scoped per agent only, but messageId is a
  peer-controlled field that SDKs auto-generate and the spec only
  requires to be unique per client. Two callers colliding on "req-1"
  meant caller B received caller A's stored snapshot — the agent's full
  response text — and B's own task silently never ran. The scope now
  carries the caller principal, preferring mcp_key_id so agent-scoped
  keys resolving to one owner stay distinct.

- The unauthenticated well-known card route had no throttle, while each
  hit costs a DB read, a live Docker API call, and an HTTP call into the
  agent container. Since the URL is published by design, one address
  could stall the event loop and hammer the fleet. Adds a per-IP limit
  ahead of all that work, matching public.py/files.py/webhooks.py.

- services/a2a_gate.py named a private enterprise table in a comment.
  The comment now describes the mechanism, and the file joins SEAM_FILES
  so the guard covers this new seam (the #1461 class it was blind to).

Warnings:

- tasks/cancel discarded the bool from terminate_execution_on_agent and
  always reported "canceled". A queued task would drain later, run, and
  bill against a caller who believed it cancelled. Queued rows now cancel
  through the backlog CAS, terminal rows return TaskNotCancelable, and a
  failed terminate is reported honestly.

- An SSE client disconnect raises CancelledError, a BaseException that
  the generator's `except Exception` never caught, so the row stayed
  in_flight and wedged the messageId for the full 24h TTL.

- The allow-list UI and guide advertised DIDs and caller URLs while the
  code compares email-or-username, so a DID-only list denies everyone.
  Both now say email, and the guide states the gate fails open rather
  than presenting it as a hard boundary.

- The access gate had no coverage; adds cases for exposed-but-
  inaccessible, 404 indistinguishability, and the admit path.

Also: cancelled now maps to `canceled` rather than `failed`, the JSON-RPC
body is capped before parsing, stream replay returns SSE instead of JSON,
and architecture.md records the new routes, the a2a_exposed column, the
0.3.0 protocol version, and the seam service.

Every new test was verified to fail against the unfixed code.

Related to #1628

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dolho and others added 5 commits July 17, 2026 17:01
…t stale claims

Closes the documentation gaps from the /validate-pr re-review.

Requirements (requirements/mcp.md): the inbound server is a new
capability — two public routes, a new column, an exposure flag, the
allow-list seam, MCP control tools — but §32 covered only the #737
authenticated card, explicitly scoped "Phase 1". Adds §32.2 (inbound
server, ent#157) and §32.3 (control over MCP, ent#160), each recording
the decisions a future reader would otherwise have to reverse-engineer:
why the dedup scope carries the caller, why the public route is limited
before its own work, why cancel reports what happened, and that the
allow-list fails open.

Feature flow: adds feature-flows/a2a-inbound-server.md and indexes it.
A vertical slice across router, seam, MCP tools, Vue panel, and a private
module is the shape feature-flows exists for (cf. mcp-connector.md).

Stale claims — two user-facing docs asserted the opposite of what this
PR ships, and docs/user-docs/** auto-publishes to a public search index:

  - faq/collaboration.md said "There is no public unauthenticated
    /.well-known route yet"
  - faq/advanced-features.md said "the public /.well-known/agent-card.json
    route is not served yet"

Both now describe exposure, the two public routes, and that tasking still
requires a key.

"A2A v1.0" is corrected to 0.3.0 in eight places, including four in
a2a_card_service.py — the file that emits "0.3.0". The router docstrings
still described a single-endpoint Phase 1 surface and called public
serving a follow-up needing "a separate access-policy decision"; that
decision is the per-agent exposure flag, and it shipped here.

Related to #1628

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dex row

Two nits caught re-validating my own commit:

- §32.1 used "✅ Shipped", a label the legend doesn't define. The four
  canonical labels are ⏳ Not Started / 🚧 In Progress / ✅ Implemented /
  ❌ Removed, and "Implemented" is used 179 times elsewhere; "Shipped"
  appeared exactly once, introduced by me. (#737 is closed, so ✅ itself
  is right.)

- The requirements index row for mcp.md still said "A2A discoverability",
  which no longer routes a reader to the inbound server now living in the
  same area file.

Related to #1628

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent#180)

An exposed agent's card advertises every `template.yaml capabilities[]` tag, and
the well-known discovery route is unauthenticated by design — so today that full
capability list is world-readable for any exposed agent. This lets an operator
choose what the outside is told.

**A disclosure control, and the code says so.** The card's `skills[]` is
advertisement: inbound `message/send` dispatches free-form text via
`execute_task(triggered_by="a2a")`, with no per-skill routing. Filtering changes
what an orchestrator SEES, never what it may ASK for. That's stated in the
requirement, the seam docstring and the filter itself, because a filter
operators mistake for an invocation gate is a control that looks like security
and isn't. A real boundary (constraining a2a-triggered runs via
allowed_tools/guardrails) is separate work with its own threat model.

Extends the existing ent#157 seam rather than adding a module — same shape as
the inbound allow-list, second provider:

    provider.exposed_skills(agent_name) -> Optional[List[str]]

- No provider (OSS) → identity function; the card is byte-identical to before,
  by construction. The enterprise module owns the config, storage and UI.
- `None` = no opinion = advertise all: the unconfigured default, so exposure
  (already opt-in, default OFF) keeps every existing card unchanged on upgrade.
- `[]` ≠ `None`: an explicit "advertise nothing".
- Stale ids are inert — the selection only subtracts; `template.yaml` stays the
  source of truth for what exists.
- Fail-open on provider error (advertise all + WARNING): consistent with the
  seam's availability bias and the advertise-all default. Honest only *because*
  this isn't a security boundary — failing closed would silently empty a card
  and break discovery invisibly.

Both card surfaces (public well-known + authenticated per-agent) go through one
router helper, so they can't disagree and a future third surface gets the filter
by default rather than by remembering. `generate_a2a_card` stays pure — the
provider lookup lives in the helper.

Writing the tests found a real gap: a provider returning a str (a defect) would
iterate into single characters, match no id, and silently empty the card —
fail-CLOSED, the opposite of the contract, and invisible. Malformed returns now
take the same fail-open path as a raised error.

Requirements §32.4 written before the code (CLAUDE.md rule #1); public docs
describe the generic seam only, per the standing enterprise-docs rule.

Related to trinity-enterprise#180
The UI half. The panel showed the advertised skills read-only; it now lets an
owner choose them (the panel is already entitlement-gated as a whole, so no
extra gating here).

- Checkbox list over the agent's FULL capability set with the current selection
  applied; Save / Cancel / "Reset to all".
- The selectable list comes from `/api/agents/{name}/info`, NOT the card: the
  card now returns only the curated subset (that's the point), so it can no
  longer tell the UI what is available to pick.
- Ticking everything stores `null`, not the full list — "no opinion" keeps the
  agent advertising whatever its template declares as the template evolves,
  instead of freezing today's tags into a selection that silently goes stale on
  the next repull. Un-ticking everything stores `[]` (advertise nothing), which
  the read view names explicitly so it can't be confused with "no capabilities".
- The copy says what the control does: hiding a skill stops it being
  *advertised*, not asked for.

Verified by compiling the SFC (script + template) and asserting every new
binding is exposed. `npm run build` fails on this machine for an unrelated,
pre-existing reason — `mermaid` is declared in package.json but not installed
locally, and AgentWorkspace.vue fails to resolve it identically with my changes
stashed.

Public feature-flow documents the seam mechanism only, per the standing
enterprise-docs rule.

Related to trinity-enterprise#180
…ead (ent#157)

The rebase surfaced a collision the SQLite track absorbed silently and the
PostgreSQL track could not: #1666 landed `0024_agent_ownership_volume_base_name`
on dev while this branch carried `0024_agent_ownership_a2a_exposed`, both
chained to `0023`. Two revisions, one parent — a branched history, so
`alembic upgrade head` has multiple heads and the pg-migrations job fails at the
real PG boot path.

The two tracks fail differently, which is worth remembering: `db/migrations.py`
is a LIST, so its conflict was a union and both entries just run. Alembic is a
LINKED LIST, so the same "both sides added a migration" merges cleanly at the
text level and breaks at runtime. A green SQLite parity check says nothing about
the chain.

Renamed to `0025_agent_ownership_a2a_exposed` with
`down_revision = 0024_agent_ownership_volume_base_name`. Verified by walking the
graph: 26 revisions, exactly one head, no parent with more than one child, no
dangling down_revision, and an unbroken chain head→`0001_baseline`.

Related to trinity-enterprise#157
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants