Skip to content

Frozen-snapshot semantics for closed periods#23

Merged
pbudzik merged 1 commit into
mainfrom
feat/period-snapshot
May 16, 2026
Merged

Frozen-snapshot semantics for closed periods#23
pbudzik merged 1 commit into
mainfrom
feat/period-snapshot

Conversation

@pbudzik

@pbudzik pbudzik commented May 16, 2026

Copy link
Copy Markdown
Owner

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_adjustments so 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

Period state get_period returns
Open live total_quantity (unchanged)
Closed (this PR) frozen: { quantity, event_count } + pending_adjustments: [...] + adjustments_quantity + net_total
Closed before this PR (legacy) live_total_fallback + warning field

close_period

At close time, captures three new fields on ClosedPeriod:

  • frozen_quantity: i128 — SUM(quantity) over the period via the rollup-source executor
  • frozen_event_count: u64
  • watermark_at_close_ms: i64manifest.watermarks.hourly_rollup_ms

Idempotent re-close returns the original snapshot + closed_at_ms (does NOT refresh). Reopening + re-closing captures a fresh snapshot for the new state.

get_period for 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.quantity stays at 100 regardless of subsequent corrections — that's the value an invoice was issued for. net_total = frozen + adjustments is what a current-state query would show.

Backward compat

ClosedPeriod fields are Option<...> with #[serde(default)]. Old manifest entries closed before this PR have all three as None. The get_period handler detects that and falls back to a live total with a warning: "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 totals
  • 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 — idempotency preserved
  • reopen_then_reclose_takes_a_fresh_snapshot

Test plan

  • cargo build --all-targets clean with -D warnings
  • cargo test --all-targets — 139 tests pass (was 134; +5)
  • CI green

🤖 Generated with Claude Code

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>
@pbudzik pbudzik merged commit 7e932f9 into main May 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant