o2r: implement gen_ai.authorization.* trust-boundary attributes + A2A->MCP span link (impl of #35) #42

Closed
opened 2026-06-02 20:23:28 +00:00 by coilysiren · 4 comments
Owner

Implements the #35 design (docs/trust-boundary-semconv.md). #35 settles what the trust-boundary semantic conventions are. This issue is making the relay emit them. Gated by #35 (attribute names must settle first), unblocked-upstream by #40 (the novelty question, answered).

Observability only. Enforcement is unchanged - an agent still refuses an unauthorized request exactly as it does today. The trace now records that it did.

Architecture constraint (load-bearing)

The relay is public and must stay droppable between any two A2A agents (protocol.md). Two hard rules follow:

  • No coily in the relay. Per the repo dependency architecture, coily must not be aware of o2r at all. The relay does not shell out to coily channel verify, and the envelope schema / sign / verify logic does not live in coily. (The reverse-direction cleanup - purging coily channel from o2r docs and checking coily's repo for o2r-aware verbs - is tracked in the doc-purge issue, see Dependencies.)
  • Verification is in-process and self-contained. The relay's only dependency for verifying an authorization envelope is o2r's own code plus configured key material. No external tool, no network on the per-request hot path.

Where the envelope lives and how it is verified

  • Schema + sign + verify lib lives in o2r's own channels/ package (or core, shipped via otel-a2a-relay-core). Single-sourced. Both the relay (verify) and the issuer (sign) import the same lib.
  • Delivery is in-band. The JWS-signed envelope rides as a compact-serialization string in the A2A request metadata bag (A2A's open key-value field). No new transport, no new protocol on relay ingress - the envelope arrives inside the A2A message that already opens the session.
  • Verification is stateless JWS. RFC 7515 signature over a JCS-canonicalized payload (RFC 8785), verified against a configured trust root. This is the same primitive A2A uses for signed AgentCards, so we reuse it rather than invent.
  • Trust root = configured JWKS. Static file / mounted secret (zero network), or a cached .well-known/jwks.json fetch. Never on the per-request path.
  • Optional forge provenance. The envelope MAY also be persisted to a forge so gen_ai.authorization.envelope_ref resolves to a human-auditable copy. Verification never depends on that fetch - the forge copy is provenance for humans, not part of the trust check.

Pluggable resolver

Define a TrustRootResolver interface with one method: verify-and-extract -> the gen_ai.authorization.* fields.

  • Default backend: signed-envelope (above). Lightest thing that keeps the relay droppable and public-safe.
  • Optional backend: MCP. Verification as an MCP tool call, for deployments needing live verification (revocation, org-membership lookups a static signature cannot express). Code against the MCP protocol + a config-supplied endpoint, never against any private launcher.
  • Optional backend: CRD / kubectl. For k8s-resident deployments wanting RBAC + ServiceAccount per-pod identity as the trust root. Behind the interface, never the default.

Scope

  • Protocol doc + attribute registry. Add gen_ai.authorization.action.class, .action.destructive, .acknowledged_by, .envelope_ref, .boundary to the registry block in docs/protocol.md. Bump v0.4 -> v0.5. Regenerate docs/generated/* via scripts/emit_protocol_artifacts.py.
  • Envelope lib in channels/ (or core). Schema + JWS sign + JWS verify. The relay imports verify, tests import sign.
  • Relay emits at A2A ingress. Read the verified envelope (issuer, intended_role, action_class, destructive, acknowledgment) from request metadata, write the gen_ai.authorization.* attributes onto the a2a.task root span with boundary=agent_to_agent. Non-destructive actions leave acknowledged_by empty. A request that fails verification produces the existing a2a.relay.reject span, not an authorized task span. Decide whether reject spans should also carry gen_ai.authorization.* for forensic value.
  • Cross-boundary span link. Emit a span link from the MCP tool-call span back to the authorizing a2a.task span, carrying envelope_ref and boundary=agent_to_tool. The load-bearing part of the #35 contribution.

The hard part - the relay does not see MCP traffic

The relay sits on the A2A wire. MCP tool-call spans are emitted by the agent process's own OpenInference instrumentation, not by the relay. So the relay cannot attach the back-link itself. The authorization context has to propagate into the agent process so its MCP spans can carry envelope_ref and link back to the task span.

Candidate mechanisms (central design question, pick during impl):

  • Extend the existing using_session(context_id) propagation with the authorization envelope ref, so any OpenInference-instrumented MCP call inside the session inherits it.
  • W3C baggage carrying envelope_ref, set at relay ingress, read by agent-side MCP instrumentation.
  • A relay-side tracing helper the agent calls to open an "authorized action" scope that stamps its child MCP spans.

Whichever wins likely touches the OpenInference / MCP instrumentation seam, not just the relay. Call that out before writing code.

Sequencing gate (repo rule, do not skip)

Per AGENTS.md, protocol-shape changes go doc-first.

  1. Update docs/protocol.md + the harness together.
  2. Re-run the harness against a real Phoenix (make phoenix-up). Confirm via the GraphQL spans query that gen_ai.authorization.* appear where the spec calls for them, and that the cross-boundary span link is exposed. Risk: Phoenix historically drops span links (v0.1 moved topology to graph.node.* for exactly this reason). If links are still dropped, the MCP-boundary correlation needs a graph.node.*-style attribute fallback. Resolve this in step 2 before committing to the design.
  3. Only then: relay code changes.

Out of scope

  • Enforcement / authorization logic changes. These attributes describe existing enforcement, they do not add any.
  • The issuer CLI. The channels lib provides sign (tests use it inline). A standalone o2r-owned issuer CLI is operator surface and can be a thin follow-up - file separately if wanted.
  • The #2664 upstream comment. That is #35's deliverable form and is on hold (not posting to the OTel org at this time).
  • The Memory concept. o2r does not model it.
  • Blocked by #35 (design - attribute names must settle).
  • Blocked-ish by the doc-purge issue (coily references in protocol.md / agent-channel-requests.md / AGENTS.md should be gone before this lands, so the impl is not documented against a tool that must not exist here).
  • Upstream finding: #40 (answered).
  • Feeds #38 (landscape map - the two coverage cells this makes real).
**Implements the #35 design** (`docs/trust-boundary-semconv.md`). #35 settles *what* the trust-boundary semantic conventions are. This issue is *making the relay emit them*. Gated by #35 (attribute names must settle first), unblocked-upstream by #40 (the novelty question, answered). Observability only. Enforcement is unchanged - an agent still refuses an unauthorized request exactly as it does today. The trace now records that it did. ## Architecture constraint (load-bearing) The relay is public and must stay droppable between any two A2A agents (`protocol.md`). Two hard rules follow: - **No coily in the relay.** Per the repo dependency architecture, coily must not be aware of o2r at all. The relay does not shell out to `coily channel verify`, and the envelope schema / sign / verify logic does not live in coily. (The reverse-direction cleanup - purging `coily channel` from o2r docs and checking coily's repo for o2r-aware verbs - is tracked in the doc-purge issue, see Dependencies.) - **Verification is in-process and self-contained.** The relay's only dependency for verifying an authorization envelope is o2r's own code plus configured key material. No external tool, no network on the per-request hot path. ## Where the envelope lives and how it is verified - **Schema + sign + verify lib lives in o2r's own `channels/` package** (or `core`, shipped via `otel-a2a-relay-core`). Single-sourced. Both the relay (verify) and the issuer (sign) import the same lib. - **Delivery is in-band.** The JWS-signed envelope rides as a compact-serialization string in the A2A request `metadata` bag (A2A's open key-value field). No new transport, no new protocol on relay ingress - the envelope arrives inside the A2A message that already opens the session. - **Verification is stateless JWS.** RFC 7515 signature over a JCS-canonicalized payload (RFC 8785), verified against a configured trust root. This is the same primitive A2A uses for signed AgentCards, so we reuse it rather than invent. - **Trust root = configured JWKS.** Static file / mounted secret (zero network), or a cached `.well-known/jwks.json` fetch. Never on the per-request path. - **Optional forge provenance.** The envelope MAY also be persisted to a forge so `gen_ai.authorization.envelope_ref` resolves to a human-auditable copy. Verification never depends on that fetch - the forge copy is provenance for humans, not part of the trust check. ## Pluggable resolver Define a `TrustRootResolver` interface with one method: verify-and-extract -> the `gen_ai.authorization.*` fields. - **Default backend: signed-envelope (above).** Lightest thing that keeps the relay droppable and public-safe. - **Optional backend: MCP.** Verification as an MCP tool call, for deployments needing live verification (revocation, org-membership lookups a static signature cannot express). Code against the MCP protocol + a config-supplied endpoint, never against any private launcher. - **Optional backend: CRD / kubectl.** For k8s-resident deployments wanting RBAC + ServiceAccount per-pod identity as the trust root. Behind the interface, never the default. ## Scope - **Protocol doc + attribute registry.** Add `gen_ai.authorization.action.class`, `.action.destructive`, `.acknowledged_by`, `.envelope_ref`, `.boundary` to the registry block in `docs/protocol.md`. Bump v0.4 -> v0.5. Regenerate `docs/generated/*` via `scripts/emit_protocol_artifacts.py`. - **Envelope lib in `channels/` (or core).** Schema + JWS sign + JWS verify. The relay imports verify, tests import sign. - **Relay emits at A2A ingress.** Read the verified envelope (issuer, intended_role, action_class, destructive, acknowledgment) from request `metadata`, write the `gen_ai.authorization.*` attributes onto the `a2a.task` root span with `boundary=agent_to_agent`. Non-destructive actions leave `acknowledged_by` empty. A request that fails verification produces the existing `a2a.relay.reject` span, not an authorized task span. Decide whether reject spans should also carry `gen_ai.authorization.*` for forensic value. - **Cross-boundary span link.** Emit a span link from the MCP tool-call span back to the authorizing `a2a.task` span, carrying `envelope_ref` and `boundary=agent_to_tool`. The load-bearing part of the #35 contribution. ## The hard part - the relay does not see MCP traffic The relay sits on the A2A wire. MCP tool-call spans are emitted by the **agent process's own OpenInference instrumentation**, not by the relay. So the relay cannot attach the back-link itself. The authorization context has to **propagate into the agent process** so its MCP spans can carry `envelope_ref` and link back to the task span. Candidate mechanisms (central design question, pick during impl): - Extend the existing `using_session(context_id)` propagation with the authorization envelope ref, so any OpenInference-instrumented MCP call inside the session inherits it. - W3C baggage carrying `envelope_ref`, set at relay ingress, read by agent-side MCP instrumentation. - A relay-side `tracing` helper the agent calls to open an "authorized action" scope that stamps its child MCP spans. Whichever wins likely touches the OpenInference / MCP instrumentation seam, not just the relay. Call that out before writing code. ## Sequencing gate (repo rule, do not skip) Per `AGENTS.md`, protocol-shape changes go doc-first. 1. Update `docs/protocol.md` + the harness together. 2. Re-run the harness against a real Phoenix (`make phoenix-up`). Confirm via the GraphQL spans query that `gen_ai.authorization.*` appear where the spec calls for them, and that the cross-boundary span link is exposed. **Risk:** Phoenix historically drops span links (v0.1 moved topology to `graph.node.*` for exactly this reason). If links are still dropped, the MCP-boundary correlation needs a `graph.node.*`-style attribute fallback. Resolve this in step 2 before committing to the design. 3. Only then: relay code changes. ## Out of scope - Enforcement / authorization logic changes. These attributes describe existing enforcement, they do not add any. - The issuer CLI. The `channels` lib provides sign (tests use it inline). A standalone o2r-owned issuer CLI is operator surface and can be a thin follow-up - file separately if wanted. - The #2664 upstream comment. That is #35's deliverable form and is on hold (not posting to the OTel org at this time). - The Memory concept. o2r does not model it. ## Dependencies and cross-links - Blocked by #35 (design - attribute names must settle). - Blocked-ish by the doc-purge issue (coily references in `protocol.md` / `agent-channel-requests.md` / `AGENTS.md` should be gone before this lands, so the impl is not documented against a tool that must not exist here). - Upstream finding: #40 (answered). - Feeds #38 (landscape map - the two coverage cells this makes real).
Author
Owner

The doc-purge issue referenced above is #43. That should land (or at least the o2r-side doc sweep) before this impl, so #42 is not written against coily channel, which must not exist in this repo.

The doc-purge issue referenced above is **#43**. That should land (or at least the o2r-side doc sweep) before this impl, so #42 is not written against `coily channel`, which must not exist in this repo.
Author
Owner

Decision record - rejected alternatives (so they are not re-litigated)

The body states the chosen design. Recording the paths considered and why they lost, since that reasoning otherwise only lived in the design conversation.

  • Verification transport: no new wire protocol. Considered an out-of-band forge fetch as the default (the request carries a URL, the relay GETs the envelope, then verifies). Rejected as default because it adds a fetch dependency on the ingress hot path. Chosen: the JWS-signed envelope rides in-band in the A2A request metadata bag, verification is a local stateless call. Forge persistence stays optional, for human-auditable envelope_ref provenance only - verification never depends on it.

  • Default backend: signed-envelope (JWS), not CRD, not MCP.

    • CRD-as-default rejected: it forfeits the relay's droppable-anywhere promise (no API server on a workstation, the local A/B dogfood and examples/luca-flow have nowhere to put a CR), and a human typing a verbatim destructive-action acknowledgment is an attestation event, not declarative desired state - an impedance mismatch with CRs. Kept as an optional in-cluster backend behind the resolver interface.
    • MCP-as-default rejected: a tool round-trip on the ingress hot path plus a new unreachable-verifier failure mode, and the "via mcporter" framing risks swapping a coily dependency for an mcporter one. Kept as an optional backend (code against the MCP protocol + a config endpoint, never a private launcher) for deployments needing live verification (revocation, org-membership lookups a static signature cannot express).
  • coily fully out, no issuance carve-out. An earlier draft kept issuance/signing in coily as operator surface. Rejected: the dependency architecture forbids coily being aware of o2r at all. The envelope schema + sign + verify lib lives in o2r's own channels/core package, single-sourced; issuance is an o2r-owned CLI. coily is never imported by the relay and never references o2r. (Reverse-direction cleanup: coilyco-bridge/coily#172; o2r-side doc purge: #43.)

  • Reuse A2A's own JWS primitive, do not invent. Verification uses RFC 7515 over JCS-canonicalized JSON (RFC 8785) - the same mechanism A2A uses for signed AgentCards. Per the #40 finding, the contribution is the observability of the trust boundary, not the signing. Inventing a bespoke signature scheme here would be novel in the one place it should not be.

## Decision record - rejected alternatives (so they are not re-litigated) The body states the chosen design. Recording the paths considered and why they lost, since that reasoning otherwise only lived in the design conversation. - **Verification transport: no new wire protocol.** Considered an out-of-band forge fetch as the default (the request carries a URL, the relay GETs the envelope, then verifies). Rejected as default because it adds a fetch dependency on the ingress hot path. Chosen: the JWS-signed envelope rides **in-band** in the A2A request `metadata` bag, verification is a local stateless call. Forge persistence stays optional, for human-auditable `envelope_ref` provenance only - verification never depends on it. - **Default backend: signed-envelope (JWS), not CRD, not MCP.** - CRD-as-default rejected: it forfeits the relay's droppable-anywhere promise (no API server on a workstation, the local A/B dogfood and `examples/luca-flow` have nowhere to put a CR), and a human typing a verbatim destructive-action acknowledgment is an attestation event, not declarative desired state - an impedance mismatch with CRs. Kept as an optional in-cluster backend behind the resolver interface. - MCP-as-default rejected: a tool round-trip on the ingress hot path plus a new unreachable-verifier failure mode, and the "via mcporter" framing risks swapping a coily dependency for an mcporter one. Kept as an optional backend (code against the MCP protocol + a config endpoint, never a private launcher) for deployments needing live verification (revocation, org-membership lookups a static signature cannot express). - **coily fully out, no issuance carve-out.** An earlier draft kept issuance/signing in coily as operator surface. Rejected: the dependency architecture forbids coily being aware of o2r at all. The envelope schema + sign + verify lib lives in o2r's own `channels`/core package, single-sourced; issuance is an o2r-owned CLI. coily is never imported by the relay and never references o2r. (Reverse-direction cleanup: coilyco-bridge/coily#172; o2r-side doc purge: #43.) - **Reuse A2A's own JWS primitive, do not invent.** Verification uses RFC 7515 over JCS-canonicalized JSON (RFC 8785) - the same mechanism A2A uses for signed AgentCards. Per the #40 finding, the contribution is the observability of the trust boundary, not the signing. Inventing a bespoke signature scheme here would be novel in the one place it should not be.
Author
Owner

The o2r-owned CLI is filed as #44 (standalone Go repo, JSON Schema contract). It refines this issue: the envelope "schema + sign + verify lib" here splits into - schema as a published artifact (shared), Python verify in the relay (this issue), Go sign + verify in the #44 CLI repo. Fold that split in when this is implemented.

The o2r-owned CLI is filed as **#44** (standalone Go repo, JSON Schema contract). It refines this issue: the envelope "schema + sign + verify lib" here splits into - schema as a published artifact (shared), Python **verify** in the relay (this issue), Go **sign + verify** in the #44 CLI repo. Fold that split in when this is implemented.
Author
Owner

Backlog burndown 2026-06-17: closing low-priority (P3/P4) to bring the open count to a manageable level. Nothing lost — reopen if this resurfaces. Batch tag: burndown-2026-06.

Backlog burndown 2026-06-17: closing low-priority (P3/P4) to bring the open count to a manageable level. Nothing lost — reopen if this resurfaces. Batch tag: `burndown-2026-06`.
coilysiren 2026-06-17 08:23:48 +00:00
Commenting is not possible because the repository is archived.
No description provided.