Skip to main content

btctax_cli/chokepoint/
mod.rs

1//! Defensive Filing Wizard, sub-project-2 Phase P-A, Task 1 — the PROMOTE chokepoint: a reusable
2//! plan/confirm/apply pipeline extracted VERBATIM from the shipped CLI verb
3//! (`cmd::promote::promote_tranche`, Approach-B Task 10, `cmd/promote.rs:364-488`) so a future TUI can
4//! drive the EXACT SAME gated pipeline as the CLI. `cmd::promote::promote_tranche` is now a thin driver
5//! over this module: `Session::open` → build args → `plan_promote` (map `Refusal` → `CliError`) →
6//! `println!("{}", render_consent(&plan))` → prompt/collect ack → `apply_promote`.
7//!
8//! **Gate ordering (DFW-D2) MUST match `cmd/promote.rs:378-485` exactly:** resolve-live → BG-D5
9//! provenance → BG-D7 Part II → BG-D3 floor/coverage → BG-D6 `consent_terms` → synthetic-promote advisory
10//! → gift-only relabel → consent render (incl. `wide_window_note`) → **ack inside `apply_promote`,
11//! fail-closed** → `would_conflict` → append.
12//!
13//! ★ **I-1 (byte-parity):** the shipped verb prints, IN ORDER (`promote.rs:443-455`), the
14//! synthetic-promote ADVISORY (pre-consent) → `render_consent(&terms, &gift_only_years)` → the
15//! `wide_window_note` (post-consent). `PromotePlan` therefore carries THREE ordered pieces
16//! (`advisory_lines`, `gift_only_years`, `post_consent_note`) so this module's `render_consent(&plan)`
17//! reproduces `advisory → consent → note` byte-for-byte when printed via a single `println!` — a single
18//! flat `Vec` cannot place `terms` BETWEEN the pre-advisory and the note; do NOT collapse the three. The
19//! shipped `render_consent(terms, gift_only_years)` stays in `cmd::promote` (still `pub` — external KATs
20//! in `tests/promote_cli.rs` call it directly — and is invoked from here); `gift_only_flagged_years`/
21//! `wide_window_note` move HERE.
22//!
23//! ★ **DFW-D6 (the ONE intended behavior change — the sub-1 pseudo-off fix):** `plan_promote` forces
24//! `cfg.pseudo_reconcile = false` on its own COPY (`ProjectionConfig` is `Copy`) before `consent_terms` /
25//! `promote_prior_year_advisory` / `gift_only_flagged_years` — mirroring `would_conflict`
26//! (`project/mod.rs:118`). Without this, a pseudo-active vault's consent screen — and the RECORDED
27//! `Acknowledgment.shown_terms`, the §6664(c) good-faith artifact — could fold in a synthetic default that
28//! was never persisted, misstating what the filer actually acknowledged.
29//!
30//! ★ **arch-m-6/tax-N-1:** `Refusal::Target` covers the resolve-live gate — unknown/voided/wrong-type
31//! target only (`resolve_live_tranche`). Already-promoted (a DOUBLE promote) is NOT caught here; it
32//! surfaces as `would_conflict` at APPLY time (a `CliError`, never a plan `Refusal`) — mirroring
33//! `promote.rs:475-483`.
34//!
35//! ★ **arch-m-new-3:** `plan_promote` takes no `Session`/`state` — the shipped pipeline rebuilds
36//! everything from `events` (`promote.rs:364-488`) — so a caller (CLI or future TUI) supplies its own
37//! already-loaded `events`/`prices`/`cfg`.
38//!
39//! **Task 2 — the DECLARE chokepoint:** `plan_declare`/`apply_declare`, extracted from the shipped
40//! `cmd::tranche::declare_tranche` (`tranche.rs:120-175`). `plan_declare` gates on the shipped set
41//! (`sat>0`, `ws<=we`, `guard_tranche_vs_allocation` — the LAST one stays defined in `cmd::tranche`, not
42//! duplicated here) ALWAYS; **iff `target_shortfall = Some(id)`** it ALSO runs the DFW-D5.2 target-scoped
43//! clearance shadow: append the candidate `DeclareTranche` → re-project (pseudo FORCED off, mirroring
44//! `would_conflict`) → assert no `BlockerKind::UncoveredDisposal` remains on `id`; else `Refusal::Coverage`.
45//! `target_shortfall = None` (the CLI free-form path) is the shipped `declare_tranche` gate set,
46//! BYTE-FOR-BYTE (DFW-D8/SPEC §5) — no clearance shadow runs at all. `apply_declare` is a plain
47//! append+save (declaring is `$0`/revocable/no-Form-8275 — DFW-D8 — so unlike promote there is no
48//! acknowledgment gate and no `would_conflict` pre-check; the shipped verb never ran one either).
49//!
50//! ★ **Refusal-variant note:** the shared `Refusal` enum stays CLOSED at the four Task-1 variants (the
51//! plan review explicitly rejected adding a new `Conflict` variant for this task). The shipped-set gates
52//! (sat/window/allocation) have no promote-shaped variant of their own, so — like the new clearance
53//! failure the brief names explicitly — they map to `Refusal::Coverage`. Every variant collapses to the
54//! identical `CliError::Usage(msg)` via `From<Refusal>` (below), so this is a pure internal-taxonomy
55//! choice: the filer-facing message text is unchanged from the shipped verb either way.
56
57use crate::cmd::promote::{
58    render_consent as render_consent_terms, ProvenanceKind, PROMOTE_ACK_PHRASE, PROVENANCE_TEXT,
59    PROVENANCE_VERSION,
60};
61use crate::{CliError, Session};
62use btctax_core::conservative::{self, Direction};
63use btctax_core::conservative_promote::{self, PromoteRefusal};
64use btctax_core::conventions::tax_date;
65use btctax_core::event::{
66    Acknowledgment, ConsentTerm, DeclareTranche, EventPayload, FloorMethod, PromoteTranche,
67};
68use btctax_core::persistence::{append_decision, load_all};
69use btctax_core::price::PriceProvider;
70use btctax_core::project::ProjectionConfig;
71use btctax_core::state::BlockerKind;
72use btctax_core::{
73    project, EventId, LedgerEvent, LedgerState, RemovalKind, Sat, TaxDate, Usd, WalletId,
74};
75use std::collections::BTreeSet;
76use time::{OffsetDateTime, UtcOffset};
77
78/// Everything computed BEFORE the filer types the acknowledgment phrase (the `PromoteTranche` decision
79/// id, `target`, is not yet known — it is assigned at `apply_promote`'s `append_decision`). ★ I-1: the
80/// three ordered fields (`advisory_lines`, `gift_only_years`, `post_consent_note`) let `render_consent`
81/// reproduce the shipped verb's `advisory → consent → note` byte order — do NOT collapse them into one
82/// `Vec` or pre-render `gift_only_years` into a string.
83#[derive(Debug, Clone)]
84pub struct PromotePlan {
85    /// The `DeclareTranche` decision this promotes (BG-D1) — the `PromoteTranche.target` field.
86    pub target: EventId,
87    /// BG-D6 `consent_terms` output — ALSO snapshotted verbatim onto `payload`'s
88    /// `Acknowledgment.shown_terms` (the §6664(c) good-faith artifact).
89    pub terms: Vec<ConsentTerm>,
90    /// The PRE-consent synthetic-promote advisory lines (`promote.rs:443`, `for line in &advisory`).
91    pub advisory_lines: Vec<String>,
92    /// T9 handoff: an INPUT to the shipped `render_consent(terms, gift_only_years)`
93    /// (`promote.rs:333`/`:453`) — NOT a pre-rendered string.
94    pub gift_only_years: BTreeSet<i32>,
95    /// `wide_window_note`, printed AFTER the consent screen (`promote.rs:454`).
96    pub post_consent_note: Option<String>,
97    /// The `PromoteTranche` payload `apply_promote` appends on a successful acknowledgment.
98    pub payload: EventPayload,
99}
100
101/// A `plan_promote` refusal — fail-closed, BEFORE any computation past the failing gate. Each variant
102/// carries the exact filer-facing message (byte-identical to the shipped verb's `CliError::Usage` text),
103/// so mapping to `CliError` (the thin CLI driver) or a distinct TUI error surface is trivial either way.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum Refusal {
106    /// The resolve-live gate (`resolve_live_tranche`): `target` is absent, wrong-type, or voided.
107    Target(String),
108    /// BG-D5: a non-`Purchase` provenance.
109    Provenance(String),
110    /// BG-D3 (promote): `filed_basis_for` could not produce a trustworthy floor
111    /// (`NoCoverage`/`PartialCoverage`). ALSO (Task 2, declare): the shipped-set gates (`sat>0`, `ws<=we`,
112    /// `guard_tranche_vs_allocation`) AND the DFW-D5.2 target-scoped clearance shadow (a candidate tranche
113    /// that does not clear the named shortfall) — see the module doc's "Refusal-variant note".
114    Coverage(String),
115    /// BG-D7: an empty/whitespace Form 8275 Part II narrative.
116    PartII(String),
117}
118
119impl From<Refusal> for CliError {
120    fn from(r: Refusal) -> CliError {
121        let msg = match r {
122            Refusal::Target(m) => m,
123            Refusal::Provenance(m) => m,
124            Refusal::Coverage(m) => m,
125            Refusal::PartII(m) => m,
126        };
127        CliError::Usage(msg)
128    }
129}
130
131/// True iff a live (non-voided) `VoidDecisionEvent` names `id`. Moved verbatim from `cmd/promote.rs`.
132fn is_voided(events: &[LedgerEvent], id: &EventId) -> bool {
133    events.iter().any(
134        |e| matches!(&e.payload, EventPayload::VoidDecisionEvent(v) if v.target_event_id == *id),
135    )
136}
137
138/// Resolve `target_event_id` to a LIVE (present, non-voided) `DeclareTranche`, or `Refusal::Target`. A
139/// record-time convenience guard — the engine's own `DecisionConflict` adjudication is the backstop for
140/// any target this misses (moved verbatim from `cmd/promote.rs::resolve_live_tranche`, DFW-D2 gate 1).
141fn resolve_live_tranche(
142    events: &[LedgerEvent],
143    target_event_id: &EventId,
144) -> Result<DeclareTranche, Refusal> {
145    let not_live = || {
146        Refusal::Target(format!(
147            "{} is not a live DeclareTranche (absent, wrong type, or voided) — see `btctax events list` \
148             for event refs + decision status",
149            target_event_id.canonical()
150        ))
151    };
152    if is_voided(events, target_event_id) {
153        return Err(not_live());
154    }
155    events
156        .iter()
157        .find(|e| e.id == *target_event_id)
158        .and_then(|e| match &e.payload {
159            EventPayload::DeclareTranche(t) => Some(t.clone()),
160            _ => None,
161        })
162        .ok_or_else(not_live)
163}
164
165/// BG-D5: refuse a non-`Purchase` provenance — the closed enumeration, fail-closed, before any
166/// computation. Moved verbatim from `cmd/promote.rs::refuse_non_purchase`.
167fn refuse_non_purchase(provenance: ProvenanceKind) -> Refusal {
168    Refusal::Provenance(format!(
169        "promote-tranche requires purchase provenance: {PROVENANCE_TEXT}. This tranche was declared as \
170         acquired by {label} — a {label} recipient already has a documented, real basis (income \
171         FMV-at-receipt; a §1015 donor carryover for a gift; a §1014 date-of-death basis for an \
172         inheritance) — model the real acquisition instead (a documented \
173         Acquire/Income/gift-received event), not a conservative-filing tranche promote.",
174        label = provenance.label(),
175    ))
176}
177
178/// BG-D3: translate a `filed_basis_for` refusal into a record-time message. Moved verbatim from
179/// `cmd/promote.rs::refuse_no_floor`.
180fn refuse_no_floor(e: PromoteRefusal, window_start: TaxDate, window_end: TaxDate) -> Refusal {
181    let detail = match e {
182        PromoteRefusal::NoCoverage => {
183            "no bundled daily-close price exists anywhere in the window — never fabricate a floor over a \
184             total data gap"
185        }
186        PromoteRefusal::PartialCoverage => {
187            "the window has a gap in bundled daily-close data — the covered-part minimum is not provably \
188             the window's true minimum, so it cannot be filed as a trustworthy floor"
189        }
190    };
191    Refusal::Coverage(format!(
192        "cannot compute a promotion floor for the window [{window_start}, {window_end}]: {detail}. \
193         Narrow the window to a fully-covered range, or leave this tranche at its filed $0 basis."
194    ))
195}
196
197/// A filer-facing caution (SPEC §1, "two honest limits"): a wide acquisition window yields a LOW
198/// ("trivial") floor relative to a tight one. Purely informational, non-gating; conditioned on the
199/// window exceeding one year. Moved verbatim from `cmd/promote.rs::wide_window_note`.
200fn wide_window_note(window_start: TaxDate, window_end: TaxDate) -> Option<String> {
201    let days = (window_end - window_start).whole_days();
202    if days > 365 {
203        Some(format!(
204            "note: this tranche's declared window spans {days} days (over a year). A WIDE window tends \
205             to produce a LOW (\"trivial\") floor relative to a tight one — for some filers it may be \
206             simpler, and just as conservative, to leave this tranche at its filed $0 basis and skip the \
207             Form 8275 disclosure surface entirely."
208        ))
209    } else {
210        None
211    }
212}
213
214/// Thread ONE synthetic `PromoteTranche(tranche_id, filed_basis)` onto `events` (mirrors
215/// `conservative_promote::with_synthetic_promote`, private there). Moved verbatim from
216/// `cmd/promote.rs::with_synthetic_promote`.
217fn with_synthetic_promote(
218    events: &[LedgerEvent],
219    tranche_id: &EventId,
220    filed_basis: Usd,
221    now: OffsetDateTime,
222) -> Vec<LedgerEvent> {
223    let seq = events
224        .iter()
225        .filter_map(|e| match e.id {
226            EventId::Decision { seq } => Some(seq),
227            _ => None,
228        })
229        .max()
230        .map_or(1, |m| m + 1);
231    let mut out = events.to_vec();
232    out.push(LedgerEvent {
233        id: EventId::decision(seq),
234        utc_timestamp: now,
235        original_tz: UtcOffset::UTC,
236        wallet: None,
237        payload: EventPayload::PromoteTranche(PromoteTranche {
238            target: tranche_id.clone(),
239            method: FloorMethod::WindowLowClose,
240            filed_basis,
241            coverage: conservative::Coverage::Full,
242            provenance_attested: true,
243            acknowledgment: Acknowledgment {
244                phrase: String::new(),
245                shown_terms: Vec::new(),
246                provenance_text: String::new(),
247                provenance_version: String::new(),
248            },
249            part_ii_narrative: String::new(),
250        }),
251    });
252    out
253}
254
255/// T9 handoff (progress.md Task 9): `consent_terms`/`Uncomputable` sum the §170(e) charitable-deduction
256/// change and the §1015 gift-basis change into ONE `deduction_delta_usd` figure per year. This re-derives
257/// which flagged years are GIFT-only directly from the SAME with/without fold pair the T8 advisory
258/// already builds. Moved verbatim from `cmd/promote.rs::gift_only_flagged_years`.
259fn gift_only_flagged_years(
260    prices: &dyn PriceProvider,
261    config: &ProjectionConfig,
262    events: &[LedgerEvent],
263    with_events: &[LedgerEvent],
264) -> BTreeSet<i32> {
265    let without_state = project(events, prices, config);
266    let with_state = project(with_events, prices, config);
267
268    let mut years: BTreeSet<i32> = BTreeSet::new();
269    for st in [&with_state, &without_state] {
270        for r in &st.removals {
271            years.insert(r.removed_at.year());
272        }
273    }
274
275    let rem =
276        |st: &btctax_core::LedgerState, y: i32, k: RemovalKind| -> Vec<btctax_core::Removal> {
277            st.removals
278                .iter()
279                .filter(|r| r.removed_at.year() == y && r.kind == k)
280                .cloned()
281                .collect()
282        };
283
284    years
285        .into_iter()
286        .filter(|&y| {
287            let gift_changed =
288                rem(&with_state, y, RemovalKind::Gift) != rem(&without_state, y, RemovalKind::Gift);
289            let don_changed = rem(&with_state, y, RemovalKind::Donation)
290                != rem(&without_state, y, RemovalKind::Donation);
291            gift_changed && !don_changed
292        })
293        .collect()
294}
295
296/// The acknowledgment gate (BG-D6) — a PURE exact-compare, no I/O (mirrors `require_attestation`,
297/// `lib.rs:208`). Moved verbatim from `cmd/promote.rs::require_promote_ack`; now called from
298/// `apply_promote`, fail-closed, BEFORE `would_conflict`/append.
299fn require_promote_ack(acknowledge: Option<&str>) -> Result<(), CliError> {
300    match acknowledge.map(str::trim) {
301        Some(p) if p == PROMOTE_ACK_PHRASE => Ok(()),
302        Some(_) => Err(CliError::Usage(format!(
303            "the acknowledgment phrase did not match. Type it EXACTLY (trimmed, case-sensitive): {PROMOTE_ACK_PHRASE:?}."
304        ))),
305        None => Err(CliError::Usage(format!(
306            "promote-tranche requires acknowledging the estimated-basis risk shown above — pass \
307             --i-acknowledge {PROMOTE_ACK_PHRASE:?} (or type it at the interactive prompt)."
308        ))),
309    }
310}
311
312/// Plan a `PromoteTranche` decision — the DFW-D2 gate order, everything computable BEFORE the filer types
313/// the acknowledgment phrase: resolve-live → BG-D5 provenance → BG-D7 Part II → BG-D3 floor/coverage →
314/// BG-D6 `consent_terms` → synthetic-promote advisory → gift-only relabel. `events`/`prices`/`cfg` are the
315/// caller's own already-loaded state (arch-m-new-3: no `Session` here — the CLI's thin driver and a
316/// future TUI each supply their own).
317pub fn plan_promote(
318    events: &[LedgerEvent],
319    prices: &dyn PriceProvider,
320    cfg: &ProjectionConfig,
321    target: &EventId,
322    provenance: ProvenanceKind,
323    part_ii: &str,
324    now: OffsetDateTime,
325) -> Result<PromotePlan, Refusal> {
326    // Resolve + assert live (BG-D1).
327    let tranche = resolve_live_tranche(events, target)?;
328
329    // BG-D5: purchase provenance only — fail-closed, before any computation.
330    if provenance != ProvenanceKind::Purchase {
331        return Err(refuse_non_purchase(provenance));
332    }
333
334    // BG-D7: an empty/whitespace Part II narrative is refused at record time (present-by-construction).
335    if part_ii.trim().is_empty() {
336        return Err(Refusal::PartII(
337            "promote-tranche requires a non-empty Form 8275 Part II narrative (filer facts, Reg. \
338             §1.6662-4(f) — 'in sufficient detail') — pass --part-ii-file pointing at a file with real \
339             acquisition/window facts, not an empty or blank file"
340                .into(),
341        ));
342    }
343
344    // BG-D3: the computed whole-tranche filed_basis floor — hard-refuse on Partial/No coverage.
345    let floor = conservative_promote::filed_basis_for(
346        prices,
347        tranche.sat,
348        tranche.window_start,
349        tranche.window_end,
350    )
351    .map_err(|e| refuse_no_floor(e, tranche.window_start, tranche.window_end))?;
352
353    // ★ DFW-D6 (the ONE intended behavior change): force pseudo OFF on an own COPY (ProjectionConfig is
354    // Copy) before consent_terms / promote_prior_year_advisory / gift_only_flagged_years — mirrors
355    // `would_conflict` (`project/mod.rs:118`). The recorded Acknowledgment.shown_terms must always
356    // reflect the HONEST (non-synthetic) figures, never a pseudo-active default folded in.
357    let mut honest_cfg = *cfg;
358    honest_cfg.pseudo_reconcile = false;
359
360    let tables = btctax_adapters::BundledTaxTables::load();
361    // A single stored TaxProfile cannot fit the multi-year span this consent/advisory ranges over, so
362    // `None` is passed throughout — mirrors the void-direction path (`cmd/reconcile.rs`
363    // `promote_void_advisory_lines`): the tax-Δ arm falls back to the gain/deduction-Δ sign, and the
364    // amend direction is still correct.
365    let terms = conservative_promote::consent_terms(
366        events,
367        prices,
368        &honest_cfg,
369        target,
370        floor.filed_basis,
371        None,
372        &tables,
373    );
374
375    // Thread ONE synthetic promote so the Direction::Promote advisory AND this layer's own
376    // gift-vs-donation year classification (T9 handoff) see the SAME post-promote fold.
377    let with_events = with_synthetic_promote(events, target, floor.filed_basis, now);
378    let synthetic_id = with_events
379        .last()
380        .expect("with_synthetic_promote always pushes exactly one event")
381        .id
382        .clone();
383
384    // T8 handoff (progress.md): `current` is the injected `now`'s tax year (the BTCTAX_NOW seam) — NEVER
385    // a wall clock. Years `< current` are presumed already filed; the year still being authored
386    // (>= current) is excluded, so it is never told it needs an amended return.
387    let current = tax_date(now, UtcOffset::UTC).year();
388    let advisory_lines = conservative::promote_prior_year_advisory(
389        &with_events,
390        prices,
391        &honest_cfg,
392        &synthetic_id,
393        Direction::Promote,
394        None,
395        &tables,
396        current,
397    );
398
399    // T9 handoff: which flagged years are GIFT-ONLY (no donation) — relabels that year's deduction/basis-Δ
400    // as a §1015 donee-basis change, never Schedule-A, in the consent screen below.
401    let gift_only_years = gift_only_flagged_years(prices, &honest_cfg, events, &with_events);
402
403    let payload = EventPayload::PromoteTranche(PromoteTranche {
404        target: target.clone(),
405        method: FloorMethod::WindowLowClose,
406        filed_basis: floor.filed_basis,
407        coverage: floor.coverage,
408        provenance_attested: true,
409        acknowledgment: Acknowledgment {
410            phrase: PROMOTE_ACK_PHRASE.to_string(),
411            shown_terms: terms.clone(),
412            provenance_text: PROVENANCE_TEXT.to_string(),
413            provenance_version: PROVENANCE_VERSION.to_string(),
414        },
415        part_ii_narrative: part_ii.to_string(),
416    });
417
418    Ok(PromotePlan {
419        target: target.clone(),
420        terms,
421        advisory_lines,
422        gift_only_years,
423        post_consent_note: wide_window_note(tranche.window_start, tranche.window_end),
424        payload,
425    })
426}
427
428/// Re-emit the shipped verb's ordered filer-visible text: `advisory_lines` → the shipped
429/// `render_consent(&plan.terms, &plan.gift_only_years)` (`cmd::promote`) → `post_consent_note` — ★ I-1:
430/// byte-identical to `promote.rs:443-455` when the RESULT is printed via a single
431/// `println!("{}", render_consent(&plan))` (the shipped verb instead used three separate `println!`
432/// calls; a single combined string reproduces the exact same stdout bytes because `println!` always adds
433/// exactly one trailing `\n`). Do NOT collapse this into `plan.terms`/`plan.gift_only_years` alone — the
434/// pre-advisory must land BEFORE the consent screen and the note AFTER it.
435pub fn render_consent(plan: &PromotePlan) -> String {
436    let mut out = String::new();
437    for line in &plan.advisory_lines {
438        out.push_str(line);
439        out.push('\n');
440    }
441    out.push_str(&render_consent_terms(&plan.terms, &plan.gift_only_years));
442    if let Some(note) = &plan.post_consent_note {
443        out.push('\n');
444        out.push_str(note);
445    }
446    out
447}
448
449/// Apply a planned promote: the acknowledgment gate (BG-D6, fail-closed, INSIDE apply) → `would_conflict`
450/// pre-check (BG-D9 — a second live promote on this target, or any other resolver-level conflict; refuses
451/// BEFORE appending, NOT last-wins) → append + save. Reloads `events`/`cfg` fresh from `session`
452/// (arch-m-new-3: `plan_promote` took no `Session`) — a single synchronous CLI/TUI invocation cannot
453/// append anything between `plan_promote` and `apply_promote`, so this is behavior-preserving.
454pub fn apply_promote(
455    session: &mut Session,
456    plan: PromotePlan,
457    acknowledge: Option<&str>,
458    now: OffsetDateTime,
459) -> Result<EventId, CliError> {
460    require_promote_ack(acknowledge)?;
461
462    let events = load_all(session.conn())?;
463    let cfg = session.config()?.to_projection();
464
465    // BG-D9: pre-check `would_conflict` (a second live promote on this target, or any other resolver-level
466    // conflict, e.g. UX-P4-3) — refuse BEFORE appending (fail-closed). NOT last-wins.
467    if let Some(detail) =
468        btctax_core::would_conflict(&events, session.prices(), &cfg, &plan.payload, now)
469    {
470        return Err(CliError::Usage(format!(
471            "cannot record this promote — a decision conflict: {detail}"
472        )));
473    }
474
475    let id = append_decision(session.conn(), plan.payload, now, UtcOffset::UTC, None)?;
476    session.save()?;
477    Ok(id)
478}
479
480// ════════════════════════════════════════════════════════════════════════════════════════════════
481// Task 2 — the DECLARE chokepoint: `plan_declare`/`apply_declare` (module doc has the full contract).
482// ════════════════════════════════════════════════════════════════════════════════════════════════
483
484/// Everything needed to append a `DeclareTranche` decision. Unlike `PromotePlan`, declaring has no
485/// acknowledgment gate or consent screen (DFW-D8 — a plain `$0`, revocable confirmation) — so there is
486/// nothing else to carry beside the payload itself.
487#[derive(Debug, Clone, PartialEq, Eq)]
488pub struct DeclarePlan {
489    /// The `EventPayload::DeclareTranche` `apply_declare` appends verbatim.
490    pub payload: EventPayload,
491}
492
493/// Plan a `DeclareTranche` decision. Gates on the shipped set ALWAYS — `sat>0`, `ws<=we`,
494/// `guard_tranche_vs_allocation` (`cmd::tranche`, the single source of that guard for all four allocation
495/// append sites — NOT duplicated here) — replicating `cmd/tranche.rs:134-154` exactly (arch-m-new-3: no
496/// `Session` — the caller supplies its own already-loaded `events`/`prices`/`cfg`, mirroring
497/// `plan_promote`).
498///
499/// **Iff `target_shortfall = Some(id)`**, ALSO runs the DFW-D5.2 target-scoped clearance shadow: append
500/// the candidate `DeclareTranche` → re-project (pseudo FORCED off on a config COPY, mirroring
501/// `would_conflict`, `project/mod.rs:118`) → assert no `BlockerKind::UncoveredDisposal` remains on `id`;
502/// else `Refusal::Coverage`. **Forcing pseudo off here is load-bearing (arch-I-5):** a synthetic
503/// `SelfTransferMine{$0}` pseudo default must never stand in for a real, documented cover — else a
504/// dashboard candidate could be reported as "clears the shortfall" when only a fictional, non-persisted
505/// default actually covered it.
506///
507/// `target_shortfall = None` (the CLI free-form `declare-tranche` path) never runs the clearance shadow —
508/// the shipped verb's gate set, byte-for-byte (DFW-D8/SPEC §5).
509#[allow(clippy::too_many_arguments)]
510pub fn plan_declare(
511    events: &[LedgerEvent],
512    prices: &dyn PriceProvider,
513    cfg: &ProjectionConfig,
514    sat: Sat,
515    wallet: WalletId,
516    window_start: TaxDate,
517    window_end: TaxDate,
518    target_shortfall: Option<EventId>,
519    now: OffsetDateTime,
520) -> Result<DeclarePlan, Refusal> {
521    // The shipped gate set (cmd/tranche.rs:134-154), byte-for-byte.
522    if sat <= 0 {
523        // A `sat <= 0` tranche would bump `stats.sigma_in` by a non-positive amount (fold.rs),
524        // corrupting Σ-conservation; there is no such thing as declaring zero/negative undocumented BTC.
525        return Err(Refusal::Coverage(format!(
526            "tranche amount must be > 0 sat (got {sat})"
527        )));
528    }
529    if window_start > window_end {
530        return Err(Refusal::Coverage(format!(
531            "tranche window_start ({window_start}) must be <= window_end ({window_end})"
532        )));
533    }
534    crate::cmd::tranche::guard_tranche_vs_allocation(events, window_end).map_err(|e| match e {
535        CliError::Usage(m) => Refusal::Coverage(m),
536        other => Refusal::Coverage(other.to_string()), // unreachable today (the guard only ever returns Usage)
537    })?;
538
539    let payload = EventPayload::DeclareTranche(DeclareTranche {
540        sat,
541        wallet,
542        window_start,
543        window_end,
544    });
545
546    // DFW-D5.2: the target-scoped clearance shadow — ONLY when the caller names a shortfall to cover.
547    if let Some(id) = target_shortfall {
548        // ★ arch-I-5: pseudo FORCED off on a COPY (ProjectionConfig is Copy) — mirrors `would_conflict`
549        // (project/mod.rs:118). Without this, a pseudo-active vault could report a candidate as clearing
550        // a shortfall that only a synthetic, non-persisted SelfTransferMine{$0} default actually covered.
551        let mut honest_cfg = *cfg;
552        honest_cfg.pseudo_reconcile = false;
553
554        // Append the candidate as the resolver would (the next decision seq — mirrors `would_conflict`
555        // and `with_synthetic_promote` above).
556        let next_seq = events
557            .iter()
558            .filter_map(|e| match e.id {
559                EventId::Decision { seq } => Some(seq),
560                _ => None,
561            })
562            .max()
563            .map_or(1, |m| m + 1);
564        let candidate = LedgerEvent {
565            id: EventId::decision(next_seq),
566            utc_timestamp: now,
567            original_tz: UtcOffset::UTC,
568            wallet: None,
569            payload: payload.clone(),
570        };
571        let mut with_candidate = events.to_vec();
572        with_candidate.push(candidate);
573
574        let state = project(&with_candidate, prices, &honest_cfg);
575        let still_uncovered = state
576            .blockers
577            .iter()
578            .any(|b| b.kind == BlockerKind::UncoveredDisposal && b.event.as_ref() == Some(&id));
579        if still_uncovered {
580            return Err(Refusal::Coverage(format!(
581                "this candidate tranche does not clear the shortfall on {} — after adding it, an \
582                 UncoveredDisposal blocker still remains on that event. A tranche's synthetic acquisition \
583                 lands at window_end and sorts AFTER a same-instant import, so window_end must be \
584                 STRICTLY BEFORE the short event's date (and the wallet/sat must actually cover it) to \
585                 clear it.",
586                id.canonical()
587            )));
588        }
589    }
590
591    Ok(DeclarePlan { payload })
592}
593
594/// Apply a planned declare: append + save. No acknowledgment gate and no `would_conflict` pre-check
595/// (DFW-D8 — declaring is a plain `$0`, revocable confirmation, unlike promote's typed-phrase tier; the
596/// shipped verb never ran a `would_conflict` check either — `cmd/tranche.rs:166-174` appends immediately
597/// once `guard_tranche_vs_allocation` passes).
598pub fn apply_declare(
599    session: &mut Session,
600    plan: DeclarePlan,
601    now: OffsetDateTime,
602) -> Result<EventId, CliError> {
603    let id = append_decision(session.conn(), plan.payload, now, UtcOffset::UTC, None)?;
604    session.save()?;
605    Ok(id)
606}
607
608// ════════════════════════════════════════════════════════════════════════════════════════════════
609// 8275-completeness year enumeration. (This sat inside the Task-3 EXPORT region until 0.13.0; the
610// composed multi-year export trio around it was deleted, but this helper is a LIVE `pub(crate)`
611// dependency of `cmd/admin.rs`'s `promote_export_gate` and keeps its own unit test below.)
612
613/// BG-D8's 8275-completeness year enumeration ONLY — extracted verbatim from `promote_export_gate`'s
614/// `None` arm (`cmd/admin.rs`) so the gate and any other 8275-completeness caller single-source it (SPEC
615/// DFW-D11: "single-sourced ... but it is NOT the export set"). A year is included iff a PROMOTED
616/// disposal leg (`lot_id.origin_event_id ∈ state.promoted_origins`) files in it — DISPOSAL legs only; a
617/// promote's REMOVAL (donation/gift) reorder is invisible here by design — that is exactly what
618/// `flagged_years` (`btctax_core::conservative`), below, exists to also catch.
619///
620/// ★ whole-branch arch M-2: `pub(crate)`, not `pub`. Its only production caller is in-crate
621/// (`cmd/admin.rs`'s `promote_export_gate`); it was `pub` solely to serve one integration test, which
622/// would have put it on btctax-cli's v0.10.0 PUBLIC API for good. Public API is far cheaper to narrow
623/// before the first release than after. The disposal-legs-only contract is pinned by the in-crate unit
624/// test below instead.
625pub(crate) fn promoted_filing_years(state: &LedgerState) -> BTreeSet<i32> {
626    let mut years = BTreeSet::new();
627    for d in &state.disposals {
628        if d.legs
629            .iter()
630            .any(|l| state.promoted_origins.contains(&l.lot_id.origin_event_id))
631        {
632            years.insert(d.disposed_at.year());
633        }
634    }
635    years
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use btctax_core::state::{Disposal, DisposalLeg, Term};
642    use btctax_core::{BasisSource, DisposeKind, LotId};
643    use rust_decimal_macros::dec;
644    use time::macros::date;
645
646    fn wallet() -> WalletId {
647        WalletId::SelfCustody {
648            label: "pfy".into(),
649        }
650    }
651
652    fn leg(origin: EventId) -> DisposalLeg {
653        DisposalLeg {
654            lot_id: LotId {
655                origin_event_id: origin,
656                split_sequence: 0,
657            },
658            sat: 1_000_000,
659            proceeds: dec!(100),
660            basis: dec!(0),
661            gain: dec!(100),
662            term: Term::LongTerm,
663            basis_source: BasisSource::EstimatedConservative,
664            gift_zone: None,
665            acquired_at: date!(2016 - 03 - 31),
666            wallet: wallet(),
667            pseudo: false,
668        }
669    }
670
671    fn disposal(year: i32, origin: EventId) -> Disposal {
672        Disposal {
673            event: EventId::decision(90 + year as u64 % 10),
674            kind: DisposeKind::Sell,
675            disposed_at: time::Date::from_calendar_date(year, time::Month::June, 1).unwrap(),
676            legs: vec![leg(origin)],
677            fee_mini_disposition: false,
678        }
679    }
680
681    /// ★ whole-branch arch M-2 (replaces the cross-crate assertion that forced `promoted_filing_years`
682    /// onto btctax-cli's PUBLIC API): the BG-D8 8275-completeness enumeration is DISPOSAL-LEG-scoped and
683    /// PROMOTE-scoped — a year files here iff a disposal leg in it draws a lot whose origin is in
684    /// `state.promoted_origins`. A year whose only change is a REMOVAL (donation/gift) reorder, or whose
685    /// disposal legs draw an UNPROMOTED origin, is correctly absent — that is precisely the gap
686    /// `conservative::flagged_years` (the DFW-D11 export set) exists to cover, and why the gate's set is
687    /// explicitly NOT the export set.
688    ///
689    /// Mutation: drop the `promoted_origins` membership test (enumerate every disposal year) → 2024
690    /// appears → reds.
691    #[test]
692    fn promoted_filing_years_enumerates_promoted_disposal_legs_only() {
693        let promoted = EventId::decision(1);
694        let unpromoted = EventId::decision(2);
695
696        let state = LedgerState {
697            disposals: vec![
698                disposal(2024, unpromoted.clone()),
699                disposal(2025, promoted.clone()),
700            ],
701            promoted_origins: [promoted].into_iter().collect(),
702            ..Default::default()
703        };
704
705        let years = promoted_filing_years(&state);
706        assert_eq!(
707            years.iter().copied().collect::<Vec<_>>(),
708            vec![2025],
709            "only the year holding a PROMOTED disposal leg is enumerated: {years:?}"
710        );
711        assert!(
712            !years.contains(&2024),
713            "an unpromoted disposal leg's year must NOT enter the 8275-completeness set: {years:?}"
714        );
715    }
716}