Status: DRAFT. Not for publication until review and sign-off.

# word_digest_handle v0.1

## Status

- Version: v0.1 draft
- Date: 2026-07-04
- Stability: draft. The lexicon is pinned and final; the codec construction
  below is frozen only after review and sign-off.

This document specifies the word_digest_handle encoding: rendering the
leading bits of a digest as words from a versioned 2048-word lexicon plus
one or two checksum words. Handles are a transcription and readback aid for
humans and lossy perceptual channels. They are not a cryptographic proof: a
handle carries only `prefixBits` of the underlying digest.

## Definition

A word_digest_handle is `prefixBits / 11` data words followed by
`checksumWords` (1 or 2) checksum words, all drawn from the
`aps-handle-en-v1` lexicon. `prefixBits` is a positive multiple of 11 and
must fit in 16 bits (the checksum construction encodes it as a two-byte
big-endian header). Each word encodes 11 bits.

Lexicon layout profile id: `single-list-v1`. Data words and checksum words
draw from the same single flat 2048-word list.

## Lexicon: aps-handle-en-v1

- 2048 words, lowercase ASCII, 4 to 8 characters each.
- `lexicon_id` is the sha256 of the canonical word list text (one word per
  line, trailing newline), pinned:

  `sha256:2a9c4de3b5457154e6bde9d40af0da552c2556d8e80a2dec8b82dee4bca74510`

- Attribution: contains words derived from the EFF Long Wordlist
  (https://www.eff.org/dice), Copyright Electronic Frontier Foundation,
  licensed CC-BY 3.0.
- The lexicon is versioned and swappable: records that carry handles pin
  `lexicon_id` (and `lexicon_profile`), so a future lexicon is a new id, not
  a mutation. Custom lexicons (other languages, other channels) are future
  work.

### Derivation pipeline (summary)

The lexicon data file is generated by a deterministic pipeline committed in
`src/v2/word_handles/lexicon-source/`; rerunning it reproduces the identical
list. In order:

1. Start from the EFF Long Wordlist (7776 words); keep words matching
   `^[a-z]{4,8}$`.
2. Drop any word present in the BIP39 English list. The BIP39 English list
   (`lexicon-source/bip39_english.txt`) is used ONLY as a
   disjointness-check input: no word from it appears in the lexicon, so a
   handle can never be mistaken for wallet material at the word level.
3. Glyph-confusability filtering: each word maps to a confusion skeleton
   (bigram collapses rn to m, vv to w, cl to d; character collapses i, l to
   1, o to 0, s to 5, b to 8). One survivor per skeleton.
4. Near-homoglyph filtering: any word within Damerau-Levenshtein distance 1
   of an already-kept word is dropped.
5. Unique 4-letter prefixes: first keeper per prefix wins.
6. Exactly 2048 words selected by ascending
   `sha256("aps-handle-en-v1:" + word)`, an unbiased deterministic subset;
   the final list is sorted alphabetically.
7. Assertions re-checked at generation time and again in SDK tests: 2048
   words, disjoint from the BIP39 English input, unique 4-letter prefixes,
   unique skeletons, no distance-1 pair in the final set.

## Encoding

- Bit order is MSB-first from byte 0 of the input.
- Data word `i` encodes bits `[11i, 11i + 11)` of the input as an integer
  index into the word list.
- `packedPrefix` is the first `prefixBits` bits packed into
  `ceil(prefixBits / 8)` bytes, MSB-first, unused low-order bits of the
  final byte set to zero.
- Input is raw bytes or a hex string; a leading `sha256:` on hex input is
  stripped. A hex string supplies 4 bits per character. Encoding fails when
  `prefixBits` is not a positive multiple of 11, when the input supplies
  fewer than `prefixBits` bits, or when `checksumWords` is not 1 or 2.
- Defaults: `prefixBits = 66`, `checksumWords = 1` (the `default` profile).

## Checksum construction (position-dependent)

```
checksumDigest = sha256( BE16(prefixBits) || packedPrefix )
```

`BE16(prefixBits)` is the two-byte big-endian unsigned encoding of
`prefixBits`. Checksum word `j` (for `j = 0..checksumWords-1`) is bits
`[11j, 11j + 11)` of `checksumDigest`, appended after the data words in
order.

The construction is position-dependent: the hash runs over the ordered
packed bits, so transposing any two differing data words changes
`packedPrefix` and fails the checksum. This matters for readback use, where
word-order errors are a realistic failure mode alongside word substitution.

## Profiles

| Profile | Shape | Words | Prefix bits | Use |
| --- | --- | --- | --- | --- |
| `compact` | 4 data + 1 checksum | 5 | 44 | Set-scoped display ONLY. Render-time uniqueness plus lengthening is MANDATORY; `minUniquePrefixBits` is the tool. |
| `default` | 6 data + 1 checksum | 7 | 66 | Minimum for any cross-set reference. |
| `high_assurance` | 8 data + 2 checksum | 10 | 88 | Archival and adversarial contexts. |

The built-in profiles render 5, 7, and 10 words. None of those lengths falls
in the seed-phrase range (see Identifier prohibition).

## Collision probabilities

Handles truncate the digest to `prefixBits`, so distinct digests can share a
handle prefix. The birthday bound for a set of `n` digests at `b` prefix
bits is `p = 1 - exp(-n(n-1)/2^(b+1))`. Numbers as computed:

| Set size | 44-bit | 66-bit | 88-bit |
| --- | --- | --- | --- |
| 1e4 | 2.84e-06 | 6.78e-13 | not tabulated |
| 1e5 | 2.84e-04 | 6.78e-11 | not tabulated |
| 1e6 | 2.80e-02 | 6.78e-09 | 1.67e-15 |
| 1e7 | 0.942 | 6.78e-07 | 1.62e-13 |
| 1e8 | ~1 | 6.78e-05 | 1.62e-11 |

Reading the table: 44 bits is unsafe as any standing reference (at ten
million digests a collision is near certain) and exists only for set-scoped
display where the renderer has checked the actual displayed set. 66 bits
holds through large working sets. 88 bits is for archival scale and
adversarial contexts.

## Identifier prohibition

A word_digest_handle MUST be resolved against a full digest or a
collision-checked set and MUST NOT serve as a sole record identifier, a
secret, or wallet material; handles are never rendered in 12, 15, 18, 21,
or 24 word groupings (those lengths read as seed phrases).

Handles carry no entropy beyond the digest prefix they encode, are
enumerable, and are deliberately human-speakable. Anything that must be
unique, unforgeable, or confidential uses the full digest or a key, never
the handle.

## Detection probabilities

A wrong handle (single word substitution, or a transposition of two
differing data words) passes the checksum only by chance:

- one checksum word: miss probability `2^-11`, detection `1 - 2^-11` per
  event (profiles `compact` and `default`);
- two checksum words: miss probability `2^-22`, detection `1 - 2^-22` per
  event (profile `high_assurance`).

## Localization ambiguity

Decoding reports a best-effort `failedWordIndex` when the checksum fails and
every word is in the lexicon: the decoder scans each data position for
alternative lexicon words that would validate all checksum words. Exactly
one fixable position yields that index. No fixable position: if exactly one
given checksum word differs from the recomputed one, that absolute index;
otherwise null. More than one fixable position yields null (ambiguous).

The math: with one checksum word, a candidate replacement at a wrong
position validates with probability `2^-11`, so across the other 2047
lexicon words a wrong position is coincidentally fixable with probability
about `1 - (1 - 2^-11)^2047`, about `1 - 1/e`, about 0.63. Localization at
44 bits is therefore frequently ambiguous even though detection misses only
with probability `2^-11`. With two checksum words the wrong-position
fixability probability drops to about `2047 * 2^-22` (about 0.0005) and
localization is usually unique.

Consequence: treat `failedWordIndex` as a re-prompting hint ("please repeat
the third word"), never as a correction to apply silently.

## Decode semantics

Decoding returns
`{ prefixHex, prefixBits, checksumOk, failedWordIndex, outOfLexicon }`:

- `outOfLexicon`: indices of words not exactly equal to a lexicon word.
  Comparison is exact code-unit equality: no trimming, no case folding, no
  Unicode normalization. When non-empty, no decoding is attempted:
  `prefixHex` and `prefixBits` are null, `checksumOk` is false,
  `failedWordIndex` is null.
- `prefixHex`: lowercase hex of the packed prefix, exactly
  `2 * ceil(prefixBits / 8)` characters, trailing pad bits zero.
- `prefixBits`: returned so consumers compare BIT-scoped, never by raw
  string beyond `prefixBits`. For the 66-bit default profile, `prefixHex`
  is 18 hex characters of which the final bits are padding.
- `checksumOk`: all given checksum words equal the recomputed ones.

Decoding never throws on unknown words (reported via `outOfLexicon`); it
rejects `checksumWords` outside {1, 2} and word counts that leave no data
words. Profile-scoped decoding additionally rejects word counts that do not
match the profile shape.

## Render-time uniqueness (minUniquePrefixBits)

`minUniquePrefixBits(digestsHex, startBits = 44)` returns the smallest
multiple of 11 at or above `startBits` at which the leading prefixes of all
digests are pairwise bit-distinct. It strips optional `sha256:` prefixes,
compares case-insensitively, and fails on empty input or duplicate full
digests (no prefix length can separate identical digests).

For the `compact` profile this check is not optional: a renderer displaying
compact handles for a set MUST verify uniqueness within the displayed set
and lengthen the handles (more data words) until the set is collision-free.

## Non-goals

- Handles do not replace digests. Every handle-bearing surface keeps the
  full digest reachable.
- Handles are not identifiers, secrets, or wallet material (see Identifier
  prohibition).
- Cross-lexicon comparison. A handle is meaningful only under its pinned
  `lexicon_id` and `lexicon_profile`.

## Pointers

- Reference implementation (TypeScript):
  [src/v2/word_handles/](../src/v2/word_handles/). The Python SDK carries a
  mirrored module under `agent_passport/v2/word_handles/`.
- Lexicon generator, pinned inputs, and attribution:
  [src/v2/word_handles/lexicon-source/](../src/v2/word_handles/lexicon-source/).
- Conformance fixture family:
  [fixtures/read-fidelity-receipt/](../fixtures/read-fidelity-receipt/)
  (handle vectors are part of the read fidelity family).
- Record family that pins `lexicon_id`:
  [docs/read-fidelity-receipt-spec.md](./read-fidelity-receipt-spec.md).
