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
use crate::conventions::{Sat, TaxDate, Usd};
use crate::event::{BasisSource, DisposeKind, IncomeKind};
use crate::identity::{EventId, LotId, WalletId};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Term {
ShortTerm,
LongTerm,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GiftZone {
Gain,
Loss,
NoGainNoLoss,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Hard,
Advisory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum BlockerKind {
FmvMissing,
UncoveredDisposal,
ImportConflict,
DecisionConflict,
UnknownBasisInbound,
Unclassified,
SafeHarborUnconservable,
SafeHarborTimebar,
UnmatchedOutflows,
Pre2025MethodNote,
/// §A.5(a): a `MethodElection` whose `effective_from` precedes its made-date or TRANSITION_DATE —
/// a standing order cannot be back-dated (§1.1012-1(j) no post-hoc identification). Hard.
MethodElectionBackdated,
/// §A.4: a `LotSelection` that fails validation (unknown/cross-wallet/over-drawn lot, or principal
/// mismatch). Hard — the named identification is unusable so the disposal's tax is gated.
LotSelectionInvalid,
/// §A.7 / §7.4: the live `pre2025_method` config differs from the GOVERNING (effective) allocation's
/// recorded `pre2025_method`. The allocation conserves under ITS recorded method, so this is a method
/// drift — NOT bad data (never `SafeHarborUnconservable`). Hard: a post-attestation method change would
/// silently break conservation between the pre-2025 residue and the irrevocable allocation. Clearable by
/// reverting the live config to the recorded method; the irrevocable allocation is never rewritten.
Pre2025MethodConflictsAllocation,
/// §B.4 / B-I1: the projection carries an unresolved Hard blocker (`severity()==Hard`) anywhere, so B
/// refuses to present a number for the year (projection-wide gate). Returns a `TaxOutcome::NotComputable`
/// with this kind. Clearable by resolving the underlying Hard blocker. Hard.
TaxYearNotComputable,
/// §B.1: no `tax_profile` is set for the year being computed. B does not guess the surrounding tax
/// context — the user must supply it via `tax-profile set`. Hard.
TaxProfileMissing,
/// §B.2: no bundled tax table is available for the year being computed. Hard.
TaxTableMissing,
/// §170(f)(11)(C): the term-aware claimed-deduction proxy for a charitable donation exceeds
/// $5,000 — a qualified appraisal is likely required (CCA 202302012 confirms the
/// exchange-price/readily-valued exception does NOT apply to crypto).
/// **Advisory** — never gates `compute_tax_year`; emitted per qualifying Donate event.
QualifiedAppraisalNote,
/// Cycle A: an inbound self-transfer (`InboundClass::SelfTransferMine`) whose basis was DEFAULTED
/// to $0 (`basis == None`). $0 is a *computable*, conservative value (never gates a later disposal —
/// contrast the Hard `UnknownBasisInbound`, which creates NO lot); this is a pure honesty prompt to
/// supply the real cost. Fires on `None` only, never on an attested `Some(0)`.
/// **Advisory** — never gates `compute_tax_year`.
SelfTransferInboundZeroBasis,
/// [reconcile-defaults] An inbound self-transfer (`InboundClass::SelfTransferMine`) whose acquisition
/// date was DEFAULTED (`acquired_at == None`) to 1 year + 1 day before receipt → the holding period is
/// assumed LONG-TERM. A pure honesty prompt to supply the real `--acquired` date if the coins were held
/// ≤ 1 year. Fires INDEPENDENTLY of the basis default (so a supplied `--basis` with no `--acquired`
/// still discloses the backdating). **Advisory** — never gates `compute_tax_year`.
SelfTransferInboundDefaultedAcquired,
/// Pseudo-reconcile mode (sub-project 2) is ON and at least one synthetic (non-persisted) default
/// is CONTRIBUTING to this projection. A loud "this picture is a placeholder — correct it toward
/// truth, and do NOT file it" banner (renders automatically via `{:?}` in `verify`). Drives the
/// interim export-refusal guard (sub-2) until the sub-3 typed-attest gate ships.
/// **Advisory** — never gates `compute_tax_year` (the mode's whole point is to PRESENT a number).
PseudoReconcileActive,
}
impl BlockerKind {
pub fn severity(self) -> Severity {
use BlockerKind::*;
match self {
FmvMissing
| UncoveredDisposal
| ImportConflict
| DecisionConflict
| UnknownBasisInbound
| Unclassified
| SafeHarborUnconservable
| MethodElectionBackdated
| LotSelectionInvalid
| Pre2025MethodConflictsAllocation
| TaxYearNotComputable
| TaxProfileMissing
| TaxTableMissing => Severity::Hard,
SafeHarborTimebar
| UnmatchedOutflows
| Pre2025MethodNote
| QualifiedAppraisalNote
| SelfTransferInboundZeroBasis
| SelfTransferInboundDefaultedAcquired
| PseudoReconcileActive => Severity::Advisory,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Blocker {
pub kind: BlockerKind,
pub event: Option<EventId>,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Lot {
pub lot_id: LotId,
pub wallet: WalletId,
pub acquired_at: TaxDate, // gift loss-zone HP start = this (gift date); see donor_acquired_at for tacking
pub original_sat: Sat,
pub remaining_sat: Sat,
pub usd_basis: Usd, // gain basis
pub basis_source: BasisSource,
pub dual_loss_basis: Option<Usd>, // received gifts (TP11): loss basis when FMV-at-gift < donor basis
pub donor_acquired_at: Option<TaxDate>, // tacking (TP11/§1223(2)); gain/no-dual HP start
pub basis_pending: bool, // FMV-missing income / unknown-basis gift: gain is gated until resolved
/// Pseudo-reconcile taint (sub-project 2, [R0-C1]): `true` when this lot's EXISTENCE or its BASIS
/// traces to a synthetic (non-persisted) default decision — e.g. a `SelfTransferMine{$0}` conjured
/// for an unknown-basis inbound, or a relocated fragment carrying a pseudo source lot's taint. Rides
/// the DATA (`Lot`→`Consumed`→leg) so a REAL Sell consuming a pseudo `$0`-basis lot renders FLAGGED,
/// never as a clean `proceeds − 0`. A DEDICATED bool — never a `BasisSource` variant — so the
/// CSV/form writers OMIT it (it must NEVER reach any export file). Always `false` outside pseudo mode.
pub pseudo: bool,
}
impl Lot {
/// HP start used on the gain side / no-dual case (tacks donor period when present).
pub fn gain_hp_start(&self) -> TaxDate {
self.donor_acquired_at.unwrap_or(self.acquired_at)
}
/// HP start used on the loss side of a dual-basis gift (the gift/received date).
pub fn loss_hp_start(&self) -> TaxDate {
self.acquired_at
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisposalLeg {
pub lot_id: LotId,
pub sat: Sat,
pub proceeds: Usd, // allocated net proceeds (gross − disposition fee, TP2)
pub basis: Usd, // tax-reported basis (zone-dependent for dual-basis gifts)
pub gain: Usd,
pub term: Term,
pub basis_source: BasisSource,
pub gift_zone: Option<GiftZone>,
/// Zone-aware holding-period start: the SAME HP-start passed to `term_for` for this leg.
/// In the §1015 dual-basis loss zone this is `loss_hp_start` (the gift date — loss basis
/// does NOT tack); in all other zones it is `gain_hp_start` (tacked donor date for gifts,
/// acquisition date otherwise). Must never contradict `leg.term`. [R0-C1]
pub acquired_at: TaxDate,
/// The wallet that held the consumed lot at disposal time — the ONLY sound source (D1 [R0-I1]).
pub wallet: WalletId,
/// Pseudo-reconcile taint (sub-project 2, [R0-C1]): `true` when this leg's basis/existence traces to
/// a synthetic default — set from the consumed lot's `pseudo` bit OR the disposal event itself being
/// synthetic. Renders `[PSEUDO]` on screen; OMITTED from every CSV/form writer.
pub pseudo: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Disposal {
pub event: EventId,
pub kind: DisposeKind,
pub disposed_at: TaxDate,
pub legs: Vec<DisposalLeg>,
/// TP8 config-(b) fee-sat mini-disposition: a RECOGNITION record only — excluded from FR9 Σdisposed
/// (its sats live in Σ on-chain-fee-sats; no second conservation entry).
pub fee_mini_disposition: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemovalKind {
Gift,
Donation,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemovalLeg {
pub lot_id: LotId,
pub sat: Sat,
pub basis: Usd,
pub fmv_at_transfer: Usd,
pub term: Term,
pub basis_source: BasisSource,
/// Holding-period start = the SAME HP-start passed to `term_for` for this leg (`gain_hp_start`).
/// A removal (gift/donation) recognizes NO gain/loss (TP10), so there is NO §1015 dual-basis
/// loss-zone HP-start divergence like a `DisposalLeg` — this is ALWAYS `gain_hp_start` and can
/// never contradict `leg.term`. For a gift-received-then-donated lot, `gain_hp_start` is the
/// tacked donor acquisition date (§1223), which is the correct Form 8283 "date acquired" because
/// it matches the leg's holding-period `term` [R0-M2]. Must never contradict `leg.term`. [D1]
pub acquired_at: TaxDate,
/// Pseudo-reconcile taint (sub-project 2, [R0-C1]): `true` when this removal leg's basis/existence
/// traces to a synthetic default (consumed lot pseudo OR the removal event synthetic). Renders
/// `[PSEUDO]` on screen; OMITTED from removals.csv / Form 8283.
pub pseudo: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Removal {
pub event: EventId,
pub kind: RemovalKind,
pub removed_at: TaxDate,
pub legs: Vec<RemovalLeg>,
pub appraisal_required: bool, // donation (>$5k FMV over-flag, FOLLOWUPS)
pub donor_acquired_at: Option<TaxDate>,
/// §170(e)(1)(A) charitable-deduction amount for a Donation: `Some(Σ(LT→fmv; ST→min(fmv,basis)))`.
/// `None` for a Gift (not a charitable deduction). Standalone Schedule-A figure — does NOT
/// feed engine B / `compute_tax_year`. Pre-§170(b) AGI limits and carryover.
pub claimed_deduction: Option<Usd>,
/// Free-form donee identifier (Chunk 2). `None` for legacy events without the field.
/// Used by removals.csv and (for Donations) Form 8283. Does NOT feed engine B / tax math.
pub donee: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncomeRecord {
pub event: EventId,
pub recognized_at: TaxDate,
pub sat: Sat,
pub usd_fmv: Usd,
pub kind: IncomeKind,
pub business: bool,
/// Pseudo-reconcile taint (sub-project 2 / #41 Part B, [R0-I2]): `true` when this income's
/// recognized `usd_fmv` was SYNTHESIZED from the daily-close default in pseudo mode (a
/// `PseudoKind::PseudoFmv` — the import carried no FMV and a local price existed). Set from
/// `eff.pseudo` at BOTH fold push sites. The on-screen report flags such a row `[PSEUDO]`; the CSV
/// writers OMIT it (never exported — the estimate is export-gated). Always `false` outside pseudo
/// mode ⇒ projection byte-identical.
pub pseudo: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingLeg {
pub lot_id: LotId,
pub sat: Sat,
pub usd_basis: Usd,
pub acquired_at: TaxDate,
/// Pseudo-reconcile taint (sub-project 2, [R0-C1]): `true` when the lot removed into pending traces
/// to a synthetic default (e.g. a pseudo self-transfer-in lot later withdrawn). Never exported.
pub pseudo: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingTransfer {
pub event: EventId,
pub principal_sat: Sat,
pub fee_sat: Option<Sat>,
pub legs: Vec<PendingLeg>, // lots removed into pending (carry basis + acquired_at)
}
/// Defensive Filing Wizard Task 5 (arch-m-new-2/arch-I-2): a RAW per-emission shortfall record,
/// populated in `fold.rs` at every sat-carrying `BlockerKind::UncoveredDisposal` site (the fold already
/// has the sat amount at hand — this is NEVER derived from `Blocker.detail`). Multiple records can
/// share the same `event` (e.g. a self-transfer short on BOTH principal and its on-chain fee);
/// `defensive::discovery::shortfalls` aggregates them per event into a `Shortfall` (Σ principal+fee,
/// Σ fee separately). Additive/derived — no filed-number change.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShortfallRecord {
pub event: EventId,
pub wallet: Option<WalletId>,
pub date: TaxDate,
pub principal_sat: Sat,
pub fee_sat: Sat,
}
/// Fold accumulators that are NOT directly reconstructable from the post-fold `LedgerState` vectors
/// (FR9 `Σ in` / `Σ on-chain-fee-sats` / `Σ pending`). Carried as a FIELD on `LedgerState` (M3) —
/// `project` always returns `LedgerState` (NO `(LedgerState, FoldStats)` tuple). Populated in `finalize`;
/// a deterministic function of the events, so it is included in `PartialEq` and the determinism tests hold.
/// Zero-valued by `Default` (the early tasks leave it zero; Task 11 fills `fee_sats_consumed`, Task 13 the rest).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct FoldStats {
pub sigma_in: Sat, // externally-sourced acquisitions (Acquire + Income + classified GiftReceived)
pub fee_sats_consumed: Sat, // sole FR9 conservation home for network-fee sats
pub sigma_pending: Sat, // principal + fee sats sitting in pending_reconciliation
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LedgerState {
pub lots: Vec<Lot>,
pub holdings_by_wallet: BTreeMap<WalletId, Sat>,
pub disposals: Vec<Disposal>,
pub removals: Vec<Removal>,
pub income_recognized: Vec<IncomeRecord>,
pub pending_reconciliation: Vec<PendingTransfer>,
pub blockers: Vec<Blocker>,
pub stats: FoldStats, // M3: fold accumulators (FR9), on-state field — never a tuple return
/// Pseudo-reconcile (sub-project 2): count of synthetic (non-persisted) default decisions
/// CONTRIBUTING to this projection. `0` outside pseudo mode. Queryable signal for the banner, the
/// interim export-refusal guard [R0-I3], and sub-3's typed-attest gate. `> 0` ⇔ a
/// `PseudoReconcileActive` advisory blocker is present and every `[PSEUDO]`-flagged row is fictional.
pub pseudo_synthetic_count: usize,
/// Task 11 (BG-D3 tag-side census): the set of `DeclareTranche` origin event ids whose tranche is
/// PROMOTED in this projection (a live `PromoteTranche` rewrote its `$0` basis to a filed `>$0`
/// floor). A disposal/removal leg or a lot is promoted iff `lot_id.origin_event_id ∈
/// promoted_origins`. Populated by `fold` from `Resolution.promotes`; empty when no promote is live.
/// Lets the state-only advisories (`basis_methodology`, the overpayment nudge) distinguish a promoted
/// `>$0` estimate floor from a documented on-chain fee carry WITHOUT re-deriving the promote set —
/// both are `EstimatedConservative` with `>$0` basis, indistinguishable from the leg alone.
pub promoted_origins: BTreeSet<EventId>,
/// Defensive Filing Wizard Task 5: raw shortfall emissions (see `ShortfallRecord`), populated in
/// `fold.rs` at every sat-carrying `UncoveredDisposal` site. Aggregated by
/// `defensive::discovery::shortfalls`/`triage` — additive/derived, never a second source of truth.
pub shortfalls: Vec<ShortfallRecord>,
}
impl LedgerState {
/// Pseudo-reconcile (sub-project 2): `true` when any synthetic default contributes to this
/// projection. The single load-bearing signal for the export-refusal guard [R0-I3].
pub fn pseudo_active(&self) -> bool {
self.pseudo_synthetic_count > 0
}
pub(crate) fn add_blocker(
&mut self,
kind: BlockerKind,
event: Option<EventId>,
detail: impl Into<String>,
) {
self.blockers.push(Blocker {
kind,
event,
detail: detail.into(),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_blockers_are_hard() {
assert_eq!(
BlockerKind::MethodElectionBackdated.severity(),
Severity::Hard
);
assert_eq!(BlockerKind::LotSelectionInvalid.severity(), Severity::Hard);
assert_eq!(
BlockerKind::Pre2025MethodConflictsAllocation.severity(),
Severity::Hard
);
assert_eq!(BlockerKind::TaxProfileMissing.severity(), Severity::Hard);
assert_eq!(BlockerKind::TaxTableMissing.severity(), Severity::Hard);
assert_eq!(BlockerKind::TaxYearNotComputable.severity(), Severity::Hard);
// Task 1 KAT: QualifiedAppraisalNote MUST be Advisory — never Hard; must never gate compute_tax_year.
assert_eq!(
BlockerKind::QualifiedAppraisalNote.severity(),
Severity::Advisory
);
}
}