A2A Trust Negotiation
The agent-to-agent handshake as implemented: seven actions on one endpoint, the five attestation checks, the combined trust coefficient formula, transaction limit tiers, cache renewal and hard-signal invalidation.
Overview
shield-a2a-handshake lets two agent delegates in the same tenant establish a shared trust level and a transaction ceiling, implementing the protocol in U.S. patent application 19/561,964.
Auth lane. Every action except invalidate requires Authorization: Bearer with the Supabase session access token of a member of the tenant named in the body. Membership is checked with the is_tenant_member function, and a non-member gets 403. No API key is accepted, and an agent cannot drive the handshake with its own agent token: your backend calls these actions on behalf of your delegates. invalidate is service-to-service only and takes the service-role key in X-Service-Auth; nothing in production calls it, because hard-signal revocation happens inside shield-ingest.
The function is reachable at https://api.passkeybridge.io/v1/shield-a2a-handshake and is edge-locked, so a direct call to the Supabase functions host answers 403 Direct access denied.
Seven actions, all POST with a JSON body carrying action and tenant_id:
| Action | Requires | Effect |
|---|---|---|
initiate | Two delegate ids, a fresh nonce | Creates a pending negotiation, or returns the open one |
attest | negotiation_id, a fresh nonce | Runs the five blocking checks, moves to attested or suspended |
negotiate | negotiation_id, a fresh nonce | Computes the coefficient and limits, moves to active or revoked |
status | negotiation_id, or both delegate ids | Reads the row plus cache_valid |
lookup | delegate_id | Lists negotiations this delegate takes part in |
renew | negotiation_id, a fresh nonce | Extends the TTL of an active negotiation |
invalidate | Service-role header, signal_type | Revokes every open negotiation in the tenant |
All writes go through the service-role client; row-level security blocks direct client writes to shield_a2a_negotiations. Delegates come from Agent identity delegation.
5-phase lifecycle
Phase 1, initiate.
{
"action": "initiate",
"tenant_id": "<tenant uuid>",
"initiator_delegate_id": "<uuid>",
"responder_delegate_id": "<uuid>",
"ttl_seconds": 120,
"nonce": "<32 random bytes, hex>",
"timestamp": "2026-09-16T09:00:00Z"
}The server authenticates the caller, consumes the nonce, runs the certificate check, rejects self-negotiation, and requires both delegates to exist in the tenant and be active. If a negotiation between this ordered pair already sits in pending, attested, negotiated or active, that one is returned instead of a new row:
{ "status": "existing_negotiation", "negotiation_id": "...", "negotiation_status": "active", "combined_trust_coefficient": 0.83, "expires_at": "..." }Otherwise ttl_seconds is clamped to the range 10 to 3600 (default 60) and a row is created:
{ "status": "initiated", "negotiation_id": "...", "expires_at": "...", "server_nonce": "..." }Phase 2, attest. The negotiation must be pending. Five checks decide the outcome:
| Check | Passes when |
|---|---|
initiator_active | The initiating delegate is active |
responder_active | The responding delegate is active |
initiator_passkey_bound | The initiator's delegated_by user holds at least one row in shield_passkey_credentials for the tenant |
responder_passkey_bound | The responder's delegator holds at least one passkey credential |
no_recent_hard_signals | No hard signal in shield_events for either delegator in the last five minutes |
Two further rows, initiator_nf08_bound and responder_nf08_bound, are recorded for information and always pass. The hard-signal lookup matches events whose source_id equals a delegator's user id, so a signal only blocks attestation when it was ingested against that identifier.
All five passing writes the proofs and moves the status to attested; the response carries the signal list, the proof metadata and the spatial-binding flags. Any failure suspends the negotiation:
{ "status": "attestation_failed", "negotiation_id": "...", "signals": [{ "check": "responder_passkey_bound", "passed": false, "detail": "responder delegator has NO passkey credentials" }], "failed_checks": [{ "check": "responder_passkey_bound", "passed": false, "detail": "..." }] }A suspended negotiation is terminal for this row; start a new one once the delegator has enrolled a passkey.
Phase 3, negotiate. The negotiation must be attested.
{
"action": "negotiate",
"tenant_id": "<tenant uuid>",
"negotiation_id": "<uuid>",
"network_jitter_ms": 42,
"sim_signal_age_seconds": 30,
"nonce": "<fresh nonce>"
}network_jitter_ms (number, milliseconds, default 50) and sim_signal_age_seconds (number or null, default null) are supplied by the caller. The server measures neither. The response is the full coefficient result written to the row:
{
"status": "active",
"negotiation_id": "...",
"combined_trust_coefficient": 0.83,
"trust_state": "full",
"max_transaction_value": 25000,
"max_transactions_per_minute": 30,
"allowed_scopes": ["events:read", "verify"],
"recovery_rate": 0,
"signals": [{ "type": "geometric_mean", "value": 0.83, "detail": "..." }],
"reason": "CTC healthy—full transaction privileges"
}A coefficient below 0.20 writes status: "revoked" instead of active.
Phase 4, renew. The negotiation must be active and the body needs a fresh nonce. If either delegate has been deactivated the negotiation is revoked and the response says so. If the current scores have drifted more than 0.100 from the cached coefficient, renewal is denied:
{ "status": "renewal_denied", "reason": "Score drift 0.164 exceeds 0.100 threshold", "action_required": "re-attest" }Otherwise extend_seconds (default 60, clamped 10 to 3600) sets a new expiry and the response returns it. An expired TTL does not block renewal; drift does.
Phase 5, invalidate. Service-to-service only. Given a hard signal type it revokes every negotiation in the tenant whose status is active, negotiated or attested, zeroing the coefficient, both limits and the scope list. A soft signal type answers { "status": "skipped" }. In production the same outcome is produced directly by shield-ingest, which on a hard signal deactivates every delegate in the tenant and revokes every open negotiation inline.
Combined trust coefficient (CTC)
The coefficient is computed by computeCTC() in _shared/a2a-trust-coefficient.ts, a pure function with no I/O.
CTC = clamp( sqrt(initiator_trust * responder_trust)
- jitter_penalty
+ fingerprint_bonus
+ sim_freshness )Step 1, geometric mean. The square root of the product of the two current trust scores. It can never exceed the weaker agent's score, so one low-trust delegate holds the pair down: 0.90 with 0.30 gives 0.520 rather than the arithmetic 0.600.
Step 2, jitter penalty. Zero at or below 150 milliseconds of caller-reported round-trip jitter, then linear to a maximum of 0.20 at 1000 milliseconds:
penalty = ((jitter - 150) / 850) * 0.20Step 3, fingerprint bonus. A flat 0.05 when an NF-08 spatial binding is bound to the session. Today this does not fire: both the attestation metadata path and the live lookup query shield_spatial_bindings.user_hash, which stores a keyed digest, against the delegator's raw user id, so the comparison matches nothing. Treat the bonus as inactive until that lookup is corrected.
Step 4, SIM freshness. A carrier signal age under 300 seconds adds up to 0.03, scaled by freshness:
bonus = (1 - (age_seconds / 300)) * 0.03A 30-second-old signal adds 0.027; a 280-second-old signal adds 0.002. The age is whatever the caller sent.
Step 5, clamp. The result is clamped to the range 0.000 to 1.000 and rounded to three decimals.
Guard. If either delegate is inactive the function short-circuits to a coefficient of 0, trust state auto_revoked, zero limits and empty scopes, with the signal inactive_agent.
Every term that applied is returned in the signals array with its value and a human-readable detail, and the same array is stored in the negotiation's metadata, so a coefficient can be explained after the fact.
Trust states and transaction limits
The coefficient maps to a trust state, a transaction ceiling and a per-minute rate.
| Coefficient | Trust state | Maximum transaction value | Maximum transactions per minute |
|---|---|---|---|
| 0.900 and above | full | 100000 | 60 |
| 0.700 to 0.899 | full | 25000 | 30 |
| 0.400 to 0.699 | read_only | 5000 | 10 |
| 0.200 to 0.399 | verify_only | 500 | 3 |
| Below 0.200 | auto_revoked | 0 | 0 |
Values are in whole units of your own currency and are defaults written to the negotiation row. PasskeyBridge stores and reports them; it does not sit in your payment path, so enforcing the ceiling is your backend's job.
Allowed scopes. The starting point is the intersection of the two delegates' current scope lists. The trust state then filters it: full keeps the intersection, read_only keeps entries containing the substring read plus verify, verify_only keeps only verify, and auto_revoked empties it. An initiator holding ["ingest:read", "events:read", "verify"] negotiating with a responder holding ["events:read", "verify", "admin"] gets ["events:read", "verify"] at full trust.
Recovery rate. A negotiation below 0.700 is written with recovery_rate: 0.050, and a full-trust one with 0. No job reads that column, so nothing heals on its own: a degraded negotiation improves only when you run attest and negotiate again after the underlying delegate scores have recovered.
Confirm it worked. Dashboard > Agents > A2A trust negotiations shows the negotiation with both agent labels, its status badge, its trust state, a gauge for the coefficient, and the transaction ceiling and per-minute rate on the right.
Cache validity and re-attestation
shouldReAttest() decides whether a cached coefficient can still be trusted. It returns true on the first of three conditions:
- Hard signal. A caller can pass a hard-signal flag, which always forces re-attestation. In practice
invalidaterevokes the negotiation outright, so this branch is not exercised by the shipped callers, which both pass false. - TTL expiry. The negotiation's
expires_athas passed. - Score drift. The geometric mean of the two delegates' current trust scores differs from the stored coefficient by more than 0.100 in absolute terms. This is an absolute distance, not a percentage.
Reading it. The status action returns the stored row plus the verdict:
{
"id": "...",
"status": "active",
"combined_trust_coefficient": 0.83,
"trust_state": "full",
"max_transaction_value": 25000,
"max_transactions_per_minute": 30,
"allowed_scopes": ["events:read", "verify"],
"expires_at": "2026-09-16T09:02:00Z",
"initiator_trust_score": 0.88,
"responder_trust_score": 0.79,
"cache_valid": true,
"re_attest_reason": null
}Query it by negotiation_id, or by both initiator_delegate_id and responder_delegate_id, in which case only active, negotiated and attested rows are considered. Nothing found answers 404 with { "status": "not_found" }. status needs no nonce.
Renewal against re-attestation. renew extends the TTL without re-running any check, and refuses only on drift. Re-attestation means calling attest and then negotiate again, which re-runs the five checks, mints fresh proofs and recomputes the coefficient from the current scores. Because attest requires status pending, an active or suspended negotiation cannot be re-attested in place: start a new negotiation with initiate.
Bidirectional lookup
lookup finds every negotiation a delegate takes part in, whichever side it is on.
curl -X POST https://api.passkeybridge.io/v1/shield-a2a-handshake \
-H "Authorization: Bearer <dashboard session access token>" \
-H "Content-Type: application/json" \
-d '{
"action": "lookup",
"tenant_id": "<tenant uuid>",
"delegate_id": "<uuid>",
"status_filter": ["active", "negotiated", "attested"]
}'tenant_id and delegate_id must both be UUIDs and the caller must be a member of that tenant. status_filter is optional; entries outside active, negotiated, attested, pending, revoked and suspended are dropped, and an empty or fully invalid filter falls back to the default of active, negotiated and attested. Results are the 50 newest, ordered by creation time.
{
"negotiations": [
{
"id": "...",
"status": "active",
"combined_trust_coefficient": 0.83,
"trust_state": "full",
"max_transaction_value": 25000,
"max_transactions_per_minute": 30,
"allowed_scopes": ["events:read", "verify"],
"expires_at": "2026-09-16T09:02:00Z",
"initiator_trust_score": 0.88,
"responder_trust_score": 0.79,
"role": "initiator",
"peer_delegate_id": "..."
}
],
"count": 1
}role and peer_delegate_id are computed per row so the caller does not have to work out which side it was on. No nonce is required, and nothing is written.
Security layers
Replay protection. initiate, attest, negotiate and renew each require a fresh nonce; there is no opt-out. The value is hashed with SHA-256 before storage in shield_a2a_nonces, and the unique constraint is what detects a replay, so the check is atomic under concurrency. A reused nonce answers 409:
{ "error": "Nonce already consumed—replay detected", "code": "REPLAY_DETECTED" }An optional timestamp is validated against the server's clock with a tolerance of 300 seconds. Stored nonce hashes expire after five minutes. status and lookup need no nonce, because they write nothing.
mTLS certificate pinning. initiate, attest and negotiate verify both delegates against shield_agent_certificates, reading the presented fingerprint from X-Client-Cert-Fingerprint, X-SSL-Client-SHA256 or X-Agent-Cert-Fingerprint in that order. A delegate with no pin passes, a delegate with pins and no presented fingerprint fails, and an expired pin is skipped. A failure answers 403 with code: "MTLS_FAILED" and the per-side reason. Because no shipped surface writes a pin, this check passes for every tenant today.
Attestation proofs. Dual-layer signing with HMAC-SHA-256 and ML-DSA-65 (FIPS 204). The stored initiator_proof_hash and responder_proof_hash are the first 44 characters of the classical and post-quantum outputs, kept as evidence that the step ran rather than as verifiable signatures, with the algorithm and a truncated key fingerprint in metadata. A post-quantum failure falls back to halves of a SHA-256 digest, recorded as proof_scheme: "sha256_fallback".
Tenant isolation. Every query filters on tenant_id, and membership is checked before any handler runs, so cross-tenant discovery is not possible.
Passkey-bound root of trust. Attestation fails unless both delegators hold a passkey credential in the tenant, which is what ties an autonomous negotiation back to two humans who enrolled an authenticator.
Errors
| Status | Body | Cause |
|---|---|---|
| 400 | Unknown action | action outside the seven |
| 400 | Invalid JSON body | Body did not parse |
| 413 | Payload too large | Body above the small-JSON cap |
| 400 | Missing or invalid tenant_id, initiator_delegate_id, or responder_delegate_id (must be UUIDs) | Identifier missing or malformed |
| 401 | Unauthorized—missing Bearer token or Unauthorized—invalid token | No session token, or one that did not resolve |
| 403 | Forbidden—not a member of this tenant | Valid session, wrong tenant |
| 409 | code: "REPLAY_DETECTED" | Nonce missing, reused, or timestamp outside the 300-second tolerance |
| 403 | code: "MTLS_FAILED" | A delegate has pinned certificates and the presented fingerprint did not match |
| 400 | Agent cannot negotiate with itself | Both delegate ids are the same |
| 404 | One or both delegates not found in this tenant | A delegate id belongs to another tenant |
| 400 | Both agents must be active to initiate negotiation | A delegate is revoked or expired |
| 404 | Negotiation not found | Unknown negotiation_id for this tenant |
| 400 | Cannot attest—status is 'active', expected 'pending' | Wrong phase for attest |
| 400 | Cannot negotiate—status is 'pending', expected 'attested' | Wrong phase for negotiate |
| 400 | Cannot renew—status is 'suspended', expected 'active' | Wrong phase for renew |
| 404 | { "status": "not_found" } | status matched no row |
| 403 | Forbidden—service authentication required | invalidate without the service-role header |
| 403 | Direct access denied. Use https://api.passkeybridge.io/v1/{function}. | Called at the Supabase functions host |
A renewal refused on drift is not an error: it answers 200 with status: "renewal_denied" and action_required: "re-attest".
Related from the blog
- Anatomy of an A2A Handshake: How Two AI Agents Establish Trust Without a Shared Secretsecurity · 13 min read
- A2A Trust Negotiation: How Two AI Agents Prove Identity to Each Other Without a Humansecurity · 15 min read
- Agent Attestation Fatigue: When Too Many Trust Checks Become the Vulnerabilitysecurity · 21 min read