Skip to main content

btctax_forms/
verify.rs

1//! **Geometric, map-INDEPENDENT read-back.** The naive "re-read each value through the same map"
2//! is circular — a swapped-column map would pass. Instead we re-derive the column-x / row-y bands
3//! straight from the bundled PDF's widget `/Rect`s (structural grouping by `Row{n}` subform, column
4//! order by x-position, row order by y-position) and assert that every value we wrote landed in the
5//! band its LOGICAL cell demands. A mis-mapped cell puts the value in the wrong band → we fail
6//! closed. We also assert NO field outside the authorized set carries a value. **The map is what we
7//! distrust; the PDF's geometry is the oracle.**
8
9use crate::error::FormsError;
10use crate::pdf::{button_on_states, checkbox_on, text_value, Field};
11use lopdf::Document;
12use std::collections::{HashMap, HashSet};
13
14const EPS: f32 = 1.0;
15
16/// Where a written value is supposed to land.
17#[derive(Debug, Clone)]
18pub enum Geo {
19    /// A data-grid cell: 0-based row (top→bottom) and column (a=0 … h=7).
20    Data {
21        /// 0-based data row (Row1 = 0, the topmost).
22        row: usize,
23        /// 0-based column (a=0, b=1, … h=7).
24        col: usize,
25    },
26    /// A per-part totals-row cell in column `col`, which must sit BELOW the data grid.
27    Total {
28        /// 0-based column (d=3, e=4, g=6, h=7).
29        col: usize,
30    },
31    /// A checkbox / non-grid value — excluded from the column geometry, still in the no-unmapped set.
32    Check,
33}
34
35/// One authorized write: the field we set, and the logical cell it must occupy.
36#[derive(Debug, Clone)]
37pub struct Placement {
38    /// Fully-qualified field name that was written.
39    pub fqn: String,
40    /// The logical cell the value must land in.
41    pub geo: Geo,
42}
43
44fn page_of(fqn: &str) -> usize {
45    if fqn.contains("Page2") {
46        1
47    } else {
48        0
49    }
50}
51
52/// The row-group key of a data-grid cell: the subform component **immediately after the table token**
53/// (`Row1[0]` on Form 8949, `Line3[0]`/`Line1b[0]` on the 2017 Schedule D whose grid rows are named
54/// `Line{n}`, not `Row{n}`). Rows are later ordered by geometry (widget y-center), so the key need
55/// only be unique per row, not numeric. Returns `None` for a non-grid field.
56fn row_key(fqn: &str, table_token: &str) -> Option<String> {
57    let after = fqn.split_once(table_token)?.1; // e.g. "[0].Row1[0].f1_3[0]"
58    let mut it = after.split('.');
59    it.next()?; // the table token's own "[0]"
60    let key = it.next()?; // "Row1[0]" / "Line3[0]"
61    it.next()?; // require a leaf beyond the row subform (so the table node itself is skipped)
62    Some(key.to_string())
63}
64
65/// Column-x and row-y bands re-derived from one page's data grid — the geometry oracle.
66struct GridBands {
67    /// Per-column-index (0..=7) x-interval `(min_x0, max_x1)`, ordered left→right by geometry.
68    col_x: Vec<(f32, f32)>,
69    /// Per-row-index (0.., top→bottom by geometry) y-interval `(y0, y1)`.
70    row_y: Vec<(f32, f32)>,
71    /// The lowest data-row bottom edge — the totals row must sit below this.
72    min_row_y0: f32,
73}
74
75fn derive_bands(fields: &[Field], page: usize, table_token: &str) -> Result<GridBands, FormsError> {
76    // Group this page's data-grid widgets by structural row subform (independent of the map).
77    let mut rows: HashMap<String, Vec<&Field>> = HashMap::new();
78    for f in fields {
79        if page_of(&f.fqn) == page && f.fqn.contains(table_token) && f.rect.is_some() {
80            if let Some(k) = row_key(&f.fqn, table_token) {
81                rows.entry(k).or_default().push(f);
82            }
83        }
84    }
85    if rows.is_empty() {
86        return Err(FormsError::Structure(format!(
87            "page {page}: no data-grid widgets found for band derivation"
88        )));
89    }
90    // Order rows top→bottom by widget y-center (geometry, NOT the row subform label).
91    let mut ordered: Vec<(String, Vec<&Field>)> = rows.into_iter().collect();
92    let row_cy =
93        |v: &[&Field]| -> f32 { v.iter().filter_map(|f| f.cy()).sum::<f32>() / (v.len() as f32) };
94    ordered.sort_by(|a, b| row_cy(&b.1).partial_cmp(&row_cy(&a.1)).unwrap());
95
96    let ncols = ordered[0].1.len();
97    let mut col_x: Vec<(f32, f32)> = vec![(f32::INFINITY, f32::NEG_INFINITY); ncols];
98    let mut row_y: Vec<(f32, f32)> = Vec::with_capacity(ordered.len());
99    let mut min_row_y0 = f32::INFINITY;
100
101    for (_n, mut widgets) in ordered {
102        if widgets.len() != ncols {
103            return Err(FormsError::Structure(format!(
104                "page {page}: inconsistent column count ({} vs {ncols})",
105                widgets.len()
106            )));
107        }
108        // Column order is defined by x-position, purely from geometry.
109        widgets.sort_by(|a, b| a.rect.unwrap()[0].partial_cmp(&b.rect.unwrap()[0]).unwrap());
110        let mut y0 = f32::INFINITY;
111        let mut y1 = f32::NEG_INFINITY;
112        for (c, w) in widgets.iter().enumerate() {
113            let r = w.rect.unwrap();
114            col_x[c].0 = col_x[c].0.min(r[0]);
115            col_x[c].1 = col_x[c].1.max(r[2]);
116            y0 = y0.min(r[1]);
117            y1 = y1.max(r[3]);
118        }
119        min_row_y0 = min_row_y0.min(y0);
120        row_y.push((y0, y1));
121    }
122    Ok(GridBands {
123        col_x,
124        row_y,
125        min_row_y0,
126    })
127}
128
129/// Whether a coordinate lies within a band (with float tolerance).
130pub fn in_band(v: f32, band: (f32, f32)) -> bool {
131    v >= band.0 - EPS && v <= band.1 + EPS
132}
133
134/// Left→right x-bands of a table's amount columns, re-derived from the PDF geometry (independent of
135/// any map). Used by the Schedule D read-back to catch a mis-mapped d/e/g/h column.
136pub fn column_x_bands(
137    fields: &[Field],
138    page: usize,
139    table_token: &str,
140) -> Result<Vec<(f32, f32)>, FormsError> {
141    Ok(derive_bands(fields, page, table_token)?.col_x)
142}
143
144/// Verify a Form 8949 fill: (1) every written value lands in the geometrically-expected column/row
145/// band, and (2) no unmapped field carries a value. Fails closed. `table_token` is the per-year
146/// data-grid subform token (`Table_Line1` for 2024, `Table_Line1_Part` for 2025).
147pub fn verify_8949(
148    doc: &Document,
149    fields: &[Field],
150    placements: &[Placement],
151    table_token: &str,
152) -> Result<(), FormsError> {
153    let index: HashMap<&str, &Field> = fields.iter().map(|f| (f.fqn.as_str(), f)).collect();
154    // Independently derive the geometry oracle for every page a placement touches (0 = Part I,
155    // 1 = Part II) — up front, so a mis-mapped cell cannot dodge the check.
156    let mut pages: Vec<usize> = placements
157        .iter()
158        .filter(|p| matches!(p.geo, Geo::Data { .. } | Geo::Total { .. }))
159        .map(|p| page_of(&p.fqn))
160        .collect();
161    pages.sort_unstable();
162    pages.dedup();
163    let mut bands: HashMap<usize, GridBands> = HashMap::new();
164    for page in pages {
165        bands.insert(page, derive_bands(fields, page, table_token)?);
166    }
167
168    for p in placements {
169        let field = index
170            .get(p.fqn.as_str())
171            .ok_or_else(|| FormsError::MapFieldMissing(p.fqn.clone()))?;
172        match &p.geo {
173            Geo::Check => {} // geometry N/A; only participates in the no-unmapped scan below
174            Geo::Data { row, col } => {
175                let page = page_of(&p.fqn);
176                let b = &bands[&page];
177                let cx = field.cx().ok_or_else(|| miss_rect(&p.fqn))?;
178                let cy = field.cy().ok_or_else(|| miss_rect(&p.fqn))?;
179                let colb = *b.col_x.get(*col).ok_or_else(|| {
180                    FormsError::Geometry(format!("column {col} out of range on page {page}"))
181                })?;
182                let rowb = *b.row_y.get(*row).ok_or_else(|| {
183                    FormsError::Geometry(format!("row {row} out of range on page {page}"))
184                })?;
185                if !in_band(cx, colb) {
186                    return Err(FormsError::Geometry(format!(
187                        "{}: x-center {cx:.1} not in column {col} band {colb:?} (mis-mapped column)",
188                        p.fqn
189                    )));
190                }
191                if !in_band(cy, rowb) {
192                    return Err(FormsError::Geometry(format!(
193                        "{}: y-center {cy:.1} not in row {row} band {rowb:?} (mis-mapped row)",
194                        p.fqn
195                    )));
196                }
197            }
198            Geo::Total { col } => {
199                let page = page_of(&p.fqn);
200                let b = &bands[&page];
201                let cx = field.cx().ok_or_else(|| miss_rect(&p.fqn))?;
202                let cy = field.cy().ok_or_else(|| miss_rect(&p.fqn))?;
203                let colb = *b.col_x.get(*col).ok_or_else(|| {
204                    FormsError::Geometry(format!("total column {col} out of range on page {page}"))
205                })?;
206                if !in_band(cx, colb) {
207                    return Err(FormsError::Geometry(format!(
208                        "{}: total x-center {cx:.1} not in column {col} band {colb:?}",
209                        p.fqn
210                    )));
211                }
212                if cy >= b.min_row_y0 {
213                    return Err(FormsError::Geometry(format!(
214                        "{}: total y-center {cy:.1} is not below the data grid (>= {:.1})",
215                        p.fqn, b.min_row_y0
216                    )));
217                }
218            }
219        }
220    }
221    no_unmapped_filled(doc, fields, placements)
222}
223
224fn miss_rect(fqn: &str) -> FormsError {
225    FormsError::Geometry(format!("{fqn}: field has no /Rect to verify"))
226}
227
228/// Assert that every field carrying a value is in the authorized (placement) set.
229pub fn no_unmapped_filled(
230    doc: &Document,
231    fields: &[Field],
232    placements: &[Placement],
233) -> Result<(), FormsError> {
234    let allowed: HashSet<&str> = placements.iter().map(|p| p.fqn.as_str()).collect();
235    assert_only_filled(doc, fields, &allowed)
236}
237
238/// The form-agnostic core of the no-unmapped guard: every filled field must be in `allowed`. Shared by
239/// the SP1 [`no_unmapped_filled`] and the SP2 flat-form verifier.
240pub fn assert_only_filled(
241    doc: &Document,
242    fields: &[Field],
243    allowed: &HashSet<&str>,
244) -> Result<(), FormsError> {
245    for f in fields {
246        let filled = if f.is_button {
247            checkbox_on(doc, f.id).is_some()
248        } else {
249            text_value(doc, f.id).is_some_and(|s| !s.is_empty())
250        };
251        if filled && !allowed.contains(f.fqn.as_str()) {
252            return Err(FormsError::UnmappedField(f.fqn.clone()));
253        }
254    }
255    Ok(())
256}
257
258// ── [★ R0-C3] SP2 per-form geometric oracle for the FLAT (non-grid) forms ─────────────────────────
259//
260// Schedule SE / Form 8283 / Form 1040 have no `Row{n}` data-grid subform, so the SP1 grid oracle does
261// not fit. Instead this oracle asserts, map-INDEPENDENTLY, that each written value's widget landed:
262//   (1) in its logical column's hand-pinned x-cluster (measured from the blank PDF; catches a
263//       cross-column swap, e.g. SE line 12 (amount) ↔ line 13 (mid));
264//   (2) in strictly-descending center-y within a logically ordered sequence (catches a same-column
265//       swap, e.g. SE 10 ↔ 11) — asserted PER descent GROUP so 8283's two-table columns [R0-M1] each
266//       descend within their own field set;
267//   (3) for the 1040 Digital-Asset question, that the "Yes" value is the LEFT member of the top-most
268//       same-y `/Btn` pair whose on-states are exactly {`/1`,`/2`} (a [`topmost_yes_no_pair`] check);
269//   (4) on the expected page; 1-pt preprinted-constant spacer fields are never map targets.
270// The map is what we distrust — a mis-mapped cell lands in the wrong cluster / breaks monotonicity and
271// FAILS CLOSED. `assert_only_filled` still guards against any stray write.
272
273/// One authorized SP2 write. `col` indexes a hand-pinned column-x cluster (`None` = geometry-exempt,
274/// e.g. a wide free-text identity field); `descent` = `(group, ordinal)` for per-group strictly-
275/// descending-y ordering (`None` = not in any ordered sequence); `check` marks a checkbox (only in the
276/// no-unmapped scan + any same-y-pair predicate).
277#[derive(Debug, Clone)]
278pub struct FlatPlacement {
279    /// Fully-qualified field name that was written.
280    pub fqn: String,
281    /// 0-based page the write must land on.
282    pub page: usize,
283    /// Logical column index into the per-form hand-pinned x-cluster table (`None` = not column-checked).
284    pub col: Option<usize>,
285    /// `(descent_group, ordinal)` — within a group, center-y must strictly decrease as ordinal rises.
286    pub descent: Option<(u32, u32)>,
287    /// `true` iff this is a checkbox (geometry-exempt; still in the no-unmapped set).
288    pub check: bool,
289}
290
291impl FlatPlacement {
292    /// A column-checked, descent-participating money/text cell.
293    pub fn cell(fqn: impl Into<String>, page: usize, col: usize, grp: u32, ord: u32) -> Self {
294        Self {
295            fqn: fqn.into(),
296            page,
297            col: Some(col),
298            descent: Some((grp, ord)),
299            check: false,
300        }
301    }
302    /// A column-checked cell that does NOT participate in any descent sequence.
303    pub fn col_only(fqn: impl Into<String>, page: usize, col: usize) -> Self {
304        Self {
305            fqn: fqn.into(),
306            page,
307            col: Some(col),
308            descent: None,
309            check: false,
310        }
311    }
312    /// A geometry-exempt write (wide free-text identity field): page-checked + no-unmapped only.
313    pub fn free(fqn: impl Into<String>, page: usize) -> Self {
314        Self {
315            fqn: fqn.into(),
316            page,
317            col: None,
318            descent: None,
319            check: false,
320        }
321    }
322    /// A geometry-exempt write that ALSO participates in a per-group strictly-descending-y ordinal
323    /// sequence — for a free-text field SEQUENCE with no column geometry to check but a real physical
324    /// top-to-bottom order the fill assumes (e.g. Form 8275's Part IV continuation lines, written in
325    /// `part_iv_continuation` array order): a map that reordered the array, or a bundled asset whose
326    /// lines were not actually laid out top-to-bottom in that order, fails closed here instead of
327    /// silently landing text out of sequence.
328    pub fn free_ordered(fqn: impl Into<String>, page: usize, grp: u32, ord: u32) -> Self {
329        Self {
330            fqn: fqn.into(),
331            page,
332            col: None,
333            descent: Some((grp, ord)),
334            check: false,
335        }
336    }
337    /// A checkbox: no-unmapped only (+ any same-y-pair predicate the caller runs).
338    pub fn check(fqn: impl Into<String>, page: usize) -> Self {
339        Self {
340            fqn: fqn.into(),
341            page,
342            col: None,
343            descent: None,
344            check: true,
345        }
346    }
347}
348
349/// Verify a flat-form fill: page membership + hand-pinned column-x membership + per-group ordinal-y
350/// descent + the no-unmapped scan. `clusters` is the per-form hand-pinned logical-column → `(min_x0,
351/// max_x1)` table (measured from the blank PDF). Fails closed.
352pub fn verify_flat(
353    doc: &Document,
354    fields: &[Field],
355    placements: &[FlatPlacement],
356    clusters: &[(f32, f32)],
357) -> Result<(), FormsError> {
358    let index: HashMap<&str, &Field> = fields.iter().map(|f| (f.fqn.as_str(), f)).collect();
359
360    // (4)+(1) page membership + column-x membership.
361    for p in placements {
362        let field = index
363            .get(p.fqn.as_str())
364            .ok_or_else(|| FormsError::MapFieldMissing(p.fqn.clone()))?;
365        if page_of(&p.fqn) != p.page {
366            return Err(FormsError::Geometry(format!(
367                "{}: field is on page {} but placement expected page {}",
368                p.fqn,
369                page_of(&p.fqn),
370                p.page
371            )));
372        }
373        if let Some(col) = p.col {
374            let cx = field.cx().ok_or_else(|| miss_rect(&p.fqn))?;
375            let cluster = *clusters.get(col).ok_or_else(|| {
376                FormsError::Geometry(format!(
377                    "column {col} out of range (clusters={})",
378                    clusters.len()
379                ))
380            })?;
381            if !in_band(cx, cluster) {
382                return Err(FormsError::Geometry(format!(
383                    "{}: x-center {cx:.1} not in column {col} cluster {cluster:?} (mis-mapped column)",
384                    p.fqn
385                )));
386            }
387        }
388    }
389
390    // (2) ordinal-y descent, per group.
391    let mut groups: HashMap<u32, Vec<(u32, f32, &str)>> = HashMap::new();
392    for p in placements {
393        if let Some((grp, ord)) = p.descent {
394            let cy = index[p.fqn.as_str()]
395                .cy()
396                .ok_or_else(|| miss_rect(&p.fqn))?;
397            groups
398                .entry(grp)
399                .or_default()
400                .push((ord, cy, p.fqn.as_str()));
401        }
402    }
403    for seq in groups.values_mut() {
404        seq.sort_by_key(|(ord, _, _)| *ord);
405        for w in seq.windows(2) {
406            // Earlier ordinal must sit strictly ABOVE (higher center-y) the next.
407            if w[0].1 <= w[1].1 + EPS {
408                return Err(FormsError::Geometry(format!(
409                    "ordinal-y descent broken: {} (y {:.1}) is not strictly above {} (y {:.1}) — mis-mapped row/line",
410                    w[0].2, w[0].1, w[1].2, w[1].1
411                )));
412            }
413        }
414    }
415
416    // (5) /MaxLen — a value the cell physically cannot hold. Checked on the READ-BACK (the serialized
417    // bytes), like every other leg of the oracle, and against the PDF's OWN declared capacity rather
418    // than anything the map asserts. A viewer would silently truncate an over-long value; we refuse.
419    for p in placements {
420        let field = index[p.fqn.as_str()];
421        let (Some(max_len), Some(v)) = (field.max_len, text_value(doc, field.id)) else {
422            continue;
423        };
424        // Count CHARACTERS, not bytes — /MaxLen is in characters, and a name can be non-ASCII.
425        let len = v.chars().count();
426        if len > max_len {
427            return Err(FormsError::CellOverflow {
428                fqn: p.fqn.clone(),
429                max_len,
430                len,
431            });
432        }
433    }
434
435    // (3) no unmapped write.
436    let allowed: HashSet<&str> = placements.iter().map(|p| p.fqn.as_str()).collect();
437    assert_only_filled(doc, fields, &allowed)
438}
439
440/// Maximum horizontal gap (widget-center to widget-center, PDF points) between the two boxes of the
441/// Digital-Asset Yes/No pair for them to count as **adjacent**. The real DA "Yes"/"No" boxes sit ~36pt
442/// apart on both the 2024 and 2025 1040; the 2024 **filing-status** `{/1,/2}` row (Single vs MFJ) is a
443/// same-y pair too but its boxes are ~266pt apart — the trap the top-most-y rule fell into. 80pt
444/// brackets the real gap with margin while excluding that non-adjacent row.
445const DA_PAIR_MAX_DX: f32 = 80.0;
446
447/// Map-INDEPENDENT oracle for the 1040 Digital-Asset Yes/No question: the **top-most horizontally
448/// ADJACENT** page-`page` `/Btn` pair (exactly two widgets sharing a center-y, boxes ≤ [`DA_PAIR_MAX_DX`]
449/// apart) whose on-states are exactly {`/1`,`/2`}. Returns `(yes_fqn, no_fqn)` = (LEFT member, right
450/// member). Derived from the blank PDF's widget geometry + appearance states, never the map.
451///
452/// **[R0-C2]** Selecting by adjacency (not merely top-most-y) is what keeps the 2024 fill off the
453/// FILING-STATUS `{/1,/2}` row (Single @ x≈107 vs MFJ @ x≈373, ~266pt apart) that sits ABOVE the DA
454/// pair; the DA "Yes"/"No" boxes are ~36pt apart. Re-verified against 2025 (its DA pair is the top-most
455/// `{/1,/2}` 2-widget row AND adjacent, so no regression).
456pub fn topmost_yes_no_pair(
457    doc: &Document,
458    fields: &[Field],
459    page: usize,
460) -> Result<(String, String), FormsError> {
461    // Group page buttons (that carry a rect + on-states) by rounded center-y.
462    let mut by_y: HashMap<i32, Vec<(&Field, Vec<String>)>> = HashMap::new();
463    for f in fields {
464        if !f.is_button || page_of(&f.fqn) != page {
465            continue;
466        }
467        let Some(cy) = f.cy() else { continue };
468        let states = button_on_states(doc, f.id);
469        if states.is_empty() {
470            continue;
471        }
472        by_y.entry(cy.round() as i32).or_default().push((f, states));
473    }
474    // A qualifying row: EXACTLY two widgets whose combined on-states are exactly {"1","2"} AND whose
475    // boxes are horizontally ADJACENT (≤ DA_PAIR_MAX_DX apart).
476    let mut candidates: Vec<(f32, &Field, &Field)> = Vec::new();
477    for members in by_y.values() {
478        if members.len() != 2 {
479            continue;
480        }
481        let mut states: Vec<&str> = members
482            .iter()
483            .flat_map(|(_, s)| s.iter().map(|x| x.as_str()))
484            .collect();
485        states.sort_unstable();
486        if states != ["1", "2"] {
487            continue;
488        }
489        let (a, b) = (members[0].0, members[1].0);
490        if (a.cx().unwrap() - b.cx().unwrap()).abs() > DA_PAIR_MAX_DX {
491            continue; // non-adjacent (e.g. the 2024 filing-status row) — not the DA pair.
492        }
493        let cy = a.cy().unwrap();
494        candidates.push((cy, a, b));
495    }
496    // Top-most (largest center-y) AMONG the adjacent pairs.
497    candidates.sort_by(|x, y| y.0.partial_cmp(&x.0).unwrap());
498    let (_, a, b) = candidates.first().ok_or_else(|| {
499        FormsError::Geometry(format!(
500            "no adjacent same-y {{/1,/2}} /Btn pair found on page {page}"
501        ))
502    })?;
503    // Left member = the Yes box.
504    if a.cx().unwrap() <= b.cx().unwrap() {
505        Ok((a.fqn.clone(), b.fqn.clone()))
506    } else {
507        Ok((b.fqn.clone(), a.fqn.clone()))
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::pdf;
515
516    /// ★ A value too long for its `/MaxLen` comb cell FAILS CLOSED. This is the guard that makes the
517    /// hyphens-vs-digits question un-losable: the 1040's SSN cells are 9-character combs, so a
518    /// formatted `123-45-6789` (ELEVEN characters) is not a formatting preference — it is a value the
519    /// cell cannot hold, which a PDF viewer would silently truncate or splay across the wrong teeth.
520    /// Silent truncation on a filed return is exactly the class of defect this crate refuses to ship.
521    #[test]
522    fn a_value_over_its_maxlen_comb_cell_fails_closed() {
523        const SSN_CELL: &str = "topmostSubform[0].Page1[0].f1_06[0]";
524        let fill = |value: &str| -> Result<(), FormsError> {
525            let mut doc = pdf::load(pdf::F1040_PDF_2024).unwrap();
526            let index = pdf::index(&pdf::collect_fields(&doc).unwrap());
527            pdf::apply_writes(
528                &mut doc,
529                &index,
530                &[(SSN_CELL.to_string(), pdf::FieldValue::Text(value.into()))],
531            )
532            .unwrap();
533            let bytes = pdf::save(&mut doc).unwrap();
534            let check = pdf::load(&bytes).unwrap();
535            let fields = pdf::collect_fields(&check).unwrap();
536            verify_flat(
537                &check,
538                &fields,
539                &[FlatPlacement::free(SSN_CELL, 0)],
540                &[(0.0, 612.0)],
541            )
542        };
543
544        // Nine bare digits fit exactly.
545        assert!(fill("123456789").is_ok());
546
547        // The hyphenated form is eleven characters — it does NOT fit, and must not be written.
548        let err = fill("123-45-6789").expect_err("an over-long value must fail closed");
549        assert!(
550            matches!(&err, FormsError::CellOverflow { fqn, max_len, len }
551                if fqn == SSN_CELL && *max_len == 9 && *len == 11),
552            "expected CellOverflow, got {err:?}"
553        );
554    }
555}