feat(a2a): federate Ratify proofs and revocations - #318
Conversation
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Warning Review limit reached
Next review available in: 11 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (25)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 30d1899784
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
|
|
||
| const relayMessage = a2aEngine.translateA2aToRelay(request); | ||
| const sent = await dmEngine.sendDm(db, workspace.id, authenticatedAgent!.id, { |
There was a problem hiding this comment.
Deduplicate retried inbound A2A messages
When the same message/send is retried—for example, sendToExternalAgent automatically retries a 5xx—this path always calls sendDm, which generates a fresh Relaycast message ID without checking the JSON-RPC id or message_id. A transient failure in the counter or event work after the durable DM write therefore returns 5xx and causes the retry to deliver the same proof or task multiple times; record an inbound idempotency key before writing.
Useful? React with 👍 / 👎.
| }, { | ||
| skipA2aIntercept: true, | ||
| }); |
There was a problem hiding this comment.
Apply configured mailbox limits to inbound A2A DMs
When the engine or workspace config overrides mailbox.deliveryTtlMs or mailbox.depthCap, this call passes only skipA2aIntercept, so sendDm silently uses its fixed one-hour/1000-message defaults. Registered-peer A2A deliveries can consequently remain queued beyond the configured TTL or bypass the configured depth backpressure; resolve and pass the mailbox config as the normal /v1/dm route does.
Useful? React with 👍 / 👎.
| opts?: (IdempotencyOption & { | ||
| mode?: 'wait' | 'steer'; | ||
| attachments?: string[]; | ||
| data?: Record<string, unknown> | null; | ||
| }), | ||
| ): Promise<SendDmResponse> { | ||
| const body: SendDmRequest = { | ||
| to: agent, | ||
| text, | ||
| ...(opts?.attachments ? { attachments: opts.attachments } : {}), | ||
| ...(opts?.data !== undefined ? { data: opts.data } : {}), |
There was a problem hiding this comment.
🔴 Structured message data sent or read through the TypeScript SDK has its inner field names silently rewritten
Structured DM data is passed through the SDK's automatic field-name translation (data on the DM call at packages/sdk-typescript/src/agent.ts:574) instead of being passed through untouched, so the inner field names of a proof or revocation payload are renamed and the receiver can no longer read or verify it.
Impact: Ratify proofs and signed revocation lists exchanged through the TypeScript SDK arrive with mangled field names and fail verification.
Casing transform does not treat `data`/`metadata` values as opaque user JSON
packages/sdk-typescript/src/casing.ts:39-47 defines VERBATIM_VALUE_KEYS (headers, input, output, input_schema, output_schema, …) whose values are passed through verbatim precisely because their keys are user-authored data. data and metadata are not in that set.
- Outbound:
packages/sdk-typescript/src/client.ts:280runsdecamelizeKeys(body)over the whole DM body, recursing intodata. - Inbound:
packages/sdk-typescript/src/client.ts:349runscamelizeKeys(parsedData)over the whole response, recursing into the newmetadatareturned bysendDm(packages/engine/src/engine/dm.ts:434,packages/engine/src/engine/dm.ts:626). WebSocketdm.receivedpayloads are camelized the same way (packages/sdk-typescript/src/ws.ts:262).
So a receiver reading a Ratify envelope through the SDK gets correlationId, revokedCerts, issuerPubKey, updatedAt instead of the wire names required by RatifyA2aMetadataSchema (packages/a2a/src/index.ts:39-49), and the signed RevocationList can no longer be reconstructed. The same corruption applies to the existing channel-message data/metadata fields, but this PR makes it load-bearing for the federation feature.
Prompt for agents
The TypeScript SDK applies an automatic snake_case/camelCase key transform to every request body and response (see decamelizeKeys in packages/sdk-typescript/src/client.ts:280 and camelizeKeys at packages/sdk-typescript/src/client.ts:349, plus the WebSocket path in packages/sdk-typescript/src/ws.ts). packages/sdk-typescript/src/casing.ts already maintains a VERBATIM_VALUE_KEYS set for fields whose values are user-authored JSON documents (headers, input, output, input_schema, output_schema) so their inner keys are not rewritten.
This PR adds a structured `data` argument to AgentClient.dm() and starts returning public `metadata` on DM responses and dm.received events, intended to carry the Ratify envelope documented in docs/a2a-ratify-federation.md. Those envelopes use snake_case wire field names (correlation_id, revoked_certs, issuer_pub_key, updated_at) that are data, not protocol shape. Because `data` and `metadata` are missing from VERBATIM_VALUE_KEYS, the read path camelizes them, so an SDK consumer never sees the canonical field names and cannot reconstruct or verify the signed RevocationList / ProofBundle.
Fix by treating `data` and `metadata` values as opaque in both transforms (mirroring how input/output are handled), and add a regression test covering a DM metadata payload containing snake_case keys surviving a request/response round trip unchanged.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if ( | ||
| !workspace | ||
| && !c.req.query('workspace') | ||
| && !c.req.param('workspace') | ||
| ) { | ||
| const candidates = await db.select().from(workspaces).limit(2); | ||
| if (candidates.length === 1) { | ||
| workspace = candidates[0]!; | ||
| } | ||
| } |
There was a problem hiding this comment.
🟨 Bare agent-card URL falls back to the only workspace even after a failed host-based tenant guess
handleWorkspaceAgentCard treats only a ?workspace= query value or an explicit /:workspace/ path segment as an "explicit selector" before applying the sole-workspace fallback (packages/engine/src/routes/a2a.ts:246-255). A hostname-derived hint (extractWorkspaceHint at packages/engine/src/routes/a2a.ts:61-81) that resolves to no workspace does not block the fallback, so a request to any unrecognized subdomain of a single-workspace deployment still returns that workspace's card — which enumerates every agent name and persona in the workspace (getWorkspaceAgentCard in packages/engine/src/engine/a2a.ts:646-692). The endpoint is unauthenticated by design, so the practical impact is limited to a deployment that expected host-scoped card exposure now serving it on any Host header.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
3 issues found across 21 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/engine/src/__tests__/conformance/a2aFederation.test.ts">
<violation number="1" location="packages/engine/src/__tests__/conformance/a2aFederation.test.ts:268">
P2: The 'applies a far-side revocation only after issuer signature verification' test only exercises a locally reimplemented applyIfValid helper and never drives the engine's inbound A2A path, so it cannot detect a regression that stops enforcing revocations in production. Consider routing an actual /a2a/rpc message/send with this revocation metadata through the engine and asserting the grant is refused, instead of validating the inline helper.</violation>
</file>
<file name="packages/types/src/message.ts">
<violation number="1" location="packages/types/src/message.ts:81">
P1: Swift DM/event consumers cannot access the newly preserved metadata (including Ratify metadata), because their `CoreMessagePayload` silently drops it during decoding. Add optional `[String: JSONValue]` metadata to the matching Swift public model.</violation>
</file>
<file name="packages/sdk-typescript/src/agent.ts">
<violation number="1" location="packages/sdk-typescript/src/agent.ts:574">
P1: The new structured `data` payload passed through `AgentClient.dm()` goes through the SDK's automatic snake_case/camelCase key transform along with the rest of the request/response body. Since `data`/`metadata` aren't included in the verbatim-value key set used for opaque user JSON (unlike `input`/`output`), the inner field names of a Ratify proof or revocation envelope (`correlation_id`, `revoked_certs`, `issuer_pub_key`, etc.) would get rewritten, and the receiver would fail to reconstruct/verify the payload.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| text: z.string(), | ||
| injection_mode: MessageInjectionModeSchema.optional(), | ||
| attachments: z.array(FileAttachmentSchema).optional(), | ||
| metadata: z.record(z.string(), z.unknown()).optional(), |
There was a problem hiding this comment.
P1: Swift DM/event consumers cannot access the newly preserved metadata (including Ratify metadata), because their CoreMessagePayload silently drops it during decoding. Add optional [String: JSONValue] metadata to the matching Swift public model.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/types/src/message.ts, line 81:
<comment>Swift DM/event consumers cannot access the newly preserved metadata (including Ratify metadata), because their `CoreMessagePayload` silently drops it during decoding. Add optional `[String: JSONValue]` metadata to the matching Swift public model.</comment>
<file context>
@@ -78,6 +78,7 @@ export const CoreMessagePayloadSchema = z.object({
text: z.string(),
injection_mode: MessageInjectionModeSchema.optional(),
attachments: z.array(FileAttachmentSchema).optional(),
+ metadata: z.record(z.string(), z.unknown()).optional(),
});
export type CoreMessagePayload = z.infer<typeof CoreMessagePayloadSchema>;
</file context>
| to: agent, | ||
| text, | ||
| ...(opts?.attachments ? { attachments: opts.attachments } : {}), | ||
| ...(opts?.data !== undefined ? { data: opts.data } : {}), |
There was a problem hiding this comment.
P1: The new structured data payload passed through AgentClient.dm() goes through the SDK's automatic snake_case/camelCase key transform along with the rest of the request/response body. Since data/metadata aren't included in the verbatim-value key set used for opaque user JSON (unlike input/output), the inner field names of a Ratify proof or revocation envelope (correlation_id, revoked_certs, issuer_pub_key, etc.) would get rewritten, and the receiver would fail to reconstruct/verify the payload.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-typescript/src/agent.ts, line 574:
<comment>The new structured `data` payload passed through `AgentClient.dm()` goes through the SDK's automatic snake_case/camelCase key transform along with the rest of the request/response body. Since `data`/`metadata` aren't included in the verbatim-value key set used for opaque user JSON (unlike `input`/`output`), the inner field names of a Ratify proof or revocation envelope (`correlation_id`, `revoked_certs`, `issuer_pub_key`, etc.) would get rewritten, and the receiver would fail to reconstruct/verify the payload.</comment>
<file context>
@@ -561,12 +561,17 @@ export class AgentClient {
to: agent,
text,
...(opts?.attachments ? { attachments: opts.attachments } : {}),
+ ...(opts?.data !== undefined ? { data: opts.data } : {}),
mode: opts?.mode ?? 'wait',
};
</file context>
|
|
||
| const revoked = new Set<string>(); | ||
| const trustedIssuers = new Map<string, HybridPublicKey>([[signed.issuer_id, issuer.publicKey]]); | ||
| const applyIfValid = async (wire: typeof wireRevocation): Promise<boolean> => { |
There was a problem hiding this comment.
P2: The 'applies a far-side revocation only after issuer signature verification' test only exercises a locally reimplemented applyIfValid helper and never drives the engine's inbound A2A path, so it cannot detect a regression that stops enforcing revocations in production. Consider routing an actual /a2a/rpc message/send with this revocation metadata through the engine and asserting the grant is refused, instead of validating the inline helper.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/__tests__/conformance/a2aFederation.test.ts, line 268:
<comment>The 'applies a far-side revocation only after issuer signature verification' test only exercises a locally reimplemented applyIfValid helper and never drives the engine's inbound A2A path, so it cannot detect a regression that stops enforcing revocations in production. Consider routing an actual /a2a/rpc message/send with this revocation metadata through the engine and asserting the grant is refused, instead of validating the inline helper.</comment>
<file context>
@@ -0,0 +1,311 @@
+
+ const revoked = new Set<string>();
+ const trustedIssuers = new Map<string, HybridPublicKey>([[signed.issuer_id, issuer.publicKey]]);
+ const applyIfValid = async (wire: typeof wireRevocation): Promise<boolean> => {
+ const trustedKey = trustedIssuers.get(wire.issuer_id);
+ if (!trustedKey) return false;
</file context>
30d1899 to
d5dcc95
Compare
|
Addressed the P1s and the 🔴 in Devin 🔴 / cubic P1 — SDK rewrote inner field names of
Codex P1 — retried inbound messages delivered twice. Correct. The inbound handler called cubic P1 — Swift dropped the metadata. Correct; Both new tests are non-vacuousChecked rather than assumed:
Now disjoint from #319The agent-card discovery fix has been removed from this branch and ships on its own in #319, which is where its review lives. SuitesRemaining open here are the P2/P3 items (mailbox limits on inbound A2A DMs, the observer-history and idempotency-fingerprint notes, schema nits). Working through those next. |
|
Follow-up on the casing change — I flagged it as breaking, then measured it instead of leaving that as an assertion. What can actually change: only multi-word keys, and only for callers reading or writing What is in production: 1025 agents, 723 with metadata. Eight multi-word keys in use — What reads them: grepped So the blast radius inside our own systems is zero. The residual is an external SDK consumer reading multi-word metadata keys — and for anyone who deliberately wrote snake_case keys this is a fix, since they were already getting a different key back than they wrote. The changelog entry now says that, rather than leading with a bare "Breaking". |
There was a problem hiding this comment.
All reported issues were addressed across 1 file (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
|
cubic P2 — pending release level. Accepted, raised to Worth recording that there is precedent pointing the other way, since silently overriding it would be the wrong way to resolve this. 6.2.0 shipped the identical class of change as a Minor, framed as a Fix — that release stopped key-rewriting inside Taking Major anyway, because the two differ in one material way. Happy to be overruled back to Minor on the precedent; flagging the reasoning so it is a decision rather than an accident. |
|
Remaining P2/P3 items addressed in Mailbox config (raised twice, same defect) — inbound A2A passed only Workspace/observer DM history dropped metadata — Idempotency fingerprint size — Inbound acknowledgement latency — fanout, delivery routing and rejection notification were awaited, so the counterparty's "message accepted" waited on our recipient's delivery, including a slow HTTP-push receiver. Moved to
Base64 validation — Proof-bundle size guard (P3) — the whole string was encoded before deciding it was oversized, so an attacker could force a multi-megabyte encode plus a second same-size allocation before rejection, on unauthenticated inbound traffic. String length is checked first; UTF-8 cannot shrink, so length over the cap already proves byte length over it. Revocation test — you are right that it validated a test-local helper, though the engine is not the enforcement point (a Ratify verifier is), so there is no production enforcement here to drive. The property the engine does own is that a signed document crosses unmodified, so the test now asserts the received wire deep-equals the sent one in addition to verifying its signature. A key rename fails with a readable diff instead of an opaque signature failure — which is exactly how the casing bug in this PR would have surfaced. |
There was a problem hiding this comment.
All reported issues were addressed across 6 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
|
cubic P1 — unpadded base64 rejected. Correct, and it is a regression I introduced while hardening this very field. Requiring a multiple-of-four length rejects unpadded base64, which is legal and commonly emitted — and the value it would reject is a signed revocation list. Over-strict validation there fails the kill switch closed over a formatting preference, which is a worse failure than the loose check it replaced. Fixed in Worth noting for the record: this is the second time in this PR that a change intended to protect the revocation path would have broken it. The first was the SDK casing transform corrupting |
92a6a6f to
5c2ec3a
Compare
…metadata Devin 🔴 / cubic P1 — the SDK's automatic key transform rewrote the inner field names of message `data` and `metadata`. For a Ratify envelope that is not cosmetic: a RevocationList whose `revoked_certs` arrives as `revokedCerts` cannot be reconstructed byte-for-byte, so its signature no longer verifies and a cross-deployment revocation is rejected for the wrong reason. Both keys join VERBATIM_VALUE_KEYS alongside `headers`, `input` and `input_schema`, which are already exempt on exactly this reasoning: their keys are caller data, not wire protocol. BEHAVIOUR CHANGE, called out in the SDK changelog rather than buried: callers who relied on the SDK snake_casing their metadata keys on the wire now see the keys exactly as written, including for agent registration metadata. The setup.test.ts assertion that expected `favorite_color` encoded the corruption rather than the contract — a caller who deliberately wrote `favoriteColor` got a different key back — so that expectation is corrected rather than preserved. Reviewers should weigh this: it is the one part of this PR that changes behaviour for existing consumers. Codex P1 — inbound A2A `message/send` called sendDm directly, bypassing the idempotency the DM route already applies to every send. `sendToExternalAgent` retries on 5xx, and the counter, webhook, workspace event and delivery routing all run after the durable write, so a transient failure there delivered the counterparty's proof or task twice. Inbound now runs through runIdempotent, keyed on the caller's message_id (falling back to the JSON-RPC id) and scoped to the registered caller so two counterparties cannot collide. Replays skip the side effects and return the original result. cubic P1 — Swift's CoreMessagePayload silently dropped the newly preserved metadata during decoding, so Swift consumers could not see Ratify envelopes at all. Added as optional [String: JSONValue], mirroring CoreMessagePayloadSchema. Both new tests verified non-vacuous: reverting only the casing change fails the two round-trip assertions, and disabling only the idempotency key delivers the retried message twice. Also drops the agent-card discovery changes from this branch — they ship separately in #319, so the two PRs are now disjoint. engine 53 files / 556 tests, sdk-typescript 419 tests, full turbo build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous entry led with 'Breaking', which is technically true and measurably alarmist. Checked against production before claiming a blast radius: 1025 agents, 723 with metadata, 8 multi-word keys in use (node_id, registered_at, cloud_workspace_id, cloud_agent_id, invocation_id, identity_key, room_membership_id, room_identity_id) — all stored snake_case. Single-word keys are identical in both casings and cannot be affected. Grepped relay, relaycast, cloud and the VS Code extension for the camelCase forms these would be read as today (metadata.nodeId, metadata.cloudWorkspaceId, …): zero hits. The one consumer that reads agent metadata at all, cloud/packages/web/lib/room/relaycast-access.ts, checks metadata.cloud_workspace_id in snake_case and reaches the API through globalThis.fetch rather than this SDK, so it reads the wire format and is unaffected either way. Entry now states what actually changes and for whom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cubic P2, accepted — with a note, because there is precedent pointing the other way and it deserves stating rather than silently overriding. 6.2.0 shipped the identical class of change (input_schema/output_schema, input/ output and headers stopped being key-rewritten) as a Minor, framed as a Fix, on the reasoning that rewriting user-authored keys was corruption rather than contract. By that precedent this entry is Minor too: data/metadata are the same kind of value and were simply missed from the same set. Taking Major anyway, because the two differ in one material way. input/output and headers are action payloads whose reader is essentially the code that produced them, so a rename is self-consistent. Agent and message metadata is written by one system and read by another — fleet and cloud write node_id, registered_at, cloud_workspace_id; other code reads them — and a silent rename across a system boundary is exactly where this bites. A behavioural change on a cross-system field-name contract should not ride a minor bump. Root and sdk-typescript headings raised; the engine and types headings stay Minor, since neither changes behaviour for existing callers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mailbox config (two comments, same defect): inbound A2A delivery passed only
skipA2aIntercept, so sendDm fell back to its fixed one-hour / 1000-message
defaults and registered-peer deliveries were the one path on the deployment
exempt from its own configured TTL and depth cap. Now resolves and passes the
mailbox config as /v1/dm does.
Workspace/observer DM history dropped metadata: getDmMessagesForWorkspace never
selected or projected messages.metadata, so a federated delivery's Ratify
envelope was visible to the recipient and invisible in history — the same
message reading differently depending on which door you came through. Projected
through publicMessageMetadata, which strips the internal __relaycast_* keys.
Idempotency fingerprint size: `data` was embedded whole. A proof bundle runs to
128 KiB, and the fingerprint is serialized into the stored record, kept for the
TTL, and string-compared on every replay — roughly 256 KiB per DM in KV and a
full-payload comparison each time. Now digested with sha256Hex, which answers
the only question the fingerprint asks in constant size.
Inbound acknowledgement latency: fanout, delivery routing and rejection
notification were awaited, so the counterparty's "message accepted" waited on
our recipient's delivery, including a slow HTTP-push receiver. Moved to
runInBackground, matching /v1/dm. The durable write is what the response
attests to.
message/stream: accepted and then served as a one-shot, returning a task already
terminal with no stream channel. A client calling message/stream expects a
working task plus somewhere to subscribe; a completed one-shot is a wrong answer
dressed as a right one and would silently truncate a conversation the caller
believes is open. Now refused with -32601 and a message naming message/send, so
the caller falls back immediately. NOTE: the agent card still advertises
message/stream — that is a wider decision than this PR and is flagged rather
than changed here.
Base64 validation: issuer_pub_key and signature are documented as base64 but
only checked non-empty, so a value that could never decode passed validation at
the edge and failed later inside the verifier as an opaque error. Validated as
standard base64. Existing test fixtures used placeholders ('pub-ed', 'sig-ed')
that are not base64; replaced with real values and added cases for the rule.
Proof-bundle size guard: the whole string was encoded before deciding it was
oversized, so an attacker could force a multi-megabyte encode and a second
same-size allocation before rejection — on unauthenticated inbound federation
traffic. String length is checked first; UTF-8 cannot shrink, so length over the
cap already proves byte length over it.
Revocation test: asserts the received wire deep-equals the sent one, not just
that its signature verifies. The engine is not the enforcement point — a Ratify
verifier is — so the property the engine owns is that a signed document crosses
unmodified. A key rename now fails with a readable diff rather than an opaque
signature failure.
turbo test 18/18 tasks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cubic P1, and a regression I introduced while hardening validation. Requiring a multiple-of-four length rejected unpadded base64 — legal and commonly emitted — and the value it would have rejected is a signed revocation list. Over-strict validation there fails the kill switch closed over a formatting preference, which is worse than the loose check it replaced. Padding is now optional: a padded value must still be a multiple of four, and an unpadded one is rejected only at length ≡ 1 (mod 4), which no base64 quantum can produce. Tests cover 'QUJDRA' and 'QUJDRA==' (same bytes, both accepted) alongside the malformed cases. turbo test 18/18. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5c2ec3a to
c14e841
Compare
Summary
Discovery decision
Option (b), sole-workspace fallback, is the actual standards fix because it makes the bare /.well-known/agent-card.json path work without Relaycast-specific query or path conventions. The fallback only runs without an explicit selector and only when exactly one workspace exists. Hosted subdomain selection remains authoritative and is covered by a test.
The explicit path selector was also moved before host inference. That is route correctness, not a substitute for option (b): without it, the documented /:workspace/.well-known/agent-card.json route remains dead on every conformant multi-label authority. Invalid explicit selectors do not fall through to another tenant.
This production discovery defect is independently deployable and deserves its own expedited PR. It is included here because it is on the federation critical path; the discovery hunks/tests can be split or cherry-picked if maintainers want separate release cadence.
Wire, authentication, and trust
The interface and reciprocal three-step credential handshake are documented in docs/a2a-ratify-federation.md. The conformance test sends the full proof A-to-B and the signed revocation stream B-to-A, with a distinct registration-issued bearer token authenticating each direction.
The named test blocks unauthenticated and unregistered peers from injecting Ratify proof metadata. It checks both a request with no credential and a workspace key that is valid for management but is not a registered peer token; neither creates a local DM. Registered peer tokens also cannot relay to another external A2A target.
Revocations are applied by the verifier only after issuer_id resolves to a pre-trusted hybrid public key, the carried key matches it, and Ratify verifyRevocationList succeeds. A carried public key is never treated as its own trust anchor.
Measurements
The engine path is measured. The two in-memory deployments preserve the complete HTTP, authentication, JSON-RPC, persistence, signature-verification, and refusal path, but do not include real DNS, TLS, or a production reverse proxy. The production ingress body cap is not measurable without deploying or production A2A credentials, both outside this task constraints; the real two-host rehearsal must confirm that edge separately.
Negative controls
I temporarily removed each protection, rebuilt first, and confirmed the relevant tests turned red:
The original mutation run produced seven expected failures. Removing reciprocal credential persistence additionally made the reverse-direction revocation test fail 401. Every guard was restored.
Verification
Live-run blockers