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.

Last reviewed September 16, 2026Fresh

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:

ActionRequiresEffect
initiateTwo delegate ids, a fresh nonceCreates a pending negotiation, or returns the open one
attestnegotiation_id, a fresh nonceRuns the five blocking checks, moves to attested or suspended
negotiatenegotiation_id, a fresh nonceComputes the coefficient and limits, moves to active or revoked
statusnegotiation_id, or both delegate idsReads the row plus cache_valid
lookupdelegate_idLists negotiations this delegate takes part in
renewnegotiation_id, a fresh nonceExtends the TTL of an active negotiation
invalidateService-role header, signal_typeRevokes 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:

CheckPasses when
initiator_activeThe initiating delegate is active
responder_activeThe responding delegate is active
initiator_passkey_boundThe initiator's delegated_by user holds at least one row in shield_passkey_credentials for the tenant
responder_passkey_boundThe responder's delegator holds at least one passkey credential
no_recent_hard_signalsNo 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.20

Step 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.03

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

CoefficientTrust stateMaximum transaction valueMaximum transactions per minute
0.900 and abovefull10000060
0.700 to 0.899full2500030
0.400 to 0.699read_only500010
0.200 to 0.399verify_only5003
Below 0.200auto_revoked00

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:

  1. Hard signal. A caller can pass a hard-signal flag, which always forces re-attestation. In practice invalidate revokes the negotiation outright, so this branch is not exercised by the shipped callers, which both pass false.
  2. TTL expiry. The negotiation's expires_at has passed.
  3. 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

StatusBodyCause
400Unknown actionaction outside the seven
400Invalid JSON bodyBody did not parse
413Payload too largeBody above the small-JSON cap
400Missing or invalid tenant_id, initiator_delegate_id, or responder_delegate_id (must be UUIDs)Identifier missing or malformed
401Unauthorized—missing Bearer token or Unauthorized—invalid tokenNo session token, or one that did not resolve
403Forbidden—not a member of this tenantValid session, wrong tenant
409code: "REPLAY_DETECTED"Nonce missing, reused, or timestamp outside the 300-second tolerance
403code: "MTLS_FAILED"A delegate has pinned certificates and the presented fingerprint did not match
400Agent cannot negotiate with itselfBoth delegate ids are the same
404One or both delegates not found in this tenantA delegate id belongs to another tenant
400Both agents must be active to initiate negotiationA delegate is revoked or expired
404Negotiation not foundUnknown negotiation_id for this tenant
400Cannot attest—status is 'active', expected 'pending'Wrong phase for attest
400Cannot negotiate—status is 'pending', expected 'attested'Wrong phase for negotiate
400Cannot renew—status is 'suspended', expected 'active'Wrong phase for renew
404{ "status": "not_found" }status matched no row
403Forbidden—service authentication requiredinvalidate without the service-role header
403Direct 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