Skip to content

feat(core): D24 — snapshot/fork keyset portability contract#158

Merged
heyoub merged 2 commits into
mainfrom
feat/d24-snapshot-fork-keyset-contract
Jul 3, 2026
Merged

feat(core): D24 — snapshot/fork keyset portability contract#158
heyoub merged 2 commits into
mainfrom
feat/d24-snapshot-fork-keyset-contract

Conversation

@heyoub

@heyoub heyoub commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Closes the last silent-degradation member of the 0.9.0 kill-list: an encrypted store's snapshot/fork could silently produce an unrestorable backup. D24 makes keyset portability fail-closed by construction. Lands before the v0.9.0 tag, so the fail-closed default needs no migration note (payload encryption has never shipped).

Contract

  • Default = typed refusal. snapshot/fork of a store with payload encryption active return StoreError::KeysetNotPortable. A copy without its keys is silently unrestorable; a copy carrying its keys would let a restore resurrect crypto-shredded data — neither is safe.
  • Opt-out = KeysetPolicy::ExcludeKeys (SnapshotOptions + Store::snapshot_with_evidence_with_options; ForkOptions::keyset_policy). The op proceeds and the report is stamped KeysExcluded; the keyset must then be managed out-of-band.
  • Restore side = StoreError::KeysetMissing, loud and distinct from a lawful shred (PayloadShredded) — "operator lost the keys" ≠ "scope lawfully erased". Distinguished by a KeyStore::absent_on_load flag cleared on first mint, so a normal fresh store's lawful shred still reads Shredded.
  • Carrying keys stays forbidden until the pluggable keyset-backend seam (D16) exists.

Tests (pin the contract both ways)

Fail-closed default · ExcludeKeys proceeds + stamps the report · restore-without-keyset → KeysetMissing (not Shredded) · shred → snapshot(ExcludeKeys) → restore → still unreadable (backups cannot resurrect) · plaintext store unaffected. Plus a regression run of the existing crypto_shred_payload suite.

Notes

  • Complexity budget kept by splitting, not pin-bumping (SnapshotCopyAcc, fmt_cursor_checkpoint_violation).
  • Corrects the stale file_classification.rs "Stage B / no encryption" comments to the real rationale.
  • Public-API baseline re-blessed (10 additive lines; gated StoreError variants correctly absent under default features), rustdoc redundant-link warnings fixed.
  • Docs: CHANGELOG [0.9.0], lib.rs crypto-shred narrative, ROADMAP §0 (StoreError contract-mirror gap 9→11).

Verified locally: structural-check, traceability-check, clippy --all-features --all-targets, fmt, D24 5/5 + crypto_shred_payload 10/10, public-api baseline matches, 0 rustdoc warnings.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added keyset portability controls for encrypted snapshots and forks, including new SnapshotOptions, ForkOptions.keyset_policy, and evidence/reporting of keys intentionally excluded.
  • Bug Fixes
    • Encrypted snapshot/fork now fail closed by default when keysets can’t be made portable.
    • Restores from encrypted copies without the required keyset now return KeysetMissing (and not a shredded-style lookalike).
  • Documentation / Tests
    • Updated error/contract docs and expanded crypto keyset portability test coverage.

Greptile Summary

This PR adds fail-closed keyset portability behavior for encrypted store snapshots and forks. The main changes are:

  • Default snapshot and fork now return StoreError::KeysetNotPortable when payload encryption is active.
  • KeysetPolicy::ExcludeKeys allows explicit keys-excluded copies and stamps evidence reports with KeysExcluded.
  • Restores without the out-of-band keyset now report StoreError::KeysetMissing instead of a shredded-payload result.
  • Snapshot/fork APIs, reports, public API baselines, docs, and payload-encryption tests were updated for the new contract.

Confidence Score: 5/5

This PR is safe to merge based on the reviewed changes.

The changed lifecycle paths gate encrypted snapshot/fork before destination mutation. Reports are stamped only for explicit ExcludeKeys on encryption-active stores. The tests cover the main contract branches reviewed here. No new actionable issues were identified.

No files require special attention.

T-Rex T-Rex Logs

What T-Rex did

  • Ran the D24 contract tests and confirmed a passing run (5 passed; 0 failed) with exit code 0.
  • Ran the crypto_shred_payload regression tests and confirmed a passing run (10 passed; 0 failed) with exit code 0.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
bpk-lib/crates/core/src/store/lifecycle.rs Introduces the shared snapshot/fork keyset portability gate.
bpk-lib/crates/core/src/store/lifecycle_snapshot.rs Applies the keyset portability gate before snapshot destination mutation and stamps ExcludeKeys evidence.
bpk-lib/crates/core/src/store/lifecycle_fork.rs Applies the keyset portability gate before fork destination mutation and stamps ExcludeKeys evidence.
bpk-lib/crates/core/src/store/keyscope.rs Adds absent-on-load tracking for keysets; the remaining state-preservation concern was already covered in an existing PR thread.
bpk-lib/crates/core/src/store/read_api.rs Maps absent-on-load encrypted payload reads to KeysetMissing instead of shredded plaintext.
bpk-lib/crates/core/tests/crypto_shred_snapshot_fork_keyset.rs Adds payload-encryption tests covering fail-closed defaults, ExcludeKeys evidence, missing-keyset reads, and plaintext behavior.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Caller
participant Store
participant Gate as resolve_keyset_exclusion
participant Copy as snapshot/fork copy
participant Report as Evidence report
participant Restored as Restored Store

Caller->>Store: snapshot/fork(options)
Store->>Gate: key_store + keyset_policy
alt encryption active + Refuse
    Gate-->>Store: StoreError::KeysetNotPortable
    Store-->>Caller: typed refusal before destination mutation
else encryption active + ExcludeKeys
    Gate-->>Store: "keys_excluded = true"
    Store->>Copy: copy eligible store files excluding keyset
    Copy->>Report: add KeysExcluded finding
    Store-->>Caller: evidence report
    Caller->>Restored: open keys-excluded copy without keyset
    Restored-->>Caller: StoreError::KeysetMissing on encrypted read
else plaintext/no payload encryption
    Gate-->>Store: "keys_excluded = false"
    Store->>Copy: normal snapshot/fork
    Store-->>Caller: evidence report without KeysExcluded
end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Caller
participant Store
participant Gate as resolve_keyset_exclusion
participant Copy as snapshot/fork copy
participant Report as Evidence report
participant Restored as Restored Store

Caller->>Store: snapshot/fork(options)
Store->>Gate: key_store + keyset_policy
alt encryption active + Refuse
    Gate-->>Store: StoreError::KeysetNotPortable
    Store-->>Caller: typed refusal before destination mutation
else encryption active + ExcludeKeys
    Gate-->>Store: "keys_excluded = true"
    Store->>Copy: copy eligible store files excluding keyset
    Copy->>Report: add KeysExcluded finding
    Store-->>Caller: evidence report
    Caller->>Restored: open keys-excluded copy without keyset
    Restored-->>Caller: StoreError::KeysetMissing on encrypted read
else plaintext/no payload encryption
    Gate-->>Store: "keys_excluded = false"
    Store->>Copy: normal snapshot/fork
    Store-->>Caller: evidence report without KeysExcluded
end
Loading

Reviews (2): Last reviewed commit: "Merge branch 'main' into feat/d24-snapsh..." | Re-trigger Greptile

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds keyset portability controls for encrypted snapshot and fork operations, new StoreError variants, absent-on-load keyset tracking, updated lifecycle wiring, and tests/docs for the new fail-closed and keys-excluded flows.

Changes

Snapshot/fork keyset portability (D24)

Layer / File(s) Summary
StoreError variants and formatting
bpk-lib/crates/core/src/store/error.rs, bpk-lib/crates/core/src/store/error/display.rs
Adds KeysetNotPortable and KeysetMissing, updates source(), and extends Display handling including cursor-checkpoint helper refactoring.
KeyStore absent-on-load tracking
bpk-lib/crates/core/src/store/keyscope.rs, bpk-lib/crates/core/src/store/keyscope/persist.rs, bpk-lib/crates/core/src/store/read_api.rs
Adds absent-on-load state, new constructors/accessors, load/rehydrate wiring, and distinct missing-key read behavior.
Policy and report types
bpk-lib/crates/core/src/store/fork_report.rs, bpk-lib/crates/core/src/store/snapshot_report.rs, bpk-lib/crates/core/src/store/mod.rs
Introduces KeysetPolicy, extends ForkOptions, adds KeysExcluded findings, and exports SnapshotOptions.
Snapshot/fork lifecycle wiring
bpk-lib/crates/core/src/store/lifecycle.rs, lifecycle_api.rs, lifecycle_fork.rs, lifecycle_snapshot.rs, file_classification.rs
Adds keyset-exclusion resolution and threads it through snapshot/fork flows, report construction, and snapshot API entry points.
Tests, benchmarks, public API, and docs
bpk-lib/crates/core/tests/*, bpk-lib/crates/core/benches/fork_cost.rs, bpk-lib/traceability/*, CHANGELOG.md, ROADMAP.md, bpk-lib/crates/core/src/lib.rs
Adds portability tests, updates existing fork cases and benchmark defaults, and refreshes traceability and documentation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main change: D24 keyset portability for snapshot/fork.
Description check ✅ Passed It covers the change summary, contract details, tests, docs, and verification, though it doesn't use the repo's exact template headings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/d24-snapshot-fork-keyset-contract

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
bpk-lib/crates/core/src/store/read_api.rs (1)

246-277: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

# Errors doc for open_encrypted_payload_bytes omits the new KeysetMissing case.

The function can now return StoreError::KeysetMissing when the keyset was absent on load, but the doc comment only lists PayloadDecryptFailed and the internal Serialization invariant-break.

📝 Proposed doc fix
     /// # Errors
     /// [`StoreError::PayloadDecryptFailed`] if the key is present but the
     /// ciphertext/nonce/bound identity fails to authenticate (tamper), or an
     /// internal [`StoreError::Serialization`] if reached with no keyset (an
     /// invariant break — callers gate on `key_store.is_some()`).
+    /// [`StoreError::KeysetMissing`] if the requested key is absent because the
+    /// keyset FILE was never loaded (as opposed to a lawfully-destroyed scope).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bpk-lib/crates/core/src/store/read_api.rs` around lines 246 - 277, The `#
Errors` documentation for `open_encrypted_payload_bytes` is missing the newly
introduced `StoreError::KeysetMissing` path. Update the doc comment above
`open_encrypted_payload_bytes` to list `KeysetMissing` alongside
`PayloadDecryptFailed` and the internal `Serialization` invariant-break,
matching the branch that returns `StoreError::KeysetMissing { event_id }` when
`key_store.lock().was_absent_on_load()` is true.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@bpk-lib/crates/core/src/store/keyscope.rs`:
- Around line 406-413: The missing-keyset state is being tracked too broadly in
keyscope logic, so a later mint clears the signal for all scopes and can make a
restored missing scope look shredded. Update the `KeyScope`/`get_or_create` flow
in `keyscope.rs` to track `absent_on_load` per scope (or equivalent per-scope
state) instead of a store-wide boolean, and keep `PayloadShredded` vs
`StoreError::KeysetMissing` decisions tied to the specific scope being read.
Also add coverage around restore → append/mint → read to verify one scope’s
minting does not affect another scope’s missing-keyset behavior.

---

Nitpick comments:
In `@bpk-lib/crates/core/src/store/read_api.rs`:
- Around line 246-277: The `# Errors` documentation for
`open_encrypted_payload_bytes` is missing the newly introduced
`StoreError::KeysetMissing` path. Update the doc comment above
`open_encrypted_payload_bytes` to list `KeysetMissing` alongside
`PayloadDecryptFailed` and the internal `Serialization` invariant-break,
matching the branch that returns `StoreError::KeysetMissing { event_id }` when
`key_store.lock().was_absent_on_load()` is true.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8fcebc09-33a4-4367-b898-d92706388548

📥 Commits

Reviewing files that changed from the base of the PR and between f376709 and ff8b9b6.

📒 Files selected for processing (22)
  • CHANGELOG.md
  • ROADMAP.md
  • bpk-lib/crates/core/benches/fork_cost.rs
  • bpk-lib/crates/core/src/lib.rs
  • bpk-lib/crates/core/src/store/error.rs
  • bpk-lib/crates/core/src/store/error/display.rs
  • bpk-lib/crates/core/src/store/file_classification.rs
  • bpk-lib/crates/core/src/store/fork_report.rs
  • bpk-lib/crates/core/src/store/keyscope.rs
  • bpk-lib/crates/core/src/store/keyscope/persist.rs
  • bpk-lib/crates/core/src/store/lifecycle.rs
  • bpk-lib/crates/core/src/store/lifecycle_api.rs
  • bpk-lib/crates/core/src/store/lifecycle_fork.rs
  • bpk-lib/crates/core/src/store/lifecycle_snapshot.rs
  • bpk-lib/crates/core/src/store/mod.rs
  • bpk-lib/crates/core/src/store/read_api.rs
  • bpk-lib/crates/core/src/store/snapshot_report.rs
  • bpk-lib/crates/core/tests/crypto_shred_snapshot_fork_keyset.rs
  • bpk-lib/crates/core/tests/store_fork.rs
  • bpk-lib/crates/core/tests/store_fork_isolation.rs
  • bpk-lib/traceability/complexity_ratchet.yaml
  • bpk-lib/traceability/public_api/batpak.txt
💤 Files with no reviewable changes (1)
  • bpk-lib/traceability/complexity_ratchet.yaml

Comment on lines +406 to +413
/// `true` when this keyset was rehydrated from an ABSENT keyset file — the
/// file did not exist at open, so no keys were ever loaded. Distinguishes a
/// lost/withheld keyset (a keys-excluded snapshot opened without its
/// out-of-band keyset) from a lawful per-scope crypto-shred: with this set, a
/// missing scope key on read is reported as [`StoreError::KeysetMissing`]
/// rather than a Shredded lookalike (D24). Never cleared — the original keys
/// are permanently absent even after new keys are later minted/flushed.
absent_on_load: bool,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether any existing test exercises "restore with excluded keys, then append
# a new scope, then read an old missing scope" to confirm this gap is untested.
rg -n -B2 -A40 'fn restoring_without_the_keyset_reads_keyset_missing_not_shredded' \
  bpk-lib/crates/core/tests/crypto_shred_snapshot_fork_keyset.rs
rg -n 'get_or_create' bpk-lib/crates/core/src/store/keyscope.rs bpk-lib/crates/core/src/store/write/*.rs

Repository: freebatteryfactory/batpak

Length of output: 2157


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== keyscope.rs relevant sections =="
sed -n '380,540p' bpk-lib/crates/core/src/store/keyscope.rs

echo
echo "== load_with_fs / new_absent call sites =="
rg -n -B2 -A4 'new_absent|absent_on_load|was_absent_on_load|get_or_create' bpk-lib/crates/core/src/store -g '!**/target/**'

echo
echo "== tests around the restore scenario =="
sed -n '120,220p' bpk-lib/crates/core/tests/crypto_shred_snapshot_fork_keyset.rs

Repository: freebatteryfactory/batpak

Length of output: 32084


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find any tests that exercise a restore, then append, then read an old event.
rg -n -A30 -B10 'ExcludeKeys|KeysetMissing|PayloadShredded|snapshot_with_evidence_with_options' \
  bpk-lib/crates/core/tests -g '*.rs'

# Check whether get_or_create is used by code paths that could run before a read of old events.
rg -n -A3 -B3 'get_or_create\(' bpk-lib/crates/core/src -g '*.rs'

Repository: freebatteryfactory/batpak

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only the definitions to see how the flag is interpreted on reads.
sed -n '1,220p' bpk-lib/crates/core/src/store/keyscope.rs | rg -n -A4 -B4 'was_absent_on_load|absent_on_load|KeysetMissing|PayloadShredded|destroy|destroyed'

Repository: freebatteryfactory/batpak

Length of output: 2924


Track absent-on-load per scope, not with a store-wide boolean bpk-lib/crates/core/src/store/keyscope.rs:484-490get_or_create clears absent_on_load globally on the first mint, so a keys-excluded restore can start returning PayloadShredded for pre-existing missing scopes after any later append that mints a new scope. The current tests only cover restore→immediate read, not restore→append→read; keep the lost-keyset vs shredded distinction per scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bpk-lib/crates/core/src/store/keyscope.rs` around lines 406 - 413, The
missing-keyset state is being tracked too broadly in keyscope logic, so a later
mint clears the signal for all scopes and can make a restored missing scope look
shredded. Update the `KeyScope`/`get_or_create` flow in `keyscope.rs` to track
`absent_on_load` per scope (or equivalent per-scope state) instead of a
store-wide boolean, and keep `PayloadShredded` vs `StoreError::KeysetMissing`
decisions tied to the specific scope being read. Also add coverage around
restore → append/mint → read to verify one scope’s minting does not affect
another scope’s missing-keyset behavior.

Source: Coding guidelines

Comment thread bpk-lib/crates/core/src/store/keyscope.rs
Snapshot/fork of a store with payload encryption active now FAIL CLOSED by
default (StoreError::KeysetNotPortable): a copy without its keys is silently
unrestorable, and a copy carrying its keys would let a restore resurrect
crypto-shredded data. Opt into a keys-excluded copy with
KeysetPolicy::ExcludeKeys (SnapshotOptions +
Store::snapshot_with_evidence_with_options; ForkOptions::keyset_policy) — the
report is stamped KeysExcluded and the keyset must then be managed out-of-band.

Restore side: opening an encrypted store whose keyset FILE is entirely absent (a
keys-excluded copy restored without its out-of-band keyset) now reports the new
StoreError::KeysetMissing — loud and distinct from a lawful crypto-shred
(PayloadShredded), never a Shredded lookalike. Distinguished via a
KeyStore::absent_on_load flag cleared on first mint, so a normal fresh store's
lawful shred still reads Shredded (regression-guarded by crypto_shred_payload).

Also corrects the stale "Stage B / no encryption" comments in
file_classification.rs to the real D24 rationale, and keeps snapshot/fork within
the function-complexity budget by splitting (not pin-bumping): SnapshotCopyAcc
and a fmt_cursor_checkpoint_violation helper.

Tests: 5 D24 integration tests (fail-closed default / ExcludeKeys+marker /
KeysetMissing != Shredded / backups-cannot-resurrect / plaintext-unaffected),
plus Display coverage. Docs: CHANGELOG [0.9.0], lib.rs crypto-shred narrative,
ROADMAP section 0. Public-API baseline (traceability/public_api/batpak.txt)
re-blessed (10 additive lines) and rustdoc redundant-link warnings fixed.

Gates run out-of-band: structural-check ok, traceability-check ok, clippy
(all-features all-targets) clean, fmt clean, D24 + crypto_shred_payload green,
public-api baseline matches, 0 rustdoc warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnRLGgP2VtnoggMn4BtKpP
@heyoub heyoub force-pushed the feat/d24-snapshot-fork-keyset-contract branch from ff8b9b6 to 05c9b82 Compare July 3, 2026 21:19
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