Skip to main content

btctax_cli/cmd/
admin.rs

1//! `config`, `export-snapshot` (FR10), `backup-key` — administrative commands. Config surfaces the TP8
2//! (c)/(b) treatment + the pre-2025 lot method; export/backup arrive in Task 15.
3use crate::cli::FormArg;
4use crate::config::{set_fee_treatment, set_pre2025_method as config_set_pre2025_method};
5use crate::render::write_csv_exports;
6use crate::{require_attestation, CliConfig, CliError, Session};
7use btctax_adapters::BundledTaxTables;
8use btctax_core::{
9    compute_se_tax, se_net_income, FeeTreatment, LotMethod, ScheduleDPart, Severity, TaxTables, Usd,
10};
11use btctax_forms::Form1040Inputs;
12use btctax_store::{fsperms, Passphrase};
13use std::path::{Path, PathBuf};
14
15/// Outcome of the CLI `export_snapshot` wrapper: the written snapshot path plus the count of
16/// UNRESOLVED Hard blockers (`severity() == Hard`) in the projection. Any Hard blocker gates EVERY
17/// tax year (`compute_tax_year` short-circuits on the projection-wide first Hard blocker), so
18/// `unresolved_hard > 0` means every exported Form 8949 / Schedule D / projection CSV is
19/// INFORMATIONAL, not final — the `ExportSnapshot` main.rs arm warns on stderr accordingly. A
20/// fully-resolved ledger yields `0` and no warning. Advisory blockers (incl. `PseudoReconcileActive`,
21/// `SelfTransferInboundZeroBasis`) never count.
22#[derive(Debug, Clone)]
23pub struct ExportReport {
24    pub path: PathBuf,
25    pub unresolved_hard: usize,
26}
27
28pub fn show_config(vault_path: &Path, pp: &Passphrase) -> Result<CliConfig, CliError> {
29    Session::open(vault_path, pp)?.config()
30}
31
32/// Persist a new TP8 fee treatment (None = leave unchanged), then return the resulting config.
33pub fn set_config(
34    vault_path: &Path,
35    pp: &Passphrase,
36    fee_treatment: Option<FeeTreatment>,
37) -> Result<CliConfig, CliError> {
38    let mut session = Session::open(vault_path, pp)?;
39    if let Some(t) = fee_treatment {
40        set_fee_treatment(session.conn(), t)?;
41        session.save()?;
42    }
43    session.config()
44}
45
46/// Persist the pre-2025 lot identification method and attestation flag, then return the resulting config.
47pub fn set_pre2025_method(
48    vault_path: &Path,
49    pp: &Passphrase,
50    m: LotMethod,
51    attested: bool,
52) -> Result<CliConfig, CliError> {
53    let mut session = Session::open(vault_path, pp)?;
54    config_set_pre2025_method(session.conn(), m, attested)?;
55    session.save()?;
56    session.config()
57}
58
59/// FR10 / NFR2 exception: decrypted SQLite image (via the store) + the projected ledger as CSV.
60/// When `tax_year` is `Some(y)`, the per-tax-year Form 8949 + Schedule D CSVs are also written,
61/// year-scoped to `y` (P2-B); when `None`, only the all-years CSVs are written.
62///
63/// Sub-project 3 attestation gate: when the projection is pseudo-active (a synthetic default
64/// contributes), producing any form/data file requires the exact `ATTEST_PHRASE` in `attest`
65/// (trimmed, case-sensitive). Checked FIRST — before any bytes are written — so a refused export
66/// leaves `out_dir` untouched. A fully-real (not-pseudo-active) ledger ignores `attest` entirely.
67pub fn export_snapshot(
68    vault_path: &Path,
69    pp: &Passphrase,
70    out_dir: &Path,
71    tax_year: Option<i32>,
72    attest: Option<&str>,
73) -> Result<ExportReport, CliError> {
74    let session = Session::open(vault_path, pp)?;
75    // Attestation gate: REFUSE before ANY bytes are written when a synthetic default contributes and the
76    // attestation is missing/wrong — no fictional snapshot/8949/Schedule D leaves the machine unguarded.
77    // Checked FIRST (before the vault snapshot / CSV writes), so a refused export leaves out_dir untouched.
78    let (state, _cfg) = session.project()?;
79    if state.pseudo_active() {
80        require_attestation(attest)?;
81    }
82    let sqlite = session.vault().export_snapshot(out_dir)?; // writes out_dir/snapshot.sqlite
83                                                            // P2-D: standalone Schedule SE §1401 figure for the year-scoped export. Needs the year's filing
84                                                            // status (profile) + the year's ss_wage_base (bundled table); `None` when either is absent or
85                                                            // there is no business SE income. The "present but no table" note is a text-report concern
86                                                            // (render_schedule_se) — the CSV carries the computed figure only.
87    let se_result = match tax_year {
88        Some(y) => {
89            let tables = BundledTaxTables::load();
90            // Resolve (ReturnInputs-derived → stored → …); an uncomputable/refused profile just omits the
91            // SE figure — the export (a data snapshot) still proceeds, never emitting a wrong number.
92            let profile = match session.resolve_screened(&state, y, &tables)? {
93                crate::resolve::ProfileOutcome::Ready { profile, .. } => profile,
94                crate::resolve::ProfileOutcome::Uncomputable { .. } => None,
95            };
96            profile.and_then(|p| {
97                tables.table_for(y).and_then(|t| {
98                    compute_se_tax(
99                        &state,
100                        y,
101                        p.filing_status,
102                        t,
103                        p.w2_ss_wages,
104                        p.w2_medicare_wages,
105                        p.schedule_c_expenses,
106                    )
107                })
108            })
109        }
110        None => None,
111    };
112    let donation_details = session.donation_details()?;
113    write_csv_exports(
114        out_dir,
115        &state,
116        tax_year,
117        se_result.as_ref(),
118        &donation_details,
119    )?;
120    // [R0-I1] Count UNRESOLVED Hard blockers only. Any Hard blocker gates every year, so the count
121    // alone (no per-year `compute_tax_year` call, no profile/tables dependency) drives the main.rs
122    // stderr "INFORMATIONAL, not final" disclosure. Advisory blockers never count.
123    let unresolved_hard = state
124        .blockers
125        .iter()
126        .filter(|b| b.kind.severity() == Severity::Hard)
127        .count();
128    Ok(ExportReport {
129        path: sqlite,
130        unresolved_hard,
131    })
132}
133
134/// Probe: would an export be gated? `true` when the projection is pseudo-active (a synthetic default
135/// contributes). Used by the `export-snapshot` CLI arm to decide whether to PROMPT for the attestation
136/// phrase; the authoritative gate lives inside `export_snapshot` itself. Kept in the library so main.rs
137/// stays a thin dispatch (no session-open / projection business logic in the binary).
138pub fn export_pseudo_active(vault_path: &Path, pp: &Passphrase) -> Result<bool, CliError> {
139    let (state, _cfg) = Session::open(vault_path, pp)?.project()?;
140    Ok(state.pseudo_active())
141}
142
143/// Outcome of `export_irs_pdf`: the written PDF paths, the unresolved-Hard-blocker count (same
144/// INFORMATIONAL disclosure as `export-snapshot`), whether the fill was watermarked (pseudo-active),
145/// the count of rows that MIGHT belong on a separate 1099-DA-reported 8949 (Box G/H/J/K — the [I5]
146/// advisory), and the SP2 packet (Schedule SE + Form 8283 + Form 1040 cap-gains) with the advisories
147/// each one drives.
148#[derive(Debug, Clone)]
149pub struct IrsPdfReport {
150    pub f8949_path: Option<PathBuf>,
151    pub schedule_d_path: Option<PathBuf>,
152    pub tax_year: i32,
153    pub unresolved_hard: usize,
154    pub broker_reported_rows: usize,
155    pub watermarked: bool,
156    /// Schedule SE — written only when SE income ≥ the $400 floor (and selected).
157    pub schedule_se_path: Option<PathBuf>,
158    /// SE tax was computed but net earnings were below the $400 floor → SE not owed, form skipped.
159    pub se_below_floor: bool,
160    /// `Some(addl)` when the §1401(b)(2) Additional Medicare Tax is nonzero — a Form 8959 item, NOT on
161    /// Schedule SE (a loud advisory).
162    pub se_addl_medicare: Option<Usd>,
163    /// SE-eligible business income exists but no profile/table was available (`compute_se_tax` → None
164    /// for a reason other than "no SE income") → a NOTE, not a silent skip (the `se_net_income`
165    /// discriminator).
166    pub se_income_without_profile: bool,
167    /// Form 8283 — written only when there are donations (and selected).
168    pub form_8283_path: Option<PathBuf>,
169    /// Any Form 8283 row needs manual review (incomplete appraiser/donee declaration) → escalate.
170    pub form_8283_needs_review: bool,
171    /// The Form 8283 section actually written (`Some(true)` = Section B, `Some(false)` = Section A).
172    pub form_8283_section_b: Option<bool>,
173    /// Form 1040 cap-gains — written only when there is reportable digital-asset activity (and selected).
174    pub form_1040_path: Option<PathBuf>,
175    /// Line 7a received a value on the written 1040.
176    pub form_1040_filled_7a: bool,
177    /// The 1040 was skipped for a NET LOSS on line 7a (the §1211 line-21 cap is the filer's).
178    pub form_1040_loss: bool,
179    /// ★ The FULL-RETURN packet's files, in Attachment Sequence order (empty on the crypto-slice path).
180    /// The two paths write NON-OVERLAPPING names, so no two runs can be collated into a chimera return.
181    pub full_return_paths: Vec<PathBuf>,
182    /// The full-return packet's manifest (the filer's stapling order).
183    pub full_return_manifest: Option<PathBuf>,
184}
185
186/// Whether a form is included: the packet is every applicable form unless `--forms` opts in to a subset.
187fn wants(selected: &[FormArg], f: FormArg) -> bool {
188    selected.is_empty() || selected.contains(&f)
189}
190
191/// A Schedule D part is "active" (worth reporting) iff it has any proceeds/cost/gain.
192fn sd_part_active(p: &ScheduleDPart) -> bool {
193    !p.proceeds.is_zero() || !p.cost_basis.is_zero() || !p.gain.is_zero()
194}
195
196/// `export-irs-pdf`: fill the OFFICIAL IRS PDFs for `tax_year` and write them (owner-only) to
197/// `out_dir`. The packet is Form 8949 + Schedule D (always applicable) plus — when applicable and
198/// selected — Schedule SE (SE income ≥ $400), Form 8283 (donations), and Form 1040 cap-gains
199/// (reportable digital-asset activity). The form data is REUSED from the projection
200/// (`form_8949`/`schedule_d`/`form_8283`/`compute_se_tax`) — nothing capital-gains is recomputed; the
201/// SE §1401 figure is computed here from the year's stored `TaxProfile`. Same pseudo-active
202/// attestation gate as `export-snapshot`: checked FIRST, so a refused export leaves `out_dir`
203/// untouched; a pseudo fill is additionally DRAFT-watermarked.
204pub fn export_irs_pdf(
205    vault_path: &Path,
206    pp: &Passphrase,
207    out_dir: &Path,
208    tax_year: i32,
209    forms: &[FormArg],
210    attest: Option<&str>,
211) -> Result<IrsPdfReport, CliError> {
212    let session = Session::open(vault_path, pp)?;
213    let (state, _cfg) = session.project()?;
214
215    // ★ THE DISPATCH (P6.5). Exactly one function decides which pipeline runs, and the two write
216    // NON-OVERLAPPING filenames, so artifacts from two runs can never be collated into a chimera
217    // return: the full packet writes `f1040.pdf`, `f1040s1.pdf`, … + a manifest; the crypto slice
218    // writes `form_1040_capgains.pdf`, `schedule_d.pdf`, `f8949.pdf`, ….
219    //
220    // This REPLACES the P5-C1 refusal (`CryptoSliceExportForFullReturnYear`). That guard existed only
221    // because the slice's Schedule D carries the crypto totals alone — no line 13 for 1099-DIV box-2a
222    // capital-gain distributions, no lines 6/14 for capital-loss carryovers — so on a full-return year
223    // it was a complete-LOOKING form with income missing. The full pipeline fills all of them, plus
224    // every attachment the forms cite, so the reason for the refusal is gone. Deleting it downgrades a
225    // type-level impossibility to a branch, which is why the branch is HERE, alone, and pinned by KATs
226    // in BOTH directions.
227    if crate::return_inputs::exists(session.conn(), tax_year)? {
228        return export_full_return(&session, &state, out_dir, tax_year, attest);
229    }
230
231    // Attestation gate — no fictional tax form leaves the machine unguarded, and a refusal
232    // writes no bytes. (A fully-real ledger ignores `attest`.)
233    let watermarked = state.pseudo_active();
234    if watermarked {
235        require_attestation(attest)?;
236    }
237
238    // Reuse the projection's capital-gains data verbatim (no recompute).
239    let rows = btctax_core::form_8949(&state, tax_year);
240    let totals = btctax_core::schedule_d(&state, tax_year);
241
242    // A pseudo-active fill DRAFT-watermarks every page before it hits disk.
243    let stamp = |bytes: Vec<u8>| -> Result<Vec<u8>, CliError> {
244        Ok(if watermarked {
245            btctax_forms::stamp_draft_watermark(&bytes)?
246        } else {
247            bytes
248        })
249    };
250
251    fsperms::mkdir_owner_only(out_dir)?;
252
253    // ── Form 8949 + Schedule D (always applicable). ──
254    let f8949_path = if wants(forms, FormArg::F8949) {
255        let bytes = stamp(btctax_forms::fill_form_8949(&rows, tax_year)?)?;
256        let path = out_dir.join("f8949.pdf");
257        write_bytes_owner_only(&path, &bytes)?;
258        Some(path)
259    } else {
260        None
261    };
262    let schedule_d_path = if wants(forms, FormArg::ScheduleD) {
263        let bytes = stamp(btctax_forms::fill_schedule_d(&totals, tax_year)?)?;
264        let path = out_dir.join("schedule_d.pdf");
265        write_bytes_owner_only(&path, &bytes)?;
266        Some(path)
267    } else {
268        None
269    };
270
271    // ── Schedule SE (self-employment tax). Compute the §1401 figure from the year's TaxProfile. ──
272    let se_computed = {
273        let tables = BundledTaxTables::load();
274        let profile = match session.resolve_screened(&state, tax_year, &tables)? {
275            crate::resolve::ProfileOutcome::Ready { profile, .. } => profile,
276            crate::resolve::ProfileOutcome::Uncomputable { .. } => None, // export proceeds; SE omitted
277        };
278        profile.and_then(|p| {
279            tables.table_for(tax_year).and_then(|t| {
280                compute_se_tax(
281                    &state,
282                    tax_year,
283                    p.filing_status,
284                    t,
285                    p.w2_ss_wages,
286                    p.w2_medicare_wages,
287                    p.schedule_c_expenses,
288                )
289                .map(|se| (se, p.w2_ss_wages, t.ss_wage_base))
290            })
291        })
292    };
293    // Discriminator: SE income present but `compute_se_tax` returned None (no profile / no table) → a
294    // NOTE, not a silent skip (mirrors the render layer; never a fabricated form).
295    let se_income_without_profile =
296        se_computed.is_none() && !se_net_income(&state, tax_year).is_zero();
297    let mut schedule_se_path = None;
298    let mut se_below_floor = false;
299    let mut se_addl_medicare = None;
300    if wants(forms, FormArg::ScheduleSe) {
301        if let Some((se, w2_ss_wages, ss_wage_base)) = se_computed {
302            if !se.addl.is_zero() {
303                se_addl_medicare = Some(se.addl);
304            }
305            match btctax_forms::fill_schedule_se(&se, w2_ss_wages, ss_wage_base, tax_year)? {
306                Some(bytes) => {
307                    let bytes = stamp(bytes)?;
308                    let path = out_dir.join("schedule_se.pdf");
309                    write_bytes_owner_only(&path, &bytes)?;
310                    schedule_se_path = Some(path);
311                }
312                None => se_below_floor = true, // below the $400 floor — SE not owed.
313            }
314        }
315    }
316
317    // ── Form 8283 (noncash charitable contributions). ──
318    let mut form_8283_path = None;
319    let mut form_8283_needs_review = false;
320    let mut form_8283_section_b = None;
321    if wants(forms, FormArg::Form8283) {
322        let details = session.donation_details()?;
323        let rows_8283 = btctax_core::form_8283(&state, tax_year, &details);
324        if let Some(bytes) = btctax_forms::fill_form_8283(&rows_8283, tax_year)? {
325            form_8283_needs_review = rows_8283.iter().any(|r| r.needs_review);
326            form_8283_section_b = rows_8283
327                .iter()
328                .find_map(|r| r.section)
329                .map(|s| s == btctax_core::Form8283Section::B);
330            let bytes = stamp(bytes)?;
331            let path = out_dir.join("form_8283.pdf");
332            write_bytes_owner_only(&path, &bytes)?;
333            form_8283_path = Some(path);
334        }
335    }
336
337    // ── Form 1040 cap-gains cells + the digital-asset question. ──
338    let mut form_1040_path = None;
339    let mut form_1040_filled_7a = false;
340    let mut form_1040_loss = false;
341    if wants(forms, FormArg::Form1040) {
342        let da_yes = !rows.is_empty()
343            || state
344                .income_recognized
345                .iter()
346                .any(|i| i.recognized_at.year() == tax_year)
347            || state
348                .removals
349                .iter()
350                .any(|r| r.removed_at.year() == tax_year);
351        let inputs = Form1040Inputs {
352            da_yes,
353            schedule_d_active: sd_part_active(&totals.st) || sd_part_active(&totals.lt),
354            schedule_d_line16: totals.st.gain + totals.lt.gain,
355        };
356        if let Some(fill) = btctax_forms::fill_form_1040_capgains(&inputs, tax_year)? {
357            form_1040_filled_7a = fill.filled_7a;
358            form_1040_loss = fill.loss;
359            let bytes = stamp(fill.pdf)?;
360            let path = out_dir.join("form_1040_capgains.pdf");
361            write_bytes_owner_only(&path, &bytes)?;
362            form_1040_path = Some(path);
363        }
364    }
365
366    let unresolved_hard = state
367        .blockers
368        .iter()
369        .filter(|b| b.kind.severity() == Severity::Hard)
370        .count();
371    Ok(IrsPdfReport {
372        full_return_paths: Vec::new(),
373        full_return_manifest: None,
374        f8949_path,
375        schedule_d_path,
376        tax_year,
377        unresolved_hard,
378        broker_reported_rows: btctax_forms::rows_possibly_broker_reported(&rows),
379        watermarked,
380        schedule_se_path,
381        se_below_floor,
382        se_addl_medicare,
383        se_income_without_profile,
384        form_8283_path,
385        form_8283_needs_review,
386        form_8283_section_b,
387        form_1040_path,
388        form_1040_filled_7a,
389        form_1040_loss,
390    })
391}
392
393/// Write `bytes` to `path` with owner-only (0o600) permissions, matching the CSV export path.
394fn write_bytes_owner_only(path: &Path, bytes: &[u8]) -> Result<(), CliError> {
395    use std::io::Write;
396    let mut f = fsperms::open_owner_only(path)?;
397    f.write_all(bytes)?;
398    f.flush()?;
399    Ok(())
400}
401
402/// §8: export the passphrase-protected key (escape hatch; HIGH-security write).
403pub fn backup_key(vault_path: &Path, pp: &Passphrase, out_path: &Path) -> Result<(), CliError> {
404    Session::open(vault_path, pp)?
405        .vault()
406        .backup_key(out_path)?;
407    Ok(())
408}
409
410/// ★ **The full-return export** (P6.3b / P6.5) — the whole filable packet, not the crypto slice.
411///
412/// Runs the same fail-closed screens the report runs (a return the report will not compute is a return
413/// the exporter must not print), assembles the printed packet in CORE, and fills it ALL-OR-NOTHING: if
414/// any member form refuses, zero bytes reach the disk. A 1040 whose line 2b cites a Schedule B that is
415/// not attached is a wrong return, so partial emission would be a fail-open.
416///
417/// **The packet exports CLEAN** (no DRAFT watermark, no attestation) — the user's §9 decision, folded
418/// into the SPEC. The one exception is PSEUDO-reconciled figures, which are FICTIONAL and can never be
419/// filed: those are watermarked regardless, and that gate composes with (and dominates) everything else.
420fn export_full_return(
421    session: &Session,
422    state: &btctax_core::state::LedgerState,
423    out_dir: &Path,
424    tax_year: i32,
425    attest: Option<&str>,
426) -> Result<IrsPdfReport, CliError> {
427    use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
428    use btctax_core::tax::tables::{FullReturnTables, TaxTables};
429    use std::fmt::Write as _;
430
431    let tables = BundledTaxTables::load();
432    let fr_tables = BundledFullReturnTables::load();
433    let (Some(params), Some(table)) = (
434        fr_tables.full_return_for(tax_year),
435        tables.table_for(tax_year),
436    ) else {
437        return Err(CliError::Usage(format!(
438            "no full-return tables for {tax_year} — the full-return packet needs a supported tax year \
439             (TY2024)"
440        )));
441    };
442
443    let ri = crate::return_inputs::get(session.conn(), tax_year)?
444        .ok_or_else(|| CliError::Usage(format!("no return_inputs stored for {tax_year}")))?;
445
446    // Fail-closed screens, in the same order the report runs them. A refusal writes NO bytes.
447    let refuse = |r: btctax_core::tax::return_refuse::Refusal| {
448        CliError::Usage(format!(
449            "the {tax_year} return is not computable [{:?}]: {} — no forms were written",
450            r.reason, r.detail
451        ))
452    };
453    if let Some(r) = btctax_core::tax::return_refuse::screen_inputs(&ri, table, params) {
454        return Err(refuse(r));
455    }
456    if let Some(r) =
457        btctax_core::tax::return_1040::screen_compute_dependent(&ri, state, tax_year, params)
458    {
459        return Err(refuse(r));
460    }
461    let ar = btctax_core::tax::return_1040::assemble_absolute(&ri, state, params, table, tax_year);
462    if let Some(r) = btctax_core::tax::return_1040::screen_absolute(&ri, &ar, params) {
463        return Err(refuse(r));
464    }
465
466    // Pseudo figures are FICTIONAL: they are watermarked no matter what, and the attestation gate for
467    // them is unchanged. A clean (real-ledger) packet needs no attestation — SPEC §9 as amended.
468    let watermarked = state.pseudo_active();
469    if watermarked {
470        require_attestation(attest)?;
471    }
472
473    let details = session.donation_details()?;
474    let printed = btctax_core::tax::packet::assemble_printed_return(
475        &ri, state, &details, &ar, table, tax_year,
476    )
477    .map_err(|e| {
478        // `HeaderError`'s Display carries the right remedy per variant (a malformed SSN, an unanswered
479        // declaration, or an MFJ return with no spouse) — no longer always "fix the identity" (P9 §3.2).
480        CliError::Usage(format!("the {tax_year} return cannot be printed: {e}"))
481    })?;
482
483    // ★ ALL-OR-NOTHING: every form fills BEFORE anything is written.
484    let packet = btctax_forms::fill_full_return(&printed, tax_year)?;
485
486    fsperms::mkdir_owner_only(out_dir)?;
487    let mut manifest = String::from("# btctax full-return packet — staple in this order\n");
488    let mut paths: Vec<PathBuf> = Vec::new();
489    for form in &packet {
490        let bytes = if watermarked {
491            btctax_forms::stamp_draft_watermark(&form.bytes)?
492        } else {
493            form.bytes.clone()
494        };
495        // ★ The packet's filenames are SEQUENCE-PREFIXED (`00_f1040.pdf`, `12A_f8949.pdf`, …). Two
496        // reasons, and the first is a correctness guarantee: the crypto slice writes bare stems
497        // (`f8949.pdf`, `schedule_d.pdf`, `schedule_se.pdf`) and THREE of them collided with the
498        // packet's — so a full-return run and a slice run into one directory could silently interleave
499        // a cents Schedule SE with a whole-dollar 1040: the chimera return the dispatch mitigation
500        // exists to prevent (Fable P6 r1 I7). Second, the prefix IS the stapling order.
501        let path = out_dir.join(format!(
502            "{}_{}.pdf",
503            form.attachment_sequence.unwrap_or("00"),
504            form.name
505        ));
506        write_bytes_owner_only(&path, &bytes)?;
507        let seq = form.attachment_sequence.unwrap_or("—");
508        let _ = writeln!(
509            manifest,
510            "{seq:>4}  {}",
511            path.file_name().unwrap_or_default().to_string_lossy()
512        );
513        paths.push(path);
514    }
515    let manifest_path = out_dir.join("manifest.txt");
516    write_bytes_owner_only(&manifest_path, manifest.as_bytes())?;
517
518    let unresolved_hard = state
519        .blockers
520        .iter()
521        .filter(|b| b.kind.severity() == Severity::Hard)
522        .count();
523    Ok(IrsPdfReport {
524        watermarked,
525        tax_year,
526        unresolved_hard,
527        broker_reported_rows: 0,
528        full_return_paths: paths,
529        full_return_manifest: Some(manifest_path),
530        // The crypto-slice PATHS are absent (the two pipelines are disjoint), but the 8283's loud
531        // escalations are NOT slice-specific: the packet can contain a Section-B 8283 whose appraiser
532        // declaration is unsigned, and silencing that guard on the one path that announces a "clean,
533        // filable" packet would be a fail-open (Fable P6 r1 I8b).
534        f8949_path: None,
535        schedule_d_path: None,
536        schedule_se_path: None,
537        se_below_floor: false,
538        se_addl_medicare: None,
539        se_income_without_profile: false,
540        form_8283_path: None,
541        form_8283_needs_review: printed
542            .forms
543            .f8283
544            .as_ref()
545            .is_some_and(|r| r.rows().iter().any(|row| row.needs_review)),
546        form_8283_section_b: printed.forms.f8283.as_ref().map(|r| {
547            r.rows()
548                .iter()
549                .any(|row| row.section == Some(btctax_core::Form8283Section::B))
550        }),
551        form_1040_path: None,
552        form_1040_filled_7a: false,
553        form_1040_loss: false,
554    })
555}