spec: §10.7 — an exhausted RESOURCE_REQ may carry parts

A conformant sender fulfils any bundled `requested_map_hashes` AND
sends the RESOURCE_HMU. Verified against RNS 1.2.9 (`Resource.py:982-1071`):
part fulfilment runs unconditionally for every REQ, and the HMU branch
runs in addition. The reference receiver (`request_next`) routinely
bundles parts into an exhausted REQ. §10.7 now states the correct
rule; part-less exhausted REQs are an allowed receiver-side
simplification. `playbook.md` §7 records the matching fwdsvc
conformance bug (since fixed in `reticulum-forwarding-service` PR #10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rob 2026-05-19 17:19:56 -04:00 committed by GitHub
commit 375521c963
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 35 additions and 0 deletions

28
SPEC.md
View file

@ -2508,6 +2508,34 @@ The segment_index is `part_index // HASHMAP_MAX_LEN`. The receiver applies this
If the part_index doesn't land on a `HASHMAP_MAX_LEN` boundary, the sender treats it as a sequencing error and cancels the resource (`Resource.py:1043-1046`).
> **An exhausted RESOURCE_REQ MAY still carry parts — a conformant
> sender fulfils them *and* sends the RESOURCE_HMU.** When the
> `hashmap_exhausted_flag` is `0xFF`, the REQ body may still end with a
> non-empty `requested_map_hashes` trailer (§10.5). The sender MUST emit
> the requested part packets **and** the hashmap continuation; the two
> are independent. Serving the HMU is not a substitute for fulfilling
> the bundled part requests.
>
> In the RNS reference (`Resource.py:982-1071`, `request()` — verified
> against RNS 1.2.9, the current release), the part-fulfilment loop runs
> for every REQ regardless of the flag, and the `if wants_more_hashmap:`
> HMU branch runs afterward, in addition. The reference receiver
> (`request_next`, `Resource.py:931-981`) routinely produces this
> packet shape: as its window scan reaches the end of the known
> hashmap, it has already accumulated the still-outstanding part-hashes
> from the known region into `requested_hashes`, then sets the exhausted
> flag and stops — emitting
> `0xFF || last_map_hash(4) || resource_hash(32) || requested_map_hashes`.
>
> A receiver MAY keep part requests and hashmap pulls in separate REQ
> packets — emitting a **part-less** exhausted REQ
> (body `0xFF || last_map_hash(4) || resource_hash(32)`, no trailing
> map_hashes) purely to pull the continuation. This interoperates with
> every conformant sender. But it is a receiver-side simplification
> only: a sender MUST NOT assume peers do this, and MUST NOT skip part
> fulfilment for an exhausted REQ. A sender that does drops every
> bundled part silently — see `playbook.md` §7 (2026-05-19).
### 10.8 RESOURCE_PRF — final proof
When the receiver has assembled the full resource (`received_count == total_parts`), it runs `assemble()` (`Resource.py:672-726`):

View file

@ -191,6 +191,13 @@ Spec-only repos with a "the source is the source of truth" attitude die slowly b
Each entry: date, one-line symptom, spec section that governs it, one-line fix, one-sentence lesson. Append-only. New entries go at the top.
### 2026-05-19 — fwdsvc dropped parts bundled into an exhausted RESOURCE_REQ
- **Symptom:** Images relayed mobile→mobile through the Fwd service never arrive (whole LXMF message lost); mobile→Sideband through the same service works. Recipient logs hundreds of `RESOURCE chunk did not match any known hashmap slot`. Only triggers for resources large enough to need RESOURCE_HMU (>`HASHMAP_MAX_LEN` ≈ 74 parts).
- **Spec section:** §10.7. An `exhausted == 0xFF` RESOURCE_REQ MAY still carry a `requested_map_hashes` trailer, and a conformant sender serves those parts **and** the RESOURCE_HMU. The fwdsvc Go sender did `if req.Exhausted { serveHmu(req); continue }`, skipping `fulfillRequest` entirely — its own comment claimed this "mirrors upstream `Resource.request()`", but upstream (`Resource.py:982-1071`, checked against RNS 1.2.9, the current release) runs part fulfilment unconditionally and *then* sends the HMU. The mobile receiver flags `exhausted` on the first REQ of each hashmap window and bundles ~74 part-hashes with it — which a reference RNS sender honours — so fwdsvc served HMUs and dropped every bundled part across all 19 windows.
- **Fix:** `resource_sender.go` Run loop now runs `fulfillRequest` for every REQ, then `serveHmu` when `req.Exhausted`. It never skips part fulfilment. (`reticulum-forwarding-service`.)
- **Lesson:** mobile→Sideband "working" was a false green — a reference RNS receiver drains each segment before it flags exhausted, so on a clean link it sends part-less exhausted REQs and never exercised the bug. A lenient/conventional peer masks a divergence as effectively as a self-round-trip does (§5.1); the fault is receiver-dependent in a hop whose sender is constant. A `// mirrors upstream` comment proves nothing without the §2.4 / §6.2 check behind it.
### 2026-05-10 — LRPROOF signed_data signalling asymmetry
- **Symptom:** Mobile-app's Kotlin engine fails LRPROOF signature verification against fwdsvc on every attempt. Falls back to opportunistic; link delivery never works.