A2A capability cards and lineage interop¶
This page covers the cross-organisation A2A surface: signed capability cards that one orchestrator publishes so a peer can decide whether to delegate to it, and the lineage chain wrapper that carries a signed Bernstein lineage chain through the A2A evidence envelope so a delegated run stays auditable across an organisational boundary.
If you only have time for one paragraph: a capability card is a JCS-canonical JSON body (RFC 8785) signed as a detached JWS (RFC 7515 A.5) with an Ed25519 key (RFC 8037 / EdDSA). It declares the issuer's identity, advertised tools, enforced policies (cost cap, redaction tier, sandbox profile), the public key that verifies it, and an expiry. A consumer fetches the card, verifies the signature against a trusted-issuer set, and proceeds only if the advertised policies meet its required policies. The lineage wrapper reuses the existing core/lineage/tracker_audit.py HMAC chain.
This is distinct from the A2A v1.0 agent card served at /.well-known/agent.json (see A2A v1.0 signed agent cards). The agent card describes wire formats and auth schemes for protocol discovery; the capability card described here is a delegation-trust manifest.
Capability card fields¶
| Field | Meaning |
|---|---|
schema_version | Card schema version (currently "1"). |
issuer | Stable issuer id (organisation / orchestrator). |
name, description | Human-readable issuer identity. |
advertised_tools[] | Tool names the issuer exposes for delegation. |
policies.cost_cap_usd | Maximum spend the issuer accepts per sub-task. |
policies.redaction_tier | Redaction tier applied before artefacts leave the boundary. |
policies.sandbox_profile | Sandbox profile delegated work runs under. |
public_key_pem | SPKI PEM Ed25519 public key that verifies the signature. |
kid | Key identifier carried in the JWS protected header. |
created_at, expires_at | Issue and expiry timestamps (Unix seconds). |
The signature is a detached compact JWS (header..signature, empty payload) over the JCS canonicalisation of the body, with the protected-header typ set to a2a-capability+jws so it can never be confused with the agent-card+jws identity-card context.
Issue side¶
Generate a signed capability card for the local orchestrator:
bernstein interop a2a card \
--issuer acme \
--name "Acme Orchestrator" \
--tool task_orchestration \
--tool code_review \
--cost-cap-usd 10 \
--redaction-tier standard \
--sandbox-profile container \
--ttl-seconds 86400 \
--output card.json
When no --private-key is supplied a fresh Ed25519 keypair is minted and the private key is written to card.json.key.pem with 0600 permissions so the operator can re-issue without changing identity. Pass --private-key card.json.key.pem on a later run to keep the same key fingerprint.
The command prints the card's key fingerprint (sha256:...). Share that fingerprint with peers out of band so they can pin you in their trusted-issuer set.
Consume side¶
Before delegating a sub-task to a peer, Bernstein:
- fetches the peer's capability card;
- verifies the detached JWS and rejects expired cards;
- confirms the card's signing-key fingerprint is in the operator's trusted-issuer set;
- proceeds only if the card's advertised policies meet the operator's required policies.
Programmatically:
from bernstein.core.interop import (
SignedCapabilityCard,
PolicyRequirements,
consume_peer_card,
)
from bernstein.core.interop.a2a_consume import PeerCardRejected
signed = SignedCapabilityCard.from_json(peer_card_json)
requirements = PolicyRequirements(
max_cost_cap_usd=10.0,
min_redaction_tier="standard",
min_sandbox_profile="container",
)
try:
consume_peer_card(
signed,
trusted_issuer_fingerprints={"sha256:..."},
requirements=requirements,
)
except PeerCardRejected as exc:
# exc.reason is one of: signature, untrusted_issuer, policy
raise
The policy gate is conservative and fails closed:
| Policy | Rule |
|---|---|
| Cost cap | Peer's advertised cap must be at or below the operator ceiling. |
| Redaction tier | Peer's tier must rank at or above the required tier. |
| Sandbox profile | Peer's profile must rank at or above the required profile. |
Tier and profile ordering (weakest to strongest):
- Redaction:
none < basic < standard < strict - Sandbox:
none < process < container < microvm
An unranked custom value on either side fails the gate.
CLI verify¶
Confirm a peer card is valid (signature plus, optionally, trust and policy):
# signature + expiry only
bernstein interop a2a verify --card peer-card.json
# also require the key be trusted and policies meet requirements
bernstein interop a2a verify \
--card peer-card.json \
--trusted-fingerprint sha256:... \
--require-cost-cap-usd 10 \
--require-redaction-tier standard \
--require-sandbox-profile container
The command exits 0 when the card passes every requested check and 1 otherwise, printing the specific reasons it failed. Add --json at the top level (bernstein --json interop a2a verify ...) for machine-readable output.
Lineage chain interop¶
When a Bernstein run delegates work to a peer over A2A, the run's signed lineage chain travels inside the A2A evidence envelope under the bernstein.lineage_v2 field. The receiving side appends the delegated work to its own chain with a cross-org boundary marker, so an auditor can see exactly where one organisation's chain hands off to another.
The wrapper reuses the existing HMAC chain in core/lineage/tracker_audit.py. No new signing primitive is introduced: the boundary entry the receiver records is a normal signed tracker-audit entry (tracker_name = "a2a-cross-org-boundary") and verifies under the receiver's operator HMAC key via the existing bernstein lineage tracker-audit verify.
Envelope payload shape (the value of bernstein.lineage_v2):
{
"schema_version": 1,
"source_issuer": "acme",
"chain_digest": "sha256:...",
"entries": [ { "...tracker-audit entry..." } ]
}
chain_digest binds the carried entries and their order. The receiver recomputes the digest on parse and rejects an envelope whose digest does not match, so the chain cannot be tampered with in transit. The boundary marker the receiver appends records source_issuer and source_chain_digest, so the receiver's own chain is cryptographically bound to the exact source chain it received.
Programmatically:
from bernstein.core.interop import (
wrap_lineage_chain,
LineageEnvelope,
append_cross_org_segment,
)
from bernstein.core.lineage.tracker_audit import TrackerActor, TrackerAuditLog
# Sender: wrap the local chain into an envelope field.
envelope = wrap_lineage_chain(sender_log, source_issuer="acme")
payload = envelope.to_envelope_field() # splice into the A2A envelope
# Receiver: extract, then append a boundary marker to its own chain.
envelope = LineageEnvelope.from_envelope_field(received_payload)
actor = TrackerActor(session_id="r1", role="reviewer", model="...")
append_cross_org_segment(receiver_log, envelope, actor=actor, ticket_id="RECV-9")
Trust model and limits¶
- The card carries its own public key, but carrying the key is not trust. A verifier must independently confirm the key fingerprint is in the operator's trusted-issuer set, which
consume_peer_cardenforces. - Expiry is enforced at the verifier. A stale card is rejected even when its signature is otherwise valid, so a card cannot be replayed past its window.
- Fingerprints are computed over the raw Ed25519 public-key bytes, so they are stable across PEM whitespace differences.
- This surface does not replace the intra-org tracker handoff bus, and it does not bridge between MCP, ACP, and A2A.