//! Journal of acknowledged observations: `acknowledgments.srf`. //! //! The observation engine produces findings ("Position concentration: NVDA //! at 18.2%") on every run. The user can acknowledge a finding via the //! review tab to record reasoning ("Holding through earnings cycle") and //! suppress it from the active findings list. The journal is the durable //! record of those acknowledgments. //! //! ## File format //! //! Compact-form SRF, discriminated union by `type::`. Records have a //! positional relationship: each `type::note` record attaches to the //! most-recently-seen `type::acknowledgment`. //! //! ### `type::acknowledgment` //! //! - `observation::` - check name, e.g. `position_concentration`. //! - `target::` - per-check string convention. `"NVDA"` for single-symbol //! observations; `"sector:Technology"` for sector-scoped; `"VTI,SCHD"` //! for pair-based observations like sector dominance. //! - `acknowledged_at::` - date the user first acked. Immutable after //! creation. //! - `state::` - `active` | `acknowledged` | `resolved`. //! - `unacknowledged_at::` - info-only breadcrumb, set when the user //! most recently un-acked. Persists across re-acks. //! - `resolved_at::` - info-only, set when the engine auto-resolves. //! //! Each ack is uniquely identified by `(observation, target)`. There is //! never more than one entry per pair - `setState` mutates in place; we //! don't preserve transition history (git tracks that on the file). //! //! ### `type::note` //! //! Zero or more per ack. One field: //! //! - `line::` - single-line content. Multi-line notes are written as N //! consecutive note records following the ack. //! //! Notes are positional: a note record attaches to the most-recent //! preceding ack record. A note record before any ack is a hard parse //! error (`error.OrphanedNote`). The visual layout in the file //! (ack followed by indented-feeling note records) makes the //! relationship obvious to a human reader. //! //! Example: //! //! ``` //! #!srfv1 //! type::acknowledgment,observation::position_concentration,target::NVDA,acknowledged_at::2026-06-08,state::acknowledged //! type::note,line::Holding through earnings cycle. //! type::note,line::Will trim by Q3 2026. //! type::acknowledgment,observation::sector_dominance,target::VTI,SCHD,acknowledged_at::2026-06-08,state::acknowledged //! ``` //! //! ## Lifecycle //! //! - **Read:** single-pass iterator over the file. Acks push a new //! `Entry`; notes append to the last entry. Orphan note => //! `error.OrphanedNote`. //! - **Write:** `append` / `setState` mutate the in-memory `entries` and //! atomic-rewrite the file via `atomic.writeFileAtomic`. //! - **Concurrency:** the file is per-portfolio (sibling of //! `portfolio.srf`); concurrent zfin invocations on the same portfolio //! would race, but that's the existing convention for every sibling //! file in this codebase. const std = @import("std"); const srf = @import("srf"); const Date = @import("../Date.zig"); const atomic = @import("../atomic.zig"); const Journal = @This(); allocator: std.mem.Allocator, entries: []Entry, pub const State = enum { active, acknowledged, resolved, }; /// One acknowledgment record. Uniquely identified by /// `(observation, target)`. pub const Acknowledgment = struct { observation: []const u8, target: []const u8, acknowledged_at: Date, state: State, unacknowledged_at: ?Date = null, resolved_at: ?Date = null, }; /// Wire-format note record: just a single line of text. Used only by /// the SRF parser/formatter; the rest of the codebase sees notes as /// `[]const u8` slices on `Entry.notes`. const NoteRecord = struct { line: []const u8, }; /// Discriminated-union over the two SRF record types in the journal. /// SRF dispatches on the `type` tag field by convention. Internal to /// the parser/formatter. const JournalRecord = union(enum) { acknowledgment: Acknowledgment, note: NoteRecord, }; /// In-memory ack with its notes already grouped. Built by `load`; /// consumed by callers that want "the ack and its reasoning together." pub const Entry = struct { ack: Acknowledgment, /// Note fragments in the order the user entered them. Each is an /// allocator-owned slice; freed by `Journal.deinit`. notes: []const []const u8, /// Concatenate the notes with newlines into a single string. /// Allocator-owned; caller frees. pub fn fullNote(self: Entry, allocator: std.mem.Allocator) ![]u8 { return try std.mem.join(allocator, "\n", self.notes); } }; pub fn deinit(self: *Journal) void { const a = self.allocator; for (self.entries) |entry| { a.free(entry.ack.observation); a.free(entry.ack.target); for (entry.notes) |line| a.free(line); a.free(entry.notes); } a.free(self.entries); self.* = undefined; } /// Find the entry matching `(observation, target)`. Returns null if /// not found. There's only ever one entry per pair (we don't /// preserve transition history), so no tiebreaker is needed. pub fn findByTarget( self: *const Journal, observation: []const u8, target: []const u8, ) ?*const Entry { for (self.entries) |*e| { if (!std.mem.eql(u8, e.ack.observation, observation)) continue; if (!std.mem.eql(u8, e.ack.target, target)) continue; return e; } return null; } /// Load and parse a journal file. Returns an empty journal when the /// file doesn't exist (first-time use case). `path` should be an /// absolute or cwd-relative path to `acknowledgments.srf`. pub fn load( allocator: std.mem.Allocator, io: std.Io, path: []const u8, ) !Journal { const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch |err| switch (err) { error.FileNotFound => { return .{ .allocator = allocator, .entries = try allocator.alloc(Entry, 0) }; }, else => return err, }; defer allocator.free(file_data); return try parse(allocator, file_data); } /// Parse pre-read file bytes into a `Journal`. Used by `load` and by /// tests that supply synthetic data. /// /// Walks records in a single pass. Each `type::acknowledgment` /// pushes a new entry with empty notes. Each `type::note` appends /// to the most-recent entry's notes. A note before any ack /// returns `error.OrphanedNote`. /// /// **Strict**: any record that fails to deserialize (missing /// required field, unknown enum variant, garbage bytes) propagates /// the error out of `parse`. We don't silently skip - a malformed /// record means user-visible data loss (acks suppress findings; /// dropping an ack pops a finding back into the active list with /// no explanation). Better to fail loud at load time so the user /// can fix the file. pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal { // Empty input => empty journal. `srf.iterator` requires a version // banner on the first line and errors out otherwise; short-circuit. if (data.len == 0) { return .{ .allocator = allocator, .entries = try allocator.alloc(Entry, 0), }; } var entries = std.ArrayList(Entry).empty; errdefer freeEntries(allocator, &entries); // Per-entry notes lists. Lives parallel to `entries` and is // converted to owned slices at the end. We use a separate list // (instead of mutating each entry's `notes` field as we go) // because `Entry.notes` is `[]const []const u8` - a const // slice - so we can't append to it after the entry is created. var notes_per_entry = std.ArrayList(std.ArrayList([]const u8)).empty; errdefer { for (notes_per_entry.items) |*notes| { for (notes.items) |line| allocator.free(line); notes.deinit(allocator); } notes_per_entry.deinit(allocator); } var reader = std.Io.Reader.fixed(data); var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return error.InvalidData; defer it.deinit(); while (try it.next()) |fields| { const rec = try fields.to(JournalRecord, .{}); switch (rec) { .acknowledgment => |a| { try entries.append(allocator, .{ .ack = .{ .observation = try allocator.dupe(u8, a.observation), .target = try allocator.dupe(u8, a.target), .acknowledged_at = a.acknowledged_at, .state = a.state, .unacknowledged_at = a.unacknowledged_at, .resolved_at = a.resolved_at, }, .notes = &.{}, // placeholder; replaced below }); try notes_per_entry.append(allocator, std.ArrayList([]const u8).empty); }, .note => |n| { if (entries.items.len == 0) return error.OrphanedNote; const last_notes = ¬es_per_entry.items[notes_per_entry.items.len - 1]; try last_notes.append(allocator, try allocator.dupe(u8, n.line)); }, } } // Move notes lists into their entries' `notes` fields as owned // slices. After this each entry owns its own notes; the // outer `notes_per_entry` list is empty. for (entries.items, 0..) |*entry, i| { entry.notes = try notes_per_entry.items[i].toOwnedSlice(allocator); } notes_per_entry.deinit(allocator); return .{ .allocator = allocator, .entries = try entries.toOwnedSlice(allocator), }; } /// Free a partially-built entries list (for parse errdefer). fn freeEntries(allocator: std.mem.Allocator, entries: *std.ArrayList(Entry)) void { for (entries.items) |entry| { allocator.free(entry.ack.observation); allocator.free(entry.ack.target); for (entry.notes) |line| allocator.free(line); allocator.free(entry.notes); } entries.deinit(allocator); } /// Append a new acknowledgment with notes, then atomic-rewrite the /// file. The journal's in-memory state is updated to reflect the new /// entry; caller owns the journal as before. /// /// `note_fragments` is the list of single-line strings the user /// entered (one Enter press per fragment in the TUI's note input). /// Pass an empty slice to record an ack with no reasoning. pub fn append( self: *Journal, io: std.Io, path: []const u8, new_ack: Acknowledgment, note_fragments: []const []const u8, ) !void { const a = self.allocator; // Build the new entry's owned strings first. errdefer cleanup // is fiddly because we need to roll back partial allocations // on any failure; doing it in stages keeps the pattern clear. const owned_obs = try a.dupe(u8, new_ack.observation); errdefer a.free(owned_obs); const owned_target = try a.dupe(u8, new_ack.target); errdefer a.free(owned_target); var owned_notes = try a.alloc([]const u8, note_fragments.len); errdefer a.free(owned_notes); var note_idx: usize = 0; errdefer for (owned_notes[0..note_idx]) |line| a.free(line); for (note_fragments, 0..) |frag, i| { owned_notes[i] = try a.dupe(u8, frag); note_idx = i + 1; } // Grow the entries slice by one and append. var new_entries = try a.alloc(Entry, self.entries.len + 1); errdefer a.free(new_entries); @memcpy(new_entries[0..self.entries.len], self.entries); new_entries[self.entries.len] = .{ .ack = .{ .observation = owned_obs, .target = owned_target, .acknowledged_at = new_ack.acknowledged_at, .state = new_ack.state, .unacknowledged_at = new_ack.unacknowledged_at, .resolved_at = new_ack.resolved_at, }, .notes = owned_notes, }; // Replace the slice WITHOUT freeing the old strings - they're // shallow-copied into new_entries above. Just free the old slice. a.free(self.entries); self.entries = new_entries; try writeFile(self, io, path); } /// Update the state of an existing acknowledgment, set the relevant /// breadcrumb timestamp, and atomic-rewrite the file. The state /// transition machine: /// /// - `active -> acknowledged` - clears `unacknowledged_at`. /// - `acknowledged -> active` - sets `unacknowledged_at = today`. /// - `* -> resolved` - sets `resolved_at = today`. /// - `resolved -> active` - clears `resolved_at`. /// /// Returns `error.AckNotFound` if no entry matches `(observation, /// target)`. pub fn setState( self: *Journal, io: std.Io, path: []const u8, observation: []const u8, target: []const u8, new_state: State, today: Date, ) !void { var found: ?*Entry = null; for (self.entries) |*e| { if (!std.mem.eql(u8, e.ack.observation, observation)) continue; if (!std.mem.eql(u8, e.ack.target, target)) continue; found = e; break; } const entry = found orelse return error.AckNotFound; switch (new_state) { .acknowledged => { entry.ack.unacknowledged_at = null; }, .active => { if (entry.ack.state == .acknowledged) entry.ack.unacknowledged_at = today; if (entry.ack.state == .resolved) entry.ack.resolved_at = null; }, .resolved => { entry.ack.resolved_at = today; }, } entry.ack.state = new_state; try writeFile(self, io, path); } /// Atomic file write: serialize all records, then hand to /// `atomic.writeFileAtomic` which writes to `.tmp`, fsyncs, /// and renames. Crash-safe. fn writeFile(self: *const Journal, io: std.Io, path: []const u8) !void { const a = self.allocator; // Build the file contents in memory. For a journal of typical // size (dozens to low hundreds of records) this is trivially // small; if it grows large we revisit streaming. var buf: std.Io.Writer.Allocating = .init(a); defer buf.deinit(); // Flatten into a single records slice so `srf.fmt` writes the // `#!srfv1` directive header once at the top and emits every // record through its native formatter. Ack records are followed // immediately by their note records, in entry-list order. var total_records: usize = self.entries.len; for (self.entries) |e| total_records += e.notes.len; var records = try a.alloc(JournalRecord, total_records); defer a.free(records); var ri: usize = 0; for (self.entries) |e| { records[ri] = .{ .acknowledgment = e.ack }; ri += 1; for (e.notes) |line| { records[ri] = .{ .note = .{ .line = line } }; ri += 1; } } try buf.writer.print("{f}", .{srf.fmt(JournalRecord, records, .{})}); try atomic.writeFileAtomic(io, a, path, buf.writer.buffered()); } // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; test "parse: empty input produces empty journal" { var journal = try parse(testing.allocator, ""); defer journal.deinit(); try testing.expectEqual(@as(usize, 0), journal.entries.len); } test "parse: single ack with two notes round-trips" { const data = \\#!srfv1 \\type::acknowledgment,observation::position_concentration,target::NVDA,acknowledged_at::2026-06-12,state::acknowledged \\type::note,line::Holding through earnings cycle. \\type::note,line::Will trim by Q3 2026. ; var journal = try parse(testing.allocator, data); defer journal.deinit(); try testing.expectEqual(@as(usize, 1), journal.entries.len); const entry = journal.entries[0]; try testing.expectEqualStrings("position_concentration", entry.ack.observation); try testing.expectEqualStrings("NVDA", entry.ack.target); try testing.expectEqual(State.acknowledged, entry.ack.state); try testing.expectEqual(@as(usize, 2), entry.notes.len); try testing.expectEqualStrings("Holding through earnings cycle.", entry.notes[0]); try testing.expectEqualStrings("Will trim by Q3 2026.", entry.notes[1]); } test "round-trip: a note with emoji and accented text survives format + parse" { // UTF-8 note content (emoji, accented letters) must survive the // SRF format -> parse cycle byte-for-byte. Multi-byte sequences // can't collide with SRF's ASCII delimiters (',' '::' newline), so // this is safe; the test pins that guarantee for the ack-note flow. const a = testing.allocator; const records = [_]JournalRecord{ .{ .acknowledgment = .{ .observation = "position_concentration", .target = "NVDA", .acknowledged_at = Date.fromYmd(2026, 6, 12), .state = .acknowledged, } }, .{ .note = .{ .line = "Trim by Q3 \u{1F389} - café conviction" } }, }; var buf: std.Io.Writer.Allocating = .init(a); defer buf.deinit(); try buf.writer.print("{f}", .{srf.fmt(JournalRecord, &records, .{})}); var journal = try parse(a, buf.writer.buffered()); defer journal.deinit(); try testing.expectEqual(@as(usize, 1), journal.entries.len); try testing.expectEqual(@as(usize, 1), journal.entries[0].notes.len); try testing.expectEqualStrings("Trim by Q3 \u{1F389} - café conviction", journal.entries[0].notes[0]); } test "parse: notes attach to the most-recent preceding ack" { const data = \\#!srfv1 \\type::acknowledgment,observation::p,target::A,acknowledged_at::2026-06-12,state::active \\type::note,line::for A \\type::acknowledgment,observation::p,target::B,acknowledged_at::2026-06-13,state::active \\type::note,line::for B \\type::note,line::also for B ; var journal = try parse(testing.allocator, data); defer journal.deinit(); try testing.expectEqual(@as(usize, 2), journal.entries.len); try testing.expectEqual(@as(usize, 1), journal.entries[0].notes.len); try testing.expectEqualStrings("for A", journal.entries[0].notes[0]); try testing.expectEqual(@as(usize, 2), journal.entries[1].notes.len); try testing.expectEqualStrings("for B", journal.entries[1].notes[0]); try testing.expectEqualStrings("also for B", journal.entries[1].notes[1]); } test "parse: orphan note before any ack returns error.OrphanedNote" { const data = \\#!srfv1 \\type::note,line::orphaned \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active ; try testing.expectError(error.OrphanedNote, parse(testing.allocator, data)); } test "findByTarget: finds matching, returns null for non-match" { const data = \\#!srfv1 \\type::acknowledgment,observation::position_concentration,target::NVDA,acknowledged_at::2026-06-12,state::acknowledged \\type::acknowledgment,observation::sector_concentration,target::sector:Technology,acknowledged_at::2026-06-13,state::acknowledged ; var journal = try parse(testing.allocator, data); defer journal.deinit(); const found = journal.findByTarget("position_concentration", "NVDA").?; try testing.expectEqualStrings("NVDA", found.ack.target); const not_found = journal.findByTarget("position_concentration", "AAPL"); try testing.expect(not_found == null); } test "Entry.fullNote: joins fragments with newlines" { const data = \\#!srfv1 \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active \\type::note,line::first \\type::note,line::second \\type::note,line::third ; var journal = try parse(testing.allocator, data); defer journal.deinit(); const full = try journal.entries[0].fullNote(testing.allocator); defer testing.allocator.free(full); try testing.expectEqualStrings("first\nsecond\nthird", full); } test "Entry.fullNote: empty notes returns empty string" { const data = \\#!srfv1 \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active ; var journal = try parse(testing.allocator, data); defer journal.deinit(); const full = try journal.entries[0].fullNote(testing.allocator); defer testing.allocator.free(full); try testing.expectEqualStrings("", full); } test "parse: optional unacknowledged_at and resolved_at fields work when omitted" { const data = \\#!srfv1 \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::acknowledged ; var journal = try parse(testing.allocator, data); defer journal.deinit(); try testing.expect(journal.entries[0].ack.unacknowledged_at == null); try testing.expect(journal.entries[0].ack.resolved_at == null); } test "parse: optional unacknowledged_at and resolved_at fields work when set" { const data = \\#!srfv1 \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active,unacknowledged_at::2026-08-01,resolved_at::2026-12-01 ; var journal = try parse(testing.allocator, data); defer journal.deinit(); try testing.expectEqual(Date.fromYmd(2026, 8, 1).days, journal.entries[0].ack.unacknowledged_at.?.days); try testing.expectEqual(Date.fromYmd(2026, 12, 1).days, journal.entries[0].ack.resolved_at.?.days); } test "parse: malformed record returns parse error" { // A record with no `type::` discriminator should fail SRF // deserialization (ActiveTagNotFirstField), and we propagate // the error rather than silently skipping. The exact error // variant is SRF's choice; we just assert that an error is // returned. const data = \\#!srfv1 \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::acknowledged \\garbage_field::nope \\type::acknowledgment,observation::q,target::U,acknowledged_at::2026-06-13,state::active ; try testing.expectError(error.ActiveTagNotFirstField, parse(testing.allocator, data)); } // ── I/O tests (load + append + setState round-trips) ────────── test "load: missing file returns empty journal" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); defer allocator.free(dir_path); const path = try std.fmt.allocPrint(allocator, "{s}/does_not_exist.srf", .{dir_path}); defer allocator.free(path); var journal = try load(allocator, io, path); defer journal.deinit(); try testing.expectEqual(@as(usize, 0), journal.entries.len); } test "append + load round-trip: ack with two notes" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); defer allocator.free(dir_path); const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path}); defer allocator.free(path); var journal = try load(allocator, io, path); defer journal.deinit(); try testing.expectEqual(@as(usize, 0), journal.entries.len); const fragments = [_][]const u8{ "first thought", "follow-up rationale" }; try journal.append(io, path, .{ .observation = "position_concentration", .target = "NVDA", .acknowledged_at = Date.fromYmd(2026, 6, 8), .state = .acknowledged, }, &fragments); var reloaded = try load(allocator, io, path); defer reloaded.deinit(); try testing.expectEqual(@as(usize, 1), reloaded.entries.len); try testing.expectEqualStrings("position_concentration", reloaded.entries[0].ack.observation); try testing.expectEqualStrings("NVDA", reloaded.entries[0].ack.target); try testing.expectEqual(State.acknowledged, reloaded.entries[0].ack.state); try testing.expectEqual(@as(usize, 2), reloaded.entries[0].notes.len); try testing.expectEqualStrings("first thought", reloaded.entries[0].notes[0]); try testing.expectEqualStrings("follow-up rationale", reloaded.entries[0].notes[1]); } test "append: two acks land in append-order on reload" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); defer allocator.free(dir_path); const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path}); defer allocator.free(path); var journal = try load(allocator, io, path); defer journal.deinit(); try journal.append(io, path, .{ .observation = "k", .target = "B", .acknowledged_at = Date.fromYmd(2026, 6, 8), .state = .acknowledged, }, &.{}); try journal.append(io, path, .{ .observation = "k", .target = "A", .acknowledged_at = Date.fromYmd(2026, 6, 8), .state = .acknowledged, }, &.{}); var reloaded = try load(allocator, io, path); defer reloaded.deinit(); try testing.expectEqual(@as(usize, 2), reloaded.entries.len); try testing.expectEqualStrings("B", reloaded.entries[0].ack.target); try testing.expectEqualStrings("A", reloaded.entries[1].ack.target); } test "setState: acknowledged -> active sets unacknowledged_at" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); defer allocator.free(dir_path); const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path}); defer allocator.free(path); var journal = try load(allocator, io, path); defer journal.deinit(); try journal.append(io, path, .{ .observation = "k", .target = "X", .acknowledged_at = Date.fromYmd(2026, 6, 8), .state = .acknowledged, }, &.{}); try journal.setState(io, path, "k", "X", .active, Date.fromYmd(2026, 6, 9)); var reloaded = try load(allocator, io, path); defer reloaded.deinit(); try testing.expectEqual(State.active, reloaded.entries[0].ack.state); try testing.expect(reloaded.entries[0].ack.unacknowledged_at != null); try testing.expect(reloaded.entries[0].ack.unacknowledged_at.?.eql(Date.fromYmd(2026, 6, 9))); } test "setState: missing target returns AckNotFound" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); defer allocator.free(dir_path); const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path}); defer allocator.free(path); var journal = try load(allocator, io, path); defer journal.deinit(); try testing.expectError( error.AckNotFound, journal.setState(io, path, "k", "missing", .resolved, Date.fromYmd(2026, 6, 8)), ); } test "setState: -> resolved sets resolved_at" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); defer allocator.free(dir_path); const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path}); defer allocator.free(path); var journal = try load(allocator, io, path); defer journal.deinit(); try journal.append(io, path, .{ .observation = "k", .target = "X", .acknowledged_at = Date.fromYmd(2026, 6, 8), .state = .acknowledged, }, &.{}); try journal.setState(io, path, "k", "X", .resolved, Date.fromYmd(2026, 6, 10)); var reloaded = try load(allocator, io, path); defer reloaded.deinit(); try testing.expectEqual(State.resolved, reloaded.entries[0].ack.state); try testing.expect(reloaded.entries[0].ack.resolved_at != null); try testing.expect(reloaded.entries[0].ack.resolved_at.?.eql(Date.fromYmd(2026, 6, 10))); }