Frozen-snapshot semantics for closed periods#23
Merged
Conversation
Closed-period queries now return the totals as they stood at close time, not live data. Post-close Correction / Retraction events are surfaced separately as `pending_adjustments` so the invoice number stays stable while the audit trail still shows post-invoice changes. Without this, the period-lifecycle feature shipped in #22 was missing the point of "closing" a period: a query right before the close and a query right after could disagree as long as new events kept landing. Changes: - ClosedPeriod gains three Option fields (with #[serde(default)] so old manifest entries deserialize unchanged): frozen_quantity: Option<i128> frozen_event_count: Option<u64> watermark_at_close_ms: Option<i64> Legacy entries closed before this PR have all three as None and fall back to a live total with a `warning` field on read. - close_period handler now: 1. Snapshots SUM(quantity) and COUNT for the period via the rollup-source executor BEFORE acquiring the manifest write lock 2. Captures manifest.watermarks.hourly_rollup_ms at close time 3. Stores all three on the new ClosedPeriod entry 4. Idempotent re-close returns the original snapshot + closed_at_ms (does NOT refresh) - get_period handler distinguishes open vs closed: - Open: live total (unchanged) - Closed (with snapshot): returns frozen: { quantity, event_count } pending_adjustments: [Correction/Retraction events in the period] adjustments_quantity, net_total = frozen + adjustments - Closed (legacy, no snapshot): live_total_fallback + warning Tests (tests/period_lifecycle.rs, 17 total, +5 new): - close_captures_frozen_snapshot (rollup-driven setup) - get_period_returns_frozen_snapshot_after_close - correction_after_close_surfaces_as_adjustment_not_frozen — frozen stays at 100, adjustments_quantity=-40, net_total=60 - closing_twice_keeps_original_snapshot - reopen_then_reclose_takes_a_fresh_snapshot Total tests: 139 (was 134; +5). Clean under RUSTFLAGS=-D warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closed-period queries now return the totals as they stood at close time, not live data. Post-close Correction / Retraction events are surfaced separately as
pending_adjustmentsso the invoice number stays stable while the audit trail still shows what's changed since.Without this, the period-lifecycle feature shipped in #22 was missing the point of "closing" a period — a query right before the close and a query right after could disagree as long as new events kept landing.
Behavior
get_periodreturnstotal_quantity(unchanged)frozen: { quantity, event_count }+pending_adjustments: [...]+adjustments_quantity+net_totallive_total_fallback+warningfieldclose_periodAt close time, captures three new fields on
ClosedPeriod:frozen_quantity: i128— SUM(quantity) over the period via the rollup-source executorfrozen_event_count: u64watermark_at_close_ms: i64—manifest.watermarks.hourly_rollup_msIdempotent re-close returns the original snapshot +
closed_at_ms(does NOT refresh). Reopening + re-closing captures a fresh snapshot for the new state.get_periodfor closed periods{ "account_id": "acc_c", "period": "2026-05", "state": "Closed", "closed_at_ms": 1700000000000, "watermark_at_close_ms": 1700000000000, "frozen": { "quantity": "100", "event_count": 1 }, "pending_adjustments": [ { "event_id": "corr", "kind": "Correction", "quantity": -40, ... } ], "adjustments_quantity": "-40", "net_total": "60" }frozen.quantitystays at100regardless of subsequent corrections — that's the value an invoice was issued for.net_total = frozen + adjustmentsis what a current-state query would show.Backward compat
ClosedPeriodfields areOption<...>with#[serde(default)]. Old manifest entries closed before this PR have all three asNone. Theget_periodhandler detects that and falls back to a live total with awarning: "legacy ClosedPeriod (no snapshot at close time); live total returned for reference"field.Tests
5 new tests in
tests/period_lifecycle.rs(17 total):close_captures_frozen_snapshot— close response includes the frozen totalsget_period_returns_frozen_snapshot_after_closecorrection_after_close_surfaces_as_adjustment_not_frozen— frozen stays at 100, adjustments_quantity=-40, net_total=60closing_twice_keeps_original_snapshot— idempotency preservedreopen_then_reclose_takes_a_fresh_snapshotTest plan
cargo build --all-targetsclean with-D warningscargo test --all-targets— 139 tests pass (was 134; +5)🤖 Generated with Claude Code