astrid-runtime/astrid · error

migration ledger is not canonical: {}

Error message

migration ledger is not canonical: {}

What it means

After a successful JSON parse, `decode_canonical` re-serializes the value via `canonical_json` (serde_json + trailing newline) and requires the result to equal the original bytes exactly. This error is thrown when the ledger is valid JSON but not serialized in the library's canonical form (e.g. different key order, whitespace, or missing trailing newline), which the library treats as tampering or as produced by a non-canonical writer.

Source

Thrown at crates/astrid-kernel/src/legacy_migration_barrier/ledger.rs:590

pub(super) fn destination_file_proof(path: &Path) -> io::Result<DestinationProof> {
    Ok(match read_bounded_file(path, MAX_BYTES)? {
        Some(bytes) => DestinationProof::from_hashed_bytes(&bytes),
        None => DestinationProof::absent(),
    })
}

pub(super) fn decode_canonical<T: for<'de> Deserialize<'de> + Serialize>(
    bytes: &[u8],
    path: &Path,
) -> io::Result<T> {
    let value = serde_json::from_slice(bytes).map_err(|error| {
        io::Error::new(
            io::ErrorKind::InvalidData,
            format!("decode migration ledger {}: {error}", path.display()),
        )
    })?;
    if canonical_json(&value)? != bytes {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("migration ledger is not canonical: {}", path.display()),
        ));
    }
    Ok(value)
}

pub(super) fn canonical_json<T: Serialize>(value: &T) -> io::Result<Vec<u8>> {
    let mut bytes = serde_json::to_vec(value).map_err(io::Error::other)?;
    bytes.push(b'\n');
    Ok(bytes)
}

#[allow(
    clippy::too_many_lines,
    reason = "all ledger invariants are checked before admission"
)]
pub(super) fn validate_ledger_shape(ledger: &MigrationLedger) -> io::Result<()> {

View on GitHub (pinned to affd8760f4)

Solutions

  1. Restore the canonical bytes: re-run the migration/resume flow so the library rewrites the ledger in canonical form, or restore the original file from backup.
  2. If you only reformatted the file to read it, revert the reformatting (byte-for-byte identical to the original, including the trailing newline).
  3. Do not normalize the JSON yourself unless you can guarantee the exact canonical form the library produces; prefer regenerating via the library.
  4. Configure editors/backups not to write back to files under the migrations directory.

Example fix

// before: pretty-printed by jq
$ jq . ledger.json > ledger.json   # breaks canonical form
// after: keep canonical bytes
$ jq . ledger.json                 # read-only inspection
$ git checkout -- ledger.json      # restore canonical form
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-flight: re-serialize and compare bytes before handing the file to the library
fn ledger_is_canonical(path: &Path) -> io::Result<bool> {
    let bytes = std::fs::read(path)?;
    let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(io::Error::other)?;
    let mut canonical = serde_json::to_vec(&value).map_err(io::Error::other)?;
    canonical.push(b'\n');
    Ok(canonical == bytes)
}

Try / catch

match decode_canonical::<MigrationLedger>(&bytes, &path) {
    Err(e) if e.to_string().starts_with("migration ledger is not canonical") => {
        // file was reformatted: restore original bytes or regenerate via the library
    }
    other => other,
}

Prevention

When it happens

Trigger: Calling any `decode_canonical` consumer (`resume_existing_layout`, `reject_incomplete_layout_v2`, `retire_post_barrier_sources`, `legacy_secret_source_must_be_absent`) against a ledger file that was pretty-printed, had keys reordered, was reformatted by an editor, or lacks the exact canonical serialization plus trailing `\n`.

Common situations: Running `jq .` or a formatter over the ledger to inspect it and saving the result; a Git merge or sed rewrite changing whitespace; a different tool version serializing fields in non-canonical order.

Understand the failure class

Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.

Related errors


AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09). Data as JSON: /api/errors/9f2eda1ee86740f5. Report an issue: GitHub.