md-codec 0.34.0

Reference implementation of the Mnemonic Descriptor (MD) format for engravable BIP 388 wallet policy backups
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
//! Chunk header per SPEC v0.30 §2.2.
//!
//! Encodes the 37-bit chunked wire-format header. First-symbol layout
//! MSB-first: `[v3][v2][v1][v0][chunked]` (4-bit version + 1-bit chunked-flag).
//! Remainder: 20-bit chunk-set-id + 6-bit count-minus-1 + 6-bit index.
//! Total = 4 + 1 + 20 + 6 + 6 = 37 bits.
//!
//! v0.34.0: also hosts [`decode_with_correction`] — the BCH-error-correcting
//! decode entry point. Per chunk: parse → polymod-residue → (if non-zero)
//! call [`crate::bch_decode::decode_regular_errors`] → apply corrections →
//! re-encode → forward to [`reassemble`]. Atomic per plan §1 D28: any chunk
//! exceeding the BCH `t = 4` capacity fails the whole call without partial
//! output.

use crate::bitstream::{BitReader, BitWriter};
use crate::error::Error;
use crate::header::Header;

/// Wire header for a single chunk in a chunked v0.30 payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChunkHeader {
    /// Wire-format version (4 bits). v0.30 = 4.
    pub version: u8,
    /// 20-bit chunk-set identifier shared by all chunks in a set.
    pub chunk_set_id: u32,
    /// Total number of chunks in the set; valid range `1..=64`.
    pub count: u8,
    /// Zero-based index of this chunk within the set; must be `< count`.
    pub index: u8,
}

impl ChunkHeader {
    /// Encode the chunk header into `w` as 37 bits.
    ///
    /// Returns an error if `count`, `index`, or `chunk_set_id` are out of range.
    pub fn write(&self, w: &mut BitWriter) -> Result<(), Error> {
        if !(1..=64).contains(&(self.count as u32)) {
            return Err(Error::ChunkCountOutOfRange { count: self.count });
        }
        if self.index >= self.count {
            return Err(Error::ChunkIndexOutOfRange {
                index: self.index,
                count: self.count,
            });
        }
        if self.chunk_set_id >= (1 << 20) {
            return Err(Error::ChunkSetIdOutOfRange {
                id: self.chunk_set_id,
            });
        }
        w.write_bits(u64::from(self.version & 0b1111), 4);
        w.write_bits(1, 1); // chunked = 1
        w.write_bits(u64::from(self.chunk_set_id), 20);
        w.write_bits((self.count - 1) as u64, 6); // count-1 offset
        w.write_bits(u64::from(self.index), 6);
        Ok(())
    }

    /// Decode a chunk header (37 bits) from `r`.
    ///
    /// Returns [`Error::WireVersionMismatch`] if the 4-bit version field
    /// is not `WF_REDESIGN_VERSION` per SPEC §2.5 (e.g., v0.x chunked
    /// payloads where version=0 in the first 3 wire bits become version=0
    /// or version=1 under the v0.30 4-bit read depending on prior bits).
    /// Returns [`Error::ChunkHeaderChunkedFlagMissing`] if the chunked-flag
    /// bit is not set after the version check passes.
    pub fn read(r: &mut BitReader) -> Result<Self, Error> {
        let version = r.read_bits(4)? as u8;
        if version != Header::WF_REDESIGN_VERSION {
            return Err(Error::WireVersionMismatch { got: version });
        }
        let chunked = r.read_bits(1)? != 0;
        if !chunked {
            return Err(Error::ChunkHeaderChunkedFlagMissing);
        }
        let chunk_set_id = r.read_bits(20)? as u32;
        let count = (r.read_bits(6)? + 1) as u8;
        let index = r.read_bits(6)? as u8;
        Ok(Self {
            version,
            chunk_set_id,
            count,
            index,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::header::Header;

    #[test]
    fn chunk_header_round_trip() {
        let h = ChunkHeader {
            version: Header::WF_REDESIGN_VERSION,
            chunk_set_id: 0xABCDE,
            count: 3,
            index: 1,
        };
        let mut w = BitWriter::new();
        h.write(&mut w).unwrap();
        // 4 + 1 + 20 + 6 + 6 = 37 bits
        assert_eq!(w.bit_len(), 37);
        let bytes = w.into_bytes();
        let mut r = BitReader::new(&bytes);
        assert_eq!(ChunkHeader::read(&mut r).unwrap(), h);
    }

    #[test]
    fn chunk_header_count_64_round_trip() {
        let h = ChunkHeader {
            version: Header::WF_REDESIGN_VERSION,
            chunk_set_id: 0,
            count: 64,
            index: 63,
        };
        let mut w = BitWriter::new();
        h.write(&mut w).unwrap();
        let bytes = w.into_bytes();
        let mut r = BitReader::new(&bytes);
        assert_eq!(ChunkHeader::read(&mut r).unwrap(), h);
    }

    #[test]
    fn chunk_header_count_zero_rejected() {
        let h = ChunkHeader {
            version: Header::WF_REDESIGN_VERSION,
            chunk_set_id: 0,
            count: 0,
            index: 0,
        };
        let mut w = BitWriter::new();
        assert!(matches!(
            h.write(&mut w),
            Err(Error::ChunkCountOutOfRange { count: 0 })
        ));
    }

    /// SPEC v0.30 §2.5 v0.x rejection for chunk-header path. A wire crafted
    /// with version=0 and chunked-flag=1 (the v0.30-layout interpretation of
    /// what a v0.x chunked first-symbol becomes when reordered) must be
    /// rejected with `WireVersionMismatch { got: 0 }`.
    #[test]
    fn chunk_header_rejects_v0x_version() {
        // Construct first 5 bits MSB-first: [v3=0][v2=0][v1=0][v0=0][chunked=1]
        //   = 0b00001 (numeric 1)
        // Pad with 32 zero bits (chunk_set_id + count-1 + index) to reach
        // the full 37-bit chunk header length. 37 bits packed MSB-first into
        // 5 bytes (with 3 trailing zero bits beyond the bit limit).
        // Easier: use BitWriter to build the wire deterministically.
        let mut w = BitWriter::new();
        w.write_bits(0, 4); // version = 0 (v0.x)
        w.write_bits(1, 1); // chunked = 1
        w.write_bits(0, 20); // chunk_set_id
        w.write_bits(0, 6); // count-1
        w.write_bits(0, 6); // index
        assert_eq!(w.bit_len(), 37);
        let bytes = w.into_bytes();
        let mut r = BitReader::new(&bytes);
        assert!(matches!(
            ChunkHeader::read(&mut r),
            Err(Error::WireVersionMismatch { got: 0 })
        ));
    }
}

use crate::identity::Md1EncodingId;

/// Derive the 20-bit chunk-set-id from a [`Md1EncodingId`] by taking the
/// top 20 bits of the underlying 16-byte hash, MSB-first.
///
/// The chunk-set-id groups chunks belonging to the same encoded payload.
/// Returned value is in the range `0..=0xFFFFF`.
pub fn derive_chunk_set_id(id: &Md1EncodingId) -> u32 {
    // First 20 bits of Md1EncodingId[0..16], MSB-first.
    let bytes = id.as_bytes();
    ((bytes[0] as u32) << 12) | ((bytes[1] as u32) << 4) | ((bytes[2] as u32) >> 4)
}

#[cfg(test)]
mod chunk_set_id_tests {
    use super::*;

    #[test]
    fn derive_chunk_set_id_deterministic() {
        let mut bytes = [0u8; 16];
        bytes[0] = 0xab;
        bytes[1] = 0xcd;
        bytes[2] = 0xe1;
        bytes[3] = 0x23;
        let id = Md1EncodingId::new(bytes);
        let csid_a = derive_chunk_set_id(&id);
        let csid_b = derive_chunk_set_id(&id);
        assert_eq!(csid_a, csid_b);
    }

    #[test]
    fn derive_chunk_set_id_msb_first_extraction() {
        // bytes[0]=0xAB, [1]=0xCD, [2]=0xEF: top 20 bits = 0xABCDE
        let mut bytes = [0u8; 16];
        bytes[0] = 0xAB;
        bytes[1] = 0xCD;
        bytes[2] = 0xEF;
        let id = Md1EncodingId::new(bytes);
        assert_eq!(derive_chunk_set_id(&id), 0xABCDE);
    }
}

use crate::encode::Descriptor;

/// Threshold (in payload bits) above which chunking is required. Derived from
/// codex32 *regular*-form's 80-char data-part limit (per BIP 93): 3 HRP + 1
/// separator + 64 data + 13 checksum (see `codex32::REGULAR_CHECKSUM_SYMBOLS`).
/// Long-form codex32 was dropped in v0.12.0, so the legal data-symbol budget
/// per chunk is 64 = 320 bits.
/// Encoders attempt single-string emit first; if the codex32 wrapping reports
/// "too long", split into N chunks.
pub const SINGLE_STRING_PAYLOAD_BIT_LIMIT: usize = 64 * 5;

/// Split a [`Descriptor`] into N codex32 md1 strings, each carrying a chunk
/// header and a slice of the canonical payload.
///
/// Algorithm:
/// 1. Encode the full payload (`encode_payload`).
/// 2. Compute [`crate::identity::Md1EncodingId`]; derive `ChunkSetId`.
/// 3. Choose chunk count N such that each chunk fits in codex32 long form
///    after adding the 37-bit chunk header.
/// 4. Split the payload into N approximately-equal byte-boundary slices.
/// 5. For each chunk i: prepend chunk header (37 bits), wrap via codex32 with
///    the chunked-flag bit set, emit md1 string.
///
/// Note: `bytes_per_chunk` could be 0 if `payload_bytes` were empty, but the
/// encoder validates `n ≥ 1` so the payload is always non-empty.
pub fn split(d: &Descriptor) -> Result<Vec<String>, Error> {
    use crate::bitstream::BitWriter;
    use crate::encode::encode_payload;
    use crate::identity::compute_md1_encoding_id;

    let (payload_bytes, _payload_bits) = encode_payload(d)?;

    // Compute ChunkSetId from full-encoding hash.
    let md1_id = compute_md1_encoding_id(d)?;
    let chunk_set_id = derive_chunk_set_id(&md1_id);

    // Choose chunk count from payload byte count (≤7 bits of trailing
    // codex32-padding are tolerated by the reassembled-stream TLV-rollback).
    let payload_bit_count_for_sizing = payload_bytes.len() * 8;
    let chunks_needed = payload_bit_count_for_sizing.div_ceil(SINGLE_STRING_PAYLOAD_BIT_LIMIT);
    if chunks_needed > 64 {
        return Err(Error::ChunkCountExceedsMax {
            needed: chunks_needed,
        });
    }
    let count: u8 = if chunks_needed == 0 {
        1
    } else {
        chunks_needed as u8
    };

    // Split payload into `count` byte-boundary slices.
    let bytes_per_chunk = payload_bytes.len().div_ceil(count as usize);

    let mut chunks = Vec::with_capacity(count as usize);
    for index in 0..count {
        let start_byte = (index as usize) * bytes_per_chunk;
        let end_byte = ((index as usize + 1) * bytes_per_chunk).min(payload_bytes.len());
        let chunk_payload_bytes = &payload_bytes[start_byte..end_byte];

        // Build per-chunk wire: 37-bit chunk header + chunk-payload bytes
        // (full 8 bits per byte, no further fractional content). Chunk's
        // exact bit count = 37 + 8 × |chunk_payload_bytes|.
        let header = ChunkHeader {
            version: Header::WF_REDESIGN_VERSION,
            chunk_set_id,
            count,
            index,
        };
        let mut w = BitWriter::new();
        header.write(&mut w)?;
        for byte in chunk_payload_bytes {
            w.write_bits(u64::from(*byte), 8);
        }
        let chunk_bit_count = 37 + 8 * chunk_payload_bytes.len();
        let bytes = w.into_bytes();
        let s = crate::codex32::wrap_payload(&bytes, chunk_bit_count)?;
        chunks.push(s);
    }
    Ok(chunks)
}

use crate::decode::decode_payload;

/// Reassemble a [`Descriptor`] from N md1 codex32 strings.
///
/// Algorithm:
/// 1. Unwrap each string via the codex32 layer (verifies BCH per chunk).
/// 2. Parse the 37-bit chunk header from each.
/// 3. Validate consistency: same version, chunk_set_id, count.
/// 4. Sort by index; verify `0..count-1` with no gaps.
/// 5. Concatenate per-chunk payload bytes.
/// 6. Decode the reassembled payload via [`decode_payload`].
/// 7. Verify the reassembled payload's derived chunk-set-id matches the
///    chunk-set-id present in every chunk header (cross-chunk integrity).
pub fn reassemble(strings: &[&str]) -> Result<Descriptor, Error> {
    use crate::bitstream::BitReader;
    use crate::codex32::unwrap_string;
    use crate::identity::compute_md1_encoding_id;

    if strings.is_empty() {
        return Err(Error::ChunkSetEmpty);
    }

    // Unwrap each, parse 37-bit chunk header, then read whole payload bytes.
    // Use the symbol-aligned bit count returned by `unwrap_string` (NOT
    // `bytes.len() * 8`, which would over-estimate by up to 7 bits and break
    // round-trip for chunks where symbol-padding plus byte-padding crosses a
    // byte boundary — e.g. N=3, N=8, etc.).
    let mut parsed: Vec<(ChunkHeader, Vec<u8>)> = Vec::with_capacity(strings.len());
    for s in strings {
        let (bytes, symbol_aligned_bit_count) = unwrap_string(s)?;
        let mut r = BitReader::with_bit_limit(&bytes, symbol_aligned_bit_count);
        let header = ChunkHeader::read(&mut r)?;
        // Per encoder contract: chunk wire is exactly 37 + 8N bits. The
        // symbol-aligned bit count is `ceil((37+8N)/5) * 5`, which is in
        // [37+8N, 37+8N+4]. So `(symbol_aligned_bit_count - 37) / 8`
        // (floor) recovers exactly N.
        let payload_byte_count = (symbol_aligned_bit_count - 37) / 8;
        let mut chunk_payload_bytes = Vec::with_capacity(payload_byte_count);
        for _ in 0..payload_byte_count {
            let v = r.read_bits(8)? as u8;
            chunk_payload_bytes.push(v);
        }
        // Trailing ≤4 symbol-padding bits remain in r; discard.
        parsed.push((header, chunk_payload_bytes));
    }

    // Validate consistency.
    let (h0, _) = &parsed[0];
    let expected_count = h0.count;
    let expected_csid = h0.chunk_set_id;
    let expected_version = h0.version;
    for (h, _) in &parsed {
        if h.count != expected_count
            || h.chunk_set_id != expected_csid
            || h.version != expected_version
        {
            return Err(Error::ChunkSetInconsistent);
        }
    }
    if parsed.len() != expected_count as usize {
        return Err(Error::ChunkSetIncomplete {
            got: parsed.len(),
            expected: expected_count as usize,
        });
    }

    // Sort by index; verify 0..count-1 with no gaps.
    parsed.sort_by_key(|(h, _)| h.index);
    for (i, (h, _)) in parsed.iter().enumerate() {
        if h.index as usize != i {
            return Err(Error::ChunkIndexGap {
                expected: i as u8,
                got: h.index,
            });
        }
    }

    // Concatenate chunk payload bytes.
    let mut full_bytes = Vec::new();
    for (_, chunk_bytes) in &parsed {
        full_bytes.extend_from_slice(chunk_bytes);
    }

    // Decode payload. bit_len = bytes.len() * 8; TLV-rollback handles trailing padding.
    let descriptor = decode_payload(&full_bytes, full_bytes.len() * 8)?;

    // Cross-chunk integrity check.
    let md1_id = compute_md1_encoding_id(&descriptor)?;
    let derived_csid = derive_chunk_set_id(&md1_id);
    if derived_csid != expected_csid {
        return Err(Error::ChunkSetIdMismatch {
            expected: expected_csid,
            derived: derived_csid,
        });
    }

    Ok(descriptor)
}

// ---------------------------------------------------------------------------
// v0.34.0: BCH-error-correcting decode (plan §1 D22 + §2.B.1).
// ---------------------------------------------------------------------------

/// Per-correction report emitted by [`decode_with_correction`]. One entry
/// per repaired character. `position` is 0-indexed into the codex32
/// data-part (i.e. the characters following the `md1` HRP + separator);
/// `was` is the original (corrupted) char from the input; `now` is the
/// corrected char.
///
/// Atomic per plan §1 D28: when [`decode_with_correction`] succeeds the
/// returned vector aggregates corrections across all chunks; chunks that
/// were already valid contribute nothing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CorrectionDetail {
    /// 0-indexed position of the chunk in the caller's `&[&str]` slice.
    pub chunk_index: usize,
    /// 0-indexed position of the corrected character within the chunk's
    /// data-part (post-HRP-and-separator).
    pub position: usize,
    /// The original (corrupted) character at this position.
    pub was: char,
    /// The corrected character at this position.
    pub now: char,
}

/// Local codex32 alphabet (BIP 173 lowercase). Each char = one 5-bit
/// symbol. Duplicated from `codex32.rs` (which keeps it private) so this
/// module doesn't widen the codex32 public surface; the mapping is
/// constant per BIP 173.
const CODEX32_ALPHABET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";

/// BIP 173 separator character between HRP and data-part for md1 strings.
const HRP_PREFIX: &str = "md1";

/// Parse a single md1 chunk into its 5-bit data-part symbol vector.
/// Returns the data-with-checksum symbols (i.e. all symbols after `md1`).
/// Visual separators (whitespace + `-`) are stripped per codex32 convention.
fn parse_chunk_symbols(chunk: &str, chunk_index: usize) -> Result<Vec<u8>, Error> {
    let lower = chunk.to_ascii_lowercase();
    if !lower.starts_with(HRP_PREFIX) {
        return Err(Error::Codex32DecodeError(format!(
            "chunk {chunk_index}: string does not start with HRP md1"
        )));
    }
    let rest = &lower[HRP_PREFIX.len()..];
    let mut symbols: Vec<u8> = Vec::with_capacity(rest.len());
    for c in rest.chars() {
        if c.is_whitespace() || c == '-' {
            continue;
        }
        let lc = c as u8;
        let sym = CODEX32_ALPHABET
            .iter()
            .position(|&b| b == lc)
            .ok_or_else(|| {
                Error::Codex32DecodeError(format!(
                    "chunk {chunk_index}: character {c:?} not in codex32 alphabet"
                ))
            })? as u8;
        symbols.push(sym);
    }
    Ok(symbols)
}

/// Re-encode a 5-bit data-part symbol vector as a complete md1 string.
fn encode_chunk_string(data_with_checksum: &[u8]) -> String {
    let mut out = String::with_capacity(HRP_PREFIX.len() + data_with_checksum.len());
    out.push_str(HRP_PREFIX);
    for &v in data_with_checksum {
        out.push(CODEX32_ALPHABET[(v & 0x1F) as usize] as char);
    }
    out
}

/// BCH-error-correcting decode for a chunk-set of md1 strings.
///
/// Per plan §1 Q1 lock — full-decode semantics: this is the single entry
/// point that callers needing both "did anything get repaired?" AND "the
/// fully-decoded descriptor" should use.
///
/// Algorithm:
/// 1. For each chunk, parse the data-part into 5-bit symbols and compute
///    the BCH polymod residue (`hrp_expand("md") || data_with_checksum`)
///    XOR'd against [`crate::bch::MD_REGULAR_CONST`].
/// 2. Residue `== 0` ⇒ chunk passes through unchanged.
/// 3. Residue `!= 0` ⇒ invoke
///    [`crate::bch_decode::decode_regular_errors`]. If `None`, return
///    `Err(Error::TooManyErrors { chunk_index, bound: 8 })` per plan §2.B.4
///    D29 error-mapping table.
/// 4. Apply corrections to the chunk's symbol vector, re-encode as a
///    fresh md1 string, and record one [`CorrectionDetail`] per repaired
///    character.
/// 5. After ALL chunks have been processed (any single uncorrectable
///    chunk aborts atomically per plan §1 D28), forward the corrected
///    chunk strings to [`reassemble`] to produce the [`Descriptor`].
///
/// On success returns `(Descriptor, Vec<CorrectionDetail>)`. The
/// correction-detail vector is in (`chunk_index` ascending,
/// `position` ascending within chunk) order; an empty vector means every
/// input chunk was already a valid codeword.
pub fn decode_with_correction(
    strings: &[&str],
) -> Result<(Descriptor, Vec<CorrectionDetail>), Error> {
    if strings.is_empty() {
        return Err(Error::ChunkSetEmpty);
    }

    let mut corrected_strings: Vec<String> = Vec::with_capacity(strings.len());
    let mut all_details: Vec<CorrectionDetail> = Vec::new();

    for (chunk_index, chunk) in strings.iter().enumerate() {
        let symbols = parse_chunk_symbols(chunk, chunk_index)?;

        // Polymod residue against md1's target constant.
        let mut input = crate::bch::hrp_expand("md");
        input.extend_from_slice(&symbols);
        let residue = crate::bch::polymod_run(&input) ^ crate::bch::MD_REGULAR_CONST;

        if residue == 0 {
            // Already valid — pass through unchanged.
            corrected_strings.push((*chunk).to_string());
            continue;
        }

        // Attempt BCH correction.
        let (positions, magnitudes) =
            crate::bch_decode::decode_regular_errors(residue, symbols.len()).ok_or(
                Error::TooManyErrors {
                    chunk_index,
                    bound: 8,
                },
            )?;

        // Apply corrections; record (was, now) chars per position.
        let mut corrected = symbols.clone();
        let mut details: Vec<CorrectionDetail> = Vec::with_capacity(positions.len());
        for (&pos, &mag) in positions.iter().zip(&magnitudes) {
            if pos >= corrected.len() {
                // Defensive: chien_search bounded pos to [0, L); but a
                // pathological 5+-error pattern could in principle skirt
                // that. Treat as uncorrectable per Q2 absorption rules.
                return Err(Error::TooManyErrors {
                    chunk_index,
                    bound: 8,
                });
            }
            let was_byte = corrected[pos];
            let now_byte = was_byte ^ mag;
            let was = CODEX32_ALPHABET[(was_byte & 0x1F) as usize] as char;
            let now = CODEX32_ALPHABET[(now_byte & 0x1F) as usize] as char;
            details.push(CorrectionDetail {
                chunk_index,
                position: pos,
                was,
                now,
            });
            corrected[pos] = now_byte;
        }

        // Defensive re-verify (catches pathological 5+-error patterns
        // that happen to produce a degree-≤4 locator with 4 valid roots).
        let mut verify_input = crate::bch::hrp_expand("md");
        verify_input.extend_from_slice(&corrected);
        let verify_residue =
            crate::bch::polymod_run(&verify_input) ^ crate::bch::MD_REGULAR_CONST;
        if verify_residue != 0 {
            return Err(Error::TooManyErrors {
                chunk_index,
                bound: 8,
            });
        }

        corrected_strings.push(encode_chunk_string(&corrected));
        all_details.extend(details);
    }

    // Hand corrected strings to the existing reassembly path.
    let corrected_refs: Vec<&str> = corrected_strings.iter().map(|s| s.as_str()).collect();
    let descriptor = reassemble(&corrected_refs)?;
    Ok((descriptor, all_details))
}