Skip to main content

A2A Trust Negotiation

The agent-to-agent handshake: eight actions, five attestation checks, the trust coefficient, transaction limit tiers, signed step proofs and cache rules.

Last reviewed October 20, 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.

Auth lane. Every action except invalidate requires Authorization: Bearer with the Supabase session access token of an admin of the tenant named in the body. The admin role is checked with is_tenant_admin, the same check row-level security uses on negotiations and step proofs, and a caller who is not an admin 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.

Eight 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
proofsnegotiation_idReturns the negotiation's step proofs, each verified again, with the ML-DSA public key
invalidateService-role header, signal_typeRevokes every open negotiation in the tenant

Every change initiate, attest, negotiate and renew make is signed and written together with its proof, and each of those responses names the proof in a proof object (id, pqc_algorithm, key_version, public_key_fingerprint, signed_at). Step proofs describes what is signed and how to check it.

All writes go through the service-role client; row-level security blocks direct client writes to shield_a2a_negotiations and shield_a2a_proofs. 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 an unexpired negotiation between these two delegates, in either direction, already sits in pending, attested, negotiated or active, that one is returned instead of a new row. A row whose expires_at has passed is ignored:

{ "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 the row is created together with the proof of its creation:

{
  "status": "initiated",
  "negotiation_id": "...",
  "expires_at": "...",
  "server_nonce": "...",
  "proof": { "id": "...", "pqc_algorithm": "ML-DSA-65", "key_version": 0, "public_key_fingerprint": "...", "signed_at": "..." }
}

Phase 2, attest. The negotiation must be pending and expires_at must still be in the future. An expired pending row answers 400 with code: "NEGOTIATION_EXPIRED" and stays 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.

Either outcome is signed, with every check and its result in the statement. All five passing moves the status to attested; the response carries the signal list, a pqc object (pqc_algorithm, key_fingerprint, public_key_fingerprint, quantum_nonce, proof_scheme), the spatial-binding flags and the proof reference. 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": "..." }], "proof": { "id": "...", "pqc_algorithm": "ML-DSA-65", "key_version": 0, "public_key_fingerprint": "...", "signed_at": "..." } }

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. A value given for either must be a number that is not negative, or the call answers 400 with code: "INVALID_CONTEXT" before the nonce is spent. Both values are signed into the step's statement with the coefficient, the limits and the scopes. The response is the full coefficient result written to the row, with the proof reference:

{
  "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",
  "proof": { "id": "...", "pqc_algorithm": "ML-DSA-65", "key_version": 0, "public_key_fingerprint": "...", "signed_at": "..." }
}

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. The same certificate check as initiate, attest and negotiate runs first. If either delegate has been deactivated the negotiation is revoked and the response says so. Renewal is refused when expires_at has passed, when a hard signal for either delegator arrived in the last five minutes, or when the current scores have drifted more than 0.100 from the cached coefficient:

{ "status": "renewal_denied", "reason": "Trust cache TTL expired", "action_required": "re-attest" }

Otherwise extend_seconds (default 60, clamped 10 to 3600) sets a new expiry and the response returns it. A renewal and a revocation for a deactivated delegate are each signed and carry a proof reference; a denied renewal changes nothing and signs nothing.

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 either delegator has an active NF-08 spatial binding. The lookup hashes the delegator's user id the same way shield_spatial_bindings.user_hash was stored and matches those keyed digests, including the previous pepper while a rotation window is open. A binding stored under a different subject digest does not count.

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. A negative age earns no bonus, and negotiate refuses one before it reaches the formula.

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 hard-signal flag always forces re-attestation. renew sets it when shield_events has a hard signal for either delegator in the last five minutes, the same window attest uses. status still passes false, so its cache_valid flag reflects TTL and score drift only.
  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 refuses when the TTL has expired, when a hard signal for either delegator arrived in the last five minutes, or when the scores have drifted more than 0.100. It also runs the certificate check. Re-attestation means calling initiate, then attest and 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. An expired pending row is not attested either.

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 an admin 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.

Step proofs

Every change initiate, attest, negotiate and renew make to a negotiation is signed, and the whole proof is kept. A step that cannot sign answers 503 with code: "PQC_SIGNING_UNAVAILABLE" and writes nothing: no row, no status change, no proof. There is no fallback to a plain digest.

What is signed. A statement of the change: the negotiation, both delegates, the status before and after, the signing time and the step's terms.

StepTerms
initiateThe expiry, the TTL and both trust scores
attestEvery check and whether it passed, both trust scores and both spatial-binding flags
negotiateThe coefficient, the trust state, both limits, the scopes, the recovery rate, both trust scores and the context it was computed from
renewThe previous and new expiry, the renewal length and the scores it was renewed on; for a revocation, which delegate was inactive

The statement holds ids, statuses, numbers, scope names and check names, and no personal data. Its keys are sorted at every depth and it is serialised without whitespace, with "v": "pb-a2a-step-v1", so the same change always signs the same bytes.

How it is signed. hybridSign signs <statement>:qnonce:<quantum_nonce> twice under the domain a2a:<tenant_id>: HMAC-SHA-256 with a key derived from the platform master key, and ML-DSA-65 (FIPS 204). The change and its proof are written in one database transaction (shield_a2a_commit_step), so a status never changes without its proof and no proof names a change that did not happen. The write also requires the negotiation to still have the status and updated_at the step read; if another request moved it first, the step answers 409 with code: "NEGOTIATION_CHANGED" and writes nothing. initiate, attest and negotiate each prove once per negotiation; renew proves every renewal.

Reading them back.

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": "proofs",
    "tenant_id": "<tenant uuid>",
    "negotiation_id": "<uuid>"
  }'
{
  "negotiation_id": "...",
  "proofs": [
    {
      "id": "...",
      "step": "negotiate",
      "status_after": "active",
      "signed_at": "...",
      "signed_payload": "<the statement, exactly as signed>",
      "classical_signature": "...",
      "pqc_signature": "...",
      "pqc_algorithm": "ML-DSA-65",
      "quantum_nonce": "...",
      "key_version": 0,
      "key_fingerprint": "...",
      "public_key_fingerprint": "...",
      "entropy_seed_hash": "...",
      "verified": true,
      "classical_valid": true,
      "pqc_valid": true
    }
  ],
  "count": 1,
  "truncated": false,
  "public_keys": { "ML-DSA-65:0": { "kty": "...", "alg": "...", "pub": "...", "key_ops": ["verify"], "ext": true } }
}

Proofs come back whole and oldest first, up to 200; truncated is true when the negotiation holds more. Each is verified again on every read: verified needs both layers, and a proof whose statement or signature was altered reads false. key_fingerprint is a digest of the signing domain; public_key_fingerprint is a digest of the parameter set, the key version and the ML-DSA public key, and public_keys holds that key, keyed by <pqc_algorithm>:<key_version>. The ML-DSA layer can be checked with that key alone, over <signed_payload>:qnonce:<quantum_nonce>, with no PasskeyBridge secret; the HMAC layer needs the platform key and is checked by this action. proofs takes the session token of a member of the tenant, needs no nonce and writes nothing. A negotiation in another tenant answers 404.

Older rows. initiator_proof_hash and responder_proof_hash on the negotiation row held the first 44 characters of the attestation's two signatures, which cannot be verified. Nothing writes them now.

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.

Step proofs. Dual-layer signing with HMAC-SHA-256 and ML-DSA-65 (FIPS 204) on every change initiate, attest, negotiate and renew make, committed in one transaction with the change it proves. A step that cannot sign answers 503 with code: "PQC_SIGNING_UNAVAILABLE" and writes nothing; there is no SHA-256 fallback. Step proofs has the detail.

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 eight
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
400code: "INVALID_CONTEXT"negotiate was given a network_jitter_ms or sim_signal_age_seconds that is not a number, or is negative
409code: "NEGOTIATION_CHANGED"The negotiation changed between the step's read and its write; nothing was written. Read it again before retrying
503code: "PQC_SIGNING_UNAVAILABLE"The step could not be signed; nothing was written
500Proof lookup failedproofs could not read the negotiation or its proofs
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