diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74f936f..6579ac2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,24 @@
# Changelog
+This project follows a loose [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
+convention. Released versions live as frozen snapshots under `versions/vX.Y.Z/`
+in the repo; the rolling tip of `main` is what `latest` deploys.
+
+## [Unreleased]
+
+_(nothing yet)_
+
+## [v0.1.0] — 2026-05-12
+
+First cut release of the spec. Snapshotted as `versions/v0.1.0/`, deployed
+at .
+
+The dated entries below describe the substantive passes whose output is
+captured in this release. Future releases will follow the standard
+Added / Changed / Fixed / Removed grouping under their version heading.
+
+---
+
## 2026-05-12 — Publication-Readiness Pass
**Upstream baseline:** Validated against
diff --git a/README.md b/README.md
index 4c19f2c..baaef35 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,9 @@ A comprehensive specification and conformance test suite for the
[MeshCore](https://github.com/meshcore-dev/MeshCore) mesh networking protocol.
**📖 Read the spec online:**
-(rolling `latest` build, plus a frozen snapshot per `vX.Y.Z` tag — pick one
-from the version-picker in the top-right of any page)
+(rolling `latest` from `main`, plus a frozen snapshot per release —
+current: [v0.1.0](https://swaits.github.io/meshcore-spec/v0.1.0/). Use
+the version-picker in the top-right of any page to switch.)
MeshCore Spec aims to be the authoritative, machine-readable definition of the
MeshCore wire format — much like [RubySpec](https://github.com/ruby/spec) became
diff --git a/versions/v0.1.0/00-overview.md b/versions/v0.1.0/00-overview.md
new file mode 100644
index 0000000..39294af
--- /dev/null
+++ b/versions/v0.1.0/00-overview.md
@@ -0,0 +1,186 @@
+# MeshCore Protocol Specification
+
+## Section 0: Overview
+
+### Status and Provenance
+
+> **This is an unofficial, independent specification.** It is **not** part of
+> the upstream [MeshCore project](https://github.com/meshcore-dev/MeshCore),
+> not affiliated with or endorsed by its maintainers, and not authored by
+> them. Read this section before relying on the spec for anything
+> load-bearing.
+
+This document was primarily written by an AI (Claude, by Anthropic — mostly
+the Opus 4.x family) under the direction of Stephen Waits, with human
+proofreading and editing on top. Substantive edits — additions, audit passes,
+errata fixes — are produced through model-driven analysis of the upstream
+MeshCore C++ source and reviewed before commit.
+
+The **upstream MeshCore C++ firmware is the source of truth** for all
+protocol behavior. This spec is downstream and derivative. Whenever the spec
+and the upstream C++ disagree, **upstream wins** — and a fix to this spec is
+filed against this repo, not against MeshCore. Each substantive change in
+[CHANGELOG.md](https://github.com/swaits/meshcore-spec/blob/main/CHANGELOG.md)
+records the upstream commit hash it was validated against.
+
+Refinements have also been driven in part by observations from building
+DongLoRa firmware and apps against live MeshCore deployments. Where DongLoRa
+behavior diverges from upstream, this spec follows upstream and treats the
+DongLoRa side as a follow-up bug, not a spec change.
+
+Conformance against the [test corpus](https://github.com/swaits/meshcore-spec/tree/main/corpus)
+is a useful signal but **not a substitute** for cross-checking against
+upstream firmware. If you find a divergence from upstream, please
+[file an issue](https://github.com/swaits/meshcore-spec/issues) — that is
+exactly the kind of report this project exists to catch.
+
+### Spec Versions
+
+The published site preserves prior spec versions alongside the rolling
+`latest` build. The URL layout is:
+
+- `https://swaits.github.io/meshcore-spec/latest/` — built from the tip of
+ `main`. May change between any two visits.
+- `https://swaits.github.io/meshcore-spec/v0.1.0/` (and likewise for any
+ future `vX.Y.Z`) — a frozen content snapshot. Suitable for citing or for
+ certifying an implementation against.
+
+Frozen versions live as their own directory in the repo
+(`versions/vX.Y.Z/`) — a literal copy of `src/` taken at release time.
+This deliberately separates **content** (versioned, frozen) from **site
+infrastructure** (theme, picker, build pipeline; lives at the repo root
+and is unversioned). Every deploy rebuilds every published version using
+today's infrastructure, so site improvements show up everywhere without
+backporting.
+
+The version-picker in the top-right of every page lists every published
+version; switching keeps you on the same chapter where it exists in the
+target version. Spec versions use semver and are **independent** of the
+upstream MeshCore firmware's own versioning — each release records the
+upstream commit hash it was validated against in
+[CHANGELOG.md](https://github.com/swaits/meshcore-spec/blob/main/CHANGELOG.md).
+
+### Scope
+
+This specification covers:
+
+- The packet wire format (framing, header, path, payload)
+- All payload types and their binary encodings
+- Cryptographic operations (encryption, signing, key exchange, hashing)
+- The BLE/Serial companion protocol
+- The KISS modem protocol
+- The bridge protocol (RS232/ESP-NOW)
+
+It does NOT cover:
+
+- Application-level behavior (e.g., room server logic, repeater policies)
+- Radio-layer parameters (LoRa spreading factor, bandwidth, frequency)
+- Duty cycle management or transmission scheduling
+- User interface or companion app behavior
+
+### Terminology
+
+The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD",
+"SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be
+interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).
+
+Additional terms used throughout:
+
+| Term | Definition |
+|------|-----------|
+| **Packet** | The fundamental transmission unit in MeshCore. |
+| **Header** | The first byte of a packet, encoding route type, payload type, and protocol version. |
+| **Path** | A sequence of node hashes representing the route a packet has taken or should follow. |
+| **Hash** | A truncated prefix of a node's Ed25519 public key, used for routing. |
+| **Payload** | The data portion of a packet, whose structure depends on the payload type. |
+| **Transport Codes** | Optional 4-byte field present in transport-mode packets, used for regional scoping. |
+| **MAC** | Message Authentication Code — a 2-byte truncated HMAC-SHA256 used for integrity verification. |
+| **MTU** | Maximum Transmission Unit — 255 bytes for MeshCore. |
+| **Flood Routing** | Routing mode where packets are broadcast and repeated by intermediate nodes, building up the path as they travel. |
+| **Direct Routing** | Routing mode where the path is supplied by the sender and the packet follows a specific route. |
+
+### Byte Order
+
+All multi-byte integer fields in MeshCore are encoded in **little-endian** byte
+order unless explicitly stated otherwise. The one exception is CayenneLPP sensor
+data, which uses big-endian encoding.
+
+### Notation
+
+- Binary data is shown as hex bytes separated by spaces: `0D 00 EF BE AD DE`
+- Bit fields are shown in binary with MSB first: `0bVVPPPPRR`
+- Bit numbering is LSB = bit 0, MSB = bit 7
+- Field sizes are in bytes unless noted as bits
+- `[field(N)]` denotes a field of N bytes
+- `[field(N)?]` denotes an optional field of N bytes
+
+### Protocol Constants
+
+These constants define the fundamental limits of the protocol:
+
+| Constant | Value | Description |
+|----------|-------|-------------|
+| `MAX_TRANS_UNIT` | 255 | Maximum packet size on the wire (bytes) |
+| `MAX_PACKET_PAYLOAD` | 184 | Maximum payload size (bytes) |
+| `MAX_PATH_SIZE` | 64 | Maximum path size (bytes) |
+| `PUB_KEY_SIZE` | 32 | Ed25519 public key size (bytes) |
+| `PRV_KEY_SIZE` | 64 | Ed25519 private key size (bytes) |
+| `SIGNATURE_SIZE` | 64 | Ed25519 signature size (bytes) |
+| `CIPHER_KEY_SIZE` | 16 | AES-128 key size (bytes) |
+| `CIPHER_BLOCK_SIZE` | 16 | AES-128 block size (bytes) |
+| `CIPHER_MAC_SIZE` | 2 | Truncated HMAC-SHA256 MAC size (bytes) |
+| `PATH_HASH_SIZE` | 1 | Default path hash size for v1 (bytes) |
+| `MAX_HASH_SIZE` | 8 | Maximum hash size for deduplication (bytes) |
+| `MAX_ADVERT_DATA_SIZE` | 32 | Maximum advertisement app data size (bytes) |
+
+### Protocol Versions
+
+The protocol version is encoded in bits 6-7 of the header byte:
+
+| Version | Value | Status | Description |
+|---------|-------|--------|-------------|
+| V1 | `0x00` | Active | 1-byte src/dest hashes, 2-byte MAC |
+| V2 | `0x01` | Reserved | Future (e.g., 2-byte hashes, 4-byte MAC) |
+| V3 | `0x02` | Reserved | Future |
+| V4 | `0x03` | Reserved | Future |
+
+Currently only V1 is defined. Implementations MUST support V1.
+Implementations SHOULD reject packets with unrecognized version values unless
+they have explicit support for that version.
+
+### Document Organization
+
+| Section | Title | Description |
+|---------|-------|-------------|
+| 00 | Overview | This document |
+| 01 | Wire Format | Packet framing and field layout |
+| 02 | Header | Header byte encoding |
+| 03 | Path | Path length encoding and path field |
+| 04 | Payload: ACK | Acknowledgment payload |
+| 05 | Payload: Advertisement | Node advertisement and app data |
+| 06 | Payload: Encrypted | REQ/RESPONSE/TXT_MSG payloads |
+| 07 | Payload: Anonymous Request | Anonymous request payload |
+| 08 | Payload: Group | Group text and data payloads |
+| 09 | Payload: Path Return | Encrypted path return payload |
+| 10 | Payload: Trace | Path trace payload |
+| 11 | Payload: Multipart | Multi-packet payload |
+| 12 | Payload: Control | Control and discovery payloads |
+| 13 | Payload: Raw Custom | Custom raw payload |
+| 14 | Cryptography | AES-128, HMAC-SHA256, encrypt-then-MAC |
+| 15 | Identity | Ed25519 keys, ECDH, key hashing |
+| 16 | Packet Hash | SHA-256 deduplication hashing |
+| 17 | Routing | Flood and direct routing behavior |
+| 18 | Companion Protocol | BLE/Serial companion communication |
+| 19 | KISS Protocol | KISS modem framing and extensions |
+| 20 | Bridge Protocol | RS232/ESP-NOW bridge framing |
+
+### References
+
+- [MeshCore Firmware](https://github.com/meshcore-dev/MeshCore) — Reference C++ implementation
+- [MeshCore Documentation](https://docs.meshcore.io) — Official protocol documentation
+- [MeshCore.js](https://github.com/meshcore-dev/meshcore.js) — JavaScript implementation
+- [meshcore-rs](https://github.com/Duncaen/meshcore-rs) — Rust implementation (community)
+- [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) — Key words for requirement levels
+- [RFC 8032](https://www.rfc-editor.org/rfc/rfc8032) — Ed25519 digital signatures
+- [FIPS 197](https://csrc.nist.gov/publications/detail/fips/197/final) — AES specification
+- [FIPS 180-4](https://csrc.nist.gov/publications/detail/fips/180/4/final) — SHA-256 specification
diff --git a/versions/v0.1.0/01-wire-format.md b/versions/v0.1.0/01-wire-format.md
new file mode 100644
index 0000000..f409c2b
--- /dev/null
+++ b/versions/v0.1.0/01-wire-format.md
@@ -0,0 +1,140 @@
+# MeshCore Protocol Specification
+
+## Section 1: Wire Format
+
+### Overview
+
+A MeshCore packet is the fundamental transmission unit. It consists of a 1-byte
+header, an optional 4-byte transport codes field, a 1-byte path length, a
+variable-length path, and a variable-length payload. The maximum packet size on
+the wire is 255 bytes (MAX_TRANS_UNIT).
+
+### Packet Layout
+
+```
+ 0 1 2
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 ...
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Header | Transport Codes (opt, 4B) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Path Length | Path (variable) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Payload (variable) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+The fields appear in this order on the wire:
+
+```
+[header(1)][transport_codes(4)?][path_len(1)][path(0-64)][payload(1-184)]
+```
+
+### Field Summary
+
+| Field | Size | Required | Description |
+|-------|------|----------|-------------|
+| Header | 1 byte | Yes | Route type, payload type, protocol version (see [Section 2](02-header.md)) |
+| Transport Codes | 4 bytes | Conditional | Two uint16_le values; present only when route type is `TRANSPORT_FLOOD` (0x00) or `TRANSPORT_DIRECT` (0x03) |
+| Path Length | 1 byte | Yes | Encoded hash count and hash size (see [Section 3](03-path.md)) |
+| Path | 0-64 bytes | Yes | Sequence of node hashes; length = hash_count × hash_size |
+| Payload | 1-184 bytes | Yes | Packet data; structure depends on payload type |
+
+### Transport Codes
+
+Transport codes are present if and only if the route type (header bits 0-1) is
+`TRANSPORT_FLOOD` (0x00) or `TRANSPORT_DIRECT` (0x03).
+
+When present, transport codes consist of two little-endian 16-bit unsigned
+integers, for a total of 4 bytes:
+
+```
+ 0 1 2 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Code 1 (uint16_le) | Code 2 (uint16_le) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+- **Code 1**: Calculated from regional scope parameters
+- **Code 2**: Reserved (typically 0)
+
+When transport codes are not present (route types `FLOOD` and `DIRECT`), the
+implementation MUST set both transport code values to zero internally.
+
+### Encoding Algorithm
+
+To encode a packet to wire format:
+
+1. Write the header byte.
+2. If the route type is `TRANSPORT_FLOOD` or `TRANSPORT_DIRECT`:
+ a. Write transport_codes[0] as uint16_le (2 bytes).
+ b. Write transport_codes[1] as uint16_le (2 bytes).
+3. Write the path_len byte.
+4. Calculate path byte length as `hash_count × hash_size` (see [Section 3](03-path.md)).
+5. Write `path_byte_length` bytes from the path array.
+6. Write `payload_len` bytes from the payload array.
+
+The total wire length is:
+```
+1 + (hasTransportCodes ? 4 : 0) + 1 + path_byte_length + payload_len
+```
+
+This MUST NOT exceed MAX_TRANS_UNIT (255) bytes.
+
+### Decoding Algorithm
+
+To decode a packet from wire format:
+
+1. Read 1 byte as the header. Extract route type from bits 0-1.
+2. If route type is `TRANSPORT_FLOOD` (0x00) or `TRANSPORT_DIRECT` (0x03):
+ a. Read 2 bytes as transport_codes[0] (uint16_le).
+ b. Read 2 bytes as transport_codes[1] (uint16_le).
+ Otherwise, set both transport codes to zero.
+3. Read 1 byte as path_len.
+4. Validate path_len (see [Section 3](03-path.md)):
+ a. Extract hash_size = (path_len >> 6) + 1. If hash_size is 4, the packet
+ is INVALID.
+ b. Extract hash_count = path_len & 63.
+ c. If hash_count × hash_size > MAX_PATH_SIZE (64), the packet is INVALID.
+5. Calculate path_byte_length = hash_count × hash_size.
+6. Read path_byte_length bytes as the path.
+7. Let `i` be the current read position. If `i >= total_length`, the packet is
+ INVALID (payload MUST contain at least 1 byte).
+8. Calculate payload_len = total_length - i.
+9. If payload_len > MAX_PACKET_PAYLOAD (184), the packet is INVALID.
+10. Read payload_len bytes as the payload.
+
+### Size Constraints
+
+| Constraint | Value | Enforcement |
+|-----------|-------|-------------|
+| Maximum wire length | 255 bytes | Encoder MUST NOT produce packets exceeding this |
+| Maximum payload | 184 bytes | Decoder MUST reject payloads exceeding this |
+| Maximum path | 64 bytes | Decoder MUST reject paths exceeding this |
+| Minimum payload | 1 byte | Decoder MUST reject packets with zero-length payload |
+| Minimum packet | 3 bytes | Header (1) + path_len (1) + payload (1 minimum) |
+
+### Error Conditions
+
+A conforming decoder MUST reject packets with any of the following:
+
+1. Total wire length less than 3 bytes (no room for header + path_len + payload)
+2. Total wire length less than minimum required for the route type:
+ - 3 bytes for FLOOD or DIRECT
+ - 7 bytes for TRANSPORT_FLOOD or TRANSPORT_DIRECT (adds 4 transport code bytes)
+3. Invalid path_len encoding (see [Section 3](03-path.md))
+4. Path byte length exceeds remaining bytes
+5. Zero-length payload after header, transport codes, path_len, and path
+6. Payload length exceeding MAX_PACKET_PAYLOAD (184)
+
+### Cross-References
+
+- [Section 2: Header](02-header.md) — Header byte encoding details
+- [Section 3: Path](03-path.md) — Path length encoding and path field
+- Test vectors: `corpus/wire-format/framing/`, `corpus/wire-format/invalid/`
+
+### Reference Implementation
+
+- `Packet::writeTo()` in `src/Packet.cpp` — Encoding
+- `Packet::readFrom()` in `src/Packet.cpp` — Decoding
+- `Packet::getRawLength()` in `src/Packet.cpp` — Wire length calculation
diff --git a/versions/v0.1.0/02-header.md b/versions/v0.1.0/02-header.md
new file mode 100644
index 0000000..f031ff8
--- /dev/null
+++ b/versions/v0.1.0/02-header.md
@@ -0,0 +1,125 @@
+# MeshCore Protocol Specification
+
+## Section 2: Header
+
+### Overview
+
+The header is the first byte of every MeshCore packet. It encodes three fields
+using bit packing: the route type (2 bits), the payload type (4 bits), and the
+protocol version (2 bits).
+
+### Bit Layout
+
+```
+ Bit: 7 6 5 4 3 2 1 0
+ +---+---+---+---+---+---+---+---+
+ | V1 V0| P3 P2 P1 P0| R1 R0|
+ +---+---+---+---+---+---+---+---+
+ | | |
+ | | +-- Route Type (bits 0-1)
+ | +-------------- Payload Type (bits 2-5)
+ +------------------------ Protocol Version (bits 6-7)
+```
+
+The header byte value is computed as:
+
+```
+header = (version << 6) | (payload_type << 2) | route_type
+```
+
+And fields are extracted as:
+
+```
+route_type = header & 0x03
+payload_type = (header >> 2) & 0x0F
+version = (header >> 6) & 0x03
+```
+
+### Route Type (bits 0-1)
+
+The route type determines how the packet is routed through the mesh and whether
+transport codes are present.
+
+| Value | Name | Transport Codes | Description |
+|-------|------|----------------|-------------|
+| 0x00 | TRANSPORT_FLOOD | Yes (4 bytes) | Flood routing with transport codes for regional scoping |
+| 0x01 | FLOOD | No | Standard flood routing; path is built up by intermediate nodes |
+| 0x02 | DIRECT | No | Direct routing; path is supplied by the sender |
+| 0x03 | TRANSPORT_DIRECT | Yes (4 bytes) | Direct routing with transport codes |
+
+Transport codes MUST be present in the wire format when route type is 0x00 or
+0x03, and MUST NOT be present when route type is 0x01 or 0x02.
+
+### Payload Type (bits 2-5)
+
+The payload type identifies the structure of the payload data.
+
+| Value | Name | Description |
+|-------|------|-------------|
+| 0x00 | REQUEST | Encrypted request (dest_hash + src_hash + MAC + ciphertext) |
+| 0x01 | RESPONSE | Encrypted response to REQ or ANON_REQ |
+| 0x02 | TXT_MSG | Encrypted text message |
+| 0x03 | ACK | Simple acknowledgment (4-byte CRC) |
+| 0x04 | ADVERT | Node identity advertisement |
+| 0x05 | GRP_TXT | Encrypted group text message |
+| 0x06 | GRP_DATA | Encrypted group datagram |
+| 0x07 | ANON_REQ | Anonymous request (full public key instead of hash) |
+| 0x08 | PATH | Encrypted returned path information |
+| 0x09 | TRACE | Path trace with per-hop SNR collection |
+| 0x0A | MULTIPART | One part of a multi-packet sequence |
+| 0x0B | CONTROL | Control/discovery packet |
+| 0x0C | (reserved) | Reserved for future use |
+| 0x0D | (reserved) | Reserved for future use |
+| 0x0E | (reserved) | Reserved for future use |
+| 0x0F | RAW_CUSTOM | Custom raw bytes for application-defined payloads |
+
+Implementations SHOULD silently discard packets with reserved payload type values
+(0x0C-0x0E) unless they have explicit support for extended types.
+
+### Protocol Version (bits 6-7)
+
+| Value | Name | Description |
+|-------|------|-------------|
+| 0x00 | V1 | Current version: 1-byte src/dest hashes, 2-byte MAC |
+| 0x01 | V2 | Reserved for future use |
+| 0x02 | V3 | Reserved for future use |
+| 0x03 | V4 | Reserved for future use |
+
+Implementations MUST support version 0x00 (V1). Implementations SHOULD reject
+packets with unrecognized version values.
+
+### Special Header Values
+
+The header value `0xFF` is used internally as a "do not retransmit" marker. This
+is an in-memory sentinel only and MUST NOT appear on the wire. A header of
+`0xFF` would decode as version=3, payload_type=0x0F (RAW_CUSTOM), route_type=3
+(TRANSPORT_DIRECT).
+
+### Encoding Examples
+
+| Header Byte | Binary | Version | Payload Type | Route Type |
+|-------------|--------|---------|-------------|------------|
+| `0x01` | `00 000000 01` | V1 (0) | REQUEST (0) | FLOOD (1) |
+| `0x05` | `00 000001 01` | V1 (0) | RESPONSE (1) | FLOOD (1) |
+| `0x09` | `00 000010 01` | V1 (0) | TXT_MSG (2) | FLOOD (1) |
+| `0x0D` | `00 000011 01` | V1 (0) | ACK (3) | FLOOD (1) |
+| `0x11` | `00 000100 01` | V1 (0) | ADVERT (4) | FLOOD (1) |
+| `0x0C` | `00 000011 00` | V1 (0) | ACK (3) | TRANSPORT_FLOOD (0) |
+| `0x0E` | `00 000011 10` | V1 (0) | ACK (3) | DIRECT (2) |
+| `0x0F` | `00 000011 11` | V1 (0) | ACK (3) | TRANSPORT_DIRECT (3) |
+| `0x4D` | `01 000011 01` | V2 (1, reserved) | ACK (3) | FLOOD (1) |
+
+### Cross-References
+
+- [Section 1: Wire Format](01-wire-format.md) — Overall packet structure
+- [Section 3: Path](03-path.md) — Path encoding
+- Test vectors: `corpus/wire-format/header/`
+
+### Reference Implementation
+
+- `Packet::getRouteType()` in `src/Packet.h` — `header & 0x03`
+- `Packet::getPayloadType()` in `src/Packet.h` — `(header >> 2) & 0x0F`
+- `Packet::getPayloadVer()` in `src/Packet.h` — `(header >> 6) & 0x03`
+- `Packet::hasTransportCodes()` in `src/Packet.h` — route type 0x00 or 0x03
+- Route type constants in `src/Packet.h`
+- Payload type constants in `src/Packet.h`
diff --git a/versions/v0.1.0/03-path.md b/versions/v0.1.0/03-path.md
new file mode 100644
index 0000000..a8f7770
--- /dev/null
+++ b/versions/v0.1.0/03-path.md
@@ -0,0 +1,155 @@
+# MeshCore Protocol Specification
+
+## Section 3: Path
+
+### Overview
+
+The path field carries routing information as a sequence of node hashes. In
+flood routing, intermediate nodes append their hash to the path as they forward
+the packet. In direct routing, the sender supplies the full path. The path
+length byte uses bit packing to encode both the number of hashes and the size of
+each hash.
+
+### Path Length Byte
+
+The path length byte immediately follows the header (and transport codes, if
+present). It encodes two values:
+
+```
+ Bit: 7 6 5 4 3 2 1 0
+ +---+---+---+---+---+---+---+---+
+ | S1 S0| C5 C4 C3 C2 C1 C0|
+ +---+---+---+---+---+---+---+---+
+ | |
+ | +-- Hash Count (bits 0-5): 0-63
+ +----------------- Hash Size Code (bits 6-7): 0-2 (3 is reserved)
+```
+
+| Field | Bits | Extraction | Range | Description |
+|-------|------|-----------|-------|-------------|
+| Hash Count | 0-5 | `path_len & 0x3F` | 0-63 | Number of hashes in the path |
+| Hash Size Code | 6-7 | `path_len >> 6` | 0-2 | Encoded hash size: actual size = code + 1 |
+
+The **actual hash size** in bytes is:
+
+```
+hash_size = (path_len >> 6) + 1
+```
+
+| Hash Size Code | Actual Hash Size | Description |
+|---------------|-----------------|-------------|
+| 0 (`0b00`) | 1 byte | V1 default (PATH_HASH_SIZE) |
+| 1 (`0b01`) | 2 bytes | Extended precision |
+| 2 (`0b10`) | 3 bytes | Extended precision |
+| 3 (`0b11`) | **RESERVED** | MUST be rejected as invalid |
+
+The path length byte is constructed as:
+
+```
+path_len = ((hash_size - 1) << 6) | (hash_count & 0x3F)
+```
+
+### Path Field
+
+The path field immediately follows the path length byte. Its size in bytes is:
+
+```
+path_byte_length = hash_count × hash_size
+```
+
+The path contains `hash_count` consecutive hashes, each `hash_size` bytes long.
+Each hash is a prefix of a node's Ed25519 public key (see [Section 15](15-identity.md)).
+
+```
++----------+----------+-----+----------+
+| Hash 0 | Hash 1 | ... | Hash N-1 |
+| (H bytes)| (H bytes)| ... | (H bytes)|
++----------+----------+-----+----------+
+
+where H = hash_size, N = hash_count
+```
+
+When hash_count is 0, the path field is empty (zero bytes on the wire).
+
+### Validation Rules
+
+A conforming implementation MUST reject a packet if any of the following are
+true:
+
+1. **Reserved hash size**: Hash size code is 3 (bits 6-7 = `0b11`), meaning
+ hash_size would be 4. This value is reserved for future use.
+
+2. **Path overflow**: `hash_count × hash_size > MAX_PATH_SIZE` (64 bytes).
+
+The maximum number of hashes depends on hash size:
+
+| Hash Size | Max Hash Count | Max Path Bytes |
+|-----------|---------------|----------------|
+| 1 byte | 63 | 63 |
+| 2 bytes | 32 | 64 |
+| 3 bytes | 21 | 63 |
+
+Note: With 1-byte hashes the maximum hash count is 63 (not 64), because the
+count field is only 6 bits wide. With 2-byte hashes, 32 × 2 = 64 bytes exactly
+fills MAX_PATH_SIZE. With 3-byte hashes, 21 × 3 = 63 bytes is the maximum
+before 22 × 3 = 66 would overflow.
+
+### Path Manipulation
+
+**Appending a hash (flood routing):**
+When a node forwards a flood-routed packet, it appends its own hash to the path:
+
+1. Let `n` = current hash_count.
+2. Verify `(n + 1) × hash_size <= MAX_PATH_SIZE`. If not, the node MUST NOT
+ forward the packet.
+3. Copy the node's hash (first `hash_size` bytes of its public key) to
+ `path[n × hash_size]`.
+4. Increment hash_count: `setPathHashCount(n + 1)`.
+
+**Setting hash size and count:**
+```
+path_len = ((hash_size - 1) << 6) | (hash_count & 0x3F)
+```
+
+### Encoding Examples
+
+| path_len byte | Binary | Hash Size Code | Hash Count | Hash Size | Path Bytes |
+|---------------|--------|---------------|------------|-----------|------------|
+| `0x00` | `00 000000` | 0 | 0 | 1 | 0 |
+| `0x01` | `00 000001` | 0 | 1 | 1 | 1 |
+| `0x03` | `00 000011` | 0 | 3 | 1 | 3 |
+| `0x3F` | `00 111111` | 0 | 63 | 1 | 63 |
+| `0x41` | `01 000001` | 1 | 1 | 2 | 2 |
+| `0x42` | `01 000010` | 1 | 2 | 2 | 4 |
+| `0x60` | `01 100000` | 1 | 32 | 2 | 64 |
+| `0x81` | `10 000001` | 2 | 1 | 3 | 3 |
+| `0x95` | `10 010101` | 2 | 21 | 3 | 63 |
+| `0xC0` | `11 000000` | 3 | 0 | **INVALID** | — |
+| `0xFF` | `11 111111` | 3 | 63 | **INVALID** | — |
+
+### Known Implementation Discrepancy
+
+The Rust implementation [meshcore-rs](https://github.com/Duncaen/meshcore-rs)
+treats the path_len byte as a raw byte count rather than decoding hash_count and
+hash_size from the bitfields. For example, a path_len value of `0x42` (binary
+`01 000010`) should decode as hash_size=2, hash_count=2, path_bytes=4. The
+incorrect interpretation reads it as a raw count of 66 bytes.
+
+Test vectors in this corpus specifically exercise multi-byte hash sizes to catch
+this class of bug.
+
+### Cross-References
+
+- [Section 1: Wire Format](01-wire-format.md) — Overall packet structure
+- [Section 2: Header](02-header.md) — Header encoding
+- [Section 15: Identity](15-identity.md) — Hash derivation from public keys
+- Test vectors: `corpus/wire-format/path/`
+
+### Reference Implementation
+
+- `Packet::getPathHashSize()` in `src/Packet.h` — `(path_len >> 6) + 1`
+- `Packet::getPathHashCount()` in `src/Packet.h` — `path_len & 63`
+- `Packet::getPathByteLen()` in `src/Packet.h` — `getPathHashCount() * getPathHashSize()`
+- `Packet::setPathHashSizeAndCount()` in `src/Packet.h` — `((sz - 1) << 6) | (n & 63)`
+- `Packet::isValidPathLen()` in `src/Packet.cpp` — Validation logic
+- `Packet::writePath()` in `src/Packet.cpp` — Path serialization
diff --git a/versions/v0.1.0/04-payload-ack.md b/versions/v0.1.0/04-payload-ack.md
new file mode 100644
index 0000000..bf635a4
--- /dev/null
+++ b/versions/v0.1.0/04-payload-ack.md
@@ -0,0 +1,99 @@
+# MeshCore Protocol Specification
+
+## Section 4: Payload — ACK
+
+### Overview
+
+The ACK (acknowledgment) payload is the simplest payload type. It consists of a
+single 4-byte CRC value used to confirm receipt of a previously sent message.
+The CRC is a truncated SHA-256 over the original TXT_MSG plaintext prefix
+concatenated with the sender's public key.
+
+### Payload Type
+
+Header payload type field: `0x03` (ACK)
+
+### Wire Format
+
+```
+ 0 1 2 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| ACK CRC (uint32_le) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+### Fields
+
+| Field | Offset | Size | Type | Description |
+|-------|--------|------|------|-------------|
+| ack_crc | 0 | 4 bytes | uint32_le | CRC identifying the acknowledged message |
+
+### ACK CRC Computation
+
+The 4-byte ACK CRC is computed as the first 4 bytes of a SHA-256 digest over
+the acknowledged TXT_MSG's plaintext prefix followed by the **sender's**
+Ed25519 public key:
+
+```
+ack_crc = SHA-256( plaintext_prefix || sender_pub_key )[0..3]
+```
+
+Where `plaintext_prefix` is the TXT_MSG plaintext up to and including the
+message text, **excluding any trailing NUL terminator**:
+
+```
+plaintext_prefix = timestamp_le(4) || txt_type_attempt(1) || text
+```
+
+- `timestamp_le` and `txt_type_attempt` are defined in
+ [Section 6](06-payload-encrypted.md#plaintext-format-txt_msg).
+- `text` is the raw UTF-8 message bytes with no NUL terminator.
+- `sender_pub_key` is the full 32-byte Ed25519 public key of the node that
+ originated the TXT_MSG (i.e., the node expecting this ACK back).
+
+For TXT_TYPE_SIGNED_PLAIN messages, the 4-byte signature prefix is included:
+
+```
+plaintext_prefix = timestamp_le(4) || txt_type_attempt(1) || signature(4) || text
+ack_crc = SHA-256( plaintext_prefix || receiver_pub_key )[0..3]
+```
+
+Note the SIGNED_PLAIN variant hashes against the **receiver's** public key;
+see `BaseChatMesh::onPeerDataRecv()`.
+
+Because `txt_type_attempt` includes the 2-bit `attempt` counter, each retry
+with an incremented `attempt` produces a different ACK CRC. This lets the
+sender attribute each received ACK to a specific transmission attempt. See
+[Section 6: Reliable DM Delivery](06-payload-encrypted.md#reliable-dm-delivery).
+
+### Encoding
+
+1. Compute the 4-byte CRC value for the message being acknowledged (above).
+2. Write the CRC as a little-endian 32-bit unsigned integer.
+3. Set payload_len to 4.
+
+### Decoding
+
+1. Read 4 bytes from the payload as a little-endian uint32.
+2. If fewer than 4 bytes are available, the packet is INVALID.
+
+### Constraints
+
+- The payload MUST be exactly 4 bytes.
+- Implementations SHOULD log or discard ACK packets with payload_len < 4.
+
+### Cross-References
+
+- [Section 1: Wire Format](01-wire-format.md) — Packet framing
+- [Section 11: Multipart](11-payload-multipart.md) — Multipart ACK encoding
+- Test vectors: `corpus/payloads/ack/`
+
+### Reference Implementation
+
+- `Mesh::createAck()` in `src/Mesh.cpp` — Packet construction
+- `BaseChatMesh::composeMsgPacket()` in `src/helpers/BaseChatMesh.cpp` —
+ Sender-side expected-ACK computation (`expected_ack`)
+- `BaseChatMesh::onPeerDataRecv()` in `src/helpers/BaseChatMesh.cpp` —
+ Receiver-side ACK CRC computation (`ack_hash`)
+- ACK reception in `Mesh::onRecvPacket()`, case `PAYLOAD_TYPE_ACK`
diff --git a/versions/v0.1.0/05-payload-advert.md b/versions/v0.1.0/05-payload-advert.md
new file mode 100644
index 0000000..21de754
--- /dev/null
+++ b/versions/v0.1.0/05-payload-advert.md
@@ -0,0 +1,123 @@
+# MeshCore Protocol Specification
+
+## Section 5: Payload — Advertisement
+
+### Overview
+
+The advertisement payload allows a node to announce its identity to the mesh. It
+contains the node's Ed25519 public key, a timestamp, a signature proving
+ownership of the key, and optional application data describing the node's type,
+location, features, and name.
+
+### Payload Type
+
+Header payload type field: `0x04` (ADVERT)
+
+### Wire Format
+
+```
+ 0 31
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| |
+| Ed25519 Public Key (32 bytes) |
+| |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Timestamp (uint32_le) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| |
+| Ed25519 Signature (64 bytes) |
+| |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| App Data (0-32 bytes) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+### Fields
+
+| Field | Offset | Size | Type | Description |
+|-------|--------|------|------|-------------|
+| pub_key | 0 | 32 bytes | raw | Ed25519 public key of the advertising node |
+| timestamp | 32 | 4 bytes | uint32_le | Unix epoch timestamp of advertisement creation |
+| signature | 36 | 64 bytes | raw | Ed25519 signature (see Signature section below) |
+| app_data | 100 | 0-32 bytes | structured | Optional application data (see App Data section below) |
+
+### Minimum and Maximum Sizes
+
+- Minimum payload: 100 bytes (pub_key + timestamp + signature, no app_data)
+- Maximum payload: 132 bytes (100 + MAX_ADVERT_DATA_SIZE of 32)
+
+### Signature
+
+The signature is computed over the concatenation of:
+
+```
+message = pub_key(32) || timestamp_le(4) || app_data(0-32)
+```
+
+The node signs this message using its Ed25519 private key. Receivers MUST verify
+the signature using the public key from the payload. Packets with invalid
+signatures MUST be discarded.
+
+### App Data Format
+
+When present (payload_len > 100), app_data is a structured field beginning with
+a flags byte:
+
+```
+ Bit: 7 6 5 4 3 2 1 0
+ +-------+-------+-------+-------+---+---+---+---+
+ |has_ |has_ |has_ |has_ | Node Type |
+ |name |feat2 |feat1 |loc | (4 bits) |
+ +-------+-------+-------+-------+---+---+---+---+
+```
+
+| Field | Offset | Size | Condition | Type | Description |
+|-------|--------|------|-----------|------|-------------|
+| flags | 0 | 1 byte | Always | uint8 | See bit layout above |
+| latitude | 1 | 4 bytes | flags bit 4 set | int32_le | Latitude × 1,000,000 |
+| longitude | 5 | 4 bytes | flags bit 4 set | int32_le | Longitude × 1,000,000 |
+| feat1 | varies | 2 bytes | flags bit 5 set | uint16_le | Feature field 1 |
+| feat2 | varies | 2 bytes | flags bit 6 set | uint16_le | Feature field 2 |
+| name | varies | remaining | flags bit 7 set | UTF-8 | Node name (no null terminator) |
+
+Fields appear in the order listed. The offset of each field depends on which
+preceding optional fields are present.
+
+### Node Types (flags bits 0-3)
+
+| Value | Name | Description |
+|-------|------|-------------|
+| 0 | None | Unspecified |
+| 1 | Chat | Chat client node |
+| 2 | Repeater | Mesh repeater node |
+| 3 | Room | Room server node |
+| 4 | Sensor | Sensor node |
+
+### Decoding Algorithm
+
+1. Read 32 bytes as pub_key.
+2. Read 4 bytes as timestamp (uint32_le).
+3. Read 64 bytes as signature.
+4. If offset (100) > payload_len, the packet is INVALID (incomplete).
+5. If offset (100) == payload_len, there is no app_data. Done.
+6. Read remaining bytes as app_data (up to MAX_ADVERT_DATA_SIZE = 32 bytes).
+ If more than 32 bytes remain, implementations MUST truncate to 32.
+7. Construct the signature verification message: pub_key || timestamp_le || app_data.
+8. Verify the Ed25519 signature. If invalid, the receiver MUST discard the packet.
+9. Parse app_data:
+ a. Read 1 byte as flags.
+ b. If flags bit 4 set: read 4 bytes latitude (int32_le), 4 bytes longitude (int32_le).
+ c. If flags bit 5 set: read 2 bytes feat1 (uint16_le).
+ d. If flags bit 6 set: read 2 bytes feat2 (uint16_le).
+ e. If flags bit 7 set: read remaining bytes as UTF-8 name.
+
+### Cross-References
+
+- [Section 15: Identity](15-identity.md) — Ed25519 key management
+- [Section 14: Cryptography](14-crypto.md) — Signature operations
+- Test vectors: `corpus/payloads/advert/`
+
+### Reference Implementation
+
+- `Mesh::createAdvert()` in `src/Mesh.cpp` — Encoding
+- `Mesh::onRecvPacket()`, case `PAYLOAD_TYPE_ADVERT` in `src/Mesh.cpp` — Decoding and verification
diff --git a/versions/v0.1.0/06-payload-encrypted.md b/versions/v0.1.0/06-payload-encrypted.md
new file mode 100644
index 0000000..a2d2b5f
--- /dev/null
+++ b/versions/v0.1.0/06-payload-encrypted.md
@@ -0,0 +1,169 @@
+# MeshCore Protocol Specification
+
+## Section 6: Payload — Encrypted (REQ / RESPONSE / TXT_MSG)
+
+### Overview
+
+The REQUEST, RESPONSE, and TXT_MSG payload types share an identical wire format.
+They consist of a destination hash, source hash, and an encrypt-then-MAC
+ciphertext blob. The three types differ only in their semantic meaning at the
+application layer.
+
+### Payload Types
+
+| Value | Name | Description |
+|-------|------|-------------|
+| 0x00 | REQUEST | Encrypted request (e.g., login, data query) |
+| 0x01 | RESPONSE | Encrypted response to a REQUEST or ANON_REQ |
+| 0x02 | TXT_MSG | Encrypted text message |
+
+### Wire Format
+
+```
+ 0
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| dest_hash(1) | src_hash(1) | Cipher MAC (2 bytes) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| |
+| Ciphertext (N × 16 bytes, N ≥ 1) |
+| |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+### Fields
+
+| Field | Offset | Size | Type | Description |
+|-------|--------|------|------|-------------|
+| dest_hash | 0 | PATH_HASH_SIZE (1) | raw | First byte of recipient's Ed25519 public key |
+| src_hash | 1 | PATH_HASH_SIZE (1) | raw | First byte of sender's Ed25519 public key |
+| cipher_mac | 2 | CIPHER_MAC_SIZE (2) | raw | Truncated HMAC-SHA256 over ciphertext |
+| ciphertext | 4 | variable (multiple of 16) | raw | AES-128-ECB encrypted data |
+
+### Minimum Size
+
+- Minimum payload: 4 + 16 = 20 bytes (dest_hash + src_hash + MAC + 1 AES block)
+
+### Encryption
+
+The ciphertext is produced using the encrypt-then-MAC scheme described in
+[Section 14](14-crypto.md):
+
+1. Compute the ECDH shared secret between sender and recipient.
+2. Zero-pad the plaintext on the right with `0x00` bytes until its length is a
+ multiple of 16 (AES block size). If the plaintext is already a multiple of
+ 16, no padding is added (the scheme is not unambiguous PKCS#7-style
+ padding — the receiver MUST disambiguate trailing zeros using the inner
+ plaintext format; see "Plaintext Format" below).
+3. Encrypt the padded plaintext using AES-128-ECB with the first 16 bytes of
+ the shared secret as the key.
+4. Compute HMAC-SHA256 over the ciphertext using the full 32-byte shared
+ secret as the HMAC key. Truncate to 2 bytes.
+5. Prepend the 2-byte MAC to the ciphertext.
+
+### Decryption
+
+1. Extract dest_hash and src_hash.
+2. If dest_hash matches this node, search for peers matching src_hash.
+3. For each matching peer, compute or retrieve the shared secret.
+4. Verify the MAC: compute HMAC-SHA256 over the ciphertext portion using the
+ full 32-byte shared secret. Compare the first 2 bytes with cipher_mac.
+5. If the MAC is valid, decrypt the ciphertext using AES-128-ECB with the first
+ 16 bytes of the shared secret. The decrypted data may contain trailing zero
+ bytes from padding.
+6. If no peer's MAC matches, the packet is not for this node (or the peer is
+ unknown). The packet MAY still be forwarded.
+
+### Plaintext Format (TXT_MSG)
+
+For TXT_MSG payloads, the decrypted plaintext has this structure:
+
+| Field | Offset | Size | Type | Description |
+|-------|--------|------|------|-------------|
+| timestamp | 0 | 4 bytes | uint32_le | Message timestamp |
+| txt_type_attempt | 4 | 1 byte | uint8 | Bits 2-7: message type (see [Section 4](04-payload-ack.md)); bits 0-1: attempt counter (0-3). See "Attempt Counter Semantics" below. |
+| text | 5 | remaining | UTF-8 | Message text |
+
+#### Attempt Counter Semantics
+
+The `attempt` sub-field (bits 0-1 of `txt_type_attempt`) carries the low two
+bits of a transmission attempt counter used by the sender's retry logic. For
+a given logical TXT_MSG:
+
+- The `timestamp` field MUST remain stable across all retries of the same
+ message.
+- The `attempt` counter MUST be 0 on the first transmission and MUST be
+ incremented by 1 on each retry.
+- Bits 0–1 of `txt_type_attempt` MUST always carry `attempt & 3`. These bits
+ are part of the ACK CRC input (see
+ [Section 4](04-payload-ack.md#ack-crc-computation)), so each retry of a
+ message produces a distinct expected ACK CRC.
+- For attempts in `0..3`, no further encoding is needed; the plaintext ends
+ with the text.
+- For attempts `> 3`, the sender MUST append a 2-byte tail
+ `[NUL_terminator(1)][attempt_full(1)]` to the plaintext after the text,
+ where `attempt_full` is the full 1-byte counter value (not just the low
+ 2 bits). The receiver locates the text by scanning for the NUL terminator
+ and recovers the full attempt value from the trailing byte. Because this
+ tail costs 2 bytes of plaintext capacity, the maximum text length is
+ reduced by 2 bytes when `attempt > 3`.
+- The ACK CRC is computed over `timestamp(4) || txt_type_attempt(1) || text`
+ only. The `[NUL][attempt_full]` tail (when present) and the implicit C
+ string NUL terminator (when not) are excluded from the hash input. This
+ means a retry with attempt 4 and a retry with attempt 0 produce ACK CRCs
+ that match (since `4 & 3 == 0`); senders relying on attempts beyond 3
+ SHOULD also verify the trailing attempt byte if they need to distinguish
+ rolled-over attempts.
+
+See `BaseChatMesh::composeMsgPacket()` in
+`src/helpers/BaseChatMesh.cpp` for the reference encoding.
+
+### Reliable DM Delivery
+
+The following sender-side behavior is informational and documents how
+interoperating implementations deliver TXT_MSG reliably. Exact timeouts,
+backoff schedules, and queue depths are implementation-defined; see
+[Section 17 — Sender Behavior](17-routing.md#sender-behavior-informational)
+for routing-layer details.
+
+- **Expected-ACK tracking.** On transmit, the sender SHOULD compute the
+ expected `ack_crc` (see [Section 4](04-payload-ack.md#ack-crc-computation))
+ and record it in a pending-ACK table keyed by CRC. The table SHOULD bound
+ entries by capacity and age out entries after the retry schedule has
+ completed (plus a margin for in-flight ACKs).
+- **Retry.** If no matching ACK is received within an implementation-defined
+ timeout, the sender SHOULD retransmit with `attempt` incremented (per
+ "Attempt Counter Semantics" above) and re-register the new expected
+ `ack_crc`. Up to 4 attempts are representable in the 2-bit `attempt`
+ field.
+- **ACK consumption.** On receipt of a PAYLOAD_TYPE_ACK or a MULTIPART-wrapped
+ ACK whose `ack_crc` matches a pending entry, the sender MUST treat the
+ message as delivered, remove the pending-ACK entry, and cancel any queued
+ retries for that CRC.
+- **Duplicate DM handling at the receiver.** A receiver MAY cache its most
+ recent reply per peer keyed by the inbound DM's plaintext and, on a
+ duplicate inbound DM within a short window, re-emit the cached reply as a
+ loss-recovery optimization. This is not normative; the normative behavior
+ is "process once and rely on [Section 16](16-packet-hash.md) dedup."
+ Implementations that re-emit cached replies SHOULD suppress re-emission
+ while a retry for that reply is still pending, to avoid piling on.
+
+### Plaintext Format (REQUEST / RESPONSE)
+
+The plaintext format for REQUEST and RESPONSE is application-defined. Common
+patterns include:
+
+- **REQUEST**: `[timestamp(4)][request_data...]`
+- **RESPONSE**: `[timestamp(4)][response_data...]`
+
+### Cross-References
+
+- [Section 14: Cryptography](14-crypto.md) — Encrypt-then-MAC details
+- [Section 15: Identity](15-identity.md) — ECDH shared secret computation
+- Test vectors: `corpus/payloads/encrypted/`
+
+### Reference Implementation
+
+- `Mesh::createDatagram()` in `src/Mesh.cpp` — Encoding
+- `Mesh::onRecvPacket()`, cases `PAYLOAD_TYPE_REQ/RESPONSE/TXT_MSG` — Decoding
+- `Utils::encryptThenMAC()` in `src/Utils.cpp` — Encryption
+- `Utils::MACThenDecrypt()` in `src/Utils.cpp` — Decryption and verification
diff --git a/versions/v0.1.0/07-payload-anon-req.md b/versions/v0.1.0/07-payload-anon-req.md
new file mode 100644
index 0000000..adafc00
--- /dev/null
+++ b/versions/v0.1.0/07-payload-anon-req.md
@@ -0,0 +1,74 @@
+# MeshCore Protocol Specification
+
+## Section 7: Payload — Anonymous Request
+
+### Overview
+
+The anonymous request payload allows a node to send an encrypted message to a
+recipient without the recipient needing to know the sender in advance. Instead of
+a 1-byte source hash, the full 32-byte Ed25519 public key of the sender is
+included, enabling the recipient to compute the shared secret for decryption.
+
+### Payload Type
+
+Header payload type field: `0x07` (ANON_REQ)
+
+### Wire Format
+
+```
+ 0
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| dest_hash(1) | |
++-+-+-+-+-+-+-+-+ |
+| Sender Public Key (32 bytes) |
+| |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Cipher MAC (2 bytes) | |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
+| Ciphertext (N × 16 bytes, N ≥ 1) |
+| |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+### Fields
+
+| Field | Offset | Size | Type | Description |
+|-------|--------|------|------|-------------|
+| dest_hash | 0 | 1 byte | raw | First byte of recipient's Ed25519 public key |
+| sender_pub_key | 1 | 32 bytes | raw | Full Ed25519 public key of sender |
+| cipher_mac | 33 | 2 bytes | raw | Truncated HMAC-SHA256 over ciphertext |
+| ciphertext | 35 | variable (multiple of 16) | raw | AES-128-ECB encrypted data |
+
+### Minimum Size
+
+- Minimum payload: 1 + 32 + 2 + 16 = 51 bytes
+
+### Encryption
+
+1. Sender computes ECDH shared secret using its private key and the recipient's
+ public key.
+2. Encrypt plaintext using AES-128-ECB with first 16 bytes of shared secret.
+3. Compute HMAC-SHA256 over ciphertext with full 32-byte shared secret.
+ Truncate to 2 bytes.
+4. Assemble: dest_hash || sender_pub_key || MAC || ciphertext.
+
+### Decryption
+
+1. Read dest_hash. If it does not match this node, the packet is not for us
+ (but MAY be forwarded).
+2. Read 32-byte sender_pub_key. Construct an Identity from it.
+3. Compute ECDH shared secret between this node's private key and sender's
+ public key.
+4. Verify the MAC and decrypt as in [Section 14](14-crypto.md).
+
+### Cross-References
+
+- [Section 6: Encrypted Payloads](06-payload-encrypted.md) — Standard encrypted format
+- [Section 14: Cryptography](14-crypto.md) — Encrypt-then-MAC
+- [Section 15: Identity](15-identity.md) — ECDH key exchange
+- Test vectors: `corpus/payloads/anon-req/`
+
+### Reference Implementation
+
+- `Mesh::createAnonDatagram()` in `src/Mesh.cpp` — Encoding
+- `Mesh::onRecvPacket()`, case `PAYLOAD_TYPE_ANON_REQ` — Decoding
diff --git a/versions/v0.1.0/08-payload-group.md b/versions/v0.1.0/08-payload-group.md
new file mode 100644
index 0000000..184d911
--- /dev/null
+++ b/versions/v0.1.0/08-payload-group.md
@@ -0,0 +1,81 @@
+# MeshCore Protocol Specification
+
+## Section 8: Payload — Group (GRP_TXT / GRP_DATA)
+
+### Overview
+
+Group payloads enable encrypted communication within a channel. All members of a
+channel share a symmetric secret. The payload begins with a 1-byte channel hash
+(derived from the channel's shared key), followed by an encrypt-then-MAC blob.
+
+### Payload Types
+
+| Value | Name | Description |
+|-------|------|-------------|
+| 0x05 | GRP_TXT | Group text message |
+| 0x06 | GRP_DATA | Group datagram (binary data) |
+
+### Wire Format
+
+```
+ 0
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+|channel_hash(1)| Cipher MAC (2 bytes) | |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
+| Ciphertext (N × 16 bytes, N ≥ 1) |
+| |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+### Fields
+
+| Field | Offset | Size | Type | Description |
+|-------|--------|------|------|-------------|
+| channel_hash | 0 | 1 byte | raw | First byte of SHA-256(channel_shared_key) |
+| cipher_mac | 1 | 2 bytes | raw | Truncated HMAC-SHA256 over ciphertext |
+| ciphertext | 3 | variable (multiple of 16) | raw | AES-128-ECB encrypted data |
+
+### Minimum Size
+
+- Minimum payload: 1 + 2 + 16 = 19 bytes
+
+### Channel Hash Derivation
+
+The channel_hash is the first byte of the SHA-256 hash of the channel's 32-byte
+shared key (PUB_KEY_SIZE bytes):
+
+```
+channel_hash = SHA256(channel_shared_key)[0]
+```
+
+This means hash collisions are expected (1 in 256 chance). When a receiver finds
+a matching channel_hash, it attempts decryption with each matching channel's
+secret. Only a successful MAC verification confirms the correct channel.
+
+### Encryption
+
+1. Encrypt plaintext using AES-128-ECB with first 16 bytes of the channel's
+ shared key (GroupChannel.secret).
+2. Compute HMAC-SHA256 over ciphertext using all 32 bytes of the channel's
+ shared key. Truncate to 2 bytes.
+3. Assemble: channel_hash || MAC || ciphertext.
+
+### Decryption
+
+1. Read channel_hash (1 byte).
+2. Search local channel database for channels with matching hash. Multiple
+ channels may match (up to 4 in reference implementation).
+3. For each matching channel, attempt MAC verification and decryption using the
+ channel's shared key.
+4. The first channel that produces a valid MAC yields the correct decryption.
+
+### Cross-References
+
+- [Section 14: Cryptography](14-crypto.md) — Encrypt-then-MAC
+- Test vectors: `corpus/payloads/group/`
+
+### Reference Implementation
+
+- `Mesh::createGroupDatagram()` in `src/Mesh.cpp` — Encoding
+- `Mesh::onRecvPacket()`, cases `PAYLOAD_TYPE_GRP_TXT/GRP_DATA` — Decoding
+- `Mesh::searchChannelsByHash()` — Channel lookup
diff --git a/versions/v0.1.0/09-payload-path.md b/versions/v0.1.0/09-payload-path.md
new file mode 100644
index 0000000..f7cb20d
--- /dev/null
+++ b/versions/v0.1.0/09-payload-path.md
@@ -0,0 +1,90 @@
+# MeshCore Protocol Specification
+
+## Section 9: Payload — Path Return
+
+### Overview
+
+The PATH payload carries an encrypted return path from one node to another. It
+uses the same dest_hash + src_hash + encrypt-then-MAC envelope as REQ/RESPONSE/
+TXT_MSG, but the decrypted contents contain routing path data and optional extra
+application data.
+
+### Payload Type
+
+Header payload type field: `0x08` (PATH)
+
+### Wire Format (Outer)
+
+```
+ 0
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| dest_hash(1) | src_hash(1) | Cipher MAC (2 bytes) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| |
+| Ciphertext (N × 16 bytes, N ≥ 1) |
+| |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+The outer format is identical to [Section 6](06-payload-encrypted.md).
+
+### Decrypted Inner Format
+
+```
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| path_len(1) | Path (hash_count × hash_size) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| extra_type(1) | Extra Data (variable) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+| Field | Size | Type | Description |
+|-------|------|------|-------------|
+| path_len | 1 byte | encoded | Same bitfield encoding as packet path_len (see [Section 3](03-path.md)) |
+| path | hash_count × hash_size | raw | The return path to the sender |
+| extra_type | 1 byte | uint8 | Type of extra data (lower 4 bits used; upper 4 reserved) |
+| extra | remaining | raw | Extra data (may include trailing zero padding from AES) |
+
+### Extra Type Values
+
+| Value | Description |
+|-------|-------------|
+| 0x00 | No meaningful extra data (reciprocal path) |
+| 0xFF | Dummy/padding (used when no real extra data; followed by 4 random bytes) |
+| Other | Application-defined |
+
+When creating a path return with no extra data, the reference implementation
+appends `0xFF` as extra_type followed by 4 random bytes to ensure the packet
+hash is unique.
+
+### Encoding
+
+1. Build the inner plaintext:
+ a. Write path_len byte (encoded hash_size and hash_count).
+ b. Write path bytes (hash_count × hash_size).
+ c. If extra data is provided: write extra_type (lower 4 bits), write extra.
+ d. If no extra data: write `0xFF`, write 4 random bytes.
+2. Encrypt the inner plaintext using encrypt-then-MAC with the shared secret.
+3. Prepend dest_hash and src_hash.
+
+### Decoding
+
+1. Decode the outer envelope (dest_hash, src_hash, MAC, ciphertext) as in
+ [Section 6](06-payload-encrypted.md).
+2. After decryption:
+ a. Read 1 byte as path_len. Decode hash_size and hash_count.
+ b. Read hash_count × hash_size bytes as the path.
+ c. Read 1 byte as extra_type (use lower 4 bits only).
+ d. Remaining bytes are extra data (may include AES zero-padding).
+
+### Cross-References
+
+- [Section 3: Path](03-path.md) — Path length encoding
+- [Section 6: Encrypted Payloads](06-payload-encrypted.md) — Outer envelope format
+- [Section 14: Cryptography](14-crypto.md) — Encrypt-then-MAC
+- Test vectors: `corpus/payloads/path-return/`
+
+### Reference Implementation
+
+- `Mesh::createPathReturn()` in `src/Mesh.cpp` — Encoding
+- `Mesh::onRecvPacket()`, PATH case within REQ/RESPONSE/TXT_MSG handling — Decoding
diff --git a/versions/v0.1.0/10-payload-trace.md b/versions/v0.1.0/10-payload-trace.md
new file mode 100644
index 0000000..ef99943
--- /dev/null
+++ b/versions/v0.1.0/10-payload-trace.md
@@ -0,0 +1,106 @@
+# MeshCore Protocol Specification
+
+## Section 10: Payload — Trace
+
+### Overview
+
+The TRACE payload enables path discovery with per-hop signal quality measurement.
+A trace packet travels along a specified direct path, and each intermediate node
+appends its received SNR value to the packet's path field. When the trace reaches
+its final hop, the accumulated SNR values and node hashes are delivered.
+
+### Payload Type
+
+Header payload type field: `0x09` (TRACE)
+
+### Wire Format
+
+The trace payload has a fixed 9-byte header followed by an optional path hashes
+section (appended when sent via direct routing):
+
+```
+ 0 1 2 3
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Tag (uint32_le) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Auth Code (uint32_le) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Flags | Path Hashes (variable) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+### Fields
+
+| Field | Offset | Size | Type | Description |
+|-------|--------|------|------|-------------|
+| tag | 0 | 4 bytes | uint32_le | Random tag set by the initiator for correlation |
+| auth_code | 4 | 4 bytes | uint32_le | Authentication code |
+| flags | 8 | 1 byte | uint8 | Flags (see below) |
+| path_hashes | 9 | variable | raw | Node hashes for the path to trace (appended for direct send) |
+
+### Flags Field
+
+```
+ Bit: 7 6 5 4 3 2 1 0
+ +---+---+---+---+---+---+---+---+
+ | Reserved | S1 S0|
+ +---+---+---+---+---+---+---+---+
+```
+
+| Bits | Description |
+|------|-------------|
+| 0-1 | Path hash size for the trace path hashes (as a power of 2: 0=1 byte, 1=2 bytes, 2=4 bytes) |
+| 2-7 | Reserved (must be zero) |
+
+Note: The flags field path hash size encoding (`1 << (flags & 0x03)`) differs
+from the packet path_len encoding (`(code >> 6) + 1`).
+
+### Direct Sending
+
+When a TRACE packet is sent via direct routing, the path hashes are appended
+directly to the payload (after the 9-byte header), not placed in the packet's
+path field. The packet's path field is instead used to accumulate SNR values
+from intermediate nodes.
+
+```
+On creation: payload = [tag(4)][auth_code(4)][flags(1)] (9 bytes)
+On sendDirect: payload += path_hashes (9 + N bytes)
+ packet.path_len = 0 (path used for SNR)
+```
+
+### SNR Accumulation
+
+As each intermediate node forwards a TRACE packet, it appends its received SNR
+value as a single signed byte to the packet's path field:
+
+```
+path[path_len++] = (int8_t)(SNR × 4)
+```
+
+The SNR value is stored as SNR × 4 for 0.25 dB precision, matching the format
+used in RxMeta frames.
+
+### Trace Completion
+
+A TRACE is considered complete when the path_len counter (used as an offset into
+the path hashes in the payload) reaches or exceeds the remaining path hashes.
+At that point, `onTraceRecv` is called with:
+- The accumulated SNR values (in packet.path)
+- The path hashes (in packet.payload, after byte 9)
+- The path length (number of hops traversed)
+
+### Minimum Size
+
+- Minimum payload: 9 bytes (tag + auth_code + flags, no path hashes)
+
+### Cross-References
+
+- [Section 16: Packet Hash](16-packet-hash.md) — TRACE packets include path_len in hash
+- Test vectors: `corpus/payloads/trace/`
+
+### Reference Implementation
+
+- `Mesh::createTrace()` in `src/Mesh.cpp` — Creation (9-byte base)
+- `Mesh::sendDirect()` — Path appended to payload for TRACE type
+- `Mesh::onRecvPacket()`, TRACE handling — SNR accumulation and completion check
diff --git a/versions/v0.1.0/11-payload-multipart.md b/versions/v0.1.0/11-payload-multipart.md
new file mode 100644
index 0000000..967db5a
--- /dev/null
+++ b/versions/v0.1.0/11-payload-multipart.md
@@ -0,0 +1,124 @@
+# MeshCore Protocol Specification
+
+## Section 11: Payload — Multipart
+
+### Overview
+
+The MULTIPART payload wraps another payload type to indicate it is part of a
+multi-packet sequence. The first byte encodes the number of remaining packets in
+the sequence and the sub-payload type. Currently, only multipart ACK is defined
+in the reference implementation.
+
+### Payload Type
+
+Header payload type field: `0x0A` (MULTIPART)
+
+### Wire Format
+
+```
+ 0 1
+ 0 1 2 3 4 5 6 7 8 9 0 ...
++-+-+-+-+-+-+-+-+-+-+-+-+
+|Rem| SubType | Sub-payload |
++-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+First byte encoding:
+
+```
+ Bit: 7 6 5 4 3 2 1 0
+ +---+---+---+---+---+---+---+---+
+ | Remaining (4) | Sub-Type (4) |
+ +---+---+---+---+---+---+---+---+
+```
+
+### Fields
+
+| Field | Offset | Size | Type | Description |
+|-------|--------|------|------|-------------|
+| remaining | 0 (bits 4-7) | 4 bits | uint | Number of remaining packets in sequence (0-15) |
+| sub_type | 0 (bits 0-3) | 4 bits | uint | Payload type of the wrapped content |
+| sub_payload | 1 | variable | raw | The wrapped payload data |
+
+```
+first_byte = (remaining << 4) | (sub_type & 0x0F)
+```
+
+### Multipart ACK
+
+The only currently defined multipart sub-type is ACK (sub_type = 0x03):
+
+```
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| (rem<<4)|0x03| ACK CRC (uint32_le) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+Total payload: 5 bytes (1 byte header + 4 byte CRC).
+
+Multipart ACKs are used to send additional ACK retransmissions for reliability.
+The `remaining` field indicates how many more ACK packets follow this one,
+counting down: a chain of K extra copies is emitted as MULTIPART with
+`remaining = K, K-1, …, 1`, followed by exactly one final plain
+`PAYLOAD_TYPE_ACK` packet (NOT a MULTIPART with `remaining = 0`). All copies
+in the chain carry the same `ack_crc`. Receivers MUST treat the first copy
+received as the authoritative ACK and dedup subsequent copies by `ack_crc`
+at the application layer.
+
+### Multipart ACK Usage Constraints
+
+- A sender MUST only emit MULTIPART-wrapped ACKs on direct-routed return
+ paths (i.e., when a direct path to the original sender is known). When the
+ return path is unknown, the sender MUST fall back to emitting a plain
+ PAYLOAD_TYPE_ACK via flood routing. This reflects the firmware behavior in
+ `Mesh::routeDirectRecvAcks()` (which emits MULTIPART copies only when a
+ direct path is available) and `BaseChatMesh::sendAckTo()` (which falls back
+ to `sendFloodScoped()` when `out_path_len == OUT_PATH_UNKNOWN`).
+- All MULTIPART-ACK copies and the final plain ACK for the same acknowledged
+ message carry an identical 4-byte `ack_crc`. Receivers MUST treat them as
+ the same logical ACK: the first one received satisfies the acknowledgment,
+ and subsequent duplicates MUST be deduplicated per
+ [Section 16](16-packet-hash.md) (each copy has a distinct packet hash
+ because the wrapper byte differs, so ordinary dedup does not suppress them
+ on the wire — application-level dedup by `ack_crc` is required).
+- The typical inter-copy delay in the reference implementation is
+ approximately 300 ms plus the direct retransmit delay
+ (`Mesh::routeDirectRecvAcks()`); exact timing is implementation-defined.
+- The number of extra copies is configured via `getExtraAckTransmitCount()`
+ and is implementation-defined. The firmware default is 0 (plain ACK only).
+
+A receiver MUST NOT infer delivery reliability from the `remaining` counter
+alone: the counter is a hint about how many additional copies the sender
+intends to emit, not a guarantee that they will arrive.
+
+### Encoding (Multipart ACK)
+
+1. Set first byte to `(remaining << 4) | PAYLOAD_TYPE_ACK`.
+2. Copy the 4-byte ACK CRC starting at offset 1.
+3. Set payload_len to 5.
+
+### Decoding (Multipart ACK)
+
+1. Read first byte. Extract remaining = byte >> 4, sub_type = byte & 0x0F.
+2. If sub_type == 0x03 (ACK) and payload_len >= 5:
+ a. Read 4 bytes at offset 1 as ACK CRC (uint32_le).
+3. For other sub_types: reserved for future use.
+
+### Constraints
+
+- Payload MUST be at least 2 bytes (1 header + 1 sub-payload minimum).
+- For multipart ACK, payload MUST be at least 5 bytes.
+
+### Cross-References
+
+- [Section 4: ACK](04-payload-ack.md) — ACK CRC format
+- Test vectors: `corpus/payloads/multipart/`
+
+### Reference Implementation
+
+- `Mesh::createMultiAck()` in `src/Mesh.cpp` — Encoding
+- `Mesh::routeDirectRecvAcks()` in `src/Mesh.cpp` — Direct-only gating and
+ inter-copy spacing
+- `BaseChatMesh::sendAckTo()` in `src/helpers/BaseChatMesh.cpp` — Flood
+ fallback when no direct return path is known
+- `Mesh::onRecvPacket()`, case `PAYLOAD_TYPE_MULTIPART` — Decoding
diff --git a/versions/v0.1.0/12-payload-control.md b/versions/v0.1.0/12-payload-control.md
new file mode 100644
index 0000000..5d4f7f5
--- /dev/null
+++ b/versions/v0.1.0/12-payload-control.md
@@ -0,0 +1,68 @@
+# MeshCore Protocol Specification
+
+## Section 12: Payload — Control
+
+### Overview
+
+The CONTROL payload carries control and discovery data. Control packets are raw
+byte payloads whose structure is determined by the first byte. A subset of
+control packets (those with bit 7 of the first byte set) are restricted to
+zero-hop delivery only.
+
+### Payload Type
+
+Header payload type field: `0x0B` (CONTROL)
+
+### Wire Format
+
+```
+ 0
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Control Byte | Data... |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+### Fields
+
+| Field | Offset | Size | Type | Description |
+|-------|--------|------|------|-------------|
+| control_byte | 0 | 1 byte | uint8 | Control type identifier |
+| data | 1 | variable | raw | Control-specific data |
+
+### Zero-Hop Restriction
+
+When bit 7 (`0x80`) of the control byte is set, the packet is restricted to
+zero-hop delivery only. The reference implementation enforces this:
+
+- If `(payload[0] & 0x80) != 0` AND the packet is direct-routed:
+ - It is processed only if `getPathHashCount() == 0`.
+ - It is NOT forwarded to other nodes.
+
+### Discovery Protocols
+
+Control packets are commonly used for node discovery. The specific sub-protocol
+formats are application-defined, but typically include:
+
+- **Discovery Request**: Sent zero-hop to find nearby nodes
+- **Discovery Response**: Contains node type, name, SNR, and other metadata
+
+### Encoding
+
+1. Construct the raw control data bytes.
+2. If the control packet should be zero-hop only, set bit 7 of the first byte.
+3. Copy data to payload, set payload_len.
+
+### Constraints
+
+- Payload MUST be at least 1 byte (the control byte).
+- Maximum payload: 184 bytes (MAX_PACKET_PAYLOAD).
+
+### Cross-References
+
+- [Section 17: Routing](17-routing.md) — Zero-hop delivery
+- Test vectors: `corpus/payloads/control/`
+
+### Reference Implementation
+
+- `Mesh::createControlData()` in `src/Mesh.cpp` — Encoding
+- `Mesh::onRecvPacket()`, CONTROL handling — Zero-hop check and delivery
diff --git a/versions/v0.1.0/13-payload-raw-custom.md b/versions/v0.1.0/13-payload-raw-custom.md
new file mode 100644
index 0000000..8b47f6e
--- /dev/null
+++ b/versions/v0.1.0/13-payload-raw-custom.md
@@ -0,0 +1,49 @@
+# MeshCore Protocol Specification
+
+## Section 13: Payload — Raw Custom
+
+### Overview
+
+The RAW_CUSTOM payload carries application-defined raw bytes with no prescribed
+structure. It is intended for applications that implement their own encryption,
+framing, or payload formats on top of the MeshCore transport.
+
+### Payload Type
+
+Header payload type field: `0x0F` (RAW_CUSTOM)
+
+### Wire Format
+
+```
+ 0
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+| Raw Data (1-184 bytes) |
++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+```
+
+### Fields
+
+| Field | Offset | Size | Type | Description |
+|-------|--------|------|------|-------------|
+| data | 0 | 1-184 bytes | raw | Application-defined payload |
+
+### Routing
+
+In the current reference implementation, RAW_CUSTOM packets are only processed
+when received via direct routing. They are NOT flood-routed.
+
+### Constraints
+
+- Payload MUST be at least 1 byte.
+- Maximum payload: 184 bytes (MAX_PACKET_PAYLOAD).
+- Maximum total data: limited by `sizeof(Packet::payload)` which is
+ MAX_PACKET_PAYLOAD (184).
+
+### Cross-References
+
+- Test vectors: `corpus/payloads/raw-custom/`
+
+### Reference Implementation
+
+- `Mesh::createRawData()` in `src/Mesh.cpp` — Encoding
+- `Mesh::onRecvPacket()`, case `PAYLOAD_TYPE_RAW_CUSTOM` — Processing (direct only)
diff --git a/versions/v0.1.0/14-crypto.md b/versions/v0.1.0/14-crypto.md
new file mode 100644
index 0000000..09db99f
--- /dev/null
+++ b/versions/v0.1.0/14-crypto.md
@@ -0,0 +1,120 @@
+# MeshCore Protocol Specification
+
+## Section 14: Cryptography
+
+### Overview
+
+MeshCore uses a combination of symmetric and asymmetric cryptography. Data
+confidentiality is provided by AES-128 in ECB mode with zero padding. Data
+integrity is provided by HMAC-SHA256 truncated to 2 bytes. These are combined
+in an encrypt-then-MAC scheme.
+
+### Algorithms
+
+| Operation | Algorithm | Key Size | Output |
+|-----------|-----------|----------|--------|
+| Symmetric Encryption | AES-128-ECB | 16 bytes | Multiple of 16 bytes |
+| Message Authentication | HMAC-SHA256 | 32 bytes | 2 bytes (truncated) |
+| Digital Signature | Ed25519 | 32/64 bytes | 64 bytes |
+| Key Exchange | X25519 (ECDH) | 32 bytes | 32 bytes |
+| Hashing | SHA-256 | — | Up to 32 bytes |
+
+### AES-128-ECB Encryption
+
+MeshCore uses AES-128 in Electronic Codebook (ECB) mode with zero-byte padding.
+
+**Key**: The first 16 bytes (CIPHER_KEY_SIZE) of the shared secret.
+
+**Encryption**:
+
+1. Set the AES-128 key to `shared_secret[0..15]`.
+2. Process the plaintext in 16-byte blocks:
+ a. For each complete 16-byte block, encrypt in place.
+ b. For the final partial block (< 16 bytes):
+ - Copy the remaining bytes to a 16-byte buffer.
+ - Fill remaining bytes with zeros.
+ - Encrypt the padded block.
+3. If the plaintext is empty, encrypt a 16-byte all-zero block.
+4. The output is always a multiple of 16 bytes.
+
+**Decryption**:
+
+1. Set the AES-128 key to `shared_secret[0..15]`.
+2. Process the ciphertext in 16-byte blocks, decrypting each.
+3. The output length equals the ciphertext length.
+4. The final block MAY contain trailing zero bytes from padding.
+ The application must know the original plaintext length or use
+ a length-prefixed format to determine where meaningful data ends.
+
+### HMAC-SHA256 (Truncated to 2 Bytes)
+
+MeshCore computes HMAC-SHA256 over the ciphertext and truncates the result to
+CIPHER_MAC_SIZE (2) bytes.
+
+**Key**: The full 32 bytes (PUB_KEY_SIZE) of the shared secret.
+
+Note: The HMAC key uses the full 32-byte shared secret, while the AES key uses
+only the first 16 bytes. This is an important distinction.
+
+**Computation**:
+
+```
+mac = HMAC-SHA256(key=shared_secret[0..31], message=ciphertext)[0..1]
+```
+
+The first 2 bytes of the HMAC-SHA256 output are used as the MAC.
+
+### Encrypt-then-MAC
+
+The encrypt-then-MAC scheme combines AES-128-ECB encryption with HMAC-SHA256
+authentication:
+
+**Encoding** (`encryptThenMAC`):
+
+1. Encrypt the plaintext using AES-128-ECB (see above).
+ Store ciphertext at `dest[2..]` (offset by MAC size).
+2. Compute HMAC-SHA256 over the ciphertext using the full 32-byte shared
+ secret. Truncate to 2 bytes.
+3. Store the 2-byte MAC at `dest[0..1]`.
+4. Return total length: 2 + ciphertext_length.
+
+Output format:
+```
+[MAC (2 bytes)][Ciphertext (N × 16 bytes)]
+```
+
+**Decoding** (`MACThenDecrypt`):
+
+1. If input length ≤ 2 (CIPHER_MAC_SIZE), return failure (invalid).
+2. Recompute HMAC-SHA256 over `src[2..]` (the ciphertext portion) using the
+ full 32-byte shared secret. Truncate to 2 bytes.
+3. Compare the computed MAC with `src[0..1]`.
+4. If MACs do not match, return failure (0 = invalid HMAC).
+5. If MACs match, decrypt `src[2..]` using AES-128-ECB.
+6. Return the decrypted plaintext length.
+
+### Security Notes
+
+- **ECB mode**: AES-ECB encrypts each 16-byte block independently. Identical
+ plaintext blocks produce identical ciphertext blocks. This is a known
+ weakness of ECB mode, but MeshCore's short payloads and mesh-network context
+ make this acceptable for its use case.
+- **2-byte MAC**: The truncated HMAC provides only 16 bits of authentication.
+ This means there is approximately a 1-in-65536 chance of a forged MAC being
+ accepted. This trade-off prioritizes bandwidth over authentication strength.
+- **Key derivation**: The shared secret is used directly as both AES key
+ (first 16 bytes) and HMAC key (full 32 bytes). No key derivation function
+ (KDF) is applied.
+
+### Cross-References
+
+- [Section 6: Encrypted Payloads](06-payload-encrypted.md) — Usage in packet payloads
+- [Section 15: Identity](15-identity.md) — Shared secret computation via ECDH
+- Test vectors: `corpus/crypto/`
+
+### Reference Implementation
+
+- `Utils::encrypt()` in `src/Utils.cpp` — AES-128-ECB encryption
+- `Utils::decrypt()` in `src/Utils.cpp` — AES-128-ECB decryption
+- `Utils::encryptThenMAC()` in `src/Utils.cpp` — Encrypt-then-MAC
+- `Utils::MACThenDecrypt()` in `src/Utils.cpp` — MAC-then-Decrypt
diff --git a/versions/v0.1.0/15-identity.md b/versions/v0.1.0/15-identity.md
new file mode 100644
index 0000000..e62491d
--- /dev/null
+++ b/versions/v0.1.0/15-identity.md
@@ -0,0 +1,92 @@
+# MeshCore Protocol Specification
+
+## Section 15: Identity
+
+### Overview
+
+Every MeshCore node has an Ed25519 key pair. The 32-byte public key serves as
+the node's identity. Hashes used in routing are truncated prefixes of the public
+key. Shared secrets for encryption are computed via ECDH key exchange using
+X25519 (Ed25519 keys transposed to Curve25519).
+
+### Key Types
+
+| Type | Size | Description |
+|------|------|-------------|
+| Public Key | 32 bytes (PUB_KEY_SIZE) | Ed25519 public key |
+| Private Key | 64 bytes (PRV_KEY_SIZE) | Ed25519 private key (seed + public) |
+| Signature | 64 bytes (SIGNATURE_SIZE) | Ed25519 signature |
+| Shared Secret | 32 bytes (PUB_KEY_SIZE) | ECDH shared secret |
+
+### Hash Derivation
+
+A node's routing hash is simply a prefix of its public key:
+
+```
+hash = pub_key[0..hash_size-1]
+```
+
+Where `hash_size` is 1, 2, or 3 bytes depending on the protocol version and
+path encoding (see [Section 3](03-path.md)).
+
+For V1, `PATH_HASH_SIZE` is 1, meaning a node's hash is the first byte of its
+public key. This gives only 256 unique hash values, so hash collisions are
+common and expected. The protocol handles this by attempting decryption with all
+peers matching a given hash.
+
+### Hash Matching
+
+To check if a hash matches a node's identity:
+
+```
+match = memcmp(hash, pub_key, hash_size) == 0
+```
+
+### Ed25519 Signing
+
+Used for advertisement signatures (see [Section 5](05-payload-advert.md)):
+
+1. Construct the message to sign (e.g., pub_key || timestamp || app_data).
+2. Sign using the Ed25519 private key.
+3. The 64-byte signature is included in the advertisement payload.
+
+Verification:
+
+1. Extract the public key and signature from the payload.
+2. Reconstruct the message.
+3. Verify the signature using Ed25519 verification.
+4. Receivers MUST discard the packet if verification fails.
+
+### ECDH Key Exchange (X25519)
+
+To compute a shared secret between two nodes:
+
+1. Convert the Ed25519 keys to X25519 (Curve25519) format.
+2. Perform X25519 Diffie-Hellman: `shared_secret = X25519(my_private, their_public)`.
+3. The resulting 32-byte shared secret is used for:
+ - AES-128 key: `shared_secret[0..15]`
+ - HMAC-SHA256 key: `shared_secret[0..31]`
+
+### Identity Serialization
+
+Identities can be serialized for storage:
+
+- **Public Identity**: 32 bytes (public key only)
+- **Local Identity**: 64 bytes (private key) + 32 bytes (public key) = 96 bytes
+
+Hex representation uses uppercase characters with 2 hex digits per byte.
+
+### Cross-References
+
+- [Section 3: Path](03-path.md) — Hash size in path encoding
+- [Section 5: Advertisement](05-payload-advert.md) — Signature usage
+- [Section 14: Cryptography](14-crypto.md) — Shared secret usage
+- Test vectors: `corpus/crypto/ed25519/`, `corpus/crypto/ecdh/`
+
+### Reference Implementation
+
+- `Identity` class in `src/Identity.h` — Public key and verification
+- `LocalIdentity` class in `src/Identity.h` — Key pair, signing, ECDH
+- `Identity::copyHashTo()` — Hash derivation (prefix copy)
+- `Identity::isHashMatch()` — Hash comparison
+- `LocalIdentity::calcSharedSecret()` — ECDH key exchange
diff --git a/versions/v0.1.0/16-packet-hash.md b/versions/v0.1.0/16-packet-hash.md
new file mode 100644
index 0000000..3660fe7
--- /dev/null
+++ b/versions/v0.1.0/16-packet-hash.md
@@ -0,0 +1,67 @@
+# MeshCore Protocol Specification
+
+## Section 16: Packet Hash
+
+### Overview
+
+MeshCore uses SHA-256 hashing for packet deduplication. Each node maintains a
+table of recently seen packet hashes and discards packets it has already
+processed. The hash is computed over the payload type and payload contents, with
+a special case for TRACE packets.
+
+### Hash Computation
+
+The packet hash is computed as:
+
+```
+hash = SHA-256(payload_type_byte || payload_data)[0..MAX_HASH_SIZE-1]
+```
+
+Where:
+- `payload_type_byte` is a single byte: `(header >> 2) & 0x0F`
+- `payload_data` is the raw payload bytes (payload_len bytes)
+- The SHA-256 output is truncated to MAX_HASH_SIZE (8) bytes
+
+### TRACE Packet Special Case
+
+For TRACE packets (payload_type = 0x09), the path_len byte is included in the
+hash to distinguish trace packets that revisit the same node on their return
+path:
+
+```
+hash = SHA-256(payload_type_byte || path_len_byte || payload_data)[0..MAX_HASH_SIZE-1]
+```
+
+### Algorithm
+
+```
+function calculatePacketHash(packet):
+ sha = SHA256_init()
+ type_byte = getPayloadType(packet.header) // single byte
+ SHA256_update(sha, type_byte)
+ if type_byte == 0x09: // TRACE
+ SHA256_update(sha, packet.path_len) // 1 byte (as uint8)
+ SHA256_update(sha, packet.payload, packet.payload_len)
+ hash = SHA256_finalize(sha)
+ return hash[0..7] // first MAX_HASH_SIZE bytes
+```
+
+### Properties
+
+- The hash does NOT include the header byte, path, or transport codes. This
+ means the same logical message received via different routes produces the
+ same hash.
+- The hash does NOT include the route type or version. Only the payload type
+ and payload content determine the hash.
+- For TRACE packets, including path_len ensures that the same trace payload
+ at different stages of traversal produces different hashes.
+
+### Cross-References
+
+- [Section 10: Trace](10-payload-trace.md) — TRACE packet special handling
+- Test vectors: `corpus/crypto/sha256/packet-hash.json`
+
+### Reference Implementation
+
+- `Packet::calculatePacketHash()` in `src/Packet.cpp`
+- `MeshTables::hasSeen()` — Deduplication table lookup
diff --git a/versions/v0.1.0/17-routing.md b/versions/v0.1.0/17-routing.md
new file mode 100644
index 0000000..8b25bfd
--- /dev/null
+++ b/versions/v0.1.0/17-routing.md
@@ -0,0 +1,208 @@
+# MeshCore Protocol Specification
+
+## Section 17: Routing
+
+### Overview
+
+MeshCore supports two routing modes: flood routing and direct routing. Flood
+routing broadcasts packets to all neighbors, building up a path as the packet
+propagates. Direct routing sends packets along a pre-determined path.
+
+### Flood Routing
+
+In flood routing, the sender emits a packet with an empty path. Each
+intermediate node that forwards the packet appends its hash to the path before
+retransmitting.
+
+**Sending (flood)**:
+
+1. Set route type to FLOOD (0x01) or TRANSPORT_FLOOD (0x00).
+2. Set path to empty: `setPathHashSizeAndCount(hash_size, 0)`.
+3. Transmit the packet.
+
+**Forwarding (flood)**:
+
+1. Receive a flood-routed packet.
+2. Check the deduplication table. If already seen, discard.
+3. Process the packet (decrypt if addressed to this node, etc.).
+4. If the packet should be forwarded (node is a repeater):
+ a. Verify `(hash_count + 1) × hash_size <= MAX_PATH_SIZE`.
+ b. Append this node's hash at `path[hash_count × hash_size]`.
+ c. Increment hash_count.
+ d. Retransmit with a random delay.
+
+**Path growth**: The path grows by one hash per hop. When the path reaches
+MAX_PATH_SIZE (64 bytes), no more nodes can be appended and the packet stops
+propagating.
+
+### Direct Routing
+
+In direct routing, the sender provides the complete path. Each intermediate
+node checks whether it is the next hop, removes itself from the path, and
+forwards to the next node.
+
+**Sending (direct)**:
+
+1. Set route type to DIRECT (0x02) or TRANSPORT_DIRECT (0x03).
+2. Set the path to the destination's known route.
+3. Transmit the packet.
+
+**Forwarding (direct)**:
+
+1. Receive a direct-routed packet.
+2. Check if the first hash in the path matches this node.
+3. If yes, and the node allows forwarding:
+ a. Remove the first hash from the path (shift remaining hashes left).
+ b. Decrement hash_count.
+ c. Retransmit.
+4. If no, discard (this node is not the next hop).
+
+**Path removal** (`removeSelfFromPath`):
+
+1. Decrement hash_count.
+2. Shift the path array: for each index k from 0 to (hash_count × hash_size),
+ copy `path[k + hash_size]` to `path[k]`.
+
+### Zero-Hop Delivery
+
+Zero-hop packets are direct-routed packets with hash_count = 0. They reach only
+immediate neighbors (nodes within radio range). Used for:
+
+- Control packets with bit 7 set in the control byte
+- Discovery protocols
+- Local-only operations
+
+### Transport Codes
+
+Transport codes (present in TRANSPORT_FLOOD and TRANSPORT_DIRECT) enable
+regional scoping. Nodes can filter packets based on transport codes, accepting
+only packets from their region.
+
+### TRACE Routing
+
+TRACE packets are a special case. They are sent via direct routing, but instead
+of the normal path field, the path hashes are appended to the payload. The
+packet's path field is repurposed to accumulate SNR values from each hop
+(see [Section 10](10-payload-trace.md)).
+
+### Retransmission Priority
+
+Packets are prioritized for transmission:
+
+| Priority | Packet Type | Description |
+|----------|-------------|-------------|
+| 0 (highest) | Direct routed | Routed traffic |
+| 1 | Path return, standard | Most flood packets |
+| 2 | Path return (flood) | Path packets |
+| 3 | Advertisement | De-prioritized |
+| 5 | Trace | Trace forwarding |
+| N (hash_count) | Flood forwarded | Lower priority for more distant sources |
+
+### Sender Behavior (Informational)
+
+This section documents sender-side behavior that is not fully constrained by
+the wire protocol but is common across interoperating implementations. The
+reference firmware establishes the baseline; client implementations SHOULD
+follow these patterns to interoperate cleanly. Specific numeric tuning
+parameters (timeouts, queue sizes, retry counts) are implementation-defined.
+
+#### Path Learning
+
+- When a node receives a flood-routed packet from a peer, the accumulated
+ `path` field records the hops the packet traversed. The receiving node
+ SHOULD cache the **reversed** path keyed by the peer's source hash and
+ subsequently use that cached path to send direct-routed traffic back.
+- When multiple paths are observed for the same peer, the **reference
+ implementation unconditionally replaces** the cached path with the most
+ recently observed one (`BaseChatMesh::onContactPathRecv()` in
+ `src/helpers/BaseChatMesh.cpp` — its source comment explicitly flags
+ hash-size or SNR-based selection as future work). Implementations MAY
+ layer a quality heuristic on top (e.g., prefer larger hash sizes or
+ better SNR margin) if they store and compare multiple candidate paths,
+ but this is not part of the reference behavior and the exact policy is
+ implementation-defined.
+- Cached paths in the reference implementation do **not** expire on a
+ timer; they live as long as the contact entry, and are overwritten
+ whenever a fresher path is observed. Implementations MAY add a TTL or
+ age-based eviction policy as an extension. The reference implementation
+ does not persist paths across reboots.
+
+#### PATH_RETURN
+
+After receiving a flood-routed DM, a receiver MAY proactively emit a
+PAYLOAD_TYPE_PATH packet ([Section 9](09-payload-path.md)) that embeds the
+reversed path and optionally an ACK as extra data. This lets the original
+sender learn a direct return path before its first reply, converting
+subsequent traffic from flood to direct routing. See
+`BaseChatMesh::onPeerDataRecv()` and `Mesh::createPathReturn()`.
+
+The reference implementation schedules the PATH_RETURN transmission with a
+small delay (`TXT_ACK_DELAY`, default 200 ms) to avoid colliding with other
+packets the receiver is about to emit. Exact timing is
+implementation-defined; the goal is to space PATH_RETURN, ACK, and any
+application reply across distinct on-air slots.
+
+#### Flood Fallback and Retries
+
+- A sender SHOULD track outstanding DMs by their expected `ack_crc`
+ (see [Section 4](04-payload-ack.md)) and retry transmissions that are not
+ acknowledged within a timeout. Timeout values and backoff schedules are
+ implementation-defined and typically depend on LoRa airtime parameters.
+- On each retry, the sender MUST increment the `attempt` sub-field of
+ `txt_type_attempt` (see
+ [Section 6](06-payload-encrypted.md#plaintext-format-txt_msg)). The
+ `timestamp` field MUST be held constant across retries of the same logical
+ message. This changes the ACK CRC per attempt, letting the sender attribute
+ a returned ACK to a specific transmission.
+- After N unsuccessful direct-routed retries, the sender SHOULD discard the
+ cached direct path and retry via flood routing, so that a stale cached path
+ does not indefinitely block delivery. The value of N is
+ implementation-defined (the reference is small — single-digit).
+- On receipt of a matching ACK (plain or MULTIPART, per
+ [Section 11](11-payload-multipart.md#multipart-ack-usage-constraints)),
+ the sender MUST cancel any queued retries for that ACK CRC.
+
+#### Transmission Priority (sender-originated)
+
+Client implementations that maintain a TX queue SHOULD prioritize
+sender-originated packets roughly as follows (highest first):
+
+| Priority | Packet Type |
+|----------|-------------|
+| 0 | ACK (plain or MULTIPART) |
+| 1 | PATH / PATH_RETURN |
+| 2 | Direct-routed reply (DM) |
+| 3 | Flood-routed reply (DM) |
+| 4 | Request/response (e.g., login, keep-alive) |
+| 5 | Group text / group data |
+| 6 | Advertisement |
+
+This ordering complements the forwarding-priority table above: ACKs and
+PATH returns are time-sensitive and short; direct replies take precedence
+over flood replies to reduce channel occupancy. Exact ordering and queue
+depth are implementation-defined.
+
+### Packet Deduplication
+
+Before processing or forwarding any packet, nodes check if the packet has
+already been seen using `hasSeen()` with the packet hash from
+[Section 16](16-packet-hash.md). This prevents infinite loops in flood routing.
+
+Once a packet is marked as seen, it is NOT forwarded again even if received via
+a different route. This is a "first packet wins" approach.
+
+### Cross-References
+
+- [Section 1: Wire Format](01-wire-format.md) — Packet structure
+- [Section 3: Path](03-path.md) — Path encoding
+- [Section 10: Trace](10-payload-trace.md) — TRACE routing special case
+- [Section 16: Packet Hash](16-packet-hash.md) — Deduplication
+
+### Reference Implementation
+
+- `Mesh::sendFlood()` in `src/Mesh.cpp` — Flood send
+- `Mesh::sendDirect()` in `src/Mesh.cpp` — Direct send
+- `Mesh::sendZeroHop()` in `src/Mesh.cpp` — Zero-hop send
+- `Mesh::routeRecvPacket()` in `src/Mesh.cpp` — Flood forwarding
+- `Mesh::removeSelfFromPath()` in `src/Mesh.cpp` — Path manipulation
+- `Mesh::onRecvPacket()` in `src/Mesh.cpp` — Direct forwarding
diff --git a/versions/v0.1.0/18-companion-protocol.md b/versions/v0.1.0/18-companion-protocol.md
new file mode 100644
index 0000000..0e3612a
--- /dev/null
+++ b/versions/v0.1.0/18-companion-protocol.md
@@ -0,0 +1,96 @@
+# MeshCore Protocol Specification
+
+## Section 18: Companion Protocol
+
+### Overview
+
+The companion protocol enables BLE (Bluetooth Low Energy) and serial
+communication between a MeshCore radio and a companion app (phone, tablet, or
+computer). It uses a binary framing format over the Nordic UART Service (NUS)
+BLE profile.
+
+### BLE Service
+
+| UUID | Description |
+|------|-------------|
+| `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` | Nordic UART Service |
+| `6E400002-...` | RX Characteristic (app writes to radio) |
+| `6E400003-...` | TX Characteristic (radio notifies app) |
+
+### Frame Format
+
+Each companion protocol frame consists of a 1-byte type identifier followed by
+variable-length data:
+
+```
+[type(1)][data(0-171)]
+```
+
+Maximum frame size is 172 bytes.
+
+### Message Types (Radio to App)
+
+| Type | Value | Description |
+|------|-------|-------------|
+| PACKET_CHANNEL_MSG_RECV | 0x01 | Channel message received |
+| PACKET_CONTACT_MSG_RECV | 0x02 | Contact message received |
+| PACKET_ADV_RECV | 0x03 | Advertisement received |
+| PACKET_CONTACT_MSG_RECV_V3 | 0x04 | Contact message V3 (includes SNR) |
+| PACKET_CHANNEL_MSG_RECV_V3 | 0x05 | Channel message V3 (includes SNR) |
+
+### Command Types (App to Radio)
+
+| Command | Value | Description |
+|---------|-------|-------------|
+| CMD_APP_START | 0x01 | Initialize connection |
+| CMD_DEVICE_QUERY | 0x02 | Query device info |
+| CMD_SET_CHANNEL | 0x03 | Set active channel |
+| CMD_SEND_CHANNEL_MESSAGE | 0x04 | Send message to channel |
+| CMD_SEND_CONTACT_MESSAGE | 0x05 | Send message to contact |
+| CMD_GET_CONTACTS | 0x06 | Get contact list |
+| CMD_GET_CHANNELS | 0x07 | Get channel list |
+| CMD_SET_TIME | 0x08 | Set device time |
+| CMD_ADD_CONTACT | 0x09 | Add a contact |
+| CMD_ADD_CHANNEL | 0x0A | Add a channel |
+| CMD_SEND_LOGIN | 0x0B | Login to room/repeater |
+| CMD_GET_SETTINGS | 0x0C | Get device settings |
+| CMD_SET_SETTINGS | 0x0D | Set device settings |
+| CMD_REMOVE_CONTACT | 0x0E | Remove a contact |
+| CMD_REMOVE_CHANNEL | 0x0F | Remove a channel |
+| CMD_SHARE_CONTACT | 0x10 | Share contact info |
+| CMD_SET_ADV_NAME | 0x11 | Set advertisement name |
+| CMD_REBOOT | 0x12 | Reboot device |
+| CMD_SEND_RAW | 0x13 | Send raw packet |
+
+### Connection Sequence
+
+1. Scan for BLE devices advertising the Nordic UART Service.
+2. Connect and discover services.
+3. Enable notifications on the TX characteristic.
+4. Send `CMD_APP_START` to initialize.
+5. Send `CMD_DEVICE_QUERY` to get device info.
+6. Send `CMD_SET_TIME` to synchronize clock.
+7. Send `CMD_GET_CONTACTS` and `CMD_GET_CHANNELS` to fetch state.
+
+### Byte Order
+
+All multi-byte integers in the companion protocol are **little-endian**, except
+for CayenneLPP sensor data which uses **big-endian**.
+
+### Message Length Limit
+
+Text messages are limited to 133 characters.
+
+### MTU Considerations
+
+The default BLE MTU is 23 bytes (20 bytes of payload). For complex operations,
+implementations SHOULD request a larger MTU (up to 512 bytes).
+
+### Cross-References
+
+- [Section 1: Wire Format](01-wire-format.md) — Underlying packet format
+- Test vectors: `corpus/companion/`
+
+### Reference Implementation
+
+- `docs/companion_protocol.md` in the MeshCore repository
diff --git a/versions/v0.1.0/19-kiss-protocol.md b/versions/v0.1.0/19-kiss-protocol.md
new file mode 100644
index 0000000..b3e54a6
--- /dev/null
+++ b/versions/v0.1.0/19-kiss-protocol.md
@@ -0,0 +1,180 @@
+# MeshCore Protocol Specification
+
+## Section 19: KISS Modem Protocol
+
+### Overview
+
+The KISS (Keep It Simple, Stupid) modem protocol provides a serial interface to
+MeshCore LoRa radios. It follows the KA9Q/K3MC KISS TNC specification with
+MeshCore-specific SetHardware extensions for cryptographic operations, radio
+control, and telemetry.
+
+### Serial Configuration
+
+- Baud rate: 115200
+- Data bits: 8
+- Parity: None
+- Stop bits: 1
+- Flow control: None
+
+### Frame Format
+
+```
+┌──────┬───────────┬──────────────┬──────┐
+│ FEND │ Type Byte │ Data (escaped)│ FEND │
+│ 0xC0 │ 1 byte │ 0-510 bytes │ 0xC0 │
+└──────┴───────────┴──────────────┴──────┘
+```
+
+### Special Bytes
+
+| Byte | Name | Value | Description |
+|------|------|-------|-------------|
+| FEND | Frame End | 0xC0 | Frame delimiter |
+| FESC | Frame Escape | 0xDB | Escape character |
+| TFEND | Transposed FEND | 0xDC | FESC + TFEND represents 0xC0 in data |
+| TFESC | Transposed FESC | 0xDD | FESC + TFESC represents 0xDB in data |
+
+### Byte Stuffing
+
+To send a data byte of 0xC0 (FEND), emit: 0xDB 0xDC (FESC TFEND).
+To send a data byte of 0xDB (FESC), emit: 0xDB 0xDD (FESC TFESC).
+
+### Type Byte
+
+```
+ Bit: 7 6 5 4 3 2 1 0
+ +---+---+---+---+---+---+---+---+
+ | Port (4) | Command (4) |
+ +---+---+---+---+---+---+---+---+
+```
+
+Port is 0 for single-port TNC (standard for MeshCore).
+
+### Standard KISS Commands (Host to TNC)
+
+| Command | Value | Data | Description |
+|---------|-------|------|-------------|
+| DataFrame | 0x00 | Raw packet | Queue packet for transmission |
+| TXDELAY | 0x01 | 1 byte | Transmitter keyup delay (× 10ms) |
+| Persistence | 0x02 | 1 byte | CSMA persistence (0-255) |
+| SlotTime | 0x03 | 1 byte | CSMA slot interval (× 10ms) |
+| TXtail | 0x04 | 1 byte | Post-TX hold time (× 10ms) |
+| FullDuplex | 0x05 | 1 byte | 0=half, nonzero=full |
+| SetHardware | 0x06 | Sub-cmd + data | MeshCore extensions |
+| Return | 0xFF | — | Exit KISS mode (no-op) |
+
+### TNC to Host
+
+| Type | Value | Data | Description |
+|------|-------|------|-------------|
+| DataFrame | 0x00 | Raw packet | Received packet from radio |
+
+Data frames carry raw MeshCore packets (up to 255 bytes, MAX_TRANS_UNIT).
+
+### SetHardware Extensions (0x06)
+
+MeshCore extends the KISS protocol via the SetHardware command. The first data
+byte is a sub-command identifier.
+
+**Request Sub-commands (Host to TNC):**
+
+| Sub-cmd | Value | Data | Description |
+|---------|-------|------|-------------|
+| GetIdentity | 0x01 | — | Get node's Ed25519 public key |
+| GetRandom | 0x02 | len(1) | Get random bytes (1-64) |
+| VerifySignature | 0x03 | pubkey(32)+sig(64)+data | Verify Ed25519 signature |
+| SignData | 0x04 | data | Ed25519 sign |
+| EncryptData | 0x05 | key(32)+plaintext | AES-128 encrypt |
+| DecryptData | 0x06 | key(32)+mac(2)+ciphertext | AES-128 decrypt with MAC |
+| KeyExchange | 0x07 | remote_pub(32) | X25519 ECDH |
+| Hash | 0x08 | data | SHA-256 hash |
+| SetRadio | 0x09 | freq(4)+bw(4)+sf(1)+cr(1) | Set radio parameters |
+| SetTxPower | 0x0A | power(1) | Set TX power (dBm) |
+| GetRadio | 0x0B | — | Get radio parameters |
+| GetTxPower | 0x0C | — | Get TX power |
+| GetCurrentRssi | 0x0D | — | Get current RSSI |
+| IsChannelBusy | 0x0E | — | Check if channel busy |
+| GetAirtime | 0x0F | pkt_len(1) | Estimate air time |
+| GetNoiseFloor | 0x10 | — | Get noise floor |
+| GetVersion | 0x11 | — | Get firmware version |
+| GetStats | 0x12 | — | Get RX/TX statistics |
+| GetBattery | 0x13 | — | Get battery voltage |
+| GetMCUTemp | 0x14 | — | Get MCU temperature |
+| GetSensors | 0x15 | perms(1) | Get sensor data |
+| GetDeviceName | 0x16 | — | Get device name |
+| Ping | 0x17 | — | Ping |
+| Reboot | 0x18 | — | Reboot device |
+| SetSignalReport | 0x19 | enable(1) | Enable/disable RxMeta |
+| GetSignalReport | 0x1A | — | Get signal report status |
+
+**Response Sub-commands (TNC to Host):**
+
+Response codes: `response = command | 0x80`
+
+| Sub-cmd | Value | Data |
+|---------|-------|------|
+| Identity | 0x81 | pubkey(32) |
+| Random | 0x82 | random_bytes(1-64) |
+| Verify | 0x83 | result(1): 0=invalid, 1=valid |
+| Signature | 0x84 | signature(64) |
+| Encrypted | 0x85 | mac(2)+ciphertext |
+| Decrypted | 0x86 | plaintext |
+| SharedSecret | 0x87 | secret(32) |
+| HashResult | 0x88 | hash(32) |
+| Radio | 0x8B | freq(4)+bw(4)+sf(1)+cr(1) |
+| TxPower | 0x8C | power(1) |
+| CurrentRssi | 0x8D | rssi(1, signed) |
+| ChannelBusy | 0x8E | busy(1): 0=clear, 1=busy |
+| Airtime | 0x8F | millis(4) |
+| NoiseFloor | 0x90 | dBm(2, signed) |
+| Version | 0x91 | version(1)+reserved(1) |
+| Stats | 0x92 | rx(4)+tx(4)+errors(4) |
+| Battery | 0x93 | millivolts(2) |
+| MCUTemp | 0x94 | temp(2, signed, tenths °C) |
+| Sensors | 0x95 | CayenneLPP data |
+| DeviceName | 0x96 | name(UTF-8) |
+| Pong | 0x97 | — |
+| SignalReport | 0x9A | status(1) |
+| OK | 0xF0 | — |
+| Error | 0xF1 | error_code(1) |
+| TxDone | 0xF8 | result(1): 0=fail, 1=success |
+| RxMeta | 0xF9 | snr(1)+rssi(1) |
+
+### Error Codes
+
+| Code | Value | Description |
+|------|-------|-------------|
+| InvalidLength | 0x01 | Request data too short |
+| InvalidParam | 0x02 | Invalid parameter value |
+| NoCallback | 0x03 | Feature not available |
+| MacFailed | 0x04 | MAC verification failed |
+| UnknownCmd | 0x05 | Unknown sub-command |
+| EncryptFailed | 0x06 | Encryption failed |
+
+### Unsolicited Events
+
+**TxDone (0xF8)**: Sent after packet transmission. 0x01 = success, 0x00 = fail.
+
+**RxMeta (0xF9)**: Sent after each received data frame. Contains:
+- SNR: 1 byte, signed, × 4 for 0.25 dB precision
+- RSSI: 1 byte, signed, dBm
+
+### Data Format Notes
+
+- Maximum payload per frame: 255 bytes (MAX_TRANS_UNIT)
+- Frames larger than 255 unescaped bytes are silently dropped
+- All multi-byte integers are little-endian
+- Radio frequency is in Hz (e.g., 869618000)
+- Battery voltage in millivolts
+- MCU temperature in tenths of °C (e.g., 253 = 25.3°C)
+
+### Cross-References
+
+- [Section 1: Wire Format](01-wire-format.md) — Packet format within data frames
+- [Section 14: Cryptography](14-crypto.md) — Crypto operations via SetHardware
+- Test vectors: `corpus/kiss/`
+
+### Reference Implementation
+
+- `docs/kiss_modem_protocol.md` in the MeshCore repository
diff --git a/versions/v0.1.0/20-bridge-protocol.md b/versions/v0.1.0/20-bridge-protocol.md
new file mode 100644
index 0000000..ea62622
--- /dev/null
+++ b/versions/v0.1.0/20-bridge-protocol.md
@@ -0,0 +1,69 @@
+# MeshCore Protocol Specification
+
+## Section 20: Bridge Protocol
+
+### Overview
+
+The bridge protocol enables MeshCore packet forwarding over non-LoRa transports
+such as RS232 serial links and ESP-NOW WiFi. It adds a simple framing layer
+with a magic number and checksum around the raw MeshCore packet.
+
+### Frame Format
+
+```
+┌──────────────┬──────────────┬──────────────┬──────────────┐
+│ Magic (2B) │ Payload │ Checksum (2B)│ │
+│ 0xC03E │ (variable) │ Fletcher-16 │ │
+└──────────────┴──────────────┴──────────────┴──────────────┘
+```
+
+### Fields
+
+| Field | Size | Type | Description |
+|-------|------|------|-------------|
+| Magic | 2 bytes | uint16 | `0xC03E` — identifies the frame as a MeshCore bridge packet |
+| Payload | variable | raw | Raw MeshCore packet (as produced by `Packet::writeTo()`) |
+| Checksum | 2 bytes | Fletcher-16 | Fletcher-16 checksum over the payload |
+
+### RS232 Variant
+
+The RS232 variant adds a length field between the magic and payload:
+
+```
+┌──────────────┬──────────────┬──────────────┬──────────────┐
+│ Magic (2B) │ Length (2B) │ Payload │ Checksum (2B)│
+│ 0xC03E │ uint16_le │ (variable) │ Fletcher-16 │
+└──────────────┴──────────────┴──────────────┴──────────────┘
+```
+
+| Field | Size | Type | Description |
+|-------|------|------|-------------|
+| Length | 2 bytes | uint16_le | Length of the payload in bytes |
+
+### Fletcher-16 Checksum
+
+The Fletcher-16 checksum is computed over the payload bytes:
+
+```
+function fletcher16(data):
+ sum1 = 0
+ sum2 = 0
+ for byte in data:
+ sum1 = (sum1 + byte) mod 255
+ sum2 = (sum2 + sum1) mod 255
+ return (sum2 << 8) | sum1
+```
+
+### ESP-NOW Variant
+
+For ESP-NOW transport, the frame format is the same as the basic format (magic +
+payload + checksum) without the length field, since ESP-NOW provides its own
+length framing.
+
+### Cross-References
+
+- [Section 1: Wire Format](01-wire-format.md) — Packet format within payload
+
+### Reference Implementation
+
+- Bridge handling in MeshCore firmware (board-specific implementations)
diff --git a/versions/v0.1.0/SUMMARY.md b/versions/v0.1.0/SUMMARY.md
new file mode 100644
index 0000000..5df1005
--- /dev/null
+++ b/versions/v0.1.0/SUMMARY.md
@@ -0,0 +1,35 @@
+# Summary
+
+[Introduction](00-overview.md)
+
+# Wire Format
+
+- [Wire Format](01-wire-format.md)
+- [Header](02-header.md)
+- [Path](03-path.md)
+
+# Payloads
+
+- [ACK](04-payload-ack.md)
+- [Advertisement](05-payload-advert.md)
+- [Encrypted (REQ / RESPONSE / TXT_MSG)](06-payload-encrypted.md)
+- [Anonymous Request](07-payload-anon-req.md)
+- [Group (GRP_TXT / GRP_DATA)](08-payload-group.md)
+- [Path Return](09-payload-path.md)
+- [Trace](10-payload-trace.md)
+- [Multipart](11-payload-multipart.md)
+- [Control](12-payload-control.md)
+- [Raw Custom](13-payload-raw-custom.md)
+
+# Cryptography & Identity
+
+- [Cryptography](14-crypto.md)
+- [Identity](15-identity.md)
+- [Packet Hash](16-packet-hash.md)
+
+# Routing & Higher-Layer Protocols
+
+- [Routing](17-routing.md)
+- [Companion Protocol](18-companion-protocol.md)
+- [KISS Modem Protocol](19-kiss-protocol.md)
+- [Bridge Protocol](20-bridge-protocol.md)