Skip to main content

btctax_cli/
optimize_attest.rs

1//! `optimize_attestation` side-table — persists the user's per-disposal contemporaneous-ID
2//! attestations (a projection input, **not** ledger state). The `attested_set` accessor feeds the
3//! `compliance_overlay` as its `attested: &BTreeSet<EventId>` argument; `get` returns the stored
4//! attestation text so the overlay can enforce attested-binds-the-exact-selection (R2-I1: a later
5//! divergent pick is NOT covered). Modeled on `tax_profile.rs` discipline (idempotent DDL, defensive
6//! guard on every accessor, BTreeSet for NFR4 determinism).
7use crate::CliError;
8use btctax_core::EventId;
9use rusqlite::{Connection, OptionalExtension};
10use std::collections::{BTreeMap, BTreeSet};
11
12/// Create the `optimize_attestation` side-table if it does not exist (idempotent).
13/// Called by `Session::from_fresh_vault`; also called at the top of every accessor as a
14/// defensive ensure-table-then-read guard (robust to older vaults).
15pub fn init_table(conn: &Connection) -> Result<(), CliError> {
16    conn.execute_batch(
17        "CREATE TABLE IF NOT EXISTS optimize_attestation \
18         (disposal_event TEXT PRIMARY KEY, attestation TEXT NOT NULL, attested_at TEXT NOT NULL);",
19    )?;
20    Ok(())
21}
22
23/// Record a narrow attestation for `disposal` (upsert — replaces any prior value).
24/// `attestation` is a caller-supplied opaque string (e.g. JSON-encoded `LotSelection`) that
25/// binds the exact lot picks the user attested; `attested_at` is an ISO-8601 date string.
26pub fn set(
27    conn: &Connection,
28    disposal: &EventId,
29    attestation: &str,
30    attested_at: &str,
31) -> Result<(), CliError> {
32    init_table(conn)?;
33    conn.execute(
34        "INSERT INTO optimize_attestation(disposal_event,attestation,attested_at) \
35         VALUES(?1,?2,?3) \
36         ON CONFLICT(disposal_event) DO UPDATE \
37         SET attestation=excluded.attestation, attested_at=excluded.attested_at",
38        rusqlite::params![disposal.canonical(), attestation, attested_at],
39    )?;
40    Ok(())
41}
42
43/// Return the stored attestation text for `disposal`, or `None` if none has been recorded.
44/// Robust to older vaults (defensive `init_table` guard).
45pub fn get(conn: &Connection, disposal: &EventId) -> Result<Option<String>, CliError> {
46    init_table(conn)?;
47    Ok(conn
48        .query_row(
49            "SELECT attestation FROM optimize_attestation WHERE disposal_event=?1",
50            [disposal.canonical()],
51            |r| r.get(0),
52        )
53        .optional()?)
54}
55
56/// Return all stored attestations as a `BTreeMap<EventId, (String, String)>`, where each value
57/// is `(attestation, attested_at)`, keyed by the disposal `EventId` (NFR4-stable deterministic
58/// order). CREATE-IF-NOT-EXISTS guard first. Mirrors `tax_profile::all` discipline.
59pub fn all(conn: &Connection) -> Result<BTreeMap<EventId, (String, String)>, CliError> {
60    init_table(conn)?;
61    let mut stmt = conn.prepare(
62        "SELECT disposal_event, attestation, attested_at \
63         FROM optimize_attestation ORDER BY disposal_event",
64    )?;
65    let rows = stmt.query_map([], |r| {
66        Ok((
67            r.get::<_, String>(0)?,
68            r.get::<_, String>(1)?,
69            r.get::<_, String>(2)?,
70        ))
71    })?;
72    let mut out = BTreeMap::new();
73    for row in rows {
74        let (disposal_str, attestation, attested_at) = row?;
75        out.insert(
76            crate::eventref::parse_event_id(&disposal_str)?,
77            (attestation, attested_at),
78        );
79    }
80    Ok(out)
81}
82
83/// Delete the `optimize_attestation` row for `disposal` (idempotent — no-op if absent).
84/// Called by `reconcile void` when the voided decision is a `LotSelection`, so that revocation
85/// is COMPLETE: the decision void and the row clear are co-persisted in the same `session.save()`.
86pub fn clear(conn: &Connection, disposal: &EventId) -> Result<(), CliError> {
87    init_table(conn)?;
88    conn.execute(
89        "DELETE FROM optimize_attestation WHERE disposal_event=?1",
90        [disposal.canonical()],
91    )?;
92    Ok(())
93}
94
95/// Return all attested disposal `EventId`s as a sorted `BTreeSet` (NFR4-stable).
96/// Feeds the `compliance_overlay` as its `attested` input. Robust to older vaults.
97pub fn attested_set(conn: &Connection) -> Result<BTreeSet<EventId>, CliError> {
98    init_table(conn)?;
99    let mut stmt =
100        conn.prepare("SELECT disposal_event FROM optimize_attestation ORDER BY disposal_event")?;
101    let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
102    let mut out = BTreeSet::new();
103    for r in rows {
104        out.insert(crate::eventref::parse_event_id(&r?)?);
105    }
106    Ok(out)
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use btctax_core::{EventId, Source, SourceRef};
113
114    fn eid(seq: u64) -> EventId {
115        EventId::decision(seq)
116    }
117
118    fn eid_import(src_ref: &str) -> EventId {
119        EventId::import(Source::Coinbase, SourceRef::new(src_ref.to_string()))
120    }
121
122    fn mem() -> rusqlite::Connection {
123        let c = rusqlite::Connection::open_in_memory().unwrap();
124        init_table(&c).unwrap();
125        c
126    }
127
128    #[test]
129    fn set_then_get_round_trips() {
130        let c = mem();
131        let disposal = eid(1);
132        set(&c, &disposal, r#"{"lots":[]}"#, "2025-03-15").unwrap();
133        let stored = get(&c, &disposal).unwrap().unwrap();
134        assert_eq!(stored, r#"{"lots":[]}"#);
135    }
136
137    #[test]
138    fn get_missing_returns_none() {
139        let c = mem();
140        assert_eq!(get(&c, &eid(99)).unwrap(), None);
141    }
142
143    #[test]
144    fn get_on_tableless_vault_returns_none() {
145        let c = rusqlite::Connection::open_in_memory().unwrap(); // no init_table
146        assert_eq!(get(&c, &eid(1)).unwrap(), None);
147    }
148
149    #[test]
150    fn attested_set_on_tableless_vault_returns_empty() {
151        let c = rusqlite::Connection::open_in_memory().unwrap(); // no init_table
152        assert!(attested_set(&c).unwrap().is_empty());
153    }
154
155    #[test]
156    fn attested_set_returns_keys_in_deterministic_sorted_order() {
157        let c = mem();
158        // Insert in arbitrary order; expect BTreeSet ordering on canonical strings.
159        let d3 = eid(3);
160        let d1 = eid(1);
161        let d2 = eid(2);
162        set(&c, &d3, "sel3", "2025-01-03").unwrap();
163        set(&c, &d1, "sel1", "2025-01-01").unwrap();
164        set(&c, &d2, "sel2", "2025-01-02").unwrap();
165        let set_out = attested_set(&c).unwrap();
166        // BTreeSet<EventId> — ordering by EventId's Ord impl; all must be present.
167        assert_eq!(set_out.len(), 3);
168        assert!(set_out.contains(&d1));
169        assert!(set_out.contains(&d2));
170        assert!(set_out.contains(&d3));
171        // Verify iteration order is stable across calls (determinism check).
172        let set_out2 = attested_set(&c).unwrap();
173        let keys1: Vec<_> = set_out.iter().collect();
174        let keys2: Vec<_> = set_out2.iter().collect();
175        assert_eq!(keys1, keys2);
176    }
177
178    #[test]
179    fn upsert_replaces_prior_attestation() {
180        let c = mem();
181        let disposal = eid(1);
182        set(&c, &disposal, "original_sel", "2025-01-01").unwrap();
183        set(&c, &disposal, "updated_sel", "2025-06-01").unwrap();
184        assert_eq!(get(&c, &disposal).unwrap().unwrap(), "updated_sel");
185    }
186
187    #[test]
188    fn all_returns_both_attestations_in_deterministic_order() {
189        let c = mem();
190        let d1 = eid(1);
191        let d2 = eid(2);
192        // Insert in reverse order to verify BTreeMap (not insertion order) drives output.
193        set(&c, &d2, r#"{"lots":["lot2"]}"#, "2025-04-01").unwrap();
194        set(&c, &d1, r#"{"lots":["lot1"]}"#, "2025-03-15").unwrap();
195        let result = all(&c).unwrap();
196        assert_eq!(result.len(), 2);
197        let (sel1, at1) = result.get(&d1).expect("d1 must be present");
198        assert_eq!(sel1, r#"{"lots":["lot1"]}"#);
199        assert_eq!(at1, "2025-03-15");
200        let (sel2, at2) = result.get(&d2).expect("d2 must be present");
201        assert_eq!(sel2, r#"{"lots":["lot2"]}"#);
202        assert_eq!(at2, "2025-04-01");
203        // BTreeMap iteration order is by EventId (deterministic).
204        let mut keys = result.keys();
205        assert_eq!(keys.next().unwrap(), &d1);
206        assert_eq!(keys.next().unwrap(), &d2);
207    }
208
209    #[test]
210    fn all_on_tableless_vault_returns_empty() {
211        let c = rusqlite::Connection::open_in_memory().unwrap(); // no init_table
212        assert!(all(&c).unwrap().is_empty());
213    }
214
215    #[test]
216    fn r2_i1_side_table_to_overlay_enforcement() {
217        // Full side-table → attested_set → compliance_overlay integration (R2-I1).
218        // `e2e_attested_divergent_stays_noncompliant` in btctax-core covers the optimize_year
219        // end-to-end path; this test exercises the enforcement using the actual side-table
220        // accessors (`set` + `attested_set` + `all`) feeding `compliance_overlay` directly.
221        use btctax_core::identity::WalletId;
222        use btctax_core::optimize::compliance_overlay;
223        use btctax_core::project::{ComplianceStatus, DisposalCompliance};
224        use time::macros::date;
225
226        let c = mem();
227        let disposal_d = eid(42);
228        let attested_sel = r#"{"lots":[{"lot":"decision|1","sat":100000}]}"#;
229
230        // Record attestation in the side-table.
231        set(&c, &disposal_d, attested_sel, "2025-03-15").unwrap();
232
233        // `attested_set` feeds the overlay — must reflect the stored disposal.
234        let attested = attested_set(&c).unwrap();
235        assert!(
236            attested.contains(&disposal_d),
237            "attested_set must reflect the stored attestation"
238        );
239
240        let wallet = WalletId::SelfCustody {
241            label: "cold".into(),
242        };
243        let row = DisposalCompliance {
244            disposal: disposal_d.clone(),
245            wallet: wallet.clone(),
246            date: date!(2026 - 06 - 01),
247            status: ComplianceStatus::NonCompliant,
248        };
249
250        // Case 1 — R2-I1 no-laundering: D is attested but the proposed pick DIVERGED from the
251        // persisted one (D ∉ unchanged). `compliance_overlay` must NOT upgrade to AttestedRecording.
252        let unchanged_empty = std::collections::BTreeSet::new();
253        let result_divergent =
254            compliance_overlay(std::slice::from_ref(&row), &attested, &unchanged_empty);
255        assert_eq!(
256            result_divergent[0].status,
257            ComplianceStatus::NonCompliant,
258            "R2-I1: divergent pick (D ∉ unchanged) must stay NonCompliant even when attested"
259        );
260
261        // Case 2 — positive control: D is attested AND the proposed pick equals the persisted
262        // one (D ∈ unchanged, self-custody envelope). Overlay must upgrade to AttestedRecording.
263        let unchanged_with_d: std::collections::BTreeSet<_> = [disposal_d.clone()].into();
264        let result_unchanged =
265            compliance_overlay(std::slice::from_ref(&row), &attested, &unchanged_with_d);
266        assert_eq!(
267            result_unchanged[0].status,
268            ComplianceStatus::AttestedRecording,
269            "positive control: attested + unchanged self-custody must upgrade to AttestedRecording"
270        );
271
272        // Also verify `all` returns the stored record with correct attestation text and timestamp.
273        let all_records = all(&c).unwrap();
274        assert_eq!(all_records.len(), 1);
275        let (stored_sel, stored_at) = all_records.get(&disposal_d).expect("D must be in all");
276        assert_eq!(stored_sel, attested_sel);
277        assert_eq!(stored_at, "2025-03-15");
278    }
279
280    #[test]
281    fn attested_set_reflects_all_stored_disposals() {
282        let c = mem();
283        let d_a = eid_import("TX-A");
284        let d_b = eid_import("TX-B");
285        set(&c, &d_a, "sel_a", "2025-01-01").unwrap();
286        set(&c, &d_b, "sel_b", "2025-01-02").unwrap();
287        let s = attested_set(&c).unwrap();
288        assert!(s.contains(&d_a));
289        assert!(s.contains(&d_b));
290        assert!(!s.contains(&eid(99))); // unrelated disposal not present
291    }
292
293    #[test]
294    fn table_created_on_existing_conn_without_explicit_init() {
295        // Simulates the defensive-guard path: vault opened (no prior init_table) and a get/set
296        // is called directly — the guard inside each function creates the table.
297        let c = rusqlite::Connection::open_in_memory().unwrap();
298        let disposal = eid(5);
299        // No init_table called explicitly — the defensive guard in set must handle it.
300        set(&c, &disposal, "some_sel", "2025-05-01").unwrap();
301        assert_eq!(get(&c, &disposal).unwrap().unwrap(), "some_sel");
302    }
303}