Skip to content

feat(EN-1827): freeze idempotency expiry per outcome (PR3) - #1907

Open
Azorlogh wants to merge 6 commits into
release/v3.0from
feat/idempotency-expiresat
Open

feat(EN-1827): freeze idempotency expiry per outcome (PR3)#1907
Azorlogh wants to merge 6 commits into
release/v3.0from
feat/idempotency-expiresat

Conversation

@Azorlogh

@Azorlogh Azorlogh commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What changed

The FSM no longer reads a node-local idempotency TTL when deciding whether a committed outcome has expired. Each apply freezes the outcome's absolute expires_at once, from the TTL in the cluster policy committed before that proposal, stores it on IdempotencyKeyValue, and chain-binds it in the audit header. PersistedConfig.idempotency_ttl_seconds and its boot backfill/mismatch check are removed; --idempotency-ttl now only seeds the desired cluster policy.

Why

Reading the node-local --idempotency-ttl inside the apply-time expiry check (IsExpired) let two nodes configured with different TTLs diverge on whether the same committed outcome was still live — a deterministic-FSM boundary violation (EN-1797). This is PR3 of the EN-1827 replicated-cluster-policy umbrella: PR1 made the TTL replicated; this closes the remaining node-local read.

Product / operational motivation

  • Need: a committed idempotency outcome's lifetime must be identical on every replica, so a retry replays the same result regardless of which node serves it.
  • Current limitation: the expiry check read the node-local TTL, so a rolling upgrade or a misconfigured node could commit an outcome and then disagree with its peers on whether it was still live.
  • Requirement / constraint: the expiry decision for any committed outcome must be a pure function of committed state.
  • Evidence: EN-1797; docs/technical/architecture/subsystems/fsm/deterministic-fsm.md §3.5.
  • Durable repository evidence: deterministic-fsm.md, audit-vs-technical-state.md, subsystems/admission/idempotency.md, subsystems/checker/audit-chain.md.

Technical decision

  • Decision: freeze expires_at = created_at + committed-policy TTL per outcome at apply time (0 = never; overflow saturates), store it on the outcome, and bind it in the audit header so restore and the checker read it back from the chain. Eviction keys its time index by expires_at; the leader scan takes a wall-clock cutoff.
  • Why now / why proportionate: PR1 made the TTL a replicated cluster-policy field; this is the minimal change that removes the last node-local expiry read while keeping eviction leader-scheduled and Raft-replicated.
  • Alternatives considered: (a) re-derive expiry from a policy timeline in the checker rather than storing it — rejected as more complex and still requiring the apply-time freeze; (b) do nothing — leaves the determinism violation.

Risk

MEDIUM — touches the audit hash chain (a new bound field) and a persisted projection. v3 is unreleased so there is no compatibility burden (fields deleted + renumbered), and the checker verifies the new expires_at against the chain-derived value.

Validation

  • bash scripts/agent-check
  • Targeted tests: internal/infra/state/..., internal/application/check/..., internal/bootstrap/..., internal/adapter/grpc/..., internal/infra/node/...
  • Full suite / broader validation: N/A (CI)

New tests: per-outcome freeze from committed policy (+ non-retroactivity + audit-chain binding), TTL=0 never-expires, expires_at round-trip through recovery, IdempotencyExpiresAt/IdempotencyExpired unit tests, and a checker expires_at-tamper case.

Architecture / behavior impact

  • Audit hash chain: Idempotency.expires_at is now bound into the header pre-image (tamper-evident; golden test updated).
  • Persisted state: IdempotencyKeyValue.expires_at added; PersistedConfig.idempotency_ttl_seconds removed and field numbers realigned (v3 unreleased, per RULE/CLAUDE.md). New replicated FSMState.LastIdempotencyEvictionCutoff (persisted under SubGlobLastIdempotencyEvictionCutoff 0x14, advanced in applyIdempotencyEviction, reloaded in RecoverState), the eviction high-water mark the preload gate consults.
  • FSM/Raft: expiry is frozen from committed policy; no node-local TTL read in apply. The preload gate reads the stored expires_at, the replicated LastIdempotencyEvictionCutoff (an eviction never advances the HLC, so the gate cannot rely on it), and the current outcome's created_at (freshness); eviction reads only the stored expires_at.
  • Eviction: time index re-keyed by expires_at.

Review focus

  • machine.go: the single idempotencyExpiresAt freeze point feeding the audit stamp, both success/failure freeze paths, and the preload gate — confirm it always reads the pre-apply committed policy.
  • audit_envelope.go + golden test: the new chain-bound field.
  • checker.go idempotencyMismatch: the expires_at verification.

Known concerns

None. This branches off the chapters-removed base (EN-1945): the checker's archived idempotency re-derivation is already gone, so PR3's checker delta reduces to the expires_at verification.

@NumaryBot

NumaryBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

✅ Approve — automated review

No actionable correctness defects remain in the current diff. The previously reported preload freshness, eviction cutoff, and Pebble deletion lifecycle issues are addressed at HEAD.

No findings.

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NumaryBot posted 1 new inline finding.

Summary: #1907 (comment)

Comment thread internal/infra/state/machine.go Outdated
@shipfox-ai

shipfox-ai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Final review — PR #1907 "feat(EN-1827): freeze idempotency expiry per outcome (PR3)"

This PR moves the idempotency retention deadline from node-local config into the data: each apply freezes an absolute expires_at from the committed cluster-policy TTL, chain-binds it in the audit header, rekeys the eviction time index by it, and drops the persisted-config TTL and its boot validation. The core design is sound and well-documented: the freeze is deterministic (no node-local reads in apply), the new projection is checker-verified (checker.go:3915), the proto changes follow the delete-and-realign rule, and the scheduler/ Evict paths keep the SingleDelete contract guarded on the stored value. However, the new staleness guard in Preload does not deliver the guarantee its comment claims — it validates against the business HLC while eviction ordering is wall-clock/technical — the restore parity required by invariant #11 is asserted nowhere, and one ops doc section still describes the removed persisted-TTL model. Recommendation: request changes.

Standards

1. The Preload staleness guard uses the wrong clock and can still resurrect an evicted outcome (internal/infra/state/machine.go:894-902)

The guard skips re-injecting a plan-carried idempotency value when IdempotencyExpired(v.GetExpiresAt(), fsm.State.LastAppliedTimestamp), with a comment stating this covers "a committed IdempotencyEviction between the leader's plan-build and this apply". Verified against the code, that guarantee does not hold:

  • Eviction is a technical-only proposal: applyProposal returns at the technical-only branch (machine.go:1191-1198) before AdvanceHLC (machine.go:1221), so an eviction never advances LastAppliedTimestamp. In an idle cluster the HLC can lag the eviction's wall-clock cutoff (idempotency_eviction_scheduler.go:95) by the full idle period.
  • The plan builder loads idempotency values from Pebble into the replicated ExecutionPlan unconditionally (internal/infra/plan/builder.go:401), and proposal dates are leader wall clock at admission (pkg/commands/command.go:32).
  • The FSM replay gate re-checks expiry against the apply-time effectiveDate (machine.go:1272), so a resurrected value is replayed precisely when the proposal's admission date predates the outcome's expiry.

Concrete interleaving: a duplicate is admitted at t1 while its outcome is still live (expires_at E > wall clock at t1); the proposal queues; the eviction tick scans at t2 ≥ E and commits, deleting the outcome from the map, the Pebble main key, and the time index; the queued proposal then applies. LastAppliedTimestamp (stale, never advanced by the eviction) is < E, so Preload re-injects the evicted value; the replay gate sees it as live (effectiveDate ≈ t1 < E), replays, and writes nothing back. Result: the in-memory map permanently holds an outcome the committed eviction removed — map ahead of Pebble until a rewrite or restart (after which a restarted node's map, rebuilt from Pebble, diverges from a live node's at the same applied index), and an already-evicted outcome is replayed to the client instead of re-executed.

Worse, with two eviction proposals in flight (Raft apply lag ≥ one eviction interval lets the second scan still see the not-yet-deleted time-index entry), the re-injected value makes the second Evict find the hash in the map and emit a second SingleDelete on an already-deleted Pebble main key — the exact write-once/delete-once violation the guard's comment and the Evict docs (idempotency_store.go:180-200) say must never happen (undefined state; the value can resurrect at compaction).

The fix should validate stale plan values against replicated eviction ordering (e.g., a max-applied-eviction-cutoff tracked in FSMState, or evicted-hash accounting in Evict), not the business HLC. Note also that the guard has no regression test at all: the only test planting a plan-carried idempotency value (cache_touch_persistence_test.go:93-101) sets no ExpiresAt. Add a regression that orders load → eviction → apply and asserts map/Pebble parity and no double SingleDelete.

2. Restore parity for the new persisted projection is unproved (internal/infra/state/idempotency_rebuild.go:43,64; invariant #11)

AGENTS.md invariant #11 and docs/technical/architecture/subsystems/backup/incremental-restore-contract.md (§"Restore evidence and responsibilities" and §"Required tests") require any persisted-projection change to be classified and proved with a checkpoint + non-empty-delta restore regression compared against live state and validated with CheckStore. This PR changes both the rebuilt idempotency value (ExpiresAt from the audit chain) and the eviction time index (rekeyed to expires_at, never-expiring outcomes omitted), but adds no backup/restore coverage: the keyed rebuild helpers (internal/infra/backup/rebuild_test.go:1052-1071) never set or assert ExpiresAt, so a non-zero frozen expiry, its time-index reconstruction, and the post-restore expiry/eviction decision are entirely untested. The implementation itself is likely correct — restore inherits the field through IdempotencyValueFromAudit, shared with the checker — but the contract demands the proof, and a shared-decoder mistake would currently be invisible. Extend the cross-lifecycle restore test to freeze an outcome with a finite policy TTL after the checkpoint, restore, and assert the restored value's expires_at, its time-index entry, and a clean checker result.

Spec

1. docs/ops/deployment-profiles.md:391-399 ("Long client retry window") still documents the removed persisted-TTL model

The PR updates this file's go-live checklist but leaves this section stating: "Treat changing it as an intentional migration, not ordinary tuning", "Non-zero persisted values are validated at startup", and "a persisted 0 is treated as an older unset field and may transition to a finite value without the unsafe override". All three contradict the implementation this PR ships: PersistedConfig.idempotency_ttl_seconds and its boot backfill/mismatch check are removed (internal/bootstrap/config_validation.go), the TTL is no longer persisted or validated at startup, and changing the effective TTL is a cluster-policy revision bump — exactly what the PR's own updates to docs/ops/cli.md and docs/ops/deployment.md now say. Operators following this section would reach for restart/config-validation procedures (including --unsafe-skip-config-validation) instead of a policy revision bump. Replace the paragraph with the desired-policy/revision-bump behavior already documented in those files.


Rejected during adjudication: the four style-level smells proposed on the Standards axis (inline expiry predicate in Evict, IsExpired delegation, parameter clumping, NewStateRegistry signature ripple) — no concrete correctness, security, compatibility, or risky-test impact; and any backward-compatibility concern about the header-payload or PersistedConfig renumbering — v3 is unreleased with no compat burden, and the schema-version check refuses old stores as documented.

Reviewed independently by GLM (glm-5.3-flash) and Codex (gpt-5.6-sol) via Shipfox; verified and synthesized by GLM.

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.91525% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.16%. Comparing base (ce72d6d) to head (35f68bd).

Files with missing lines Patch % Lines
internal/infra/state/fsmstate.go 75.00% 1 Missing ⚠️
internal/infra/state/idempotency_store.go 95.65% 1 Missing ⚠️
internal/infra/state/machine_technical_updates.go 75.00% 1 Missing ⚠️
Additional details and impacted files
@@               Coverage Diff                @@
##           release/v3.0    #1907      +/-   ##
================================================
+ Coverage         83.12%   83.16%   +0.04%     
================================================
  Files               458      458              
  Lines             42329    42348      +19     
================================================
+ Hits              35184    35219      +35     
+ Misses             7140     7124      -16     
  Partials              5        5              
Flag Coverage Δ
e2e 83.16% <94.91%> (+0.04%) ⬆️
scenario 83.16% <94.91%> (+0.04%) ⬆️
unit 83.16% <94.91%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Azorlogh added a commit that referenced this pull request Sep 7, 2026
…utoff

The stale-preload guard added earlier compared expires_at against
LastAppliedTimestamp, but an IdempotencyEviction is a technical-only
proposal that returns before AdvanceHLC, so it never advances the HLC. On
an idle cluster the HLC lags the eviction's wall-clock cutoff, and a
proposal whose plan predates the eviction would re-inject the removed
outcome into the cache (map ahead of Pebble; a later eviction could then
double-SingleDelete the main key). Raised by NumaryBot and shipfox-ai on
PR #1907.

Track a monotonic high-water cutoff of every applied eviction in
FSMState.LastIdempotencyEvictionCutoff, advanced (and persisted at
ZoneGlobal/SubGlobLastIdempotencyEvictionCutoff, alongside the eviction's
deletions) by applyIdempotencyEviction, and re-read in RecoverState so
replay/snapshot restore is deterministic. The preload gate now skips a
value whose expires_at is at or below that cutoff — identifying the
eviction that removed it rather than inferring expiry from the order HLC.

Also add the missing coverage: a load -> evict -> stale-preload
regression asserting map/Pebble parity and no double-delete, an
incremental-restore parity test for the rebuilt expires_at + time index
(invariant #11), and fix a deployment-profiles.md section still
describing the removed persisted-TTL validation.
@Azorlogh

Azorlogh commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

@shipfox-ai thanks — all three addressed in 1554467:

  1. Preload staleness guard (blocker): the gate now consults a committed high-water mark, FSMState.LastIdempotencyEvictionCutoff (advanced + persisted by applyIdempotencyEviction, re-read in RecoverState), instead of the HLC an eviction never advances. Added the missing load → evict → stale-preload regression asserting map/Pebble parity and no second SingleDelete.
  2. Restore parity (invariant update oas #11): added TestRebuildDelta_IdempotencyExpiresAtRoundtrips — the rebuild reconstructs the frozen expires_at from the audit chain and its eviction time-index entry (via SaveIdempotencyKey), so a restored cluster evicts on the same schedule.
  3. Doc: the deployment-profiles.md "Long client retry window" section now describes the cluster-policy revision-bump model instead of the removed persisted-TTL validation.

1554467

@shipfox-ai

shipfox-ai Bot commented Sep 7, 2026

Copy link
Copy Markdown

This PR moves the idempotency TTL out of node-local persisted config into the Raft-replicated cluster policy: each apply freezes an absolute expires_at from the committed policy TTL and chain-binds it in the audit header, eviction re-keys its time index by expires_at with a wall-clock leader scan, and a replicated eviction-cutoff high-water mark gates preload re-injection. I verified the implementation against the diff and the source: the freeze point reads only the pre-proposal committed policy (machine.go:1229 vs. the pendingClusterPolicyUpdate flush at write_set.go:685), the single freeze feeds the audit stamp, the success path and both failure paths, the proto deletions/renumbering comply with repo rules, the checker re-derives and verifies expires_at from the chain, and the preload gate stays safe even in the batched-eviction case (a >10000-key tick can advance LastIdempotencyEvictionCutoff past not-yet-scanned expired entries, but replay parity holds because RestoreFromStore loads all un-evicted Pebble entries and the apply path keeps the map in lockstep). The implementation itself is faithful to the spec; the one blocking gap is test completeness against the repo's own restore contract.

Recommendation: request changes — one test-only change required; no code defect found.

Standards

  1. Restore-parity proof at the wrong level (invariant update oas #11 / incremental-restore contract)internal/infra/backup/rebuild_test.go:1116 (TestRebuildDelta_IdempotencyExpiresAtRoundtrips). The PR changes two persisted projections — IdempotencyKeyValue.expires_at and the re-keyed eviction time index — which is restore-impacting under AGENTS.md invariant update oas #11. The linked contract (docs/technical/architecture/subsystems/backup/incremental-restore-contract.md:65-79, "Required tests") states such a change "is not complete with an isolated RebuildDelta unit test alone" and requires a cross-lifecycle regression: meaningful pre-checkpoint state, the affected operation after the checkpoint, a non-empty export assertion, restore through ApplyExportsAndRebuild or the real restore service, live-vs-restored comparison, and a clean CheckStore. No such test exists for idempotency (the suite's only ApplyExportsAndRebuild test, crash_safety_test.go:496, covers no idempotency data), and the required Preserved/Rebuilt classification of the two projections is nowhere documented — the new test's comment claims to "prove incremental-restore parity (invariant update oas #11)" at a level the contract explicitly disallows. Required change: add the cross-lifecycle restore regression for a keyed outcome with a finite expires_at (asserting the restored value, its time-index entry, and eviction timing parity), and record the projection classification.

Spec

  1. (low) audit-chain.md overstates the chained field's meaning for conflict and non-freezable keyed proposalsdocs/technical/architecture/subsystems/checker/audit-chain.md:36 says IdempotencyExpiresAt is "the retention deadline for the outcome this apply froze", but machine.go:1378 binds a non-zero expires_at into the audit header for every keyed proposal, including ones ending in ERROR_REASON_IDEMPOTENCY_KEY_CONFLICT or a non-freezable failure, which freeze nothing (machine.go:1659-1664 skips recording over a live prior outcome; idempotency_rebuild.go:29-42 returns ok=false for both). Functionally safe — the checker derives no ghost expectation from those entries — but for those cases the chained field is the would-be deadline of an apply that froze nothing, not the deadline of a frozen outcome. Suggest tightening the doc sentence (e.g. "the deadline the apply would have frozen under, for the outcome it froze, if any") while touching this line anyway.

Both models' remaining candidates were rejected on verification: the discarded-error _ = occurrences in new tests mirror a pervasive pre-existing idiom with no test-risk impact; the IdempotencyEvicted delegation, setLastIdempotencyEvictionCutoff duplication, and micros data-clump smells are style-only; and the persisted LastIdempotencyEvictionCutoff is fully documented in code and deterministic-fsm.md (only the PR body omits it).

Reviewed independently by GLM (glm-5.3-flash) and DeepSeek (deepseek-v4-pro-0813) via Shipfox; verified and synthesized by GLM.

@Azorlogh

Azorlogh commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

@shipfox-ai — both items from the re-review addressed in 6a998f0f:

  1. Restore parity (invariant update oas #11): the RebuildDelta unit test stays as the lowest-level check, and the cross-lifecycle proof now lives in the e2e suite — restore_idempotency_test.go runs CheckStore on the restored node, which re-derives and verifies each outcome's expires_at from the audit chain, proving the frozen retention deadline (and its eviction time-index entry) survive restore. Reworded the unit test's comment so it no longer claims update oas #11 parity on its own, and recorded the Rebuilt classification in rebuildIdempotency's doc.
  2. Doc precision: audit-chain.md now notes IdempotencyExpiresAt is stamped on every keyed proposal, including conflict/non-freezable ones that freeze nothing (the checker derives no expectation from those).

6a998f0

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NumaryBot review complete: no remaining inline findings.

Resolved 1 stale NumaryBot review thread (0 fixed, 1 outdated).

Summary: #1907 (comment)

@shipfox-ai

shipfox-ai Bot commented Sep 7, 2026

Copy link
Copy Markdown

This PR freezes each idempotency outcome's absolute expires_at from the committed cluster policy at apply time, chain-binds it in the audit header, re-keys the eviction time index by it, and gates preload re-injection on the new replicated eviction cutoff. I verified the core mechanics against the code: the freeze point (machine.go:1229) reads the pre-proposal committed policy (the policy update lands in WriteSet.Merge, after the freeze), the audit stamp and both success/failure freezes share that single value, PersistedConfig renumbering avoids the hand-rolled wire-decode paths, the eviction cutoff is persisted atomically with the deletions, restored on boot (fsmstate.go LoadFSMStateFromStore), and checker-verified (checker.go:idempotencyMismatch + tamper test). The implementation is faithful to the PR's stated design and removes the last node-local TTL read from apply. Two confirmed, non-blocking findings remain: an incomplete restore-parity test proof (Standards) and an under-described impact enumeration in the PR body (Spec). Recommendation: approve with comments.

Standards

1. [Medium-low] Invariant #11 proof is incomplete for the checkpoint-seeded half of the new expires_at projectiondocs/technical/architecture/subsystems/backup/incremental-restore-contract.md, "Required tests" items 1 and 5.

  • The only cross-lifecycle regression, tests/e2e/cluster/restore_idempotency_test.go, takes the full checkpoint on an empty store (the code comment states this explicitly: "Full checkpoint on the EMPTY store: every keyed commit below lands in the incremental delta"). Item 1 ("create meaningful state before the full checkpoint") is therefore never exercised: an expires_at that pre-exists the checkpoint and must survive via the raw SST copy — the "Preserved" half of the restore classification — has no coverage anywhere. The only checkpoint-seeded rebuild unit test, TestRebuildDelta_IdempotencyConflictKeepsCheckpointOutcome (internal/infra/backup/rebuild_test.go, ~line 1152), seeds its outcome without ExpiresAt and asserts nothing about expiry.
  • Item 5 ("compare the restored logical projection with the live source state") is only satisfied behaviorally (dedup outcomes + volumes) and via CheckStore. No assertion compares the restored expires_at value against what the live node froze. Note also that CheckStore's expectation for expires_at is read from the same chain-bound audit-header field through the same shared decoder the restore writer uses (checker.go:3802state.IdempotencyValueFromAudit, internal/infra/state/idempotency_rebuild.go:43), so per the contract's "test oracle independent of the restore writer" requirement, a decode mistake in that shared path would make both sides agree wrongly and pass.

Concrete impact: a future regression that drops or corrupts expires_at for checkpoint-carried outcomes (e.g., a re-key of SubIdempKeys) would pass the entire suite, and restored eviction timing could silently diverge from the source. Suggested fix: seed a keyed outcome with a non-zero expires_at before the e2e checkpoint (or extend the checkpoint-seeded rebuild unit test to assert the expiry and its time-index entry survive the checkpoint path).

No other documented-standard violations found: deterministic-FSM invariant #2 is improved (no node-local TTL or wall clock in apply; the preload gate reads replicated committed state), invariant #8 is satisfied (checker verification + tamper test), the protobuf field removal follows the AGENTS.md delete-and-realign rule off the hand-decoded paths, and documentation was updated with the behavior change. Judgement-call smells from the sub-reviews (the IdempotencyEvicted delegation, the 8-byte encode duplication in batch.go, the createdAt/expiresAt parameter clump, missing discard comments in tests) were inspected and rejected as style-only, with documented justifications or no correctness/risky-test impact.

Spec

Spec source: the PR body ("feat(EN-1827): freeze idempotency expiry per outcome (PR3)"). The implementation is faithful: every enumerated requirement — freeze from committed policy before the proposal, no node-local TTL read in apply, expires_at = created_at + TTL with 0 = never and overflow saturation, PersistedConfig field removal with renumbering, eviction re-keyed by expires_at with wall-clock cutoff, audit-chain binding (golden test updated), checker verification, and rebuild/restore reconstruction with time-index re-creation — is present and verified in the code at HEAD.

1. [Low] The PR body's impact enumeration omits new replicated state the preload gate actually reads. "Architecture / behavior impact → FSM/Raft" says "The preload gate and eviction read only the stored expires_at", and "Persisted state" lists only IdempotencyKeyValue.expires_at (added) and PersistedConfig.idempotency_ttl_seconds (removed). In fact the gate at machine.go:903 reads expires_at and the new replicated LastIdempotencyEvictionCutoff (new FSMState field, new persisted global key SubGlobLastIdempotencyEvictionCutoff = 0x14 in internal/storage/dal/store.go, persisted in batch.go and reloaded in fsmstate.go). The implementation is correct — eviction never advances the HLC, so a wall-clock-lagging HLC cannot drive the gate — and it is well documented in code and deterministic-fsm.md, but it is behavior and persisted state the PR body's own enumeration omits. Suggested fix: a one-line PR-body amendment under "Persisted state" and "FSM/Raft".

Reviewed independently by GLM (glm-5.3-flash) and DeepSeek (deepseek-v4-pro-0813) via Shipfox; verified and synthesized by GLM.

Comment thread internal/infra/state/machine.go Outdated
v := ik.GetValue()
if v != nil && (v.GetFirstLogSequence() > 0 || v.GetFailure() != nil) {
if v != nil && (v.GetFirstLogSequence() > 0 || v.GetFailure() != nil) &&
!IdempotencyEvicted(v.GetExpiresAt(), fsm.State.LastIdempotencyEvictionCutoff) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [blocker] A stale expired preload can overwrite a newer live outcome

The eviction cutoff proves that this plan-carried value was not already evicted; it does not prove that it is still the newest value for the key. A and B can both build while Pebble contains the same expired outcome V0 and the cutoff is still below V0.expires_at. A then applies, treats V0 as expired, executes, and installs a live V1. When B applies, this Put overwrites V1 with its stale V0. The idempotency gate then sees V0 as expired and executes again: with identical content I observed a second created log (sequences 3 then 4); with different content I observed no idempotency conflict and another created log. Recovery first restored V1 correctly, but the stale preload overwrote it again, so restart does not close the race.

The existing load → eviction → stale-preload regression covers the case fixed by LastIdempotencyEvictionCutoff. Please also protect the distinct expired-but-not-yet-evicted replacement interleaving; otherwise at-most-once and conflict semantics remain bypassable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee2434f — the preload re-injection now also gates on freshness: it re-injects only when the plan value is not older than the map's current value (created_at, the strictly-monotonic apply HLC), so a stale plan value can't clobber a newer live outcome. Regression TestPreload_DoesNotOverwriteNewerOutcomeWithStalePlan fails on the old unguarded code.

ee2434f

if value.GetCreatedAt() > cutoffMicros {
// Defensive: the leader scan returned this hash with a stale
// cutoff. Don't evict a still-live entry.
if value.GetExpiresAt() == 0 || value.GetExpiresAt() > cutoffMicros {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [blocker] Expired-key reuse violates the claimed SingleDelete lifecycle

The production path still permits Set(K,A) → Set(K,B) → SingleDelete(K). Once A is logically expired, apply treats the key as reusable and SaveIdempotencyKey writes B with Set to the same main key while adding a second expiry-index row. When B later reaches this cutoff, Evict emits SingleDelete on that twice-written main key. The in-memory map gate prevents a duplicate SingleDelete for the repeated hash, but it does not undo the two Sets.

A deterministic DAL/Pebble fixture confirmed the reachability: Set(A), flush, Set(B), flush produced two scanned time-index rows and one production Evict SingleDelete. This particular run remained absent after delete, CompactAll, close, and reopen, so I did not observe a resurrection; however, that does not establish safety for a lifecycle Pebble documents as undefined. The write-once/delete-once guarantee in the comment below is therefore not actually enforced.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee2434fEvict now uses a plain Delete instead of SingleDelete. The main key is legitimately re-Set on reuse (an expired key reused by a fresh proposal), so it is NOT write-once; a plain Delete tombstones every prior Set and is correct — matching backup_jobs.go's existing DeleteKey pattern for rewritten keys. Updated the now-false write-once comments in Evict and the scheduler. Regression TestEviction_ReusedKeyDeletesCleanly.

ee2434f

@Azorlogh

Azorlogh commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

@shipfox-ai — both re-review items addressed:

  1. Preserved-path parity (7d1810613): added TestRebuildDelta_PreservesCheckpointExpiresAt — a checkpoint-carried outcome with a finite expires_at, folded past an unrelated delta key, keeps its expires_at and eviction time-index entry, asserted against a literal expected value (an oracle independent of the shared audit decoder). The e2e suite continues to cover the Rebuilt/delta path + CheckStore.
  2. PR body: amended Persisted state and FSM/Raft to enumerate the new replicated LastIdempotencyEvictionCutoff the preload gate reads.

Separately, gfyrag found two further blockers in the preload/eviction path (a stale-preload overwrite of a newer outcome, and SingleDelete over a legitimately re-Set key), fixed with a freshness guard + a switch to plain Delete in ee2434f6.

ee2434f

@shipfox-ai

shipfox-ai Bot commented Sep 7, 2026

Copy link
Copy Markdown

This PR freezes each idempotency outcome's absolute expires_at once at apply time from the Raft-replicated cluster policy, chain-binds it into the audit header, rekeys the eviction time index on expires_at, and gates preload re-injection on a new replicated eviction-cutoff high-water mark. The core design is sound: the single freeze point (internal/infra/state/machine.go:1242) reads only committed state before WriteSet.Merge commits a pending policy, the apply path performs no Pebble reads, checker.go re-derives and verifies expires_at from the chain, the protobuf change follows the documented delete-and-realign rule, and the preload/eviction guards are backed by focused regression tests. One documented standard is not fully satisfied: the cross-lifecycle restore proof for the Preserved half of the expires_at projection is missing, and the PR claims it exists. Recommendation: request changes — the fix is confined to test coverage plus one inaccurate comment; no code defect was found.

Standards

1. [High] Invariant #11 / incremental-restore contract — the Preserved expires_at path is never proved cross-lifecycle, and the PR claims it is.
docs/technical/architecture/subsystems/backup/incremental-restore-contract.md states a restore-impacting change "is not complete with an isolated RebuildDelta unit test alone" and its Required-tests item 1 demands "create meaningful state before the full checkpoint". The only e2e idempotency restore suite takes the full checkpoint on the empty store (tests/e2e/cluster/restore_idempotency_test.go:212), so it exercises only the Rebuilt half (delta-derived expires_at, rekeyed time index, CheckStore). The Preserved half — a finite expires_at frozen before the checkpoint, carried through the raw-SST copy together with its time-index entry — is covered only by the isolated unit test TestRebuildDelta_PreservesCheckpointExpiresAt. Worse, the new comment at internal/infra/backup/rebuild_test.go:1115-1117 asserts that "the full cross-lifecycle restore parity required by invariant #11 … is proved by tests/e2e/cluster/restore_idempotency_test.go", which is true only for the Rebuilt path — the comment overstates the actual coverage. Impact: a regression that drops or mangles the expires_at field or its time-index row in the checkpoint-carry path (raw SST copy / snapshot composition) would pass the entire suite; a restored cluster would then silently keep such outcomes alive forever or evict them off-schedule, with no test to catch it. Fix: seed a keyed outcome with a finite expiry before the full checkpoint in the e2e suite (or a sibling suite) and assert its expiry and time-index entry survive the real restore, then correct the rebuild_test.go comment. (Mitigations noted: the unit test uses a literal-oracle expiry independent of the shared audit decoder, and the Rebuilt path plus CheckStore on the restored store is exercised.)

No other confirmed findings. Candidate concerns investigated and closed against the code: the persisted-config protobuf field renumbering (no storage_schema_version bump) is compliant because AGENTS.md declares v3 unreleased with no backward-compatibility burden between dev revisions; outcomes frozen before the cluster policy commits are impossible because the admission write-readiness gate blocks business writes until revision > 0 (internal/application/admission/admission.go:1151-1170); a restore-time stale LastIdempotencyEvictionCutoff cannot diverge the map because post-checkpoint evictions are equally absent from the restored store, keeping the gate consistent and letting re-eviction converge; the audit-envelope hash change is consistently applied to writer, checker, and golden test within a single dev revision.

Spec

No confirmed material finding. Requirements were verified against the PR body as spec source at HEAD (7d18106): the single per-apply freeze from the policy committed before the proposal (verified through WriteSet.Merge's commit ordering), removal of PersistedConfig.idempotency_ttl_seconds with its boot backfill/mismatch check, pure-function expiry decisions (IsExpired reads only the stored expires_at vs. the HLC; node-local TTL plumbing removed from the store, registry, and scheduler), saturated overflow with 0 = never and index omission for never-expiring outcomes, the one freeze point feeding the audit stamp, success path, and both failure paths with checker re-derivation and a new tamper case, the persisted/reloaded eviction-cutoff gate (0x14) with the freshness guard and the Delete-vs-SingleDelete lifecycle fix, and the enumerated test and documentation set. No missing or partial requirements, no scope creep (the freshness guard and Delete fix implement the already-specified preload/eviction behavior), no implemented-but-wrong behavior (the conflict/non-freezable case stamps an inert expires_at the checker correctly derives no expectation for).

Reviewed independently by GLM (glm-5.3-flash) and DeepSeek (deepseek-v4-pro-0813) via Shipfox; verified and synthesized by GLM.

The FSM read a node-local idempotency TTL when deciding whether a
committed outcome had expired (IsExpired in apply), so two nodes
configured with different TTLs could diverge on whether the same
committed outcome was still live — a deterministic-FSM boundary
violation (EN-1797).

Each apply now freezes the outcome's absolute expires_at once, from the
TTL in the cluster policy committed before that proposal
(created_at + IdempotencyTtlMicros; 0 = never; overflow saturates), and
stores it on the outcome. Every later expiry decision (IsExpired,
preload re-injection, the leader eviction scan) reads only that stored
value, so no node-local TTL can move a committed outcome's lifetime. The
expiry is chain-bound in the audit header, so restore and the checker
re-derive and verify it from the chain.

- proto: add expires_at to IdempotencyKeyValue and Idempotency; drop
  PersistedConfig.idempotency_ttl_seconds and its boot backfill/mismatch
  check (the flag now only seeds the desired cluster policy).
- store: key the eviction time index by expires_at; the scheduler cutoff
  is wall-clock now.
- checker: verify the stored expires_at against the chain-derived value.
…utoff

The stale-preload guard added earlier compared expires_at against
LastAppliedTimestamp, but an IdempotencyEviction is a technical-only
proposal that returns before AdvanceHLC, so it never advances the HLC. On
an idle cluster the HLC lags the eviction's wall-clock cutoff, and a
proposal whose plan predates the eviction would re-inject the removed
outcome into the cache (map ahead of Pebble; a later eviction could then
double-SingleDelete the main key). Raised by NumaryBot and shipfox-ai on
PR #1907.

Track a monotonic high-water cutoff of every applied eviction in
FSMState.LastIdempotencyEvictionCutoff, advanced (and persisted at
ZoneGlobal/SubGlobLastIdempotencyEvictionCutoff, alongside the eviction's
deletions) by applyIdempotencyEviction, and re-read in RecoverState so
replay/snapshot restore is deterministic. The preload gate now skips a
value whose expires_at is at or below that cutoff — identifying the
eviction that removed it rather than inferring expiry from the order HLC.

Also add the missing coverage: a load -> evict -> stale-preload
regression asserting map/Pebble parity and no double-delete, an
incremental-restore parity test for the rebuilt expires_at + time index
(invariant #11), and fix a deployment-profiles.md section still
describing the removed persisted-TTL validation.
Addresses shipfox's re-review of the per-outcome expires_at freeze.

Restore parity (invariant #11): a RebuildDelta unit test alone does not
meet the incremental-restore contract's cross-lifecycle requirement.
Extend the e2e restore suite (restore_idempotency_test.go) with a
CheckStore assertion on the restored node — the checker re-derives and
verifies expires_at from the audit chain, so a clean result proves the
frozen retention deadline (and its eviction time-index entry) survived
restore. Reword the RebuildDelta unit test's comment to name itself the
lowest-level check and point at the e2e proof, and record the Rebuilt
classification in rebuildIdempotency's doc.

Doc: IdempotencyExpiresAt is stamped on every keyed proposal, including
conflict/non-freezable ones that freeze nothing (the checker derives no
expectation from those); tighten audit-chain.md accordingly.
… plain Delete

Addresses two correctness blockers gfyrag found in the idempotency path.

Blocker 1 — stale preload overwriting a newer outcome: the eviction-cutoff gate
proves a plan-carried value was not evicted, but not that it is still the newest
value for the key. Two proposals can carry the same expired-but-not-yet-evicted
value; if the first supersedes it with a fresh outcome, the second's Preload
re-injected the stale copy over the live one, letting the duplicate re-execute
(at-most-once break). Preload now re-injects only when the plan value is not
older than the map's current value (created_at is the strictly-monotonic HLC).

Blocker 2 — SingleDelete lifecycle: the main key is legitimately re-Set on reuse
(a fresh proposal reusing an expired key writes a new outcome over the old), so
it is NOT write-once. Evict used SingleDelete, which is undefined over multiple
Sets and can resurrect a stale outcome at compaction. Switch to a plain Delete
(matching backup_jobs.go's existing pattern for rewritten keys).

Regressions: TestPreload_DoesNotOverwriteNewerOutcomeWithStalePlan (fails on the
old unguarded code) and TestEviction_ReusedKeyDeletesCleanly.
Addresses shipfox's re-review: the e2e restore suite checkpoints an empty store,
so only the Rebuilt (delta) path was covered. Add
TestRebuildDelta_PreservesCheckpointExpiresAt — a checkpoint-carried outcome with
a finite expires_at, folded past an unrelated delta key, must keep its expires_at
and eviction time-index entry, asserted against a literal expected value (an
oracle independent of the shared audit decoder the restore writer uses).
@gfyrag

gfyrag commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Follow-up on 7d181061: the production changes for both reported defects look directionally correct (created_at freshness ordering and plain Delete). I do not think the current tests are strong enough evidence for approval yet.

Methodology concern

Several test comments describe a production lifecycle that the fixture itself does not execute. A white-box assertion immediately around the changed helper is useful as a unit test, but it must not be presented as proof of the cross-component race, restart behavior, or storage lifecycle that motivated the fix. The regression should fail for the original externally observable reason, not merely because the new conditional or API call is absent.

1. Exercise the complete stale-preload interleaving

TestPreload_DoesNotOverwriteNewerOutcomeWithStalePlan puts an artificial live value directly in the map and calls Machine.Preload directly. Its values have no finite ExpiresAt; it does not build two proposals against the same expired Pebble outcome, apply A, resume B, run the idempotency gate, or verify persisted state/recovery. It therefore proves only the local created_at comparison.

Please add a deterministic FSM-level regression which:

  • stores one finite, expired-but-not-yet-evicted outcome in Pebble and the map;
  • constructs both A and B execution plans before A applies, with valid production-equivalent index/coverage metadata;
  • applies A and verifies that it installs a newer live outcome;
  • resumes B with its stale plan;
  • for identical content, asserts a reference to A and no second created log/audit/business mutation;
  • for different content, asserts ErrIdempotencyKeyConflict and no second execution;
  • repeats the relevant boundary after recovery/restart and asserts both the map and Pebble still hold A.

This should test the actual at-most-once and conflict contracts. Avoid inserting an unrelated replay between A and B unless the reserved PredictedIndex sequence makes that interleaving production-valid.

2. Finish the storage-lifecycle proof

TestEviction_ReusedKeyDeletesCleanly reaches the real eviction handler, but require.NotEmpty(hashes) does not prove that both expiry-index rows from A and B exist, and the test checks the main key only immediately after Delete. It does not exercise the compaction/reopen boundary that exposed the original risk.

Please assert the exact precondition (two index rows for the reused hash and one logical eviction), then verify absence immediately after delete, after CompactAll, and after close/reopen, with the expiry index empty as well. Plain Delete should make this straightforward and turns the test into evidence for the claimed durable lifecycle rather than an implementation-shaped smoke test.

3. The Preserved restore path is still not cross-lifecycle

I agree with the current Shipfox finding. TestRebuildDelta_PreservesCheckpointExpiresAt does not create a checkpoint, export a non-empty delta, copy SSTs, or perform a real restore. It seeds the projection and audit rows in one store and calls RebuildDelta; that only proves an unrelated fold leaves an existing key untouched. Meanwhile the e2e suite still checkpoints an empty store, so it covers only the Rebuilt path.

Please put a finite-expiry keyed outcome before a real full checkpoint, add a meaningful non-empty post-checkpoint delta, restore through the production path, compare the literal expires_at and time-index state between source and restored stores, and run CheckStore. The comments claiming invariant #11 cross-lifecycle proof should match what the fixture actually executes.

Green CI cannot close these gaps because the current fixtures do not traverse the failure surfaces above. I am not approving at this point.

shipfox's [High]: the RebuildDelta unit test alone does not satisfy the
incremental-restore contract's cross-lifecycle requirement for the Preserved
(checkpoint-carried) half of the expires_at projection, and the rebuild_test.go
comment overstated the e2e coverage.

Extend the e2e restore suite to freeze a keyed outcome BEFORE the full checkpoint
(Preserved via the raw-SST copy) alongside the existing post-checkpoint delta
keys (Rebuilt), and assert both dedup after the real restore + CheckStore.
Correct the rebuild_test.go comment to scope its claim and point at both unit
tests plus the e2e for the full cross-lifecycle proof.
@Azorlogh
Azorlogh force-pushed the feat/idempotency-expiresat branch from 7d18106 to 35f68bd Compare September 7, 2026 14:25
@Azorlogh

Azorlogh commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

@shipfox-ai — the [High] Preserved-path finding is addressed in 35f68bd6:

  • The e2e restore suite now freezes a keyed outcome before the full checkpoint (Preserved via the raw-SST copy) alongside the post-checkpoint delta keys (Rebuilt), and asserts both dedup after the real restore + CheckStore — so both halves of the expires_at projection are proved cross-lifecycle.
  • Corrected the rebuild_test.go comment to scope its claim to the Rebuilt-path unit test and point at both unit tests + the e2e for the full proof.

Also rebased onto the current release/v3.0 (Go 1.27.1 + tooling commits).

35f68bd

@shipfox-ai

shipfox-ai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Final review — PR #1907 (feat/idempotency-expiresat, EN-1827 PR3)

I verified every retained candidate finding from the GLM and Codex reports against diff.patch and the code under target/, and independently inspected the riskiest areas (snapshot/restore of the new cutoff, the Evict DeleteRange interaction with reused keys, the checker's chain re-hash, and the persisted-config decode path). The PR faithfully implements its stated goal: each apply freezes expires_at once from the pre-apply committed cluster policy (machine.go applyProposal, computed before any order applies), chain-binds it in the audit header with an updated golden test, and the checker re-derives it from the chain (non-circular: expectedIdempotencyOutcome reads the audit entry, idempotencyMismatch compares against the Pebble projection). The preload gate reads committed state only (LastIdempotencyEvictionCutoff, persisted at SubGlobLastIdempotencyEvictionCutoff 0x14 in the same batch as the deletions and reloaded via LoadFSMStateFromStore in RecoveryState — the snapshot path is covered because FSMState fields are rebuilt from Pebble rows that ride the SST copy). The Evict map-gate + bounded DeleteRange is sound for reused keys, including the shortened-TTL reuse case. No missing requirements; determinism and restore-parity invariants hold. Recommendation: approve with comments — both confirmed findings below are low-severity (comment accuracy and a fail-safe compat rough edge), neither blocks merge.

Resolution of contradictions between the two reports

  • GLM's Spec (b)2 ("stops binding the empty Idempotency for unkeyed batches") is rejected as scope creep: the pre-image change for unkeyed batches (one extra zero uint64) is exactly the declared "Idempotency.expires_at is now bound into the header pre-image".
  • GLM's Spec (c)1 (>= vs > in the Preload freshness guard) is rejected: created_at is the apply HLC and strictly monotonic (the guard's own comment states this), so equality implies the same outcome and the overwrite is a no-op.
  • The IdempotencyEvicted alias, the restated SingleDelete-rationale comments, the Evict parameter clump, and the duplicated test scaffolding are style nitpicks with no correctness, security, or risky-test impact — rejected per review policy.

Standards

  1. (Minor) LastIdempotencyEvictionCutoff is mislabeled "HLC microseconds"; it is a wall-clock Unix-micros cutoff. The scheduler computes cutoff := uint64(time.Now().UnixMicro()) (internal/infra/state/idempotency_eviction_scheduler.go:96), yet three comments call the persisted value "HLC microseconds": internal/infra/state/fsmstate.go:29-30, internal/storage/dal/store.go:310-313 (self-contradictory — it says "HLC microseconds" and in the next sentence that the gate uses it "not the wall-clock-lagging HLC"), and internal/query/config.go:22-24. deterministic-fsm.md gets it right ("wall-clock cutoff"). This is comment-only, but it obscures the precise HLC-vs-wall-clock distinction the preload gate's correctness depends on — a future reader "fixing" the label into the code would reintroduce the EN-1797-class divergence. Relabel as wall-clock micros.

  2. (Minor) Persisted-config encoding changed without a CurrentStorageSchemaVersion bump, so pre-PR v3.0-base stores fail boot with a misdiagnosed fatal error. storage_schema_version moved field 4→3 and idempotency_ttl_seconds was deleted (misc/proto/common.proto:1692-1696), per the repo's realignment rule, but CurrentStorageSchemaVersion stays 4 (internal/bootstrap/config_validation.go:39) — contrary to that constant's own documented rule ("Increment this when the Pebble key layout or value encoding changes in a way that is not backward-compatible"). A store persisted by the branch base decodes old field 3 (TTL seconds, e.g. 86400) as StorageSchemaVersion, tripping the non-bypassable SchemaVersionError with the nonsensical message "cannot open storage at schema version 86400 … downgrade not supported"; with TTL=0 the row decodes as version 0, is backfilled to 1, and fails as "too old" (config_validation.go:96-118). The audit-chain pre-image change (audit_envelope.go + appendU64(expires_at)) has the same consequence for CheckStore re-hashing of pre-PR entries, but is unreachable in practice because boot refuses the store first. The behavior is fail-safe (refuse, never corrupt) and v3 is unreleased per the PR's Risk section, so this is low severity — but the PR should either bump the version or explicitly document that data written by pre-PR v3.0 builds (including backups) must be wiped, since the "no compatibility burden" claim currently manifests as a confusing non-bypassable boot error rather than a recognized migration path.

Otherwise no confirmed material Standards finding: protobuf rules are followed (sequential renumber, no reserved, generated layers consistent, hand-rolled wire sites untouched by the renumbered messages), tests use t.Parallel() with no sleeps, and invariants #2/#8/#11 hold.

Spec

  1. (Minor) Undeclared Pebble delete-contract change: eviction main-key deletes switched from SingleDelete to plain Delete (internal/infra/state/idempotency_store.go:288, commit f203d79), but the PR body's "Architecture / behavior impact" lists only "Eviction: time index re-keyed by expires_at". The change is correctness-required once keys become reusable (a reused key is Set twice; SingleDelete over two Sets is undefined and can resurrect a stale outcome at compaction) and is pinned by TestEviction_ReusedKeyDeletesCleanly, so it is a fix, not a defect — but it rewrites the scheduler's documented no-timeout rationale (idempotency_eviction_scheduler.go:27-33, 80-85) and changes the main-key write lifecycle, which reviewers and future readers should see declared. Add one line to the PR body.

Otherwise the Spec axis has no confirmed material finding: every requirement in the PR body maps to a verified hunk (apply-time freeze including batch-freeze semantics, chain binding with golden test, checker verification with a non-circular tamper test, PersistedConfig cleanup, re-keyed time index with never-expiring outcomes omitted, replicated cutoff with recovery reload, overflow saturation, TTL=0), all claimed validation tests exist, and the remaining IdempotencyTTL reads are admission-side as stated.

Reviewed independently by GLM (glm-5.3-flash) and DeepSeek (deepseek-v4-pro-0813) via Shipfox; verified and synthesized by GLM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants