Skip to content

Latest commit

 

History

History
431 lines (304 loc) · 125 KB

discovery.md

File metadata and controls

431 lines (304 loc) · 125 KB

Discovery and advertising

Part of the protocol reference: what a phone broadcasts, how it is found, and the names it shows.

Peer labels: handles, names, call subjects and stream titles

Status (2026-10-11): no longer experimental. Names and stream titles are on by default since 2026-10-17 with no Settings switch (an empty name is anonymous; DEBUG test_peer_labels is the benchmark switch, default off there). Naming design phase 1 with review fixes R1-R10 and R13 (design decisions in DECISIONS "Naming, uniform handle and relayed discovery"). With the switch off, every byte on air and on the link is unchanged. The wire is unchanged by the promotion; the look-alike warning (UTS #39 skeletons) is display only and sends nothing. Not yet tested on phones.

Handle (UI only, no wire change). prefix48 = SHA-256(identity SPKI DER)[0:6] (the announcement node prefix). Encode prefix48 << 2 (50 bits) as 10 Crockford Base32 symbols (0123456789ABCDEFGHJKMNPQRSTVWXYZ), most significant first, shown SSSSS-SSSSS; the last symbol's low 2 bits are zero. Parsing is case-insensitive, maps I/L to 1 and O to 0, ignores hyphens and spaces, and needs exactly 10 symbols with a zero tail. Vectors (PeerLabelTest, generated by the design's handle_vectors.py): 010203040506 -> 04106-1050R, a1b2c3d4e5f6 -> M6SC7-N75YR, 000000000000 -> 00000-00000, ffffffffffff -> ZZZZZ-ZZZZW. People who are not your contact always show the full handle (R5: 25 bits are ground in minutes). Logs, BleL2capPeer.label and host tools keep the 12-hex prefix (R10). The handle proves nothing before connecting; after connecting it is the existing 48-bit prefix check. Only the full fingerprint (QR or safety code) verifies a person.

Capability. End-to-end hello transport-mask bit 5 (0x20) = "accepts and sends LABELS". It is never set in a link hello or a BGK1 group hello. Old readers test only their own bits. A LABELS record is sent only when both endpoint hellos carry the bit; an opener that did not advertise it rejects type 10 as an unknown type, exactly as before.

Signed label BNL1 (big-endian):

"BNL1" | version 1 (1) | kind (1): 1 person name, 2 stream title, 3 retired (call subject)
| SPKI length (2, 1..512) | identity SubjectPublicKeyInfo DER
| sequence (4; counts within one binding only, starts at 0)
| binding type (1): 1 call ID, 2 stream ID | binding (16)
| text length (1, 1..64) | UTF-8 text
| signature length (2, 1..256) | ECDSA P-256 SHA-256 DER signature over
    "BluetoothVideoMesh/Label/1" || 0x00 || every byte before the signature length

Kind 3 retired (2026-11-05). Kind 3 was an optional call subject. It is retired: no client sends it or shows it, and the value is never reused. An older build may still send one in its call LABELS container; a receiver decodes it structurally and drops it silently, like an unknown kind (no rejection log, never shown, never forwarded), and the rest of the container and the call go on unchanged.

There is no timestamp (R2: it leaked clock skew). Every label is bound to the BMC2 call ID or a stream ID, so it cannot be replayed into another call or stream, and no per-signer state outlives the session. A direct stream that has no mesh stream ID binds to the all-zero stream ID; that binding is accepted only from the authenticated endpoint peer of that direct stream (a forwarded grant naming the zero stream ID gets no labels). Labels are signed with the existing identity key, once per binding.

Container LABELS (BME1 content type 10, packet flags 0): "BLB1" | count (1, 0..3) | count x (length (2) | BNL1). Count 0 is valid (R4).

Receiver checks, in order. Container lengths; label magic/version; known kind (unknown kinds are skipped, for later kinds, and so is the retired kind 3); SHA-256(SPKI)[0:16] equals the expected signer (the call's endpoint peer; the direct stream's endpoint peer; for a forwarded stream the source node ID in BFG1); signature; binding equals this call ID or stream ID; the text is already canonical under the text rules below. A label that fails is dropped and SECURITY LABEL REJECTED kind=… reason=… is logged without the text; a higher sequence replaces a lower one within the session; more than one container per 2 s or eight per minute is ignored. A label never fails a call or stream.

Text rules (LabelText). Trim, collapse whitespace runs to one space, NFC. Byte limits: person name 32, stream title 48 (the BNL1 text length stays 1..64 so a retired kind 3 subject of up to 64 bytes from an older build still decodes and is skipped). Reject control, format (bidi controls, ZWJ/ZWNJ), private-use, surrogate, unassigned and line/paragraph separator code points; more than 3 consecutive combining marks; U+2713/2714/2705/2611/1F5F8; scripts outside UTS #39 Moderately Restrictive (one script plus Common/Inherited; Latin plus one other script except Cyrillic or Greek; Latin+Han+Hiragana/Katakana, +Bopomofo or +Hangul); and handle-shaped text: after NFKC and lowercasing, ^[0-9a-z]{5}-[0-9a-z]{5}$, or text that parses as a full or short handle and contains a digit (R6: "Brave Tiger" stays valid). The unassigned check uses the receiver's Unicode version, so a newer character drops the name to the handle on an older phone (fails safe).

Calls (direct and relay chains). LABELS is an ordinary endpoint record; existing call relays copy it unparsed. The timing of a caller's name toward a stranger, the audience setting and the added text rules are in "Naming v2".

  • Caller: one container (its name if set; older builds also put their kind 3 subject in it, which receivers skip) bound to the call ID, queued immediately before Invite in the ordered endpoint sequence.
  • Callee: holds at most one container received before the Invite of the same session and verifies it against that Invite's call ID (R3).
  • Callee's own name (R1): sent only immediately before Accept, or at Ringing when the caller is a verified contact (full fingerprint). A declined, cancelled or unanswered call never reveals the callee's name.

Streams. A stream hop exchanges LABELS only when both endpoint hellos carry bit 5 and the link peer is the endpoint peer (no opaque stream relay between, since stream relays inspect BME1 types). Then exactly one record is sent at a fixed position: a direct source sends it as the first endpoint record, before the stream header (title bound to the mesh stream ID, or to the zero ID for a non-mesh stream); a forwarder sends it right after GROUP_KEY under the child's endpoint session, containing only the labels it verified from the BFG1 source for that stream ID, byte for byte (count 0 if none). The receiver verifies after reading the header (the stream ID is known then). A forwarder can withhold labels but not forge, edit or rebind them; per PROTOCOL "Forwarded streams", a malicious forwarder can still name itself as the source and sign its own title (R22).

Public title pages. A second legacy, non-connectable, scannable advertising set beside the stream offer (source or verified forwarder): primary PDU = the service UUID c8d5a101-…; scan response = manufacturer 0xFFFF:

version 3 (1) | stream route token (4, of this advertiser's stream offer) | SHA-256(title)[0:2]
| page (1): index << 4 | count (count 1..3, index < count) | 1..19 title bytes (whole code points)

Interval 250 ms, one page per second (about 4 advertising events per page). Old scanners reject the payload (not 22 bytes). Scanners with the switch on route version-3 payloads to a title board before the per-address dedup, so rotated pages are seen; they reassemble by (token, digest, count) within 30 s, require valid canonical UTF-8 matching the digest, and show nothing for a token with two different digests within 60 s. An advertised title is shown as plain text (since 2026-10-17; the conflict rule above blanks a contested title); after joining, the signed kind 2 label replaces it. If no set is free (a one-time probe of free legacy sets, logged BENCHMARK adv_set_probe free=… | extAdv=… | multiAdv=… | maxAdvData=…, found fewer than 2 free sets, so one stays spare for the stream offer's own stop + restart and OS advertisers; a forwarder that never probed probes first; or the start fails, e.g. ADVERTISE_FAILED_TOO_MANY_ADVERTISERS), only the signed title exists; the stream is never affected. iPhones cannot advertise manufacturer data, so an iPhone broadcaster has no public title.

Vectors (SignedLabelTest, TitlePageTest; the iOS repository holds the same table):

Input Bytes (hex)
Signing domain 426c7565746f6f7468566964656f4d6573682f4c6162656c2f3100
BNL1 signed part: kind 3 (retired; the bytes still decode), SPKI 3001, sequence 0, call ID 01020304-0506-0708-090a-0b0c0d0e0f10, text "Hi" 424e4c31 01 03 0002 3001 00000000 01 0102030405060708090a0b0c0d0e0f10 02 4869
Empty LABELS container 424c423100
Title page: token 0x81234567, title "Hi" 03 81234567 3639 01 4869

Compact (iPhone) call-offer amendment (R11). Before compact v1 ships: call-offer bit 18 = "may be relayed" (the same consent as call-offer capabilitySummary bit 4, used by relayed discovery, phase 2). Decoders carry it into capabilitySummary bit 4 and never reject it; call-offer bits 49..19 and 17..0 still reject. Vector: call offer, prefix 010203040506, LE CoC, VP9+AV1, cost 0, bit 18 set -> C8D64010-2030-4050-60C0-000000040000 (CompactDiscoveryAnnouncementTest).

Naming v2: generated names, label timing, name pages and verification codes (design 2026-10-12, not built)

Status: specified and independently reviewed (findings F1-F25 adopted or answered in DECISIONS "Design G"). Label timing and audience, generated names, the text rules and the claims file are coded on branch feature/naming-v2 (JVM-tested, not on phones); BVMID2 (single-use reservation, verify-only scope, K_tag vector) and the Visible-by-name pieces (codec, bounded scanner, window state) are coded behind bvmid2 (on by default since 2026-10-19, phone-tested with its presence-lease exchange) and visible_by_name (off); the window offer set, the page advertiser and the Meet screen's scanner connect loop are not bound on Android yet. Every item below is off or unchanged until its switch ships (the register is docs/dev/switches.md; reasons and alternatives are in DECISIONS "Design G"). With every switch off, every byte on air and on the link equals 0.2.0-beta.

Label timing and audience (no new bytes)

BNL1, the LABELS container, bit 5 and the receiver checks of "Peer labels" are unchanged. What changes is when a sender queues them:

  • Caller, stranger session. The caller's LABELS container with the kind 1 name (if set and the audience allows) is held until the callee's REVEAL is verified (the Phase 2 rule) and the revealed identity is not on the caller's block list; the audience is applied again at that moment, and a callee that declines or never reveals never gets it (since 2026-11-06). It is signed by the attempt pseudonym, bound to the call ID like any label, and the receiver treats it as a name claimed by an unverified peer. A callee that presented its real key gets the name before INVITE (after its OPENING when relayed) unless that key is on the caller's block list. A caller whose callee presents the real key of a blocked person in the endpoint handshake sends nothing else either (since AB3/AP3, as Windows): the INVITE goes as usual, no camera, microphone or presence record follows, the callee's media is dropped, and after 3 s + random(0..4 s) the caller ends with the ordinary CANCEL (or the ordinary HANG_UP, reason normal, if the callee answered). The Experimental arm "Show my name before they answer" (names_before_answer, off) queues it immediately before INVITE also when the session is pseudonymous (rule P of BMH3 Phase 2). The arm is the sender's setting; the receiver cannot tell the difference except by when the label arrives. No byte changes.
  • Audience. "Everyone I call or answer", "Only people I verified" or "Nobody" decides whether kind 1 is sent at all. A verified contact (real-real session) always gets the name when the audience is not "Nobody".
  • After the reveal. The label stays attached to its call session; when the reveal replaces the throwaway by the real identity, the session's claimed name is re-keyed to the real identity for display only. A label is never persisted for a stranger. For a verified contact the last verified claimed name may be stored (below). This amends Phase 2 step 5: the recent-calls entry of a call with an unverified stranger stores only the generated name and the time; the revealed real identity (node ID, fingerprint) lives in memory for the call and is persisted only if the person is then verified, so a seized phone does not hold a list of stranger keys. Since 2026-10-27 the whole recent-calls list (node prefixes, and the fingerprint of a caller that showed its real key, which lets its row block the person) is sealed with the verified-contacts Keystore key; the plain list of earlier builds is read once, rewritten sealed and removed, and a sealed list that cannot be opened is never overwritten.
  • Callee. Audience "Everyone I call or answer": unchanged (name before Accept, or at Ringing to a verified contact). Audience "Only people I verified": the callee cannot know before the caller's reveal whether the caller is verified (a contact whose lease expired arrives as a stranger), so its name is queued only after the caller's REVEAL is verified and PeerIdentityGate.admit found a verified contact; a stranger caller gets none. "Nobody": none.
  • Streams unchanged: no kind 1 on a stream hop; the title is the only label.

Text rules (addition to LabelText). The existing rules do not stop these characters (LabelText ignores the Common script, which holds ~ and its look-alikes, and rejects only control, format and a few other categories), so the reserved-form rule is explicit and runs on the canonical text and on its UTS #39 skeleton with spaces and punctuation removed and case folded: (1) a person name may not start or end with ~, U+223C, U+FF5E, U+02DC, U+2053 or U+301C; (2) it may not contain U+1F6E1 (shield), U+1F512, U+1F513, U+1F510, U+1F50F (locks), the check marks already refused, ~ anywhere, or the separators the app uses to join name parts (-, and a comma or a middle dot followed by "seen", in any case: U+00B7 or a look-alike dot U+0387, U+2027, U+2219, U+22C5, U+30FB, U+FF65); (3) its reduced skeleton may not equal the reduced skeleton of any generated-name pair (so "BlueOtter", "Blue 0tter" and "blue otter" are refused, not only "Blue Otter"). A receiver drops a label that breaks a rule exactly as for any other rule (SECURITY LABEL REJECTED kind=1 reason=reserved_form, no text logged). Old senders may produce such names; new receivers then show the generated name. Every screen renders a claimed name in its own styled element, never concatenated into one string with the marks or the generated name.

Compatibility. Labels flow only when both endpoint hellos carry bit 5. A 0.2.0-beta peer ships bit 5 off by default, so no names are exchanged with it unless the user switched them on there; nothing breaks either way. A 0.2.0-beta peer that did switch labels on receives the earlier name at Invite and shows it by its own old rules (the quoted name beside the handle, without the tilde); that is cosmetic.

Generated names names-v2 (local display, no bytes; names-v1 kept for vector history)

d    = SHA-256("BluetoothVideoMesh/Naming/StrangerName/1" || 0x00 || input)
name = adjectives[d[0]] || " " || nouns[d[1]]
chip = d[2] & 7         number = d[3] % 100

input is exactly one of: (1) the 6-byte RDI of a version 5 or compact v2 advert, as seen by a scanner or as computed by the owner for its own roles 0 and 1; (2) the 16-byte node ID of the throwaway key of an incoming stranger session, on both ends. No real identity value, advertising address or per-install secret is ever an input; fixed-prefix (version 2) adverts, Classic roles and Visible-by-handle windows show the handle. adjectives and nouns are the 256-entry files adjectives-v2.txt and nouns-v2.txt (ASCII a-z, 3-7 letters, one word per line, LF, sorted by a fixed index, SHA-256 pinned in the test); the rules for entries, the screening and the licence are in DECISIONS "Design G". The lists are frozen once released; another list is names-v3. names-v1 (adjectives-v1.txt, nouns-v1.txt) was replaced before any release (two multilingual reviews of 2026-10-12 flagged 32 of its words) and stays in the sources only so its vectors remain reproducible.

Pair blocklist (blocked-pairs-v2.txt, one adjective noun per line, sorted, SHA-256 pinned). A pair on the list is never shown: when d[0], d[1] select a blocked pair, the same input is hashed again with one byte appended, d = SHA-256("BluetoothVideoMesh/Naming/StrangerName/1" || 0x00 || input || attempt) with attempt = 1, 2, ... (a single byte; attempt 0 has no byte, so a list without a blocklist is the plain function), until the pair is allowed (at most 16 attempts, then the last one stands). Every phone skips the same pairs the same way, so the name stays something people can say aloud. The list holds famous or political phrases whose words are both listed (Blue Falcon, Gold Dawn, Gold Rain, Gold Stream, Silver Fox, Icy Crystal, Snowy Crystal) and a texture/body adjective crossed with shellfish nouns that carry an innuendo (oyster, mussel, scallop, jelly). Phrases whose words left the lists are kept as dormant lines in the screening report, not in the file.

Display rules a second implementation must follow: the name is a convenience, never an authenticator; rows are never merged by name; when two or more visible rows (different IDs) would show the same text, every row of that group gets and number as two digits (never only "the later one": arrival order is not an input), and the rows whose text still matches then get - and the last four hex digits of the ID, as in Blue Otter 42-7a7d; copied-ID warning (rows are keyed by RDI): it appears only when two different advertising addresses carry the same RDI at the same route cost, both were seen within 2 s of each other and their sightings interleave (the other address was heard again after this one first appeared), more than once. A second address that replaces the first inside an epoch (the stack's own address timer, or an advertising set restarted with a new random address) and a relay's mirror of a child's ID at route cost + 1 (a different row: a route) are not copies. Nor is a Windows PC's compact call offer sent as two sets (Design CI): exactly two recent addresses, one received as a legacy advert and one as an extended advert with a secondary PHY, whose Flags, 16-bit and 128-bit UUID list ADs are byte-identical and whose 16-bit list carries 0x180A, count as one sender. For this two-address test only, an address is left out when a newer address with the same form (the same kind and the same compared ADs) was first heard strictly after it was last heard; it counts again if heard after that. A third address, two of the same kind or any difference in those ADs still counts. A scanner that can tell legacy from extended adverts may apply this; otherwise it keeps the warning (DEBUG control arm test_copied_id_pc_pair=false). The warning disables nothing by itself; it asks for a second tap. The suffix digits and RDI digits come from the RDI, never from arrival order, so two phones that see the same rows show the same text; a phone that sees only one of two colliding rows shows no suffix, which is why a name is never evidence of who owns an ID. Vector (lists names-v2 as committed; SHA-256 of adjectives-v2.txt d8327896...7b46, of nouns-v2.txt 1560dd97...a39c, of blocked-pairs-v2.txt 381d7404...a304; the names-v1 words are in the second column of the history note below):

Input d[0..3]
RDI 44e69c0a7a7d (role 0 vector of "Experimental rotating discovery IDs") 143, 33, 182, 219 (chip 6, number 19) = "mild bream" (names-v1: "mauve cape")
RDI 67aba670e65a (same phone, next epoch) 6, 168, 149, 135 (chip 5, number 35) = "amiable otter" (names-v1: "alpine parrot")
RDI 1020000006b8 (blocklist path: attempt 0 selects the blocked pair "firm jelly" = 77, 118, 152, 4) attempt 1: 170, 168, 194, 183 (chip 2, number 83) = "plum otter"

Visible by name: window offer (version 8), name pages (version 7) and the role 3 ID (off by default; visible_by_name)

Role 3 ID. RotatingDiscoveryIds.ROLE_WINDOW_OFFER = 3, derived exactly like roles 0 and 1: salt = HMAC(day key, "BluetoothVideoMesh/Discovery/Salt/1" || 0 || u32 epoch || 3), RDI = SHA-256("BluetoothVideoMesh/Discovery/Id/1" || 0 || nodeId || u32 epoch || 3 || salt)[0:6], so it is unlinkable to the role 0 and role 1 IDs of the same epoch. The epoch is the value the ordinary IDs use (read, never ticked or advanced by a window). BDO1 openings for it carry role 3 and are checked with expectedRole = 3 (RV15 unchanged); the responder keeps the usual 60 s pin of its role 3 entry.

Window offer, version 8. A connectable legacy advertising set whose manufacturer 0xFFFF scan response is the 22-byte announcement of version 5 with version = 8: prefix = role 3 RDI, flags = accepting connections and private call available, streamToken (the route-token field) = SHA-256(UTF-8 name)[0:4], all other fields as for a call-ready offer. A 0.2.0-beta scanner rejects version 8 as unsupported (it shows no row for the window offer and does not fail). A new scanner decodes version 8 only when visible_by_name is on, treats it as a call offer for selection (BDO1 role 3, throwaway key and the whole stranger-call path unchanged) and shows it as a row with the generated name of its role 3 RDI and, if a page matches, the claimed name.

Name pages, version 7. The manufacturer 0xFFFF scan response of a second, non-connectable, scannable legacy advertising set (the stream-title set machinery with the same one-time free-set probe and the same "no free set means no pages, no effect on anything else" rule), advertised only while the window is open:

version 7 (1) | window RDI (6, the role 3 ID on air) | SHA-256(UTF-8 name)[0:4] (4)
| page (1): index << 4 | count (count 1..4, index < count) | 1..10 name bytes (whole code points)

At most 22 bytes. The name obeys the person-name text rules (32 bytes). Pages hold whole code points, and a name is split into the fewest pages; names of code points up to three bytes always fit in four pages (a page holds at least 8 of their bytes), but a name mixing three- and four-byte code points (emoji with other scripts) can need a fifth, and the window refuses such a name ("too long to show") rather than send it. (The first draft said "any mix"; a unit test found the counter-example.) One page per second, interval 250 ms. Both sets restart at each epoch boundary inside a window (same 1-8 s break-before-make as the call-ready set) with the new role 3 ID.

Commitment (anti-spoofing). A page carries no signature and no link to the advertiser. A scanner shows a name for a window row only if (a) the row's version 8 offer carries a non-zero token and (b) reassembled pages for that window RDI hash to exactly that token. Pages for an RDI with no matching offer, token 0 or a different token are ignored: a bystander can neither name a phone that is not in a window nor rename one that is, because the offer comes from the owner's own advertising address. The 4-byte digest is a commitment, not a secret: anyone can test candidate names, which is harmless because the name is public during the window.

Scanner rules. A scanner with the switch on routes version 7 and 8 payloads before the per-address dedup. It admits a page only for a window offer it is currently hearing (a version 8 offer seen in the last 60 s) and reassembles by (RDI, digest, count) within 30 s under global bounds: at most 16 assemblies in all and 4 per RDI, at most 640 buffered bytes, at most 32 pages parsed per second, and at most 24 window rows; beyond a bound the oldest assembly is dropped and the page ignored, and a page for an RDI without a heard offer costs one table lookup. A reassembled name must be valid canonical UTF-8 whose full-text hash matches the offer's token. The association is an unauthenticated claim: anyone can copy an RDI and advertise their own token and pages, and recency is not freshness, so the name is shown only as "~Name, calls itself", never with a mark or as trusted; the first authenticated call replaces it by the signed label from the actual key (a differing page name is dropped). A conflicting fragment set is ignored, not displayed. The name is shown as "~Name" with the words "calls itself" (a claim; no mark). A page never starts a connection, and the row's selection, binding (BDO1, REVEAL) and call setup are unchanged. A scanner without the switch, and every 0.2.0-beta scanner, rejects both versions as unsupported and shows no extra row.

Window. One in-memory state shared with the Visible-by-handle window (mode ID or NAME, never both). Mode NAME starts on the user's tap and ends exactly 600 s later (elapsedRealtime; a wall clock moved back cannot extend it), on Stop, on an app restart or on Bluetooth off. Mode ID keeps its 10 to 25 minutes. Debug line: rotid_visible_window: mode=name | event=start|end|stop | pages= | tokenSet= (never the name).

Link opening while a window is open. BDO1 holds at most two entries (a 0.2.0-beta decoder rejects more). While a window is open the responder sends the current role 0 entry and the current role 3 entry; the previous-epoch entries are dropped for the duration, so a call that selected the role 0 ID of the epoch that just ended within the first minute after a boundary must retry. This is the one cost of the dedicated ID on the call path.

Rejected: advancing to the next epoch's ID when the window ends. The first draft ended the window at exactly 600 s and then advertised the RDI of epoch e+1; that would link the named ID to the new one by continuity and keep the monotonic epoch E = max(wall, lastEpoch + 1) of RV4 ahead of the wall clock after every window (the open RH4 issue). The dedicated role 3 ID needs no jump. Also rejected: the first revision's "end at the first epoch boundary at least 600 s after the start with the name hash in the role 0 route token", which attributed the name to the ID already on air before the tap; it was chosen only because a dedicated ID broke 0.2.0-beta callers, which no longer matters (0.2.0-beta peers lose the window ID and its name; they still see and call the ordinary row).

Residuals. The name is linkable to the window ID and, if the window spans a boundary, the two window IDs are linked by the name. An observer who sees the window ID and the ordinary ID in one place at one time can guess they are one phone. Nothing in the page is signed; the offer commitment ties the page to the phone that owns the advertising address of the offer, not to a key. Anyone can read the page without the app.

Verification code BVMID2 (on by default since 2026-10-19; bvmid2)

BVMID2.<64 hex fingerprint>.<64 hex K_qr>.<12 hex RDI>

No display name and no epoch: the name arrives later as a signed label, and the RDI already changes with the epoch. The text is uppercase hex and . only, so it fits QR alphanumeric mode (about 150 characters, a small code that scans easily). Encoders write uppercase hex; parsers accept all-uppercase or all-lowercase hex. Reject: other magic or version, field count, lengths, alphabets, mixed case, K_qr of all zeros. BVMID1 stays readable by the parser. Since the user's 0.2.0-beta compatibility decision (2026-10-12) a BVMID1 scan gives the shield only while bvmid1_compat (Advanced, default off) is on; otherwise it records viaQr = false ("Checked by code"), because a static code has no freshness. A 0.2.0-beta phone cannot read BVMID2.

  • Secret and tag key. K_qr is 32 fresh CSPRNG bytes held in memory by the displaying phone. The tag key is K_tag = HKDF-Expand(K_qr, "BluetoothVideoMesh/BMH3/QrTag/1", 32) (not K_qr directly), used exactly where contactTagKey(lease) is used, with the v5 binding of Phase 2 (advertBinding(RDI, 0, null): the RDI already names the epoch, and a binding that included a wall epoch would fail under clock skew). A vector is added to tools/bmh3_reference.py before coding.
  • Lifetime. A code is valid for 300 s of elapsedRealtime, until its first completed use, until its RDI leaves the air (the epoch changes: the screen redraws a new code), and until it leaves the screen (screen off, app backgrounded, activity closed). One code is live at a time. The screen sets FLAG_SECURE, because the picture is a live secret. What a scan proves: the scanner reached a phone that holds the private key whose fingerprint is in the code, through a secret that was on the displaying screen. It does not prove that the two people are in the same place (a current photograph or a live relay of the screen works within the code's life), so the scanner confirms "Is this Rahul's screen you scanned?" before the shield is set, and every text says "scanned".
  • Advert. The displayer needs a version 5 role-0 call-ready advert while the code is on screen. If the user had "Reachable for calls" off, the screen starts the advert only for its own lifetime and, while only the screen caused it, accepts nothing but a connection whose tag matches K_tag (every other connection is closed before any ring or exchange), then restores the previous state, also after a crash (the state is recomputed at the next start). Since 2026-10-27 the responder refuses a BMH2 hello and closes an untagged (or lease-tagged) msg1 before it draws a key or signs anything, with pseudonyms on or off, and the call endpoint refuses every other incoming link whatever handshake it spoke (a BMH2 hello, accepted when pseudonyms are off, used to continue as an ordinary call). With rotating IDs or BMH3 off, or on a phone that cannot advertise, BVMID2 is not offered and the BVMID1 flow remains (the code's tag and the reservation exist only in BMH3).
  • Scanner. After parsing, it scans for call offers with that RDI for 10 s and tries every advertising address that carries it (a copy of the advert can answer first and fail; the next address is tried before giving up), then connects as for a call: BMH3 R_FIRST with the Phase 2 contact tag computed with K_tag, expectedPeer = the node ID inside the fingerprint. The existing checks run unchanged: the responder's verified block must carry that full node ID before the scanner presents its real key; the verification succeeds only if the handshake key's fingerprint equals the code's. A responder block that carries PSEUDONYM is refused by the scanner (a real answer was expected).
  • Displayer and single use (atomic reservation). The responder's tag checks include (K_tag, advertBinding(RDI, 0, null)) for the live code, and a match carries a per-connection gate (QrTagGate). At msg1, before any key is drawn, the gate runs: (1) a first message already seen for this code (replay, even after the global replay cache evicted it) closes silently; (2) a consumed code raises the reuse alert and closes; (3) the link handshake reserves the code for this connection (LIVE to RESERVED), spends the Meet screen's own signing budget (3 tokens, one more every 10 s; the shared budget before 2026-10-27) and counts an attempt against a per-code cap of 3 (the cap ends the code); a second connection while it is reserved closes ("busy"); (4) the end-to-end handshake of the SAME connection continues under that reservation and is refused without it; (5) a failed or closed connection releases the reservation (LIVE, the attempt stays counted); a reservation not committed within 45 s expires; (6) the completed end-to-end handshake commits it (CONSUMED). The first messages seen for a code are pinned until the code expires (at most 32). A match presents the real key (rule P). The lease-recipient rule of admitInitiator does not apply to a K_qr match.
  • Scope of the session opened with a code (exact). It exists only to verify. Link records allowed: the end-to-end handshake, end-to-end content, link heartbeats and the link opening BDO1; every other record type (store-and-forward, relay setup, mesh envelopes, group and route control) closes it. Content types allowed: LABELS (the scanner's optional kind 1 name) and, after the completed end-to-end handshake and only when both endpoint hellos carry bit 3, BDX1 kinds 2 RECOGNITION_OFFER and 3 PRESENCE_LEASE, at most one of each per direction (the lease rule below). Both phones enforce this scope; a BDX1 record is checked by kind before it is handled, and a displayer whose code was not committed by this connection refuses every BDX1. INVITE and every other call signal, BDX1 kinds 1 OPENING and 4 IDENTITY_REVEAL, a second OFFER or LEASE in one direction, streams, media, key-frame requests and group keys close it, and a violation is counted. There is no verification control message: the completed end-to-end handshake (both layers, same connection) is the verification event, on both phones. A displayer that will not answer ends the session 2 s after it; a scanner waits at most 20 s for its user's answer and an answering displayer at most 20 s for the scanner's OFFER; a side in the exchange ends it 2 s after it has sent or withheld its lease and received the peer's; a "No", a cancelled confirmation or a failed shield write ends it at once; it always ends at a fixed 60 s. No ring, recent-call entry, label persistence or message exchange results from it. (Without this scope, whoever held the code would get a real-key link with a contact's privileges.)
  • Reuse. The first committed K_qr session consumes the code. The displayer shows "Rahul scanned your code" with the scanner's claimed name and its handle, and marks nothing verified. A later tagged msg1 for the consumed code whose first message is not one already pinned for this code (a genuinely new attempt, not a replay of a sniffed first message) cancels the pending scan at once and tells the displayer: "Two phones used your code. The first one may not be the person in front of you. Start again." Replays of a sniffed msg1 are closed silently and do not raise the alert, and because the pins live as long as the code, cache eviction cannot manufacture an alert. Both phones then draw a new code.
  • Mutual completion. A phone marks a peer verified (shield) only when it scanned that peer's code, the K_qr-tagged handshake produced a key whose fingerprint equals the one in the code, and the user confirmed "Is this Rahul's screen you scanned?" (the name is the peer's claimed name from its label, else its handle). That is "I scanned them". "They scanned me" is known only by the arrival of an inbound K_qr-tagged session from their real key on the code this phone displayed. Each scan opens its own tagged session (the second scan does not reuse the link of the first), so each phone learns about the other's scan from a real, authenticated event and no extra message is needed. "You and Rahul scanned each other" is shown only when both events happened for the same fingerprint within the same Meet screen visit; the fact is kept in contact-claims.bin.
  • Leases (Design QL, 2026-10-19). A verification session carries the Phase 2 exchange ("Recognition and presence leases", items 2-4) unchanged in records, eligibility, send-time checks and storage; only the moment a side starts it differs, and that moment replaces "the call is answered" (RD6); eligibility is decided when a side starts and re-checked when each record is sent. The scanner goes first: it sends its OFFER when its user answers the confirmation with yes and the shield is stored. The displayer never offers at the commit; it answers the scanner's OFFER (its start then sends OFFER and LEASE) only if the code was committed by this connection, its own user confirmed this scanner (yes, shield stored) during the current visit of the Add contact screen, the scanner presented its real identity, and the link peer equals the endpoint peer; the visit rule is re-checked when the lease is built. A reuse alert on the displayed code withholds every lease not yet sent for the rest of the visit. An OFFER received before this side started is recorded and acted on only at start, as in a call. So the first of two scans moves no lease (the displayer has not confirmed the scanner yet) and the second moves both; after it each phone recognises the other and can call it from Contacts. A confirmation after the session ended sends nothing; the leases then follow at the next answered call. A holder of a leaked code gets nothing, even an older verified contact. An older displayer closes on the scanner's OFFER after the commit (verification done, no lease). The scanner does not wait for a BDX1 OPENING (RV15) in a verification session: the displayer sends none, and the code's fingerprint check, the tag bound to the code's RDI and route cost 0 already bind the answer to the scanned screen. With bvmid2 off (BVMID1 static codes open no session) nothing here runs. The screen says "Your phones now recognise each other." once a lease was stored in this visit, otherwise "Your phones will recognise each other after your next call."
  • What a leaked code allows. Inside its life and before first use: a phone that saw the screen (or a copy of it) can complete the scanner side (it learns the fingerprint the code shows and nothing else) and can win the race against the real person, who then sees the reuse alert. It cannot be verified by the displayer, which must scan that phone's own code and confirm whose screen it is. After use, expiry or leaving the screen: nothing. It can drive at most 3 reserved attempts.
  • Code-only comparison. The 60-digit safety code ("Identity verification") remains available as a read-aloud aid. A confirmation by code records viaQr = false, shown as "Checked by code" (outline, no shield). It keeps every protection of a stored contact: the prefix MISMATCH refusal and the expected-contact check apply to it exactly as to a scanned contact. Presence-lease eligibility (Phase 2 "Presence lease eligibility") is amended: a contact with viaQr = false neither receives nor offers new leases until it is scanned in both directions; contacts stored before this change with viaQr = false that already hold leases keep them until the next generation change. A BVMID1 scan with bvmid1_compat on keeps its 0.2.0-beta meaning (viaQr = true, shield) but cannot show "verified each other"; with it off it records viaQr = false.
  • Vector. To be generated by tools/bmh3_reference.py (a K_tag tag next to the lease tag of vector 3) before coding; the BVMID2 parser vectors go in IdentityVerificationTest.

Experimental: consent and result of the verification-session lease exchange (Design QL revision 2; design only, not built; ql_consent_ui, off by default)

No new bytes: records, kinds, versions, scope, waits and the order (scanner first) of "Leases (Design QL)" above are unchanged. With the switch off nothing here runs.

  • Consent before the OFFER. The scanner's confirmation carries the per-contact choice "Can see when I'm nearby" (until the user answers it is not yet asked, which counts as off; design decision 2026-11-06). It is stored in the presence vault after the shield and before the side starts the exchange; a failed write withholds the OFFER. The stored choice is the one the existing eligibility and send-time checks read, so the same phone answering as displayer later in the visit follows it. The visit also keeps the answer: a person counts as confirmed in the visit only after a yes that was stored, so a "No" whose write failed, or a session with that person running while the answer is being written, sends nothing. Both roles apply this visit rule when each OFFER and LEASE is sent; a LEASE leaves only after this side's own OFFER of the session left (never after a withheld one, even when the peer offered first), and nothing leaves after a reuse alert or once the Add contact screen was left (closed or sent to the background). Choosing off for a contact that never received a lease starts no generation.
  • Mutual and silent. A side that declined sends no OFFER, so neither side stores a lease (Phase 2 item 2). There is no decline record: the peer sees the session end without an OFFER, exactly as for a late answer, an older build or a lost link.
  • Leases only on the verified channel. A PRESENCE_LEASE is sent only as end-to-end content of a session that the code check authenticated (scanner) or committed for a scanner its user confirmed in this visit (displayer), or of an answered call. It never appears in an advert, a scan response, a relay hop or a store-and-forward bundle.
  • Call now. After a lease was stored in the visit, a call to that contact is a new ordinary call connection. The verification session never carries INVITE or any call signal, also after the leases moved.
  • Interop. Peers without this revision, and builds without Design QL, behave as described in "Leases (Design QL)".

Local stores added

  • identity-verification/contact-claims.bin: fingerprint (32 bytes) to last verified claimed name (at most 32 bytes, NFC), the time and a "scanned both ways" flag, sealed with the verified-contacts Keystore key, atomic writes, never overwritten while unreadable (the causes of "Damaged store" apply). Written only for verified contacts, only from a signed label that passed the receiver checks (the flag from the two K_qr events), deleted by Forget, Reset and the emergency wipe. It is separate from verified-contacts.bin, whose format is unchanged, so an older build ignores it. Losing the file loses the note and the "verified each other" wording, never a contact or its shield.
  • No change to verified-contacts.bin. Its existing viaQr flag already separates code-only entries (false, shown "Checked by code") from scanned ones (true); the new "scanned both ways" fact is kept in contact-claims.bin next to the claimed name.

Threats this section answers

Tracking by a name (names appear in no advert outside a window; a generated name is a function of the rotating ID); impersonation by claiming a contact's name (tilde, identical and skeleton flags, key-bound mark) or by naming someone else's ID (the call-offer commitment); replayed or photographed verification codes (capability, single use, 300 s, RDI-bound, scoped session, live handshake); contact-graph leakage (the tag reveals the scanner only to the displayer and the claims file is local and sealed); copied adverts collecting names (an audience and timing setting; the real key still waits for the reveal). Not answered: a user who types a real name into a public window, a passive observer who sees the QR on a screen and never connects, and the general tracking residuals already listed under "Experimental rotating discovery IDs".

Experimental iPhone compact discovery (on by default since 2026-10-17)

Settings > Features "Find iPhones" (default on since 2026-10-17, ConnectivityFeatureDefaults.IPHONE_DISCOVERY, after the first iPhone calls and streams passed both ways; a benchmark launch uses only the DEBUG extra test_ios_compact_discovery). It changes discovery hints only. Android's own advertisement, every handshake, record, key, admission and overload rule are unchanged. A scanner that refuses the masked filter (SCAN_FAILED_FEATURE_UNSUPPORTED, OUT_OF_HARDWARE_RESOURCES) drops only that filter for the rest of the process and keeps scanning for the app's own announcement (compact_filter: event=dropped). Not yet tested on phones or against an iPhone.

Why. An iOS app can advertise only a local name and service UUIDs, never manufacturer data. In the foreground one 128-bit UUID fits in the advertising PDU an Android scanner reads; further UUIDs go to Apple's overflow area, which only iOS reads, and a background iPhone puts every UUID there. An iPhone therefore announces itself in one service UUID instead of the 22-byte manufacturer-data announcement (company 0xFFFF) plus c8d5a101-….

Layout. Read the UUID as one big-endian 128-bit integer U in canonical string order (Java most- then least-significant bits, CBUUID.data; the on-air AD value is the same bytes reversed, which both stacks undo).

Bits of U Field
127..112 Magic 0xC8D6
111..110 Version 1
109..108 Kind: 0 private-call offer, 1 viewer request, 2 stream offer, 3 rejected
107..60 Node-ID prefix (6 bytes, as in the 22-byte announcement)
59, 58, 57 GATT transport (0 = LE CoC; Classic is not representable), relay capable, stream paused (stream offers only)
56..54 Codec mask: 54 VP9, 55 AV1, 56 H.264 (draft, Design HC; open question Q-HC2 proposes moving it to HEVC)
53..50 Route cost 0..15
49..19 Route-token bits 30..0. Call offer: 0. Others: token = 0x80000000 or field
18..0 Call offer: 0. Viewer request: 18..12 bitrate in 25 kbps units, 11..0 zero. Stream offer: 18..11 the sequence byte, 10..9 resolution index (426x240, 640x360, 854x480, 3 = unspecified), 8..7 FPS index (15, 24, 30, 3 = unspecified), 6..0 bitrate in 25 kbps units

Decoding. Strict: wrong magic or version, kind 3, the paused bit outside a stream offer, a call offer with any of bits 49..0 set, or a viewer request with any of bits 11..0 set rejects the UUID. Codec bits are carried, never rejected: bit 56 becomes capabilitySummary bit 2, which today's codec tests ignore, so a later H.264-capable iPhone stays visible to this build. (Draft, Design HC: if Q-HC2 moves bit 56 to HEVC, new decoders map it explicitly to summary bit 7; this build keeps mapping it to bit 2, which still reads as no common codec.) The result is an ordinary DiscoveryAnnouncement: a call offer gets FLAG_ACCEPTING_CONNECTIONS | FLAG_PRIVATE_CALL_AVAILABLE, a viewer request FLAG_ACCEPTING_CONNECTIONS, a stream offer FLAG_STREAM_AVAILABLE, each ORed with the GATT, relay and paused flags; unspecified geometry/FPS and bitrate 0 decode as 0 (unknown); there is no route recovery. Every real route token has bit 31 set (StreamRouteToken's advertiser mask sets it and logical tokens are 1..2^31-1), so implying the bit loses nothing. Not representable, and never sent by an iPhone: Classic, route recovery, route cost above 15, a viewer request without a route token (the DEBUG direct-receiver mode), geometry/FPS outside the tables (index 3), a bitrate above 3175 kbps (saturates). Android stream settings (100-1500 kbps in 25 kbps steps, the three resolutions, 15/24/30 FPS) are all exact.

Scanning. With the switch on, every BleL2capDiscovery passes a second ScanFilter.setServiceUuid(C8D60000-0000-0000-0000-000000000000, mask FFFF0000-0000-0000-0000-000000000000) in the same startScan call. Filters are ORed and it is still one scan start, so the scan-start quota is unchanged. When manufacturer data 0xFFFF is present it is used exactly as before; otherwise the first service UUID whose top 16 bits are 0xC8D6 is decoded. The DEBUG scan_stats line appends compact and compactInvalid counters while the switch is on and is byte-identical while it is off. After discovery the existing clients connect unchanged: the iPhone hosts the same GATT database (c8d5a101-… with the c8d5a102-… PSM characteristic, dynamic PSM, insecure channel; or c8d5a105-… with c8d5a103-…/c8d5a104-… chunks), then BMH2, BMS1 and BME1 run as today.

Security. The same trust level as the 22-byte announcement, which any advertiser can already forge: every field is an unauthenticated hint. The full 48-bit prefix is present, so the link and endpoint identities must still match it (and the full fingerprint for verified contacts); prefix grinding still costs about 2^48 key generations. Route tokens are broadcast correlation values, not secrets, and keep 31 bits of entropy. A random foreign UUID passes the filter with probability 2^-16 and must then pass the version, kind and reserved-bit checks; a false or forged match only causes a connection attempt that fails at GATT discovery or BMH2, bounded by the existing admission and overload guards. The decoder is fixed-size integer arithmetic.

Limits. An iPhone is discoverable only while its app is in the foreground, and it can advertise one role at a time (one UUID). An iPhone viewer that lost its parent advertises an ordinary viewer request. Masked service-UUID filters are public API since API 21 and AOSP re-checks every filter on the host for every result. Each ScanFilter takes one controller filter slot, and a scanner with more filters than free slots runs all-pass with host filtering (more wake-ups), so the switch doubles each scanner's slot use. Whether a phone's controller applies the 128-bit mask in hardware is per phone and unmeasured.

Vectors (CompactDiscoveryAnnouncementTest; the iOS repository holds the same table):

Input UUID
Call offer, prefix 010203040506, LE CoC, VP9+AV1, cost 0 C8D64010-2030-4050-60C0-000000000000
Call offer, prefix a1b2c3d4e5f6, GATT, VP9, cost 2 C8D64A1B-2C3D-4E5F-6848-000000000000
Viewer request, prefix 010203040506, LE CoC, VP9+AV1, token 0x9abcdef0, cost 0, 300 kbps C8D65010-2030-4050-60C0-D5E6F780C000
Stream offer, prefix a1b2c3d4e5f6, GATT, relay capable, paused, VP9, cost 1, token 0xdeadbeef, sequence 0x85, 640x360, 15 FPS, 300 kbps C8D66A1B-2C3D-4E5F-6E46-F56DF77C2A0C

Experimental PC announcement (Find PCs, off by default; kinds 1 and 2 are a draft, not built)

Reasons, measurements and review: DECISIONS "Design QW" and "Design QV" revision 3.

A Windows PC cannot send the 22-byte manufacturer announcement in a connectable advert. Instead, it hosts a connectable, non-discoverable GattServiceProvider whose 128-bit service data, under the fixed UUID c8d5a10a-9f88-4f65-a03d-7f5a2fb921fa, carries one announcement. Windows keeps the service data only on the extended advert (secondary PHY LE 1M). The legacy twin carries no payload and is ignored. Android never sends this form.

Scanning. On Android, only with "Find PCs" on (test_find_pcs). The scanner adds a service-data filter for the UUID with empty data, and asks for extended adverts where the adapter supports them. Precedence: the manufacturer announcement first, then a compact UUID, then PC service data. Kinds 1 and 2 additionally need test_pc_streams (default off).

Payload. Byte 0 is the version (bits 7..4, = 1) and the kind (bits 3..0).

Kind Length Bytes 1..
0, call offer (built) 9 RDI (6, role 0); flags: bit 0 GATT (required), bit 1 relay capable, bit 2 relay consent, bits 3..7 zero; byte 8: codecs (bits 0..2), cost (bits 3..6), bit 7 zero
1, viewer request (draft) 23 the 22-byte announcement of a version 5 viewer request, verbatim
2, stream offer (draft) 23 the 22-byte announcement of a version 5 stream offer, verbatim

Strict decoding. Checked in this order; any failure drops the advert and is counted.

  1. An empty payload is Length. Otherwise the version nibble must be 1, or Version.
  2. The kind must be 0, 1 or 2, or Kind. With test_pc_streams off, kinds 1 and 2 are dropped here and counted as disabled, never mapped.
  3. The length must be the kind's: 9 for kind 0, 23 for kinds 1 and 2. Otherwise Length. (The kind 0 decoder of the first build checks the length first, so it rejects every 23-byte payload as Length; the shared vectors follow the new order.)
  4. Kind 0: the rules in the table. Kinds 1 and 2: the inner 22 bytes must decode as a version 5 announcement (versions 2 and 8 are refused), and the role must match the kind exactly; anything else is Fields:
    • kind 1, request: flags exactly ACCEPTING | GATT, or ACCEPTING | GATT | ROUTE_RECOVERY with a non-zero hint (and a zero hint without it); summary 0; route-token bit 31 set.
    • kind 2, offer: flags exactly STREAM_AVAILABLE | GATT, optionally with STREAM_PAUSED; summary only codec bits 0..2 and 0x40 (audio only), plus 0x80 (HEVC) while the reader's HEVC master is on (Android 0.4.2 and later); route cost 0; no relay flag; route-token bit 31 set.
    • No Classic, call, accepting-on-offer or other flag in either kind.

The result is the ordinary DiscoveryAnnouncement and goes through acceptAnnouncement and every existing admission rule. A PC announcement is an unauthenticated hint, at the same trust level as every advert. The link and endpoint handshakes bind the identity.

Calls to a PC. A kind-0 offer is a rotating (version 5) call offer, so a caller with BMH3 on speaks BMH3 to it ("Negotiation and downgrade"). The PC answers only BMH3 and refuses a BMH2 hello (Format). A phone with BMH3 off therefore does not dial a PC call offer: it remembers the rotating IDs of kind-0 offers it saw in the last 30 minutes (at most 64) and ends such an attempt before sending a byte, with "This PC takes calls only with "Hide my ID from strangers" on". A phone's rotating offer is unaffected: a phone with BMH3 off still accepts BMH2.

Stream offers from a PC (draft).

  • The PC is the parent and dials each viewer as GATT central. Viewers request with their ordinary version 5 GATT viewer request.
  • Hops use BMH2 with role-1 BDO1, then the endpoint handshake, then BTV3/BTL1 and BMS1/BME1, all unchanged. Forwarders issue BFG1 with the PC as the original source.
  • Video is VP9 or AV1 only; audio is Opus as in calls.
  • A PC never sets the relay flag and sends no title pages.

No serves-PC bit. No advert says whether a source serves PC requests. A summary bit would break the match between a direct offer and its forwarded copies, and would fingerprint the setting. A PC that gets no answer to two 15 s requests tells its user that the phone may need an update.

iPhones (draft). iOS reads no PC service data today. An iPhone therefore neither sees kind 1 requests nor kind 2 offers until an iOS change; the PC's scanner still decodes compact v2 offers and viewer requests.

Rotation.

  • The PC restarts the provider at every rotating-ID change, with a uniform 1-8 s silence (break-before-make). The new provider has a new random address.
  • Windows' own 15-minute address change may split one epoch across two addresses, linked only by that epoch's RDI.
  • An outgoing PC connection (a call or a stream hop) uses the adapter's public address. This is a stated residual.

Experimental rotating discovery IDs (on by default for user sessions since 0.2.0-beta)

Promoted 2026-10-12 (0.2.0-beta): on by default for user sessions (one constant, RotatingDiscoveryFeature.DEFAULT_ENABLED; an explicit stored choice in the Settings > Privacy > Advanced privacy switch "Private Bluetooth ID" wins, with or without the Advanced view); a DEBUG benchmark launch reads only test_rotating_ids. Evidence: phone test P5 (zero carry-over at 3 of 3 boundaries) and the cases P1-P4 and P7 of the combined privacy session of 2026-10-11 (README "What nearby phones can see"). Reviewed design 2026-10-10 (RV1-RV19); the review settled on these answers (a time-limited Visible-by-handle window instead of permanent dual advertising, Everyone with a growing delay, mutual verified contacts with 4-week leases, 15-minute epochs). Phase 1 (strangers, call-ready presence, role 0) was coded first; Phase 1b (stream offers, viewer requests, forwarders, call-relay chains and the endpoint opening BDX1) and Phase 2 (contact presence leases and the recognition table) were designed and coded 2026-10-11 and are described below; reach pages version 6 for relayed calls are in "Experimental relayed calls and multi-hop discovery". Classic roles, the DEBUG direct-receiver mode, BGK1 group hops and the DEBUG stream relay keep version 2 and the fixed prefix (PLAN "Discovery privacy" lists the remaining phases). Off: every byte on air and on the link is unchanged, and version 5 / compact v2 adverts are rejected exactly as by old builds. Not yet tested on phones. Hardening 2026-10-11 (reference-app review; reviewed by an independent reviewer, findings RH1-RH14 marked where they changed the text): non-discoverable connectable sets and an explicit, never automatic neutral name ("Pre-handshake exposure"), independent random daily keys instead of one long-lived presence key ("Keys and epochs", "Recognition and presence leases"), address reuse on legacy-only controllers ("Address reuse on legacy-only controllers") and the field inventory ("Fields that stay constant across epochs"). With the switch off none of it runs.

Keys and epochs. Epoch e = floor(unix_seconds / 900) (u32), day d = floor(e / 96). Role r (u8): 0 call offer, 1 stream offer, 2 viewer request, 3 visible-by-name (Naming v2), 4 busy marker (Draft, Design BZ), 5..255 reserved. All integers big-endian; domains end with 0x00. Each identity keeps a presence generation g (u32, from 1) and, for each day in its window, an independent random day key L_d (32 bytes from a CSPRNG), the daily Temporary Exposure Key model of Google/Apple exposure notification (GAEN), in no-backup app storage. There is no master key and no derivation between days: L_d is not computable from any other L_x, from g or from the node ID, and it is not a ratchet (a contact holding one day's key must not be able to compute any later day, and a leaked key must reveal no earlier one). Earlier builds derived L_d = HMAC(P, ... || d) from one long-lived P, so one extracted store named every past and future ID; that derivation now exists only to migrate such stores (below). Every salt, the owner's own included, is keyed by the day's key (RV11, RD5), so contacts that later receive the keys compute the same IDs:

salt = HMAC-SHA256(L_d(e), "BluetoothVideoMesh/Discovery/Salt/1" || 0x00 || u32 e || u8 r)      (32)
RDI  = SHA-256("BluetoothVideoMesh/Discovery/Id/1" || 0x00 || nodeId (16) || u32 e || u8 r || salt)[0:6]

The day-key window is [dayOf(E - 1), dayOf(E) + 27] for the phone's monotonic epoch E (below): today and the next 27 days (the 28 keys a lease delivers) plus yesterday while E is the first epoch of a day (the poison-reverse check also computes the previous epoch's ID). A key is created once, when its day enters the window (the whole window when a generation starts, afterwards the key of day dayOf(E) + 27 when the day changes) and is deleted when its day leaves the window; a key is never recreated within a generation. A state that lacks a day it needs (first use, or a phone that was off for more than 27 days) creates it at once. Write ordering (RH13): a day key is delivered in a lease only after the file holding it was written (atomic replace, then the lease); a key that could not be stored is used for advertising from memory only and never delivered, and a crash before the write loses a key nobody has seen (a later lease then carries the regenerated key, and the receiver rule of RD1d replaces the stale record). A new generation (revocation, forward-jump recovery, "Reset presence key") creates the whole window again from fresh randomness. Maintenance (deleting the day that left the window, creating the next day's key, storing the result) runs once per day on the recognition thread, and since 2026-10-27 also whenever the store is loaded, so a phone that was off over a day boundary drops the expired days (its own keys and its contacts' lease days) at once instead of at the next daily run; it never blocks an advertiser, because every key the next epoch needs already exists.

Seizure property. A phone read at epoch E (an unlocked phone, an extracted store) yields exactly the keys of its window: it can name no ID of a day before dayOf(E - 1) (those keys are deleted and nothing derives them) and none of a day after dayOf(E) + 27 (those keys do not exist yet and are drawn later; they are not a function of anything stored). What it does give its reader (RH3): every ID of the window, that is this phone's IDs until dayOf(E) + 27. A phone that was out of its owner's hands (taken at a border and handed back, lent, or read by an examiner) can be followed by passive sensors for up to four weeks, and GAEN differs here: a GAEN phone draws each day's key on that day and holds no future keys, while a lease must hand its contacts 28 days at a time. The remedy is "Reset presence key" (a new generation: the whole window is replaced; contacts recognise the phone again after the next mutual call). The IDs are the ones its contacts already hold, so the seized phone adds nothing to their capability; it also gives away the node ID, the contact store and the generation. A contact's copy is bounded the same way: it deletes lease days earlier than dayOf(myE + skew) - 1 (one day of margin for the +-8 epoch window). Residuals: the sealed file is rewritten atomically when the window moves, but ciphertext of an earlier version may remain in flash blocks until they are overwritten and decrypts with the same non-exportable Keystore key (the same limit GAEN has for its daily keys; Android's file-based encryption probably makes old blocks unrecoverable without the user's credential, filesystem behaviour is unverified); a lease holder keeps its day keys for their days, so a holder that is itself seized reveals the IDs of the days it still holds.

Monotonic epoch (RV4). The phone persists lastEpoch. A tick happens only when the wall epoch differs from the one seen at the last tick, or 960 s of elapsedRealtime passed since it (900 s plus one minute, so a normal boundary never double-ticks); at a tick E = max(wallEpoch, lastEpoch + 1). Every other advertiser restart (after a call, a retry, a mode change) reuses E. After a reboot E is reused only while the wall still shows the epoch of the last tick. If E - wallEpoch exceeds EPOCH_TOLERANCE (2 epochs, the verifier's opening window, item 8 of Phase 1) continuously for one hour of consistent wall time (wall minus elapsed stable within 2 min), the phone starts generation g + 1 with a fresh day-key window and E = wallEpoch (DiscoveryEpochClock.MAX_AHEAD_EPOCHS; counted in epochs at every epoch length). An epoch, once left, is never advertised again, and repeated restarts cannot push E ahead. Backward clock step (RH4, decided 2026-10-14, design decision Q33). Because E = max(wall, last + 1) never goes down, a backward step of the wall clock (a manual fix, a time zone or NITZ correction, an NTP step across a boundary) leaves E ahead of the wall for good: a step back of k >= 2 epochs leaves a lead of k + 1, because the next tick still moves forward. A lead above 2 makes every opening fail OPENING_STALE (the phone cannot receive a version 5 call or stream join) and contacts holding a lease younger than 14 days (window +-1) cannot recognise it until a new lease arrives, so the recovery is the new generation once the lead has lasted the hour (cost: contacts re-sync through the next mutual call or lease exchange; the previous IDs are not advertised again). The lead is measured on the epoch the read would advertise, so the first read after a wall boundary does not restart the hour. A lead of 2 or less, or a wall clock that is itself still being corrected (offset moving more than 2 min), starts nothing; a process restart restarts the hour. Rejected: holding E until the wall catches up (one ID stays on air longer than 15 minutes, which is linkable) and a wider verifier tolerance (a weaker forgery bound, RV9). Before 2026-10-14 the bound was one day (96 epochs).

DEBUG short epoch (test_rotid_epoch_s=<60..900>, harness only). Not part of the wire format and never in a release: a debug build of a debuggable package reads the extra once at cold start, before rotating discovery starts, and uses e = floor(unix_seconds / S) with S in 60..900 instead of 900, so a functional phone test sees three boundaries in 3 S seconds. Every rule above keeps its meaning in epochs (two-epoch opening tolerance RD4, the previous-ID grace, the E - wall bound of RV4 in epochs of one day, the per-epoch BMH3 replay cache and signing budget, the reach-table flush), and the 10-25 minute Visible-by-handle window is 10 minutes to 10 minutes plus S. Presence-lease days stay real days: d = floor(e * S / 86400) (equal to floor(e / 96) at 900 s), so day keys, the 28-day window, leases, and the day roll behave as in a normal launch (use test_presence_day_offset to roll days); only the advert ID changes more often. Both phones of a test must use the same S: epoch numbers differ otherwise and the selected advert fails the two-epoch check as stale, by design (at S = 60 the tolerance is about two minutes of clock difference). lastEpoch is stored with its S; a launch with another S (including the next normal 900 s launch) restarts the counter at the wall epoch and keeps the generation, so no forward-jump recovery is needed; contacts' stored epoch skews, counted in the old length, are only corrected by the next lease exchange. Log line: rotid_epoch_s: requested= | epochSeconds= | applied=.

Announcement version 5. The 22-byte version 2 layout with byte 0 = 5 and bytes 1-6 = the role's RDI. A call offer's route token stays 0. Stream offers and viewer requests carry logical XOR mask(RDI) (Phase 1b). Old scanners reject version 5 ("Unsupported announcement version").

Compact UUID version 2 (iPhone). The compact v1 layout with version bits 111..110 = 10 and bits 107..60 = the RDI; it decodes to announcement version 5. A decoder without the switch rejects it like any other version.

Link opening, hop type 13 BDO1. Link hello transport-mask bit 3 (0x08) means "sends and accepts BDO1 as the first hop record". Call endpoints (their link hellos, every transport) set it; from Phase 1b so do LE stream hops and a call relay's child-facing hello (never an endpoint hello, a relay's parent-facing hello, a Classic stream hop or BGK1). When both link hellos carry it, the first hop record in each direction is exactly one BDO1, before the store-and-forward dispatch; a reader rejects type 13 as unknown at any other point:

"BDO1" (4) | version 1 (1) | count (1, 0..2) | count x entry (43)
entry = RDI (6) | epoch (4) | role (1) | salt (32)

Decoding is strict (magic, version, count, exact length). A sender opens only IDs of the role it advertised that are pinned to this connection: an ID that is on air, or that left the air less than 60 s earlier (the connection was accepted under it, or the boundary just passed). An ID that went off air long ago (a long call, a Visible-by-handle window, an absence) gets no fresh grace when the next one starts, and an arbitrary or older epoch is never opened. A call listener sends its role-0 entries and a call initiator count 0 (it advertised nothing); on a stream hop the connector sends role 1 and the listener role 2 (Phase 1b, below).

Check (RV15). The check follows the advert the caller selected, never the peer's hello. A selected version 5 or compact v2 advert requires bit 3 and an entry with that role whose RDI equals the selected 6 bytes and recomputes from the authenticated link node ID (RDI(nodeId, e, r, salt)); otherwise the call fails as a prefix mismatch fails today ("Peer identity changed between discovery and connection"). The entry's epoch must also lie within two epochs of the verifier's wall-clock epoch (RD4, Phase 1b item 8). A selected version 2 advert uses the prefix check, whatever the hello says. A direct version 5 call also requires the link identity to equal the end-to-end identity; a relayed one (Phase 1b) is verified end to end by the callee's BDX1 OPENING. Verified contacts still need the full fingerprint, exactly as today.

Rotation (RV6, RV12, RV1). Classic call availability keeps version 2 (the Classic address is permanent and the Classic connector finds a callee by its fixed prefix; RV2), so only LE CoC and GATT call offers rotate. At each UTC boundary an idle call listener stops (break-before-make), stays silent for a uniform 1-8 s and reopens on a new advertising set with the new RDI. Reopening also gives a new GATT server and, on stacks with per-session LE CoC listeners (Android 11+), a new PSM; Android 10 stacks keep one long-lived listener per role (CocListeners, AOSP leak) and therefore the same PSM (stated residual). A call or connection in progress keeps its link. Experimental title-page sets also restart on a new set at the boundary. Within an epoch, StableAdvertisingSet keeps its address through the low-latency -> balanced step as before. The boundary check runs on the main-thread handler at the boundary and at least every 60 s (no exact alarm, RV16); Doze can delay it, which keeps the old ID on air longer but breaks nothing.

Pre-handshake exposure (RV1, revised 2026-10-11). The GAP Device Name (0x2A00) is served to any central that connects, before BMH2, and Classic name requests answer with the same adapter name. The earlier rule (rename the adapter to Phone whenever the switch is on) is withdrawn: no reference app renames the user's adapter, a rename changes the phone for headphones, car, watch and PC, an uninstall leaves it, and flipping it per session would be a timed, observable event. Two controls replace it:

  1. Non-discoverable connectable sets (API 34+). With the switch on and Build.VERSION.SDK_INT >= 34, every connectable app advertising set (StableAdvertisingSet(connectable = true): the call listener, the stream source's and forwarder's control servers, the waiting viewer's request) is started with AdvertisingSetParameters.Builder.setDiscoverable(false) (public API 34; the default is discoverable). What AOSP does with it (verified against the sources of android14-release to main): the Flags AD is added to the advertising PDU only for connectable and discoverable sets (le_advertising_manager.cc set_data; the framework counts its 3 bytes only then), so the PDU is 3 bytes smaller; the set's discoverable value travels with the connection (OnAdvertisingSetTerminated, acl.cc), and the GAP Device Name characteristic is registered with GATT_PERM_READ_IF_ENCRYPTED_OR_DISCOVERABLE (gap_ble.cc; present in android14-release and later, absent in android13-release), so an unbonded, unencrypted central that connected through a non-discoverable set gets GATT_INSUF_AUTHENTICATION on 0x2A00 (gatt_db.cc). The API documents the other effect: "devices connecting to non-discoverable advertisements cannot initiate bonding" (the app never bonds over LE; enforcement of that sentence was not found in the SMP and DM sources of AOSP main, only the GATT permission above, so a Pairing Request from an unbonded central may still raise the pairing dialog, UNVERIFIED, tested in P7). Not covered, and stated: links this phone opens as the central (acl.cc: "if we are the central, the peer can always see discoverable characteristics"): an outgoing call, a relay's child link and above all a stream source or forwarder that connects to a viewer request, which anyone can copy or forge (RD2, RD3), so a bystander who advertises one fake request can read the source's adapter name (RH1); controllers with neither LE extended advertising nor vendor multi-advertising (the stack calls every connection to a legacy-only advertiser discoverable, le_impl.h initialize_role_specific_data, TODO b/254314964); Android 13 and older; OEM Bluetooth stacks that deviate from AOSP; and anything a Classic role serves. The in-place low-latency-to-balanced step passes the same discoverable value (the stack re-reads it in set_parameters): the value is fixed when the set starts, so a refusal that another set meets later cannot flip a running set (RH9). Fallback (RH9): a stack that refuses the non-discoverable start (ADVERTISE_FAILED_FEATURE_UNSUPPORTED, ADVERTISE_FAILED_INTERNAL_ERROR or an exception from startAdvertisingSet) gets the same set started discoverable, so a privacy flag never costs reachability. This is a privacy downgrade and is therefore visible: the status line reads "this phone's Bluetooth refused a hidden name; starting with the normal advertisement", rotid_adv: event=rejected | label= | code= | remembered= | action=discoverable is logged, the neutral-name advice for this phone then reads "recommended", and a refusal that is not transient (everything except an internal error) is remembered for the process. Scanners filter on the service UUID, never on Flags, and the app's non-connectable sets already carry no Flags AD, so discovery by every scanner of the app is unchanged (an iPhone is a phone test: CoreBluetooth does not filter on Flags either, UNVERIFIED). Control: DEBUG test_adv_non_discoverable=false (default true while the switch is on; release builds always follow the default) restores the previous sets. Log rotid_adv: nonDiscoverable= | sdk= | extendedAdv= | multiAdv= once per process and nonDiscoverable= on every adv_set_churn line.
  2. Explicit neutral name. The Settings > Privacy switch "Hide phone name" (the neutral Bluetooth name "Phone") (preference neutral_bluetooth_name, default on since 0.4.1 (DECISIONS "Hide phone name on by default"; applied only after the user answers the onboarding question or the one-time dialog after an update; a stored choice wins), independent of the rotating switch; DEBUG launch extra test_neutral_bluetooth_name=true, with test_rotating_neutral_name=true kept as its alias so existing launch lines work) sets the adapter name to the same Phone on every phone, so it carries no model or person (RV1). Turning it on from Settings first asks, in plain words: it renames the phone for everything (headphones, car, watch, PC, Quick Share), the phone's name now is <name>, turning the option off restores it, and uninstalling the app while it is on leaves Phone until the user renames the phone in Android settings. The saved original name is stored in app preferences and shown under the switch for as long as the option is on, so the user can restore it by hand after an uninstall, a data clear or an emergency wipe (the name is Settings.Secure.bluetooth_name, written by BluetoothAdapter.setName, so it survives crash, force stop, reboot and uninstall; only the saved copy is lost). The advice text next to the switch says it is recommended on Android 13 and older, on a legacy-only controller and when Classic roles are used, and not needed for connections that phones make to this phone on API 34+ phones with extended or vendor multi-advertising when non-discoverable sets are active (links this phone opens itself still show the name to the other side, RH1). The name is written only when the option changes and when the app starts, gets its permissions or Bluetooth turns on; never per call, per advertising session or per epoch. A rename is asynchronous on Android: setName only queues it and the adapter reads the new name once the stack reports it (which also sends ACTION_LOCAL_NAME_CHANGED; the app re-applies the option on that broadcast). The original is saved before the write, the write is recorded as pending, and the same write is not repeated while it is on its way; three accepted writes that never read back count as refused. While the option is on and the adapter has read back Phone, any other name the adapter shows later is the user's own choice and wins (RH12): the option switches itself off, their name is left alone and a message says so (rotid_name: paused=true). Before that, a name other than Phone (an old value read right after the write, or the saved original after a failed attempt) is not a user rename; it is written over again. Switching off restores the saved name if the adapter reads Phone or our Phone write is still on its way, and keeps the saved copy until the adapter reads it back; a name the user chose meanwhile is kept and the saved copy is dropped. Phone itself is never saved as the original. When the adapter reads Phone and no own name is saved (app data cleared), the line under the switch says so and opens a field to enter the phone's name (prefilled with Android's device name): renamed at once while the option is off, saved for the restore while it is on. The saved original is shown under the switch whenever it exists, also while a restore is pending (switched off while Bluetooth was off). A failed setName keeps the saved original and is retried when Bluetooth turns on and at the next start. The preferences, including the saved name, are not in any cloud or device-transfer backup (allowBackup="false" and exclude-all data-extraction rules), so after a reinstall or a wipe only the user's own note of the name helps. Migration (RH11): builds before 2026-10-11 renamed automatically while the rotating switch was on and kept the original under rotating_discovery_name/originalName. A normal launch that finds a saved original, an adapter still reading Phone and no stored choice turns the option on (nothing changes on the radio: the name stays) and asks once whether to keep Phone or restore the original (rotid_name: migrated=kept); a DEBUG benchmark launch has the option off and restores the test phone's name (rotid_name: migrated=true). A DEBUG benchmark launch changes the name only with the extra above, so test phones keep their names and the legacy Classic candidate plan (which matches bonded phones by name) never sees several phones called Phone. After an emergency wipe the name stays as it is and the option reads off.

The PSM characteristic stays readable before the handshake; it rotates with the listener as above. Also readable by any connected central (RH2): the GATT database (the service list of the system, the vendor and the app), the Database Hash (0x2B2A, GATT_PERM_READ), Appearance and Central Address Resolution (gap_ble.cc), and the link-layer version and feature exchange (chip vendor and sub-version). The Database Hash summarises the phone's service set, so it is probably the same in every epoch and differs between phones: a central that connects and never completes BMH2 can link epochs with it (UNVERIFIED, measured per phone in P7). A party that completes BMH2 learns the identity anyway, so this matters for silent probes, which the stranger tarpit (RV8) slows.

Draft: direct LE CoC (Design CD, 2026-10-20; not built, needs review and approval). Reasons, measurements and the AOSP cache rules: DECISIONS "Design CD". Proposed (revised after review 1), both sides behind test_coc_direct_psm (default off): byte 19 of the 22-byte announcement (streamFps, 0 in these roles) carries the listener's LE PSM (0x80-0xFF) only in a version 5 call offer at route cost 0 that is not a relay offer, and in a version 5 viewer request without a recovery hint; 0 means none. It is written only by the phone whose own per-session listener it names, opened in the current epoch (the CoC listener registry, not the SDK level, decides); every mirrored copy (a relay's parent offer), every re-announcement whose listener was not reopened in this epoch, versions 2 and 8, stream offers, recovery requests, compact forms and long-lived listeners write 0. The listener is opened after a secure-random number (0-15) of throwaway listeners, so the PSM does not follow the shared allocation cursor. A connecting phone uses byte 19 only from a current-epoch sighting, opens the channel by PSM with no GATT client first, runs the unchanged hop handshake over it, falls back once to the GATT control read only on a transport failure before the first handshake byte (never after a handshake result), and opens a GATT client in the background that requests HIGH priority at once and holds it. Listeners keep the control service and its PSM characteristic for older phones. The PSM becomes visible to passive scanners (today only to connected phones); it rotates with the listener at each boundary. No build sends it.

Address reuse on legacy-only controllers (RH7-RH9). AOSP gives a new advertising set a new resolvable private address only when the controller supports LE extended advertising or the vendor multi-advertising commands; with neither, the set advertises with the host's initiator address (le_advertising_manager.cc new_advertiser_address: "we reuse the initiator address if we are a legacy advertiser using privacy, since there's no way to use a different address"), which changes only on the stack's own timer (a random 7 to 15 minutes, acl_api.cc). An app can neither choose nor read that address (setOwnAddressType and AdvertisingSet.getOwnAddress are system and privileged API). On such a phone the boundary restart changes the RDI but not the address, and a passive scanner links consecutive epochs by address. Everywhere else a set keeps its address for at most the stack's timer, so a second address for the same ID inside one epoch is expected (not a failure), and a new set after the boundary has a new address. The reuse is a property of the gd advertising manager (Android 13 and newer; Android 12 runs it only behind a flag, btif_ble_advertiser.cc); Android 10 and 11 use btm_ble_multi_adv.cc, which generates a fresh address for every instance and writes it with LE Set Random Address (RH5). The app detects the capability, not the address: with the switch on, !isLeExtendedAdvertisingSupported() && !isMultipleAdvertisementSupported() marks the phone legacy-only (suspected) on Android 13 and newer, unknown on Android 12 and fresh on Android 10 and 11. It logs once per process rotid_address_reuse: source=capability | extendedAdv= | multiAdv= | sdk= | status=suspected|fresh|unknown (no address, no identifier; the test multiAdv is false for a vendor multi-advertising controller with fewer than five instances, so suspected can be wrong and the tracker is the ground truth). A legacy-only controller has exactly one advertising instance (controller.cc, enforced by allocate_advertiser; Android 10 ReadInstanceCount also returns 1): two app sets on air at once prove fresh, repeated ADVERTISE_FAILED_TOO_MANY_ADVERTISERS on a second set supports suspected (adv_set_probe: free= measures it), and on such a phone the call listener, discovery, title and reach sets and system advertisers compete for the one slot (RH6), shows in Settings below the switch "This phone's Bluetooth chip may keep its address when the ID changes, so a scanner nearby could still link your IDs; rotating IDs protect less here", and marks the neutral name as recommended. A DEBUG scanner logs rotid_address_reuse: event=observed | scanner= | address= | node= | previousNode= | bonded= when an address announces a new RDI; adv_address_seen carries the same bonded=. Caveat (2026-10-20): a scanner bonded to the advertiser resolves the advertiser's RPAs with the stored IRK and reports its identity address, which may be the public one (the S22 and the S24 bonded to the ASUS saw 04:D4:C4:…, ASUSTek's OUI, while the unbonded M31, A15s and Xiaomi never did). A public or constant address on a bonded=true line is resolution, not an exposure; only unbonded scanners measure address privacy. tools/rotid_tracker.py reports per phone the count of "new ID under the same address" (blocking for P5, one gate for every phone: reuse across a boundary is a privacy FAIL for that phone, reported next to the declared status, and the weaker-privacy note is shown; checking that the declared status matches the observation is a separate consistency line, not a pass), counts "second address for one ID inside an epoch" as INFO (sequential: Android's timer; concurrent: a second advertiser carrying the same ID, such as a relay mirror) and prints a layout census (below). Only unresolved random addresses of an unbonded scanner count: a scanner bonded to the advertiser, or holding its IRK, reports the resolved identity address (types 2 and 3) for every set, which says nothing about the advertising address (RH7); such sets are reported as INFO address_identity_resolved.

Fields that stay constant across epochs (RH10-RH13). A scanner links epochs by any field that does not change at the boundary; the inventory of a version 5 advert, per kind, and what is done about each (an adversary uses the whole tuple, so a field only matters by how many distinct values the fleet has):

Field Call offer (role 0) Stream offer (role 1) Viewer request (role 2) Needed before the handshake? Handling
Version 5 constant constant constant marks "rotating-ID adopter" only none
RDI, route token rotate rotate (route token = logical XOR mask(RDI)) rotate (token copied from the offer) yes rotate; the logical token of a running stream links its epochs by design
Flags: accepting, call/stream, paused constant per kind paused changes with the stream accepting yes none
Flags: transport (GATT, Classic) per phone setting; changes with the CoC-to-GATT safety net per stream copied from the offer yes (the connect method) none
Flags: relay-capable (helper) per setting (relayed calls) per forwarder none yes (helpers) none; user setting
capabilitySummary: VP9, AV1 encode AND decode mask: a per-model constant the stream's codec the viewer's decode mask: a per-model constant calls: to hide phones with no common codec; streams: same; requests: no (a source matches flags and token only, Android and iPhone) requests: sent as 0 (reduction below); calls and streams: PLAN design
capabilitySummary bit 3 (dual hint), bit 4 (relay consent) per setting none none yes (they change how a caller connects or routes) none; user settings, stated
Route cost 0 direct; a relay mirrors the child's offer at +1 hop count copied yes (route ranking) none
Sequence 0 capacity generation, full bit 0 yes (capacity) none
Width, height, FPS 0 the stream's settings (device mode list: a model hint) 0 display only (no Android consumer; the iPhone UI shows them) none: the logical token links the epochs of the running stream anyway
Bitrate 0 the stream's configured rate (can be an odd adaptive value) copied display and one DEBUG experiment rounded to the nearest 25 kbps (the unit of the compact UUID; never to 0); worth little for round presets, coarse logarithmic buckets belong to DECISIONS Design E (RH14)
Flags AD (3 bytes) absent on API 34+ with the switch on (non-discoverable), present on older Android none (non-connectable sets never carry it) absent/present as the viewer's own sets no a new per-phone constant: about one bit ("Android 14+") and a PDU length that differs (RH10); stated
TX power, advertising interval, set count, scan-response contents per stack and phase (low-latency burst, then balanced) same same no measured by the tracker's timing report, not changed

Reductions now (switch on, version 5 adverts only; version 2 and Classic are unchanged): (1) a version 5 viewer request carries capabilitySummary = 0: no sender or receiver reads the viewer's decode mask before the handshake (the source matches flags and token), and the link hello already carries decodeCodecMask, so a per-model constant leaves the air without losing matching; (2) a version 5 stream or forwarder offer advertises streamBitrateKbps rounded to the nearest 25 kbps (an odd adaptive or user-set value no longer marks one phone). Nothing is randomised: every field a receiver matches on keeps its exact value. The remaining per-phone constants (codec bits of calls and streams, the two user-setting bits, the transport bit, the stream's resolution and FPS) are quasi-identifiers of a few bits; they are stated in "Threats that remain", counted by the tracker's layout census (how many distinct layouts the fleet produces per kind) and discussed in DECISIONS "Design E" (moving the codec bits behind the handshake). Boundary timing: the restart stays synchronous at the UTC boundary with a uniform 1-8 s silence (RV12). Spreading the rotation over minutes (Google's Find Hub randomises 1 to 204 s) would make "an ID vanished, another appeared" a rarer event that a scanner can pair, while a synchronous boundary lets every phone in range change within seconds, which maximises the anonymity set of the continuity match. A lone phone is linked by continuity whichever offset is used; the tracker's continuity report measures it, and the constant is not tuned blind. Independent jitter per set of one phone (call, stream, title) is not added: it would not hide the phone but would spread its sets over more seconds.

Stranger tarpit (RV8). A call responder delays its BMH2 hello by 2^(n-6) s (31 ms for n = 1, 1 s for n = 6, capped at 30 s) for the n-th stranger handshake in the current epoch; a handshake whose call rings is forgiven. Verified contacts are never delayed. The delay runs after the initiator's hello verified and before the responder's hello, so it bounds mass probing and battery, not privacy: anyone who connects still learns the identity, once per epoch.

Visible-by-handle window (design decisions, RV5). The Settings button "Visible by handle for 10-25 minutes" (DEBUG test_rotating_visible_window) makes LE call-ready presence and, from Phase 1b, stream and forwarder offers advertise version 2 with the fixed prefix from now until the first epoch boundary at least 10 minutes later (endEpoch = ceil((start + 600 s) / 900 s)), so old builds and anyone who knows the handle can find the phone. The window starts and ends on a new advertising set (the listener restarts with the usual 1-8 s silence), and version 5 resumes with the new epoch's ID. There is no permanent dual advertising. Stated residual: every window is linkable to every other window (same prefix) and to the part of the epoch just before it. The window lives in memory only; an app restart ends it. Once over it is latched off, and it never outlasts 25 minutes of elapsed time, so a wall clock moved back cannot revive or stretch it; a window that ends between boundaries restarts the listener at once.

No handle before the handshake (RV3). A version 5 caller is "Nearby phone" with no handle and no check mark before connecting (unless recognised, below); the handle appears only after the handshake. No connection is ever started by a match; retries only continue a call the user started.

Phase 1b: streams, viewer requests and forwarders (LE CoC and GATT). Classic stream roles keep version 2 (permanent address, RV2); so do the DEBUG direct-receiver mode and BGK1 group hops. Reviewed 2026-10-11 by an independent reviewer; findings RD1-RD7 are marked where they changed the text.

  1. Stream offers. A source's or forwarder's stream offer is version 5 with that phone's role-1 RDI in bytes 1-6 (version 2 with the fixed prefix only inside a Visible-by-handle window, which covers stream offers as well as call offers). The route token is logical XOR mask(RDI), the unchanged StreamRouteToken arithmetic over the six advertised bytes, so it changes with the RDI while the logical token stays the stream's random value for its whole lifetime. A running public stream therefore stays linkable by its logical token, by design (RV14: logical = token XOR mask(RDI) is computable by anyone); the phone's identity is not on air and the next broadcast has a new token.
  2. Viewer requests. The request's version mirrors the selected offer's: a version 5 offer gets a version 5 request with the viewer's role-2 RDI; a version 2 offer gets a version 2 request with the fixed prefix, because a version 2 source cannot decode version 5 and checks "link prefix = announced prefix". A rotating viewer that joins a version 2 source therefore shows its fixed prefix while its request is on air (bounded by the connection wait; the app logs rotid_downgrade: role=viewer and shows the status line "This source does not support private discovery; your phone's ID is visible while you join."). Stated residual (RD2): anyone can advertise a decoy version 2 offer carrying a stream's logical token, so a viewer that taps the decoy row broadcasts its prefix; the decoy's owner would learn the same identity from the handshake if it connected. The route token is copied from the selected offer. FLAG_ROUTE_RECOVERY is set on a version 5 request only together with the recovery hint (item 5) in the six spare bytes; without a hint the flag is clear and the bytes are zero. Source filter (transport). A source selects a viewer request only if the request's transport equals the source's session transport; the one extension, Experimental and off (coc_auto_gatt plus test_coc_auto_gatt_streams, DECISIONS Design SG), is that an LE CoC source also selects a request with FLAG_GATT_TRANSPORT and the same stream-token rule into a separate GATT child pool, so the transport of a child link is always the transport of the request that selected it (the signed link hello already carries it); a GATT or Classic source never selects another transport's request, and no byte, bit or UUID changes.
  3. Token grace. A source or forwarder accepts a viewer request whose token equals its current route token or one of the two it replaced most recently, each for 60 s after it was replaced (version 2 and 5 tokens alike). A viewer copies the token of the newest offer it received before it starts its request and keeps it for the wait (no scan runs while it waits), so a join is valid across one boundary; a wait that outlasts the grace is retried. A forwarder derives the logical token from the announced bytes of the offer it joined (logical = token XOR mask(announced 6 bytes)), not from the authenticated node ID's prefix.
  4. Link opening on stream hops. Link hello bit 3 is signed by an LE stream hop (source, forwarder and viewer, and the forwarder's child link) when the experiment is on; never by a Classic hop or a BGK1 group hop. With both hellos carrying it, the first hop record each way is one BDO1: the connector (source or forwarder) sends its pinned role-1 entries, the listener (viewer) its pinned role-2 entries. Each side checks the advert it selected (RV15): a viewer that selected a version 5 offer requires a role-1 entry in the connector's BDO1 that recomputes to the six selected bytes from the connector's authenticated link node ID; a connector that selected a version 5 viewer request requires a role-2 entry from the listener. A selected version 2 advert uses the prefix check, whatever the hello says (a BDO1 that arrives anyway is read and ignored); the viewer applies it to the link peer, before any end-to-end handshake, and the connector keeps its own link-prefix check. Peer binding (every stream hop). On every stream hop, on both sides (the source or forwarder that opens the hop, the viewer or child that answers), on every transport (GATT, CoC, Classic, Wi-Fi native) and for every offer version (2 and 5), the node ID authenticated by the end-to-end hello must equal the node ID authenticated by the link hello (endpoint.peerNodeId == link.peerNodeId); there is no exception for version 2, and none for a hop that checked a version 5 offer first. Without it a phone that posts its own request (or offer) and dials the other side could relay the end-to-end handshake: both prefix checks pass, the media stays end-to-end encrypted, and the relay owns every hop-local control on the hop. A forwarder does not need the exception: it runs its own end-to-end handshake with each child and hands it the stream key in the source-node-ID grant (BFG1), which keeps its grant check. A mismatch fails the join closed before the stream header, never a downgrade or a retry on another version; a link peer that signs maxChildren above 1 (a BGK1 group hop) is refused the same way before any end-to-end handshake. The only exception is the DEBUG manual stream relay, which passes the end-to-end handshake through: a DEBUG build with the exemption switched on skips the equality for a version 2 hop whose link peer signed maxChildren above 0, and only then (never for a version 5 selection, which is refused before any group handshake); release builds ignore the switch. DEBUG rotid_open: role=stream|viewer | result=.
  5. Admission keys and recovery. A source keys a pending version 5 viewer by its announced six bytes and its advertising address (RD3: a copy of someone else's advert from another address is a separate attempt that fails at the opening and cannot starve the real viewer) and, after the link handshake, by the authenticated node ID; a second authenticated link from a node that already has one replaces the older link (a viewer whose request rotated while its first link was dying). Recovery hint (RecoveryHint, domain .../Recovery/2): Khint = HKDF-SHA256(Kmedia, salt = 32 x 0x00, info = "BluetoothVideoMesh/Discovery/Recovery/2") and hint = HMAC-SHA256(Khint, domain || 0x00 || failedNodeId (16) || u32 logicalToken || viewerRdi (6) || u32 routeToken)[0:6], where viewerRdi and routeToken are the request's own bytes 1-6 and token (RD3: the hint changes every epoch, so nothing constant crosses a boundary, and a hint copied into another advert matches nobody). Kmedia is the forwarded-stream key a viewer received in its parent's BFG1 grant; the source holds the same key as the sealer key of that child's end-to-end session. A viewer whose parent is the source itself has no such key and sends no hint. A source that gets a version 5 recovery request computes the hint for each connected child it holds a key for, from the request's advertised ID and token; a match makes the viewer that child's replacement exactly as version 2 does (heartbeat probe, then admit), no match means the ordinary admission path. A bystander can still copy a viewer's whole advert and spend the source's single over-capacity probe slot for one connection timeout (12 s), as any fake viewer request can; the copy cannot open the viewer's RDI, so nothing is admitted.
  6. Rotation (RV6, RV12). At a boundary the app's stream sets restart together, break-before-make, each on a new advertising set after a uniform 1-8 s silence: the source's offer set and title-page set, a waiting viewer's request set, and a forwarder's offer set and title-page set (a title page carries the route token, so it is rebuilt with the new token rather than restarted unchanged). A link in progress does not depend on any set. A viewer's request set rotates only while it is still waiting for its parent; its GATT/CoC service and PSM stay for that wait (stated residual: a viewer that waits across a boundary keeps its PSM). A forwarder with a child stops advertising anyway and has nothing to rotate. Restarts inside an epoch (a capacity-generation change, a pause) reuse the epoch's RDI (RV4).
  7. Pins (section 6). Role 1 and role 2 have their own pins (OpeningPins per role). A connector evaluates its pins when it sends BDO1, a listener when it accepts the connection; a role's ID stays openable for 60 s after its set left the air. A viewer holds one selected ID, so a join that straddles a boundary by more than that 60 s fails as "identity changed" and is retried (RD7, residual).
  8. Epoch window of an opening (RD4). A verifier accepts an opening entry only if its epoch is within two epochs of the verifier's own wall-clock epoch (DiscoverySelectionCheck.EPOCH_TOLERANCE); otherwise the result is OPENING_STALE. Without the bound a forger could build one 2^48 table for a fixed epoch and invert any victim ID of any epoch; with it the forgery cost is per target and epoch again (RV9). A phone whose advertised monotonic epoch E is more than two epochs ahead of the verifier's clock (RV4 forward-jump window, up to a day) is not callable until it recovers; this fails safe.
  9. Scanning. Every stream or viewer-request scan passes the experiment's switch to the decoder (version 5 and compact v2 become visible) and filters by the token window of item 3. With the experiment off nothing here runs and the filters are the old exact-token tests.

Definitions that two implementers must share (review finding). Epoch is the phone's own monotonic E wherever it advertises, flushes a table or computes a skew; the verifier's window (item 8) compares an entry's epoch with the verifier's wall-clock epoch. A connection is accepted when the CoC listener's accept returns or the GATT server reports the connection, before any record is read; pins are evaluated then. Previous RDI (poison reverse) is the RDI of epoch E - 1. An iPhone compact v2 viewer request has no field for a recovery hint, so an iPhone viewer sends none. FLAG_ROUTE_RECOVERY without a hint is never sent by a version 5 request.

Phase 1b: call endpoints and relay chains.

  1. Endpoint hello bit 3 (0x08). Every call endpoint with the experiment on signs it in its end-to-end hello, on every transport; never a stream endpoint, forwarder or BGK1 hello. When both endpoint hellos carry it, the callee's first end-to-end content record is exactly one BDX1 OPENING (content type 11), queued when the Invite arrives and ahead of any label, Ringing or Decline (RD6: the relay guard then needs no exception, because the OPENING is only ever sent in answer to an Invite). The caller sends none (it advertised nothing). Type 11 is rejected as unknown by every opener that did not advertise bit 3. The OPENING carries the role-0 entries pinned when the connection was accepted (evaluated at accept, not at send time: a relay chain can take minutes to deliver the Invite).
  2. Check. A caller that selected a version 5 offer requires the callee's first end-to-end record to be that OPENING (anything else, or none, ends the call) and a role-0 entry in it that recomputes to the six selected bytes from the callee's endpoint node ID (authenticated by the end-to-end hello, so it holds through relays), within the epoch window of item 8. A direct call also keeps the link BDO1 check and requires endpoint node ID = link node ID. Over a relayed route only this record proves the callee, so the caller queues its Invite first and its signed name label only after the OPENING has passed; the callee takes labels before or after the Invite alike. A hello without bit 3, a missing OPENING or a mismatch fails the call as a prefix mismatch fails today ("Peer identity changed between discovery and connection"). A selected version 2 offer keeps the end-to-end prefix check, and an OPENING that arrives anyway is not examined. A direct call binds both layers (SEC-B3, 2026-11-10): for a selected direct call (route cost 0) with a version 2 offer, real or throwaway key, the authenticated end-to-end node ID must equal the authenticated link node ID, checked right after the end-to-end handshake and before any reader is installed or hop control is acted on (a pseudonymous direct call already required one throwaway at both layers). A phone that advertises the callee's prefix at cost 0 and relays the callee's end-to-end handshake over its own link is refused as a prefix mismatch ("Peer identity changed between discovery and connection"). A version 5 direct call keeps its own rule described above: the link BDO1 check, and the end-to-end node ID against the link node ID when the callee's first end-to-end OPENING is verified, which comes after the reader has started. The selection stays direct (2026-11-11): the address refresh behind a tap follows the dialled phone to its newest advertisement of the same prefix and version, but a caller who selected a direct offer (cost 0) is never moved onto a relayed offer (cost above 0) by that refresh; the dial keeps waiting for a direct sighting. A relayed call needs the caller's own choice of a relayed row. A relayed offer (route cost above 0) is not bound: its first hop is a helper by design and keeps its consent gates. Windows and Apple use one identity at both layers, so their direct calls pass. This closes a hole and has no switch.
  3. Relay chains. A call relay with the experiment on decodes version 5 call offers (every scan of a relay passes the switch) and mirrors the child's offer unchanged, version and RDI included, at route cost + 1 (PrivateCallRelayChain.parentOffer copies the child). Its child-facing link hello also signs bit 3 and the relay is the connector: for a cost-0 child it sends BDO1 count 0 and requires the child's BDO1 to open the mirrored RDI (role 0) for the child's link node ID; a cost 1-3 child is another relay (its hello has no bit 3, so no opening) and keeps today's admission, which skips the prefix comparison for version 5 (matchesCallee has no meaning for an RDI). Its parent-facing hello never signs bit 3: a relay cannot open the callee's ID. The caller's link to the first relay therefore carries no opening, and a caller may call through a relayed version 5 offer (Phase 1 refused it), verifying only end to end by item 2. An on-demand relayed-call helper (Design C) is different: it is a call listener with its own offer, signs bit 3 on its link hello and sends its own BDO1 before the SETUP it reads.
  4. Relay guard. Unchanged: the callee's first end-to-end content record after the handshake is still its answer to the Invite, because the OPENING is only sent after an Invite.
  5. Residuals. A relay sees the mirrored RDI (not the callee's prefix) and, as today, both signed endpoint hellos and so both node IDs. The version 5 forgery cost is unchanged (RV9, RD4). BDX1 records of different kinds have different lengths, so a relay on the path can tell a pair of mutual contacts exchanging leases from the record sizes (stated residual; padding would cost up to 900 bytes per record).

Recognition and presence leases (Phase 2, RV3, RV10, RV11, RD1, RD5). Call sessions only, and only between mutual verified contacts: each side has the other's full fingerprint in its verified-contact store (QR or safety code) and has the per-contact switch "Can see when I'm nearby" answered yes (not yet asked counts as off; design decision 2026-11-06, replacing the default on of Q4).

  1. Daily leases (RD5). Contacts receive the phone's daily day keys L_d (independent random keys, "Keys and epochs") for today and the next 27 days: a PRESENCE_LEASE of up to 28 keys, 914 bytes, the exact bytes of the wire format that earlier builds sent with HMAC-derived keys, so contacts need no change to store or use them. A holder can resolve IDs of days from its delivery on, so a seized or forgotten contact phone cannot resolve what a passive sensor logged more than the current day before the delivery; and since the keys are independent a holder of day d learns nothing about day d + 1 that it was not given. Salts are keyed by L_d(e); the reviewed vectors hold with their key read as L_d.
  2. Session flow. Once the call is answered (the callee accepted; an OFFER to a call nobody answered would tell the peer that this phone holds its fingerprint, RD6), a side that qualifies sends one RECOGNITION_OFFER. A side that has both sent and received an OFFER in this session sends one PRESENCE_LEASE (generation, its current monotonic epoch E, today's day and 27 more) as soon as the second OFFER event happens. At send time one locked step in the vault (PresenceVault.deliverLease) re-checks that the peer is still a verified contact, that the switch is still on and that the store is persistent, records deliveredGeneration durably before the lease is returned and builds the lease from that one state's key and generation; if any check or the write fails nothing is sent (RD1: a Forget, a switch-off or a revocation between OFFER and LEASE cannot leave a lease on a peer that was just revoked, and a revocation that follows finds the record). An OFFER that is withheld at its own send-time check means this side never offered: it then sends no lease and stores none. The Keystore and disk work of these steps runs off the call's reader thread. A lease is stored only if this side sent an OFFER, received the peer's OFFER, and the authenticated peer fingerprint is VERIFIED in the store right now. A second OFFER or LEASE in a session, an undecodable record, a LEASE without the OFFER handshake, or a peer that is not a verified contact is dropped with SECURITY DISCOVERY RECORD REJECTED reason=; it never fails the call. One-sided verification therefore gives the other side nothing: a poster's QR code scanned by a stranger yields no lease.
  3. Receiver rules (ContactPresence.receive): a higher generation replaces the stored record; the same generation merges the delivered days and drops days before the first delivered one, but only if every day both sets contain has the same key, otherwise the record is replaced (a sender whose counter was lost can reuse a generation number for a different key; RD1d); a lower generation is ignored (a contact cannot roll a lease back); the skew sender epoch - my epoch is clamped to +-30 days; at most 32 days are kept. Pruning: at each tick and on every receipt a contact record deletes the days before dayOf(myE + skew) - 1 (the days its recognition table can no longer use; RH6), so a seized lease holder does not keep the keys of days that are over.
  4. Store (PresenceStore, file identity-verification/presence-leases.bin): sealed with the verified-contacts Keystore key (AtRestCipher, alias bluetooth-video-mesh-verification-v1), atomic writes, never overwritten while unreadable (the same cause classes as VerifiedContactStore: corrupt latches until reset, Keystore and I/O errors retry; "Reset presence key" is the reset for a corrupt file: it deletes it, raises the counter and starts a fresh generation, losing the lease records in it). While the store is unreadable the key is re-read at most every five seconds. Contents: this phone's presence state (generation and the day-key window: day, L_d pairs, 28 to 29, never a master key) and, per contact node ID, the switch, deliveredGeneration (the generation of the last lease sent to them, 0 = none), and their lease record (generation, skew, received time, up to 32 day, L_d pairs). The file is "BPS1" | version 2 | generation (4) | key count (1) | count x (day (4), L_d (32)) | contact count (2) | contacts; version 1 held one P instead of the key block. Migration: a version 1 store, and the Phase 1 plain rotating_discovery_presence_key file (imported at first use and deleted), are converted once at load: the keys of the current window are derived as L_x = HMAC-SHA256(P, "BluetoothVideoMesh/Discovery/Lease/2" || 0x00 || u32 x) (so leases already delivered to contacts stay valid and the generation is kept), the version 2 file is written, and P is zeroed and never stored again; the derivation exists only for this step and for the migration vectors. If the converted file cannot be written the phone keeps using the derived keys in memory and retries at the next load, and never uses P after the conversion (RH5). The generation counter that survives a lost or restored store is the epoch-clock state's generation (preferences, written with commit()): a new window always takes a generation above every one ever used (RD1). On load, a stored state whose generation is below the counter is treated as revoked or rolled back and its keys are replaced (contact records are kept). While the store is unreadable the phone advertises with an ephemeral in-memory window (random keys, nobody recognises them), sends no OFFER and recognises nobody; it never regenerates over an unreadable store.
  5. Recognition table (RecognitionTable): rebuilt in full at each tick (RV10), when a lease is stored or removed and when the contact set changes, for roles 0 and 1 and epochs myE + skew + [-1, +1] ([-8, +8] while the lease record is older than 14 days) that a held lease covers; role 2 is never recognised. Each decoded version 5 or compact v2 call or stream offer does one hash-map lookup. A hit shows the contact's own label and "seen nearby, unconfirmed", never a check mark (anyone can replay a contact's RDI) and never a reason to connect: no retry, reconnect or exchange starts from a hit (RV3, until BMH3). Two contacts on one RDI (about 2^-48 per pair) show as an ordinary nearby phone. The check mark appears only after the handshake and full-fingerprint check, as today.
  6. Generations (revocation). The owner starts a new generation (a whole new window of random day keys; contacts' other records stay) when: the user forgets a contact it had delivered a lease to, turns that contact's switch off after delivery, resets the verified contacts (when any lease was delivered), presses "Reset presence key", the forward-jump recovery of RV4 runs, or the emergency wipe runs (the identity changes then anyway). Revocation fails closed (RD1): the counter is raised first; if the new state cannot be stored the old keys are dropped from memory at once, an ephemeral window serves until the store works, and when the store is unreadable at the moment of a Forget, reset or switch-off the counter still moves, so the next load replaces the stored key. Other contacts keep recognising nothing from the old key; each gets the new leases at the next mutual session. Turning the switch off for a contact that never received a lease changes nothing on the wire. A contact deleted here also drops its stored lease record. A Forget or a reset that ran while the experiment was off cannot reach the vault, so the next start with the experiment on reconciles the vault with the verified-contact store off the main thread: records of contacts that are no longer verified are dropped and, if a lease of the current generation went to one of them, a new generation starts (skipped when the verified store cannot be read consistently).
  7. Not in this phase: no automatic connection, no Contacts-only gating (RV13), no lease delivery outside a live answered call or a BVMID2 verification session ("Leases (Design QL)" in "Naming v2").

Residuals (Phase 2). A lease holder (a contact, or anyone with that contact's unlocked phone or an extracted store) recognises this phone from the delivery day for up to four weeks, or until the generation changes; it can no longer resolve days it has already pruned (RH6), and it cannot compute a day it was not given (the keys are independent). A forgotten or revoked contact still holds its old daily keys (valid for those days), this phone's node ID and fingerprint, the old generation number and, roughly, when the two last met; a generation change also tells every remaining contact that a revocation happened. A recognised hit is a hint a replayer can fake.

Draft: busy marker (Design BZ, 2026-10-19; not built, needs review and approval). Reasons and alternatives: DECISIONS "Design BZ".

  1. Role 4. Role r = 4 is the busy marker. Its ID is RDI(nodeId, e, 4, salt) with salt = HMAC-SHA256(L_d(e), "BluetoothVideoMesh/Discovery/Salt/1" || 0x00 || u32 e || u8 4): the formulas of "Keys and epochs" unchanged, so a contact that holds this phone's lease computes it with the day keys it already has. No lease byte, record or key changes.

  2. Advert. An announcement of version 5 with bytes 1-6 = the role 4 ID and every other field zero: flags (no accepting, call, stream, transport, paused, relay or recovery bit), capabilitySummary, stream token, route cost and the six compact bytes. The bitrate is also zero. It is sent on a non-connectable, scannable legacy set: service UUID in the advertising PDU, the 22-byte announcement as manufacturer data in the scan response, the layout of the stream discovery set. Interval and TX power are balanced from the start (no low-latency burst).

  3. When it is sent. Only while all of these hold:

    • the phone's call endpoint is occupied by a call, from the moment it stops its role 0 offer for an Invite (incoming or outgoing) until the call has ended and the role 0 offer is back on air;
    • rotating IDs are on and the presence vault is persistent;
    • at least one contact with the per-contact switch on received a lease of the current generation that covers today. This needs a delivery day stored with deliveredGeneration (review BZ-R1). deliveredGeneration alone does not prove that today's key was delivered.

    It is never sent:

    • during a Visible-by-handle window (a version 2 marker would name the phone);
    • during a BVMID2 verification session;
    • by a relay or a relayed-call helper on duty;
    • in a DEBUG benchmark launch unless asked for.
  4. Rotation and lifetime. It rotates at the epoch boundary like every role, with break-before-make and the 1-8 s silence. It stops at the end of the call before the role 0 offer restarts (break-before-make, so a contact never sees both from one phone for longer than the stop latency). It uses the advertising slot the call listener freed and is the first set preempted for any session set.

  5. Receivers.

    • Every list (Nearby calls, Live, viewer requests, relay mirrors, reach pages) requires its kind flag and therefore ignores the marker; no connection is ever made to it.
    • The recognition table adds role 4 for each held lease, over the same epoch window as roles 0 and 1, and it looks up only version 5 announcements with all flags zero. A role 4 hit means "this contact is probably in a call". It is shown as " is in a call" on the contact's row and when a Contacts or Recent call to that contact cannot find its call offer.
    • A role 0 hit of the same contact within the last 4 s wins, and the contact is shown as free. A marker is a hint, never proof, and never a reason to connect (RV3). It is never shown for a stranger.
  6. Compatibility. No new version, length or field: a scanner that does not decode version 5 rejects it; one that does finds no kind flag and drops it; a recognition table without role 4 does not match it. Conformance vectors: an all-zero version 5 advert that every list must ignore, and role 4 RDI vectors from tools/bmh3_reference.py.

  7. Residuals. Strangers can tell that some phone nearby is in a call (as an epoch pseudonym). That is already visible from the call offer's disappearance and the LE connection. Contacts holding a lease learn when this phone is in calls while in range. A copied marker can make a contact look busy until its own call offer is heard (the offer wins).

Draft: announcement in the primary advert (Design PA, 2026-10-20; not built, needs review and approval). Reasons, alternatives and the compatibility gate: DECISIONS "Design PA".

  1. Layout (legacy, scannable, connectable or not as today).
    • Advertising data: Flags (3 bytes, only where the platform adds them: discoverable sets) + manufacturer-specific data len 25 | 0xFF | company 0xFFFF | announcement (22) = 26 bytes. Total 29 or 26 of 31.
    • Scan response: the complete list of 128-bit service UUIDs, c8d5a101-… (18 of 31 bytes).
    • Version 3 title pages, version 4 reach pages and version 7 name pages keep their own sets and layouts; this draft covers the 22-byte announcements of versions 2, 5 and 8 only.
  2. Content. The 22 bytes are exactly today's announcement for that set (version, prefix or RDI, flags, capability summary, route token, cost, compact fields, bitrate); nothing is added, removed or reordered.
  3. Scanners.
    • Accept the announcement from the advertising data or from the scan response, whichever arrives. A record holding both decodes once.
    • Filters: today's service-UUID filter, plus one manufacturer-data filter per announcement version (company 0xFFFF, data [version], mask [0xFF]).
    • Every decode stays strict (length 22, version, fields).
  4. Advertisers.
    • Experimental and off (test_primary_announcement) until the compatibility gate in DECISIONS "Design PA" passes; the old layout stays the default.
    • A phone never advertises both layouts at once.
  5. Compatibility. Today's builds read the announcement from the merged scan record, so they decode the new layout whenever their service-UUID filter matches. Whether a controller's offloaded filter matches a UUID in the scan response is the open question that the gate measures.

BDX1 formats. BDX1 = BME1 content type 11, calls only, sent only when both endpoint hellos carry bit 3 (Phase 1b and 2, above):

"BDX1" (4) | kind (1) | body
  1 OPENING           count (1, 0..2) | count x entry (43)                 (the BDO1 body after its version byte)
  2 RECOGNITION_OFFER reserved (1) = 0
  3 PRESENCE_LEASE    generation (4, >= 1) | sender epoch (4) | first day (4) | count (1, 1..28) | count x L_d (32)

Decoding is strict; a receiver drops and logs any other kind or length and never fails a call. Receiver rule for leases (design decision: mutual verified contacts, per-contact switch on by default, 4 weeks, delivered as 28 daily keys): see "Recognition and presence leases" above (PresenceLeases.kt).

Debug lines. rotating_ids: enabled=, rotid_visible_window, rotid_name: option= | changed= | pending= | paused= | migrated= (the name option, never the name itself), rotid_adv, rotid_address_reuse, rotid_tarpit, rotid_open: role=call | result= (DEBUG adds rdi= and prefix= after a pass; release builds never log both), DEBUG rotid_boundary, rotid_restart (role=call|stream|viewer|title_pages), rotid_downgrade, rotid_presence: event=offer_sent|offer_received|lease_sent|lease_stored|lease_rejected|generation|store, rotid_recognized (DEBUG only: role, epoch, skew, contact short ID), and the Phase 0 measurement lines adv_set_churn (every set start/stop/in-place step with its set identity) and adv_address_seen (a scanner's per-node advertising address when first seen or changed). tools/rotid_phase0.py joins them offline.

Vectors (RotatingDiscoveryTest, PresenceLeasesTest; P = 000102…1f is a migration input only, node ID a1b2c3d4e5f60718293a4b5c6d7e8f90, epoch of 2026-10-10T00:00:00Z = 1990656 = 0x001e6000):

Input Output
Role 0 salt / RDI d8cbe82b78824c1ba08d0c4eed26502189e19ccccd180e46c4826c5ba47e7167 / 44e69c0a7a7d
Role 1 salt / RDI dcd9349e228e00ceb4a7ec2e7e023fc0c34c507f88e99934897105ca4fae84c0 / bb2d061f6480
Role 2 salt / RDI e59970c8523f23a6b1b16a858daf75320e3291d6c86d0329df89a62899804335 / 974ae410ea91
Role 0, epoch + 1 salt / RDI 7ece98afad0378297e68c9e2b59c1a1f88448a0c70b86d91f192f65d263a763b / 67aba670e65a
Version 5 call offer, role-0 RDI, flags 0x41, VP9+AV1, token 0, cost 0 0544e69c0a7a7d410300000000000000000000000000
mask(bb2d061f6480) / route token for logical 0x12345678 0x8066c9bc / 0x92529fc4
Version 5 stream offer, role-1 RDI, flags 0x0c, VP9, that token, cost 0, sequence 1, 640x360, 15 FPS, 300 kbps 05bb2d061f64800c0192529fc40001028001680f012c
Compact v2 call offer, role-0 RDI, LE CoC, VP9+AV1, cost 0 C8D6844E-69C0-A7A7-D0C0-000000000000
BDO1, one entry (role 0 above) 42444f31010144e69c0a7a7d001e600000d8cbe82b78824c1ba08d0c4eed26502189e19ccccd180e46c4826c5ba47e7167
BDO1, empty 42444f310100
Migration derivation domain (version 1 stores only) / day of the epoch above (RD5) 426c7565746f6f7468566964656f4d6573682f446973636f766572792f4c656173652f3200 / 20736
Migration: L_d / L_(d+1) derived from P = 000102…1f 6484923eb4b0b044dfb4d38feda471b389ec3b18e6fa199a41e22642f7f786b5 / c32a563c90e92cb68e786a09357ba1b499ae20c8f122be7b10863ae635b016c7
Owner's own role 0 salt / RDI with the migrated L_d of that P (salt keyed by L_d) 81b9be731a7cdddd1954e543fb9b712dd963a24d08dcf1876ed219d00cab8ae9 / fecba64983fd
BDX1 OPENING (role 0 entry of the vector table) 42445831010144e69c0a7a7d001e600000d8cbe82b78824c1ba08d0c4eed26502189e19ccccd180e46c4826c5ba47e7167
BDX1 RECOGNITION_OFFER 424458310200
BDX1 PRESENCE_LEASE, generation 1, sender epoch above, first day 20736, L_d, L_(d+1) 424458310300000001001e60000000510002 followed by the two keys
Recovery hint (Phase 1b, RD3), Kmedia = 404142…5f, Khint = HKDF-SHA256(Kmedia, salt 32 x 0x00, info "BluetoothVideoMesh/Discovery/Recovery/2") = 07eb0508…305c28, failed node above, logical 0x12345678, request ID 974ae410ea91, route token 0x92529fc4 4d832e9d4dc7 (request ID 67aba670e65a: f9fa5ad0b780)
Version 5 viewer request, role-2 RDI, flags 0x09 (accepting + GATT), no codec summary (hardening: capabilitySummary 0), token 0x92529fc4, cost 0, 300 kbps 05974ae410ea91090092529fc400000000000000012c
Same with FLAG_ROUTE_RECOVERY (0x89) and the hint above in the six spare bytes 05974ae410ea91890092529fc4004d832e9d4dc7012c

Threats that remain (design section 9): a link sniffer reads both identities from the cleartext BMH2 hellos (closed for BMH3 links, see "Experimental BMH3 identity-hiding link handshake"); anyone who connects learns the identity (once per epoch; under BMH3 only an Everyone-mode responder reveals it, a Contacts-only responder does not); OS-paired devices resolve every address with the IRK; Classic uses the permanent address; the phone's own SCAN_REQ/CONNECT_IND addresses rotate on the stack's timer; OS beacons (Find My Device, offline finding, Fast Pair) rotate on their own schedule; a phone alone in an area is linkable by continuity; stable capability bits are a few bits of quasi-identifier (the relayed-call consent bit and, with dual listening on, the dual hint are two more); a legacy-only controller keeps its address across a boundary (the tracker counts it per phone); the GAP Device Name stays readable on Android 13 and older, on legacy-only controllers and on any OEM stack that does not follow AOSP unless the user chooses the neutral name; the sealed file may leave ciphertext of earlier day-key windows in flash; a running public stream stays linkable by its logical token (by design); a rotating viewer that joins a non-rotating source (or a decoy version 2 offer for the same stream) shows its fixed prefix while its request is on air; a lease holder recognises the phone from its delivery day for up to four weeks; path relays can tell a lease exchange from the record sizes.