Skip to content

feat(a2a): federate Ratify proofs and revocations - #318

Merged
khaliqgant merged 6 commits into
mainfrom
fix/a2a-ratify-federation
Aug 10, 2026
Merged

feat(a2a): federate Ratify proofs and revocations#318
khaliqgant merged 6 commits into
mainfrom
fix/a2a-ratify-federation

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 9, 2026

Copy link
Copy Markdown
Member

Summary

  • make standard bare agent-card discovery work for a sole workspace while keeping multi-tenant discovery fail-closed
  • make an explicit path workspace beat host inference so the documented path route works on multi-label authorities
  • define version 1 Ratify proof-bundle and signed-revocation metadata under com.agentrelay.ratify
  • preserve structured DM metadata across A2A send, receive, webhook, durable delivery, public types, SDK, and OpenAPI
  • authenticate inbound registered peers and prevent their tokens from becoming an open A2A relay
  • add a credential update step that completes reciprocal A2A registration without rotating either exchanged token

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

  • full proof bundle: 131,072 UTF-8 bytes crossed two isolated Relaycast engine instances end to end
  • resulting forwarded JSON-RPC body: 131,534 bytes
  • 131,073-byte bundle: rejected before egress
  • revocation issue-to-observable-refusal, n=20: median 14.02 ms, p95 16.66 ms, max 25.78 ms
  • with 75 ms injected transport delay: observable refusal at 96.27 ms

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:

  • path-before-host selector ordering
  • exactly-one-workspace fallback guard
  • 128 KiB proof limit
  • registered-peer inbound authentication gate
  • revocation signature verification
  • reciprocal auth-credential persistence

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

  • npx turbo build: 9/9 tasks passed, engine remains 7.0.0
  • npx turbo lint: 13/13 tasks passed
  • focused Vitest: 153/153 passed
  • TruffleHog committed-diff scan: 0 verified and 0 unverified secrets
  • GitHub CI on the amended commit passed Lint, Build & Test and Rust SDK
  • direct root Vitest invocation: 1,414 passed; 11 pre-existing failures plus one pre-existing suite-load failure remain in the known React DOM-environment and types test setup areas

Live-run blockers

  • No npm publish or deployment was performed. A stock published 7.0.0 engine image cannot contain this source fix; build the self-host image from this commit/PR or wait for a future published artifact.
  • Production edge request limits and real DNS/TLS latency still require a two-deployment rehearsal.

@cursor

cursor Bot commented Aug 9, 2026

Copy link
Copy Markdown

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.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@khaliqgant, you've reached your PR review limit, so we couldn't start this review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 03936d56-6292-4965-a705-c1a6c5edf108

📥 Commits

Reviewing files that changed from the base of the PR and between 639d654 and c14e841.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (25)
  • CHANGELOG.md
  • README.md
  • docs/a2a-ratify-federation.md
  • openapi.yaml
  • packages/a2a/src/__tests__/schemas.test.ts
  • packages/a2a/src/index.ts
  • packages/engine/CHANGELOG.md
  • packages/engine/package.json
  • packages/engine/src/__tests__/conformance/a2aFederation.test.ts
  • packages/engine/src/engine/__tests__/a2a.test.ts
  • packages/engine/src/engine/a2a.ts
  • packages/engine/src/engine/delivery.ts
  • packages/engine/src/engine/dm.ts
  • packages/engine/src/engine/dmAll.ts
  • packages/engine/src/routes/a2a.ts
  • packages/engine/src/routes/dm.ts
  • packages/sdk-swift/Sources/Relaycast/Models.swift
  • packages/sdk-typescript/CHANGELOG.md
  • packages/sdk-typescript/src/__tests__/casing.test.ts
  • packages/sdk-typescript/src/__tests__/setup.test.ts
  • packages/sdk-typescript/src/agent.ts
  • packages/sdk-typescript/src/casing.ts
  • packages/types/CHANGELOG.md
  • packages/types/src/dm.ts
  • packages/types/src/message.ts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread packages/engine/src/routes/a2a.ts Outdated
}

const relayMessage = a2aEngine.translateA2aToRelay(request);
const sent = await dmEngine.sendDm(db, workspace.id, authenticatedAgent!.id, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread packages/engine/src/routes/a2a.ts Outdated
Comment on lines +339 to +341
}, {
skipA2aIntercept: true,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +564 to +574
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 } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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:280 runs decamelizeKeys(body) over the whole DM body, recursing into data.
  • Inbound: packages/sdk-typescript/src/client.ts:349 runs camelizeKeys(parsedData) over the whole response, recursing into the new metadata returned by sendDm (packages/engine/src/engine/dm.ts:434, packages/engine/src/engine/dm.ts:626). WebSocket dm.received payloads 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +246 to +255
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]!;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟨 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@cubic-dev-ai cubic-dev-ai Bot 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.

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(),

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.

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 } : {}),

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.

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>

Comment thread packages/engine/src/routes/a2a.ts Outdated

const revoked = new Set<string>();
const trustedIssuers = new Map<string, HybridPublicKey>([[signed.issuer_id, issuer.publicKey]]);
const applyIfValid = async (wire: typeof wireRevocation): Promise<boolean> => {

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.

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>

Comment thread packages/engine/src/engine/dm.ts
Comment thread packages/engine/src/routes/a2a.ts Outdated
Comment thread packages/a2a/src/index.ts
Comment thread packages/engine/src/routes/a2a.ts Outdated
Comment thread packages/engine/src/routes/a2a.ts Outdated
Comment thread packages/a2a/src/index.ts
@khaliqgant

Copy link
Copy Markdown
Member Author

Addressed the P1s and the 🔴 in 0ae314b, and split the discovery work out so this PR is now federation-only.

Devin 🔴 / cubic P1 — SDK rewrote inner field names of data/metadata. Correct, and the consequence is worse than untidy: a RevocationList whose revoked_certs arrives as revokedCerts cannot be reconstructed byte-for-byte, so its signature fails and a cross-deployment revocation is rejected for the wrong reason — the kill switch appears to work while proving nothing. Both keys now join VERBATIM_VALUE_KEYS next to headers, input and input_schema, which were already exempt on precisely this reasoning.

⚠️ This changes behaviour for existing consumers and I have not hidden it in the changelog: callers who relied on the SDK snake_casing their metadata keys on the wire will now see keys exactly as written, including agent-registration metadata. The setup.test.ts assertion expecting favorite_color encoded the corruption rather than the contract — a caller who deliberately wrote favoriteColor got a different key back — so I corrected the expectation rather than preserving it. That is the one judgement call here worth a second opinion.

Codex P1 — retried inbound messages delivered twice. Correct. The inbound handler called sendDm directly and skipped the idempotency the DM route already applies to every send, while the counter, webhook, workspace event and delivery routing all run after the durable write. Inbound now goes 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, so a retry is observably identical to a single call.

cubic P1 — Swift dropped the metadata. Correct; CoreMessagePayload decoded successfully and silently lost the field. Added as optional [String: JSONValue], mirroring CoreMessagePayloadSchema.

Both new tests are non-vacuous

Checked rather than assumed:

  • reverting only the casing change fails the two round-trip assertions
  • disabling only the idempotency key delivers the retried message twice

Now disjoint from #319

The agent-card discovery fix has been removed from this branch and ships on its own in #319, which is where its review lives. packages/engine/src/routes/a2a.ts here carries federation code only.

Suites

engine            53 files / 556 tests
sdk-typescript    419 tests
turbo build       green

Remaining 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.

@khaliqgant

Copy link
Copy Markdown
Member Author

Follow-up on the casing change — I flagged it as breaking, then measured it instead of leaving that as an assertion. 616b300.

What can actually change: only multi-word keys, and only for callers reading or writing data/metadata through this SDK. Single-word keys (source, fleet, release, cli) are identical in both casings.

What is in production: 1025 agents, 723 with metadata. Eight multi-word keys in use — node_id (117), registered_at (117), cloud_workspace_id (20), cloud_agent_id (19), invocation_id (15), identity_key (9), room_membership_id, room_identity_id — all stored snake_case. So an SDK reader sees nodeId today and node_id after.

What reads them: grepped relay, relaycast, cloud and the VS Code extension for the camelCase forms (metadata.nodeId, metadata.cloudWorkspaceId, …) — zero hits. The only consumer that reads agent metadata at all is cloud/packages/web/lib/room/relaycast-access.ts, which checks metadata.cloud_workspace_id in snake_case and reaches the API through globalThis.fetch rather than this SDK. It reads the wire format directly and is unaffected either way.

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".

@cubic-dev-ai cubic-dev-ai Bot 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.

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

Comment thread packages/sdk-typescript/CHANGELOG.md
@khaliqgant

Copy link
Copy Markdown
Member Author

cubic P2 — pending release level. Accepted, raised to [Unreleased - Major] on the root and sdk-typescript changelogs in ad6b01d. Engine and types stay Minor; neither changes behaviour for existing callers.

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 input_schema/output_schema, input/output and headers, 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 end to end. Agent and message metadata is written by one system and read by another — fleet and cloud write node_id, registered_at, cloud_workspace_id; separate 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.

Happy to be overruled back to Minor on the precedent; flagging the reasoning so it is a decision rather than an accident.

@khaliqgant

Copy link
Copy Markdown
Member Author

Remaining P2/P3 items addressed in d45ff18. turbo test 18/18.

Mailbox config (raised twice, same defect) — inbound A2A passed only skipA2aIntercept, so sendDm fell back to its fixed one-hour / 1000-message defaults. Registered-peer delivery was the one path on the deployment exempt from its own configured TTL and backpressure. Now resolves and passes the config as /v1/dm does.

Workspace/observer DM history dropped metadatagetDmMessagesForWorkspace 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.

Idempotency fingerprint sizedata was embedded whole. At 128 KiB per proof bundle that put ~256 KiB into the KV record for the TTL and compared the full payload on every replay. Now digested with the sha256Hex the middleware already uses; a digest 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, then served as a one-shot returning an already-terminal task with no stream channel. A caller expecting incremental updates would silently believe a truncated conversation was still open. Now refused with -32601 naming message/send, so it falls back immediately. ⚠️ The agent card still advertises message/stream — reconciling that is wider than this PR, so it is flagged rather than changed here.

Base64 validationissuer_pub_key and signature are documented base64 but only checked non-empty, so a value that could never decode passed at the edge and failed later inside the verifier as an opaque error. Now validated. The existing fixtures used 'pub-ed'/'sig-ed', which are not base64 — replaced with real values, and added cases covering the rule including a valid one that must still pass.

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.

@cubic-dev-ai cubic-dev-ai Bot 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.

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

Comment thread packages/a2a/src/index.ts Outdated
@khaliqgant

Copy link
Copy Markdown
Member Author

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 92a6a6f: padding is optional. A padded value must still be a multiple of four; an unpadded one is rejected only at length ≡ 1 (mod 4), which no base64 quantum can produce. Tests now cover QUJDRA and QUJDRA== — the same bytes, both accepted — alongside the malformed cases.

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 revoked_certs; this one was my own validation. Both fail the same way — the kill switch refuses for a reason unrelated to authority — which is exactly the failure the demonstration cannot afford, because it looks identical to the protection working.

@khaliqgant
khaliqgant force-pushed the fix/a2a-ratify-federation branch from 92a6a6f to 5c2ec3a Compare August 10, 2026 14:06
khaliqgant and others added 6 commits August 10, 2026 16:09
…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>
@khaliqgant
khaliqgant force-pushed the fix/a2a-ratify-federation branch from 5c2ec3a to c14e841 Compare August 10, 2026 14:10
@khaliqgant
khaliqgant merged commit 8b01e8f into main Aug 10, 2026
4 of 5 checks passed
@khaliqgant
khaliqgant deleted the fix/a2a-ratify-federation branch August 10, 2026 14:10
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.

1 participant