feat(storage): add inode-stable ASTVOL volume-root publication by joshuajbouw · Pull Request #1801 · astrid-runtime/astrid · GitHub
Skip to content

feat(storage): add inode-stable ASTVOL volume-root publication - #1801

Draft
joshuajbouw wants to merge 6 commits into
mainfrom
codex/1797-astvol-volume-root
Draft

feat(storage): add inode-stable ASTVOL volume-root publication#1801
joshuajbouw wants to merge 6 commits into
mainfrom
codex/1797-astvol-volume-root

Conversation

@joshuajbouw

@joshuajbouw joshuajbouw commented Sep 1, 2026

Copy link
Copy Markdown
Member

Linked Issue

Closes #1797. Tracking #1780.

Summary

Adds inode-stable ASTVOL volume-root publication for hosted volume recovery.

The current head also publishes the sibling root slot before the selected copy so a torn selected-slot rewrite cannot destroy the last recoverable generation. This remains a reviewed candidate, not a release gate. It does not activate User storage, complete every reclaim path, or authorize a release by itself.

Changes

  • Publishes and recovers generation roots without replacing the volume inode.
  • Preserves the last recoverable root until a successor pointer is durable.
  • Publishes the opposite root slot first on rooted sync, then the selected slot.
  • Adds empty-volume, truncated-header, stale-pointer, uncommitted-tail, and single-copy rooted-sync regressions.

Verification

  • Independent exact-head GLM and Luna reviews accepted commit 068fbde80842b832f2edb6c859593d1c4c02e7eb.
  • Hosted volume tests on this head: astrid-storage 782 passed, 7 ignored; root tests 17/17.
  • AstridVolume::reclaim still uses the generic namespace swap; same-inode reclaim remains a test/setup path.
  • Hosted/Linux evidence does not prove macOS FSKit, Windows, or native OS behavior.
  • Merge and release decisions remain separate.

AI / Tool Assistance

Assisted-by: Codex:GLM-5.3 Flash

AI assistance was used for implementation and tests. The maintainer reviewed the complete diff and owns the landing decision.

Checklist

  • Linked issue
  • Independent exact-head review
  • Terminal integration evidence complete
  • Ready to merge

Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com>
Copilot AI balanced review requested due to automatic review settings September 1, 2026 13:38

Copilot AI 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.

Pull request overview

Adds crash-oriented, inode-stable ASTVOL1 volume-root publication and same-inode reclaim support.

Changes:

  • Adds dual-copy checksummed root recovery.
  • Implements same-inode replacement, relocation, and truncation.
  • Adds format documentation, changelog, and crash-stage tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
hosted/tests.rs Adds reclaim and recovery coverage.
hosted/recover.rs Implements root-pointer encoding and recovery.
hosted/reclaim.rs Implements same-inode publication.
hosted/open.rs Integrates rooted recovery during open.
hosted/mod.rs Adds root state and public reclaim API.
astrid-volume-v1.txt Documents the root grammar.
changes/1797.added.md Adds the changelog entry.
Suppressed comments (5)

crates/astrid-storage/src/volume/hosted/mod.rs:209

  • Root updates always start with copy 0, even when recovery left copy 0 as the only valid authority (for example, a crash after the first root write during generation-0 conversion). Rewriting that sole copy first reintroduces a window where a torn write leaves neither copy recoverable. Write the slot opposite state.root_slot first, sync it, then update the selected slot.
        if state.generation > 0 {
            recover::write_root_pointer(
                &mut state.file,
                state.generation,
                state.root_base,
                state.durable_len,
                false,

crates/astrid-storage/src/volume/hosted/reclaim.rs:334

  • When the source is generation 0, finish_image puts the staged replacement's fully valid footer at physical EOF before any root exists. recover_container then takes the generation-0 EOF-footer fallback and admits this replacement at the ReplacementDurable crash cut, so the old generation is not kept current until the root flip as required. The staged image needs an encoding/location that cannot be selected by generation-0 EOF recovery before publication.
    let replacement_end = finish_image(&mut replacement)?;
    run_stage(&volume.path, ReclaimStage::ReplacementDurable)?;

crates/astrid-storage/src/volume/hosted/reclaim.rs:330

  • The first replacement starts at the current EOF, which can be below ROOT_BYTES for a small generation-0 container (an empty volume is only 8 bytes). Publishing the fixed slots at bytes 8–135 then overwrites the just-synced replacement commit/image before pointing at it, making the intermediate root unrecoverable. Reserve the complete fixed-root area before choosing the replacement start and handle inputs too small to relocate safely.
    let mut replacement = build_image(
        &mut state,
        physical_len,
        replacement_generation,
        super::ROOT_BYTES as u64,

crates/astrid-storage/src/volume/hosted/reclaim.rs:368

  • This bound check happens only after build_image and finish_image have written the final image. Because candidate_base is also where the currently selected replacement begins, an image that does not fit has already overwritten that selected generation before this error is returned; an already-compact or empty volume can therefore be left corrupt. Preflight the complete encoded size or enforce the bound before every record/footer write so crossing it cannot mutate the selected image.
    let final_end = finish_image(&mut finalized)?;
    if final_end > candidate_base {
        return Err(io::Error::other("volume reclaim did not shrink the image"));

crates/astrid-storage/src/volume/hosted/reclaim.rs:340

  • This publishes ROOT_BYTES as the replacement's canonical base even though the replacement was built at replacement_base. Besides writing incorrect on-media metadata, repeat reclaims misclassify this first flip as FinalRootPublished because its generation is greater than 1. Publish the actual replacement base here.
        replacement_generation,
        super::ROOT_BYTES as u64,
        replacement_end

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +142 to +144
if state.physical_tail == PhysicalTail::TruncateToValid {
state.file.set_len(state.valid_len)?;
}
Comment on lines +274 to +280
let first_slot = state.root_slot == RootSlot::Second;
super::recover::write_root_pointer(
&mut state.file,
generation,
root_base,
footer_offset,
first_slot,
Comment on lines +398 to +407
#[test]
fn receipt_region_survives_all_same_inode_reclaim_stages() {
let temporary = tempfile::tempdir().unwrap();
let path = temporary.path().join("astrid.volume");
let volume = seeded_volume(&path);
let receipt = VolumeRegion::new("system/gc-outbox/receipt.ready").unwrap();
volume.create_region(&receipt, true).unwrap();
volume.write_region_at(&receipt, 0, b"proof").unwrap();
volume.sync().unwrap();
volume.reclaim_same_inode().unwrap();
Comment on lines +427 to +433
Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com>
Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com>
Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com>
Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com>
Publish the opposite root slot before the selected copy so an interrupted selected-slot write cannot invalidate the last recoverable generation.

Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com>
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.

feat(storage): add inode-stable ASTVOL volume-root publication

2 participants