Skip to content

Client protocol specification

This page is the normative specification for any Vouch client. A client is any program that holds a user’s plaintext phone book, performs the device-side half of the PSI protocol, and talks to the Vouch API.

It exists so that every client behaves identically. The protocol is distributed, stateful and cryptographic: two clients that poll on different schedules, order their sets differently, or handle a 409 differently produce different — and separately debuggable — failure modes. Divergence here is the most expensive kind of bug in this system, because a broken client looks exactly like “no matches”, which is also the correct output most of the time.

The keywords MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as in RFC 2119. Every constant in this page is part of the protocol: do not tune it per client. Changing one means changing this page first.

  • For why the cryptography works, see The matching protocol.
  • For the wire shapes of each endpoint, see the generated API Reference.
  • The reference implementation is client/src/ (crypto.ts, store.ts, sync.ts).
Constant Value Defined by
Curve / group ristretto255 protocol.py, crypto.ts
Point encoding length 32 bytes models.POINT_LEN
Hash-to-point domain string vouch/v1 (8 ASCII bytes, no separator) protocol.DOMAIN
Digest for hash-to-point SHA-512 (64 bytes) protocol.hash_to_point
Wire encoding of points RFC 4648 standard base64, padded, no line breaks schemas.py / contacts.decode_point
E.164 accept pattern ^\+[1-9][0-9]{6,14}$ protocol._E164_RE
Characters stripped before validation space, -, (, ), . protocol._STRIP_RE
Max elements per contact set 5000 schemas.MAX_ELEMENTS
Auth scheme Authorization: Bearer <JWT> (access token) auth.py
Access token lifetime 900 s (VOUCH_ACCESS_TOKEN_TTL_SECONDS); claims sub, sid, admin auth.make_access_token
Refresh token httpOnly cookie vouch_refresh, path /auth/refresh, 30 days, rotated on every use auth.py
DLEQ domain string vouch/dleq/v1 protocol.DLEQ_DOMAIN
Server matcher pass interval 60 s (VOUCH_MATCHER_INTERVAL_SECONDS) config.py

Base64 details matter: the server calls base64.b64decode(v, validate=True), which rejects the URL-safe alphabet, rejects unpadded input, and rejects whitespace. Emit +// with = padding and nothing else.

A client MUST implement exactly these four operations, with exactly these libsodium calls. Any deviation (a different domain string, a different digest, a different normalisation) yields points that are individually valid and never match anything — a silent, total failure.

Operation Definition libsodium
normalize(raw) strip the separator characters, trim, then assert the E.164 pattern
hash_to_point(e164) H = SHA-512("vouch/v1" ‖ e164); P = from_hash(H) crypto_hash_sha512, crypto_core_ristretto255_from_hash
generate_key() uniform random ristretto255 scalar crypto_core_ristretto255_scalar_random
apply_key(P, k) P·k crypto_scalarmult_ristretto255(k, P)

Rules:

  • normalize MUST be applied to every number before hashing — the user’s own number and every contact. The two devices in a pair hash independent strings; they only agree if both normalise to byte-identical E.164.
  • A client MUST NOT invent its own key derivation. k MUST come from the CSPRNG above. Deriving k from a password or device ID would let anyone who can guess that input decrypt the published set by brute-forcing phone numbers.
  • A client MUST NOT publish the identity point (all-zero encoding). The server rejects it (422), because identity·k == identity for every k, which would match every user universally.
  • Implementations MUST be validated against vectors/protocol_vectors.json before being used against a live server. Those vectors are the only thing guaranteeing cross-language byte-compatibility; a new client in a new language MUST add its own runner over that file.

This is the complete set of data a client is responsible for. Nothing here is recoverable from the server.

# Field Type Lifetime Why it matters
1 device_key 32-byte scalar Permanent, per account The single secret. Loss ⇒ every published element is dead weight and every in-flight pair silently mismatches. Compromise ⇒ an attacker can test any phone number against the user’s published set.
2 key_fingerprint first 8 bytes of SHA-256(device_key), hex Derived Cheap comparable identity for the key, so state can be tagged with which key produced it without storing the key twice.
3 own_e164 string Until the user changes it Feeds own_token; drives direct-acquaintance suppression on the other side.
4 contacts list of {display_name, e164} User-managed Plaintext phone book. MUST NOT leave the device.
5 published_index ordered list of e164 Replaced on every successful publish The pairing. Index i of this list is the plaintext behind index i of the published elements, and is what my_matched_indices refers to. Lose it and matches become unnameable.
6 published_version integer With #5 The version the server returned. Lets the client detect that its view is stale.
7 published_digest 32-byte hash (§5.3) With #5 Decides whether a republish is needed at all. Without it clients either republish too often (destroying pairs) or never (going stale).
8 published_key_fingerprint hex With #5 Binds the published set to the key that produced it. The invariant in §7.1 is checked against this.
9 matches list of MatchOut + resolved names Cache Presentation only; the server is authoritative. Each entry MUST be tagged with the published_version under which it was observed.
10 last_sync_at, sync_backoff timestamps / integer Runtime Enforce the schedule in §6.
11 session access token (memory only) + GET /me view ≤ 900 s; renewed via the refresh cookie Bearer credential. The refresh token is an httpOnly cookie the client never reads. GET /me also returns the server-bound own_token, which a publish must reproduce exactly (§5.1).
  • device_key MUST be stored in the most protected store the platform offers (iOS Keychain / Android Keystore / OS credential store; localStorage is acceptable only for the browser reference client). It MUST NOT be written to logs, crash reports, analytics, or any cloud/device backup that leaves the device.
  • contacts and published_index contain plaintext phone numbers. They MUST be stored in the app’s private storage and MUST NOT be transmitted anywhere.
  • Fields 5–8 form a single record and MUST be written atomically, after the PUT /me/contact-set call returns 200. Writing them before the response (or writing #5 without #7) leaves the client believing it published something it did not, which desynchronises every subsequent index lookup.
  • A client MUST clear all of fields 1–10 on logout or account deletion.

Any storage engine is fine. This shape is sufficient and is what the reference client persists:

{
"device_key": "<32 bytes, hex, in the secure store>",
"own_e164": "+32470123456",
"contacts": [{ "display_name": "Ada", "e164": "+32470111111" }],
"published": {
"version": 7,
"key_fingerprint": "9f2c41ab0d5e7761",
"digest": "<sha256 hex>",
"index": ["+32470111111", "+32470222222"],
"at": "2026-07-20T09:12:44Z"
},
"matches": [
{ "pair_id": "01a0999a-793f-7e42-8649-f2bc76feb0e6", "seen_at_version": 7, "...": "" }
]
}

An account has exactly one active device_key. This is a hard constraint of the current server design: the server keeps one contact set per user, and round-2 responses are only meaningful when computed with the same k that produced that set.

Consequently:

  • A second device MUST NOT simply generate its own key and publish. Doing so makes the two devices overwrite each other’s set; whichever device did not publish last will answer work items with the wrong key and will match nobody, with no error anywhere.
  • Multi-device support MUST be implemented as either (a) out-of-band transfer of device_key to the new device, or (b) explicit takeover: generate a new key, immediately republish the full set, and treat the other device as logged out.
  • Key rotation is only complete once a successful publish under the new key has returned. See the invariant in §7.1.

The published set MUST be built as follows, in this order:

  1. normalize every contact number; drop entries that fail validation and surface them to the user (a silently dropped contact is a silently missed match).
  2. Remove own_e164 if present. It is carried separately as own_token; including it inflates mutual_count and muddies direct-acquaintance semantics.
  3. De-duplicate by normalised value. The server rejects duplicate points with 422, because duplicates make mutual_count gameable and desynchronise the two sides’ index lists.
  4. Sort ascending by the byte value of the E.164 ASCII string.
  5. If more than 5000 entries remain, keep the first 5000 in that order, record that truncation happened, and tell the user. Truncation MUST be deterministic so two clients for the same user publish the same set.

Sorting is not required by the server, but it is required by this spec: it makes the published order a pure function of the contact set, so the digest in §5.3 is comparable across clients and platforms, and so a re-implementation can be diffed against another client’s output.

For the resulting list C[0..n-1]:

elements[i] = base64( apply_key( hash_to_point(C[i]), device_key ) )
own_token = base64( apply_key( hash_to_point(own_e164), device_key ) )
published_index = C # persist alongside version/digest/fingerprint

own_e164 MUST be set before publishing; own_token has no valid “absent” encoding, and a placeholder point would produce false direct-acquaintance hits.

The server learns the size of every published set. To stop it from counting a user’s contacts, a client SHOULD pad the element list to a fixed bucket before publishing:

bucket = 64
target = min(5000, ceil(max(n, 1) / bucket) * bucket)
elements = elements ++ [ base64(random_ristretto255_point()) for _ in range(target - n) ]

Padding points MUST be fresh uniformly random group elements (crypto_core_ristretto255_random), MUST come after the real elements so published_index stays aligned, and MUST NOT be persisted. A random point is not the image of any phone number under hash_to_point, so a padding element can never intersect anything: mutual_count, my_matched_indices and the direct check are unaffected, and an index ≥ n never appears in a match. The reference client pads with bucket = 64; a client MAY choose a different bucket, but all of a user’s publishes SHOULD use the same one.

Login. Either POST /auth/register / POST /auth/login (username + password) or a top-level navigation to GET /auth/google/start (Google OIDC, authorization-code + PKCE; the server never exposes Google tokens to the client). Both end with a vouch_refresh httpOnly cookie set and, for the password endpoints, { "token": "<JWT>" } in the body. After the Google redirect lands back on the app, and on every app start, call POST /auth/refresh (cookie-authenticated) to obtain an access token. Send it as Authorization: Bearer <token> on every other call; on 401 refresh once and retry (§8). POST /auth/logout revokes the session server-side; GET /me/sessions / DELETE /me/sessions/{id} manage other devices.

Phone verification. Every PSI endpoint (PUT /me/contact-set, GET /sync, POST /pairs/*) answers 403 phone_not_verified until the account has proved possession of a phone number and bound its own_token to it:

POST /me/phone/start { "e164": own_e164 } → 202, SMS sent
POST /me/phone/verify {
"code": "<6 digits>",
"own_token": base64( k·H(own_e164) ),
"own_pubkey": base64( k·G ),
"proof": { "c": base64(c), "s": base64(s) } → 200 { verified_at, republish_required }
}

(c, s) is a Chaum–Pedersen (DLEQ) proof that the same k (the device_key) is behind own_pubkey and own_token. With P = H(own_e164), G the ristretto255 basepoint, L the group order:

K = k·G, T = k·P
r ← random scalar
A1 = r·G, A2 = r·P
c = scalar_reduce( SHA-512( "vouch/dleq/v1" ‖ P ‖ K ‖ T ‖ A1 ‖ A2 ) )
s = r + c·k (mod L)

The server recomputes A1' = s·G − c·K, A2' = s·P − c·T and checks the hash. It then discards P, keeping only a keyed hash of the number (one account per number) and T = own_token. vectors/protocol_vectors.json carries a fixed (k, r) vector; a client MUST reproduce it byte for byte.

Consequences a client MUST respect:

  • own_token in PUT /me/contact-set MUST equal the verified token (422 own_token_mismatch otherwise). If the device_key changes, the user MUST re-verify before publishing.
  • Verifying a different number, or the same number with a new key, deletes every published set and pair (republish_required: true); treat it like a key rotation in §5.3.
  • Codes expire after 10 minutes, allow 5 attempts, and at most 3 sends per hour per account and per number (410, 429).
  • Rate: POST /auth/* is limited per source IP at the edge.

PUT /me/profile with gender, birthdate, seeking, age_min, age_max. The server deletes all of the user’s pairs on every profile write and re-runs the matcher, so a client MUST NOT write the profile unless a field actually changed — a no-op write still destroys in-flight pairings and cached matches.

Republishing is destructive: PUT /me/contact-set deletes every pair the user is in, cascading their responses and matches, and the pipeline restarts from zero. A client therefore MUST NOT publish on a fixed schedule, on app launch, or “just in case”. It publishes only when the set actually changed.

Compute, over the canonical list from §4.1:

digest = SHA-256( own_e164 ‖ "\n" ‖ C[0] ‖ "\n" ‖ C[1] ‖ … ‖ "\n" ‖ C[n-1] )

Publish if and only if at least one holds:

  • there is no stored published record (first run, or after logout);
  • digest != published.digest;
  • key_fingerprint != published.key_fingerprint (the key was rotated);
  • the last publish attempt failed and has not since succeeded.

Additional timing rules:

  • Debounce: after a contact-book change, wait PUBLISH_DEBOUNCE = 60 s of quiet before publishing, so a user editing several contacts causes one republish, not five.
  • Floor: a client MUST NOT issue more than one successful PUT /me/contact-set per PUBLISH_MIN_INTERVAL = 300 s, except for the first publish after login or key rotation.
  • Rescan: the client SHOULD re-read the OS contact book on app foreground and on any OS change notification, and MUST re-read it at least every 24 h when no change notification is available. Re-reading is free; publishing is what costs.

On 200, atomically store version, index, digest, key_fingerprint, and discard the local matches cache — the server has just deleted those matches.

GET /sync returns everything the client needs:

{
"work": [{ "pair_id": "<uuid>", "elements": ["<b64>", "..."], "own_token": "<b64>" }],
"matches": [{ "pair_id": "<uuid>", "other": {...},
"my_matched_indices": [3], "mutual_count": 1,
"matched_at": "..." }]
}

Identifiers are UUID strings (time-ordered UUIDv7, generated by the database). A pair has at most one match, so pair_id identifies the match as well; there is no separate match_id.

The sync algorithm is fixed:

sync():
if a sync is already running: return # single-flight, §6.3
assert published.key_fingerprint == key_fingerprint # §7.1
payload = GET /sync
for item in payload.work: # sequential, in the given order
elements = [ base64(apply_key(unbase64(e), device_key))
for e in item.elements ] # order preserved, 1:1
direct_check = base64(apply_key(unbase64(item.own_token), device_key))
POST /pairs/{item.pair_id}/response { elements, direct_check }
if payload.work was non-empty:
payload = GET /sync # exactly one extra fetch
store payload.matches (tagged with published.version)

Non-negotiable details:

  • Order preservation. elements[i] of the response MUST be the re-encryption of elements[i] of the work item, and the lists MUST have the same length. The server enforces the length (422 otherwise) but cannot detect reordering; a reordered response produces wrong my_matched_indices for the counterparty — they would see a match against the wrong contact. Never sort, filter, or de-duplicate a work item.
  • direct_check comes from the work item. It is apply_key(item.own_token, device_key) — the other user’s own-token re-encrypted with this device’s key. Computing it from any local value breaks direct-acquaintance suppression, which is the mechanism that stops the app from introducing people who already know each other.
  • Exactly one follow-up fetch. When this client posts the second response for a pair, the server compares inline, so the match exists immediately; one extra GET /sync surfaces it in the same cycle. A client MUST NOT loop further — remaining matches arrive on the next scheduled sync.
  • A client MUST NOT cache or reuse a work item across syncs. Pairs are deleted whenever either side republishes; a stale pair_id yields 404.

my_matched_indices indexes into this user’s own published set, in the order that was published. Resolve with the stored index:

names = [ published_index[i] for i in match.my_matched_indices ]

A client MUST discard (and not display) any cached match whose seen_at_version differs from the current published_version, and MUST NOT resolve indices against a published_index from a different version. Indices from an older set point at different contacts — the failure mode is showing the user the wrong mutual friend, which is a privacy incident, not a cosmetic bug.

The server only returns matches with mutual_count >= 1 and direct == false, so a client does not filter; it displays what it receives.

Before phone verification, profile or contacts, the account must carry an age declaration and an acceptance of the current privacy notice (GET /medeclarations_complete, notice_version, current_notice_version). Password registration takes age_declared and terms_accepted; Google sign-ups and re-acceptance after a notice change use POST /me/declarations. Until then the gated endpoints answer 403 declarations_required.

The profile carries orientation data, so the first PUT /me/profile MUST send "consent": true (explicit consent); without it the server answers 403 profile_consent_required. Consent stands until DELETE /me/profile, which deletes the profile and every pair and clears profile_consent_at. PUT /me/contact-set requires a profile (403 profile_required) and DELETE /me/contact-set unpublishes: every version and every pair is removed and the user is not matched until they publish again. Only the version being served is stored server-side; superseded versions are deleted on publish.

POST /me/reports { "pair_id": "<uuid>", "reason": "…" } (authenticated, not gated on phone verification) files a report against the other side of one of the caller’s pairs. One report per (reporter, reported) pair of users: a second attempt answers 409. GET /me/reports lists the caller’s reports with their resolution. Reports are reviewed by admins; a banned account answers 403 account_banned on every authenticated call (§8).

GET /me/export returns every record the server holds about the caller: account fields (hashes and tokens base64-encoded), login identities (provider and a hint, never a Google sub or a password hash), sessions, profile, contact-set versions (element counts, not elements), pairs with match results, reports made, and reports received (with the reporter and their text withheld). Both work for a banned account too. DELETE /me removes the account and cascades to all of it. Both are the technical side of the access, portability and erasure rights described in docs/privacy.md.

These values are protocol constants. They are chosen together, and they are the main reason this document exists — clients that poll on different schedules produce different end-to-end latencies and different reproduction steps for the same bug.

Constant Value Rationale
SYNC_INTERVAL 300 s (5 min) while the app is in the foreground Bounds end-to-end match latency at ~2 intervals (§6.1) while keeping the request rate at ~12/h per client.
SYNC_MIN_INTERVAL 30 s Floor across all triggers. Coalesce anything that would fire sooner.
SYNC_BACKGROUND_INTERVAL ≥ 900 s (15 min) Mobile background/scheduled work. Never poll harder in the background than in the foreground.
SYNC_BACKOFF_BASE / FACTOR / CAP 5 s / ×2 / 300 s Recovery from server errors without a thundering herd.
SYNC_BACKOFF_JITTER ±20 % Clients are woken by the same events (network regained, push); jitter de-synchronises them.
PUBLISH_DEBOUNCE 60 s One republish per editing session.
PUBLISH_MIN_INTERVAL 300 s Republish destroys all pairs; this bounds the damage from a pathological client.
CONTACT_RESCAN_MAX_AGE 24 h Upper bound on staleness when the platform gives no change events.

A sync MUST be triggered, subject to SYNC_MIN_INTERVAL, on each of:

  1. successful login or session restore at app start;
  2. app entering the foreground;
  3. immediately after a successful PUT /me/contact-set;
  4. immediately after a successful PUT /me/profile;
  5. every SYNC_INTERVAL while in the foreground;
  6. explicit user action (“sync now”), which SHOULD bypass the interval but MUST NOT bypass single-flight;
  7. network connectivity regained after a failure.

Triggers 3 and 4 matter because both endpoints run the matcher inline: pairs exist the instant they return, so syncing immediately turns a 5-minute wait into a sub-second one.

The pipeline needs two round-2 responses before a match materialises:

publish (pairs created inline)
→ device A syncs, posts response ≤ SYNC_INTERVAL
→ device B syncs, posts response ≤ SYNC_INTERVAL (compare runs inline here)
→ B sees the match immediately (follow-up fetch)
→ A sees the match on its next sync ≤ SYNC_INTERVAL

Worst case for the slower side is therefore ~2 × SYNC_INTERVAL ≈ 10 minutes from publish to visible match, and the median is well under that. Polling also cannot be replaced by event-driven syncing alone: the server’s background matcher creates pairs every 60 s in response to other users’ activity, so work appears with no local trigger at all.

On a transport error or 5xx, the next sync is delayed by min(CAP, BASE × FACTOR^failures) with ±JITTER, and the failure counter resets on the first success. Backoff replaces the regular interval; it does not add to it.

A client MUST hold a lock (or equivalent) for the duration of sync() and drop any sync requested while it is held. Two concurrent syncs fetch the same work item and race to POST /pairs/{id}/response; the loser gets 409 already responded, which is harmless but indistinguishable in logs from a genuine protocol bug.

Each of these has a silent failure mode, which is why they are stated as invariants rather than left to implementation taste.

  1. Key–set binding. The key used to answer a work item MUST be the key that produced the currently published set. Before processing work, a client MUST check published.key_fingerprint == key_fingerprint; on mismatch it MUST republish first (§5.3) and skip this sync’s work. Violation: every comparison fails; the user simply never matches, with no error client- or server-side.
  2. Index–plaintext binding. published_index[i] is the plaintext of published element i, for the lifetime of published_version. Violation: matches are attributed to the wrong contact.
  3. Order preservation in round 2. Response element i is the re-encryption of work element i. Violation: the counterparty is told the wrong contact matched.
  4. No duplicates, own number excluded. Violation: 422 on publish, or an inflated mutual_count.
  5. Canonical E.164 everywhere. Hash only the output of normalize. Violation: the same person hashes to two different points and never matches.
  6. Publish only on change. Violation: pairs are destroyed faster than they can complete; the user never matches even though everything “works”.
  7. Plaintext never leaves the device. Numbers, published_index, and the device key are local-only, including in logs and telemetry. Violation: the entire privacy claim of the system.
Status Endpoint Meaning Required client behaviour
401 any except /auth/* Access token expired (15 min), session revoked, or invalid POST /auth/refresh once; on success retry once with the new token, on 401 drop the session and show login. Do not loop.
401 POST /auth/refresh Refresh token unknown, expired, revoked, or replayed The session is gone (a replayed old token revokes it). Show login.
403 /admin/* Not an admin Do not retry.
403 phone, profile, contacts declarations_required Show the declarations screen (§5.6), then retry.
403 PUT /me/profile profile_consent_required Ask for explicit consent and resend with consent: true.
403 PUT /me/contact-set profile_required Route the user to the profile step.
403 PSI endpoints phone_not_verified Route the user to phone verification (§5.1).
403 any authenticated account_banned Show the user they are locked out; offer logout. Do not retry.
429 PUT /me/contact-set too_many_publishes: more than VOUCH_PUBLISH_PER_HOUR versions this hour Honour the Retry-After header (seconds). Keep the local state; publish again after it elapses.
409 POST /me/reports already reported Treat as success.
409/410/429 /me/phone/* Number in use / no pending code / too many attempts or sends Surface to the user; 410 means start again.
422 POST /me/phone/verify invalid_proof: the DLEQ did not verify against the number just texted A client bug (wrong P, wrong key). Do not retry with the same payload.
422 POST /me/phone/verify number_mismatch: e164 differs from the number the code was sent to Resend the number that was started; the server keeps no copy of it.
409 POST /me/phone/verify phone_in_use: another account verified this number Surface to the user. POST /me/phone/start deliberately never reveals this.
422 PUT /me/contact-set own_token_mismatch: token differs from the one bound at verification The key changed since verification. Re-verify (§5.1), then republish.
404 POST /pairs/{id}/response The pair no longer exists — the counterparty (or this user) republished Expected. Drop the work item, continue with the rest, no error to the user.
409 POST /pairs/{id}/response already responded or pair already compared Treat as success. It means a previous attempt landed. Do not retry.
409 POST /auth/register Username taken Surface to the user.
422 PUT /me/contact-set Invalid base64, non-canonical/identity point, or duplicate points A client bug. Do not retry with the same payload; log and surface.
422 POST /pairs/{id}/response Element count differs from the other side’s set A client bug (the work item was filtered or reordered) or a lost race. Drop the item and re-sync.
5xx, transport any Server or network fault Back off per §6.2. Never drop local state in response.

Responses are safe to retry only where stated: PUT endpoints are idempotent by design (a repeated contact-set publish creates a new version, so retry only on transport failure, never speculatively), and POST /pairs/{id}/response is made idempotent by the 409 rule above.

Device A Server Device B
| | |
|-- POST /auth/register ---->| |
|<-- access JWT + refresh ---| |
| cookie | |
|-- POST /me/phone/start --->| SMS code; keeps H(ownA) 10m |
| T = H(ownA)·kA, K = kA·G | |
| DLEQ proof (c, s) | |
|-- POST /me/phone/verify -->| check code + proof; store |
|<-- {verified_at} ----------| hash(ownA), T, K; drop H |
|-- PUT /me/profile -------->| (drops A's pairs, runs matcher)
| | |
| build canonical set C | |
| e[i] = H(C[i])·kA | |
| own = T (must match) | |
|-- PUT /me/contact-set ---->| store v1; drop A's pairs; |
|<-- {version, count} -------| run matcher |
| persist index+digest+fp | |
| |<---- PUT /me/contact-set ---|
| | matcher: A & B mutually |
| | compatible -> Pair(id=P, |
| | state=awaiting_both) |
| | |
|-- GET /sync -------------->| |
|<-- work[{P, B's elements, | |
| B's own_token}] --| |
| r[i] = elements[i]·kA | |
| dc = own_token·kA | |
|-- POST /pairs/P/response ->| store; state=awaiting_one |
|<-- 204 --------------------| |
|-- GET /sync -------------->| (follow-up; no match yet) |
| | |
| |<---------- GET /sync -------|
| |--- work[{P, A's elements, |
| | A's own_token}] -->|
| |<-- POST /pairs/P/response --|
| | compare_pair inline: |
| | equal 32-byte values |
| | -> mutual_count, indices |
| | direct_check hit? |
| | -> suppress |
| | state=compared |
| |----------- 204 ------------>|
| |<---------- GET /sync -------|
| |-- matches[{my_matched_...}] >|
|-- GET /sync (next tick) -->| |
|<-- matches[{P, indices}] --| |
| names = index[i] for i | |

A new client is conformant when all of the following hold:

  • Runs vectors/protocol_vectors.json in its own test suite, green.
  • Emits padded standard base64; rejects nothing the server accepts.
  • Builds the published set per §4.1 (normalise, drop own, de-duplicate, sort, cap at 5000) and pads it per §4.3 with padding after the real elements.
  • Persists index, version, digest, key_fingerprint atomically after a successful publish, and only then.
  • Publishes only when the digest or key fingerprint changed, with the debounce and floor from §5.3.
  • Refuses to process work when the key fingerprint does not match the published set (§7.1).
  • Preserves work-item order exactly in the round-2 response.
  • Derives direct_check from the work item’s own_token.
  • Performs exactly one follow-up GET /sync after posting responses.
  • Implements the trigger list and constants in §6, including single-flight and jittered backoff.
  • Treats 409 on a pair response as success and 404 as an expected drop.
  • Discards cached matches when published_version changes.
  • Keeps the device key in platform-secure storage and out of every log, backup and telemetry sink.