# ROR-001: Rollback to Height

| Link | Path |
|------|------|
| **Normative** | [NORMATIVE.md](../NORMATIVE.md) |
| **Verification** | [VERIFICATION.md](../VERIFICATION.md) |
| **Tracking** | [TRACKING.yaml](../TRACKING.yaml) |
| **Spec** | [SPEC.md](../../../../resources/SPEC.md) Section 8.1 |

---

## Summary

`rollback_to_height(&self, target_height: u64) -> Result<Vec<Bytes32>>` MUST remove canonical mappings above the target height from CF_CANONICAL, truncate the mmap file, update the chain tip, and mark rolled-back blocks as non-canonical. MUST NOT delete actual block data from CF_BLOCKS. Returns reverted block hashes.

---

## Specification

### Function Signature

```rust
pub fn rollback_to_height(&self, target_height: u64) -> Result<Vec<Bytes32>>
```

### Execution Steps

1. **Boundary validation** (see ROR-005):
   - Return `NoTip` if no chain tip is set.
   - Return `RollbackAboveTip` if `target_height > current_tip.height`.
   - Return `RollbackBelowMin` if `target_height < min_retained_height`.

2. **Collect reverted hashes**: Iterate heights from `current_tip.height` down to `target_height + 1`, collecting the canonical hash at each height.

3. **Remove CF_CANONICAL entries**: Build a `WriteBatch` that deletes all `CF_CANONICAL` entries for heights `> target_height`.

4. **Atomic RocksDB write**: Execute the WriteBatch.

5. **Truncate mmap**: Truncate `canonical.bin` to `(target_height + 1) * 32` bytes.

6. **Update records**: Set `BlockRecord.in_canonical_chain = false` for all reverted hashes.

7. **Update tip**: Set the chain tip to the block at `target_height`.

8. **Return reverted hashes**: Return the collected hashes in descending height order.

```rust
pub fn rollback_to_height(&self, target_height: u64) -> Result<Vec<Bytes32>> {
    // Validation
    let current_tip = self.tip().ok_or(BlockStoreError::NoTip)?;
    if target_height > current_tip.height {
        return Err(BlockStoreError::RollbackAboveTip {
            target: target_height,
            tip: current_tip.height,
        });
    }
    let min_height = self.min_retained_height()?;
    if target_height < min_height {
        return Err(BlockStoreError::RollbackBelowMin {
            target: target_height,
            min: min_height,
        });
    }

    // Collect and remove
    let mut reverted = Vec::new();
    let mut batch = WriteBatch::default();
    let cf = self.cf_canonical();

    for h in (target_height + 1..=current_tip.height).rev() {
        if let Some(hash) = self.get_hash_by_height(h)? {
            batch.delete_cf(&cf, h.to_be_bytes());
            reverted.push(hash);
        }
    }

    // Atomic write
    self.db.write(batch)?;

    // Truncate mmap
    self.mmap_index.truncate(target_height)?;

    // Update records
    for hash in &reverted {
        self.update_record_canonical(hash, false)?;
    }

    // Update tip
    let new_tip_hash = self.get_hash_by_height(target_height)?
        .ok_or(BlockStoreError::BlockNotInStore(Bytes32::default()))?;
    self.set_tip(ChainTip {
        hash: new_tip_hash,
        height: target_height,
    })?;

    Ok(reverted)
}
```

### Key Invariant

Block data in CF_BLOCKS and CF_HEADERS is **never** deleted by rollback. Only the canonical index (CF_CANONICAL) and the mmap file are modified. This preserves blocks for potential future reorg back to the original chain.

---

## Acceptance Criteria

- [ ] After rollback, CF_CANONICAL has no entries above target_height
- [ ] After rollback, canonical.bin file size is `(target_height + 1) * 32`
- [ ] After rollback, tip() returns the block at target_height
- [ ] Reverted hashes are returned in descending height order
- [ ] Rolled-back blocks have BlockRecord.in_canonical_chain == false
- [ ] Rolled-back blocks are still retrievable via get_block(hash)
- [ ] Rolled-back block headers are still retrievable via get_header(hash)
- [ ] CF_BLOCKS is not modified by rollback
- [ ] Rollback to current tip height is a no-op (returns empty vec)

---

## Implementation Notes

- The descending iteration order for collecting reverted hashes is important for callers that process reverted blocks in "undo" order (highest first).
- For rollback to the current tip height, the loop `target_height + 1..=tip` is empty, so no work is done and an empty vec is returned. This is correct behavior.
- The mmap truncation must happen after the RocksDB write to ensure the durable store is updated first. On crash between RocksDB write and mmap truncation, CAN-001 rebuild will correct the mmap.

---

## Test Plan

| Test Name | Type | Description |
|-----------|------|-------------|
| `test_rollback_removes_canonical` | unit | Build chain of 10, rollback to 5, verify heights 6-9 are no longer canonical |
| `test_rollback_truncates_mmap` | unit | Verify canonical.bin size after rollback matches `(target+1)*32` |
| `test_rollback_updates_tip` | unit | After rollback, verify tip() is at target_height |
| `test_rollback_returns_reverted` | unit | Verify returned hashes match the blocks that were removed, in descending order |
| `test_rollback_preserves_blocks` | unit | After rollback, verify all reverted blocks are still in CF_BLOCKS |
| `test_rollback_marks_non_canonical` | unit | After rollback, verify reverted BlockRecords have in_canonical_chain == false |
| `test_rollback_to_tip_noop` | unit | Rollback to current tip height, verify empty result and no state change |
| `test_rollback_to_zero` | unit | Rollback to height 0 (genesis), verify only genesis remains canonical |

---

## Expected Test Files

- `tests/ror_001_tests.rs`
