# Proposal: Counted Reversible Overlay Diffs ## Summary Use **per-key change counts carried by both overlay state and diffs**, together with distinct operations for confirming a prefix and reverting a suffix. Keep `phantom_removals` to distinguish cancellation from deletion of an existing value. This is a design proposal. The complete design has not been implemented in the repository. The required behavior is: - Apply diffs oldest-first and revert them newest-first. - Replay diffs on other overlays with a compatible logical starting state. - Preserve later pending changes when an earlier prefix reaches the database. - Clear all pending state when all represented changes have been applied. - Support empty values, cancellations, repeated values, and tree lifecycle changes on both Fjall and Sled. - Avoid rebuilding the entire remaining sequence during cleanup or rollback. ## 1. The information missing from the current state Consider: ```text D0: insert k, remove k D1: insert k=v D2: remove k ``` The final raw overlay state contains `removed[k]`. D0's diff contains a phantom removal for the same key. The current state-cleanup loop removes that marker unconditionally: ```rust for k in diff.removed.keys().chain(diff.phantom_removals.iter()) { self.removed.remove(k); } ``` Two histories require different results: | Pending history | Current raw state | Required state after confirming D0 | | --- | --- | --- | | D0 alone | `removed = {k}` | Empty | | D0, D1, D2 | `removed = {k}` | Keep `removed[k]` for D2 | The flattened state and D0 alone cannot distinguish these cases. Value equality does not solve the equivalent problem for repeated writes either: ```text D0: insert k=v D1: remove k D2: insert k=v ``` Clearing the cached value merely because it matches D0 would erase D2's pending insertion. ## 2. Count recorded changes, not captured diff objects For each key: - The overlay stores the total number of outstanding recorded edits. - A diff stores how many of those edits it represents. - A new recorded edit increments the overlay's count. - Replaying a diff adds **that diff's count**, rather than incrementing once. Conceptually, each key's diff is: ```text (before, after, change_count) ``` The existing value representation can remain: - `cache`: insertion or replacement, including the previous value. - `removed`: deletion of an existing value, including that value. - `phantom_removals`: absent-to-absent cancellation. Counts are additional metadata. An absent key remains distinct from an existing key whose value is empty. These counts are relative contributions, **not global IDs or timestamps**. A diff carries its own contribution, not the source overlay's entire cumulative count. ### Required invariants - Calling `diff()` repeatedly does not change counts. - Uncaptured edits are already counted and therefore protected. - `inverse()` swaps the value transition and preserves the count. - Combining diffs adds their contributions. - No-op value transitions retain their counted metadata until consumed. - Zero-count pending entries are removed. - All mutation paths preserve the accounting, including replay, clear, checkpoints, and clones. Counting registered diff objects instead would require a separate registration lifecycle, special handling for uncaptured edits, and additional handling for diffs that aggregate several blocks. ## 3. Confirmation and reversion are different operations ### Confirm an applied prefix After successfully writing a diff to the database: 1. Subtract its count from each affected key. 2. If the remaining count is positive, preserve the current overlay entry. 3. If the count reaches zero, clear the entry and counter. Do not compare values to decide whether a later change exists. A phantom-only diff still requires confirmation even though it produces no database writes. Confirmation is valid only for a prefix represented in that overlay. Counts do not establish that an arbitrary incoming diff belongs to its pending history. ### Revert a pending suffix When undoing the newest pending diff: 1. Subtract its counts. 2. Where earlier changes remain, restore the diff's **before-value** into the overlay. 3. Where no changes remain, remove the overlay entry and inherit from the backend. The diff already carries the previous values needed for ordered rollback. This does not require last-touch history or replay of the entire remaining sequence. The operation must undo a suffix; newer edits must be reverted first. ### Stage an inverse as new work This is distinct from removing a pending suffix: ```rust overlay.add_diff(&diff.inverse())?; ``` Staging an inverse **adds** its counts. It does not implicitly unregister the original diff. This distinction is particularly important because a phantom-only diff equals its own inverse. Reverting an already-applied database change uses the inverse transition. Its original pending contribution has already been consumed and must not be subtracted again. ## 4. Worked example ```text D0: insert k, remove k -> count 2 D1: insert k=v -> count 1 D2: remove k -> count 1 ``` The final live state contains: ```text removed[k] change_count[k] = 4 ``` Confirmation proceeds as follows: | Applied diff | Remaining count | Overlay action | | --- | ---: | --- | | D0 | 2 | Preserve `removed[k]` | | D1 | 1 | Preserve `removed[k]`, hiding the backend's `v` | | D2 | 0 | Clear the marker and counter | For **D0 alone**, the count starts at 2. Applying D0 subtracts 2 and clears everything. For `insert v -> remove -> insert v`, the later insertion remains represented until its own contribution is consumed. Identical values no longer confuse cleanup. ## 5. Diff subtraction must account for counts For one key, subtracting a prefix means: ```text Current: B -> C, representing n edits Prefix: B -> A, representing m edits Result: A -> C, representing n-m edits ``` This assumes the prefix belongs to the represented history and shares the starting state. If edits remain, retain the resulting entry even when its before-value and after-value are equal. For the cancellation example: ```text Combined snapshot: absent -> absent, count 4 Subtract D0: absent -> absent, count 2 Subtract D1: v -> absent, count 1 ``` D2 becomes a real removal with previous value `v`. Subtracting D0 no longer makes the intermediate diff empty: it still represents two outstanding edits. This also prevents the database layer from prematurely dropping this tree entry. Relevant emptiness and equality checks must respect the remaining change metadata. Batch generation can omit transitions requiring no database writes, while retaining their metadata for cleanup. ## 6. Replay on another overlay When overlay B imports a diff from A: 1. Apply its value transitions to B's pending state. 2. Add its count contributions to B's existing counts. No source-overlay IDs or source-global counters are copied. ### Starting-state compatibility Portability has an essential semantic condition: ```text Source diff: A -> B Destination currently contains C ``` Applying that diff's inverse restores **A**, not **C**. Therefore, replay must start from the expected logical before-state. Deliberately applying it to a different state requires producing a rebased diff with the destination's actual previous values. ### Keep the operations explicit - Append a diff to an overlay. - Write a diff to the database. - Confirm a known pending prefix after it was written. - Revert a pending suffix. Writing an incoming diff must not automatically consume unrelated pending changes merely because their keys, values, or counts match. For overlays sharing a backend, one writer can write a common prefix and the other overlays can confirm that same prefix locally. Each overlay updates its own accounting exactly once. ## 7. Tree lifecycle needs separate reversible state A complete database-level fix cannot stop at key counters. Creation, dropping, and restoration must carry explicit reversible lifecycle information, including: - Whether the tree existed before and after the change. - The logical contents needed to undo a drop. - Pending overlay state preserved while the tree is hidden. - The structural change's outstanding contribution. In particular: - An empty tree is different from a nonexistent tree. - A drop's undo image must reflect the logical state **before that diff**, including earlier pending changes. - That image must remain separate from mutable pending-state bookkeeping. - Whole-tree cleanup must respect later key and lifecycle changes. - Restoration must correctly update initial/new/dropped-tree bookkeeping. - Double inversion must preserve the lifecycle transition and its metadata. The existing dropped-tree issue needs its dedicated fix. Key counters alone cannot repair it. ## 8. Cost Confirmation and pending rollback are proportional to the keys in the affected diff, with normal map-lookup costs. They do not clone all remaining values or replay the entire sequence. Counts add per-key numeric metadata in state and diffs, plus any associated map overhead. They do not require another copy of all value history. The existing `diff(&sequence)` calculation still has its sequence-subtraction cost. This design does not eliminate that cost. ## 9. Validation The counted key-transition rules were exercised in a bounded single-key model covering **18,489 configurations and 53,838 transitions**, including: - Uncaptured edits. - Cancellation and identical-value writes. - Prefix confirmation and suffix rollback. - Empty values. - Aggregated diffs. - Forward and inverse replay on another overlay. This validates the key-level rules within that model. It is not validation of a complete backend or tree-lifecycle implementation. Implementation regressions must cover both Fjall and Sled, including: - Full cleanup after cancellation-only and mixed diffs. - Preservation of later writes and removals during prefix confirmation. - Reverting a suffix after an earlier prefix has already been confirmed. - Branch replacement and replay on another compatible overlay. - Repeated read-only diff capture and aggregate application. - Inverse and double-inverse behavior. - Tree creation, dropping, restoration, and same-block edits followed by a drop. - Checkpoint consistency and backend failure handling. ## Recommendation Implement **counted reversible deltas**, explicit **confirm-versus-revert semantics**, and separate **reversible tree-lifecycle state**. This supplies the information missing from the current flattened state while preserving portable diffs and avoiding full-sequence rebuilding during cleanup or rollback.