ic-memory 0.0.3

Prevent stable-memory slot drift across Internet Computer canister upgrades
Documentation

ic-memory

ic-memory prevents Internet Computer stable-memory slots from being accidentally reused, moved, or reassigned across canister upgrades.

It does this by keeping a durable allocation ledger that records which canonical stable key owns which physical allocation slot. The invariant is:

stable_key -> allocation_slot forever

More fully: once a logical stable key has been assigned to a physical allocation slot, that key must never point to a different slot, and that slot must never be reused for a different key, even after retirement.

This crate is not a replacement for ic-stable-structures. It is not a generic memory abstraction. It is stable-memory allocation-governance infrastructure. Use it before opening stable-memory handles, so a canister rejects an unsafe layout before it can open the wrong stable memory for a logical store.

The non-negotiable invariants are recorded in SAFETY.md.

Status

ic-memory is early infrastructure extracted from Canic. The public API is intended to stabilize around persistent allocation ownership, but framework authors should still treat this line as young infrastructure while the standalone boundary settles.

The Bug Class

Multi-store canisters and frameworks often map logical stores onto physical stable-memory slots such as ic-stable-structures::MemoryManager IDs.

For example, a canister may ship with this layout:

v1:
  app.users.v1  -> MemoryManager ID 100
  app.orders.v1 -> MemoryManager ID 101

A bad upgrade can accidentally swap those IDs:

bad v2:
  app.users.v1  -> MemoryManager ID 101
  app.orders.v1 -> MemoryManager ID 100

That upgrade may still compile and install. Rust's type system and ic-stable-structures do not automatically know that app.users.v1 used to own ID 100. The canister can boot while opening the orders memory as users, or the users memory as orders.

ic-memory exists to reject that mapping before memory handles are opened.

Why This Exists With ic-stable-structures

ic-stable-structures provides stable data structures and memory abstractions. It lets you store data in stable memory.

ic-memory records and validates durable ownership of stable-memory slots over time. It answers a different question: is this logical store still opening the same physical slot it has always owned?

The two crates are complementary. A framework can use ic-memory to validate allocation ownership, then use ic-stable-structures to open and operate on the validated memories.

How It Fits

  • ic-stable-structures stores data in stable memory.
  • ic-memory governs which logical store is allowed to open which stable-memory slot.
  • The framework or application decides namespace policy, range policy, lifecycle timing, controller authorization, and schema migration.

This crate owns allocation invariants, not framework policy. It is generic over storage substrates: MemoryManager IDs are one supported slot descriptor shape, not the whole design.

Who Needs This?

You probably do not need ic-memory if your canister has one stable structure, a small fixed hand-written layout, and no generated or framework-managed stable stores.

You may need it if you are building:

  • an IC framework
  • a multi-store canister
  • a generated canister platform
  • a plugin/module system
  • a canister family where stable-memory declarations evolve over time
  • any system where accidental stable-memory ID reuse would be catastrophic

Lifecycle

The intended flow is:

  1. Declare expected stable-memory allocations with canonical stable keys.
  2. Recover the historical allocation ledger.
  3. Validate current declarations against policy and historical ownership.
  4. Commit a new allocation generation.
  5. Open physical memory only through a validated allocation session.
  6. Export diagnostics when needed.

Opening memory handles is deliberately a later phase. Declaration and validation happen first so slot drift is caught before the application touches stable data.

Terminology

  • Stable key: a canonical logical name for one durable store, such as app.orders.v1.
  • Allocation slot: the physical stable-memory location a storage substrate can open, such as a MemoryManager ID.
  • Allocation ledger: durable history of stable-key to allocation-slot ownership.
  • Declaration: the current binary's claim that a stable key should own a slot.
  • Generation: one committed version of the allocation ledger.
  • Reservation: a slot/key pair held for future use but not yet active.
  • Retirement / tombstone: an explicit historical marker that an allocation is no longer active.
  • Validated allocation session: the capability produced after declarations pass policy and ledger-history validation.
  • Storage substrate: the implementation that interprets slots and opens physical memory handles.

Retirement is a tombstone, not a free-list operation. A retired stable key/slot pair remains historically owned and cannot be reused for a different key. This preserves rollback safety, diagnostics, and historical ABI integrity.

Non-Goals

ic-memory does not own:

  • stable data-structure schemas
  • schema migrations
  • controller authorization
  • IC management-canister calls
  • endpoint dispatch
  • framework namespace/range policy
  • application-level data validation

What It Provides

  • Canonical stable-key parsing.
  • Allocation slot descriptors.
  • Declaration collection and duplicate rejection.
  • Allocation policy and substrate traits.
  • Durable allocation history records.
  • Generation-scoped staging and commit helpers.
  • Tombstone and reservation lifecycle primitives.
  • Rollback-safe allocation validation.
  • Protected dual-slot commit recovery primitives.
  • Read-only diagnostic export shapes.

Decoded durable ledgers and public DTO structs are untrusted until validated. Recovery and commit paths use strict committed-ledger validation before a ledger can become authoritative.

Example: Declaration Phase

This example demonstrates lifecycle phase 1: collect the current binary's expected stable-memory allocations. It does not open memory.

use ic_memory::{
    AllocationDeclaration, AllocationSlotDescriptor, DeclarationCollector, SchemaMetadata,
};

let mut declarations = DeclarationCollector::default();
let declaration = AllocationDeclaration::new(
    "app.orders.v1",
    AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
    Some("orders".to_string()),
    SchemaMetadata::default(),
)
.expect("valid allocation declaration");

declarations.push(declaration);

let snapshot = declarations.seal().expect("valid declaration snapshot");
assert_eq!(snapshot.len(), 1);

Example: Rejecting Slot Drift

This example demonstrates lifecycle phase 3: validate the current declarations against historical ownership. The bad upgrade tries to move app.users.v1 from MemoryManager ID 100 to ID 101, so validation fails before an allocation session can open any memory handles.

use ic_memory::{
    AllocationDeclaration, AllocationHistory, AllocationLedger, AllocationPolicy,
    AllocationRecord, AllocationSlotDescriptor, AllocationState, AllocationValidationError,
    CURRENT_LEDGER_SCHEMA_VERSION, CURRENT_PHYSICAL_FORMAT_ID, DeclarationSnapshot,
    GenerationRecord, SchemaMetadata, StableKey, validate_allocations,
};
use std::convert::Infallible;

#[derive(Debug)]
struct AllowAllPolicy;

impl AllocationPolicy for AllowAllPolicy {
    type Error = Infallible;

    fn validate_key(&self, _key: &StableKey) -> Result<(), Self::Error> {
        Ok(())
    }

    fn validate_slot(
        &self,
        _key: &StableKey,
        _slot: &AllocationSlotDescriptor,
    ) -> Result<(), Self::Error> {
        Ok(())
    }

    fn validate_reserved_slot(
        &self,
        _key: &StableKey,
        _slot: &AllocationSlotDescriptor,
    ) -> Result<(), Self::Error> {
        Ok(())
    }
}

fn declaration(key: &str, memory_manager_id: u8) -> AllocationDeclaration {
    AllocationDeclaration::new(
        key,
        AllocationSlotDescriptor::memory_manager(memory_manager_id).expect("usable slot"),
        None,
        SchemaMetadata::default(),
    )
    .expect("valid declaration")
}

let historical_ledger = AllocationLedger {
    ledger_schema_version: CURRENT_LEDGER_SCHEMA_VERSION,
    physical_format_id: CURRENT_PHYSICAL_FORMAT_ID,
    current_generation: 1,
    allocation_history: AllocationHistory {
        records: vec![
            AllocationRecord::from_declaration(
                1,
                declaration("app.users.v1", 100),
                AllocationState::Active,
            ),
            AllocationRecord::from_declaration(
                1,
                declaration("app.orders.v1", 101),
                AllocationState::Active,
            ),
        ],
        generations: vec![GenerationRecord {
            generation: 1,
            parent_generation: Some(0),
            runtime_fingerprint: None,
            declaration_count: 2,
            committed_at: None,
        }],
    },
};

let bad_v2 = DeclarationSnapshot::new(vec![
    declaration("app.users.v1", 101),
    declaration("app.orders.v1", 100),
])
.expect("duplicate-free declarations");

let error = validate_allocations(&historical_ledger, bad_v2, &AllowAllPolicy)
    .expect_err("slot drift must be rejected");

assert!(matches!(
    error,
    AllocationValidationError::StableKeySlotConflict { .. }
));

The protected physical checksum detects torn writes and accidental corruption. It is not a cryptographic integrity mechanism and must not be treated as adversarial tamper resistance.