Introduction
Canon is a CLI tool for organizing large collections of files (photos, music, documents) scattered across old hard drives, backup folders, cloud downloads, and phone exports. It indexes files across any number of locations, identifies content by hash regardless of name or path, and lets you query and filter everything with metadata. When you’re ready, it archives what matters and excludes what is not worth keeping, both on the record. Storage that holds nothing unresolved can be retired: its complete story stays readable, in plain text, after the storage is gone.
The Problem
Files accumulate over years and across devices. Backup drives pile up. You know there are things worth keeping in there, but the scale makes it hard to even start. Manual approaches are risky: one wrong move and something irreplaceable could be gone. So the drives keep sitting in drawers.
The Approach
Canon works incrementally:
- Scan directories to index files and compute content hashes
- Enrich with metadata extracted by external tools (EXIF, file types, etc.)
- Orient: explore with filters and queries, survey a location (what’s archived, where it connects, what’s unique), or sweep all roots for the places where one decision resolves the most
- Archive selected files to a canonical location, at your own pace
- Resolve the rest: exclude what is not worth keeping, and retire storage once everything on it is archived or excluded
Each step is revisitable. You can scan new drives, add more metadata, refine your queries, and archive in small batches. Canon tracks what’s already archived, so you always know your progress. Every action that changes anything is recorded, with your optional reason, so past decisions stay reviewable.
Scanning and querying never modify or move your files. Every operation that changes anything has dry-run, preview, and confirmation.
Key Features
- Content-based deduplication: Files are identified by their content hash, not by name or location; the same photo in three backup folders is recognized as one thing
- Metadata: Import any key-value facts from external tools (EXIF data, MIME types, geolocation, or anything you want)
- Filtering: Query by any combination of facts using boolean expressions and aliases
- Archiving: Preview operations with
--dry-run, validate integrity during transfer, and track what’s been archived - Decision record: Every effectful action is recorded with an optional reason and a durable on-disk receipt;
canon trailreads the history back - Retirement: A resolved root leaves the index with its complete story bound into a plain-text book that outlives both the storage and the database
- Incremental workflow: Work at your own pace: scan a drive today, enrich it next week, archive a batch next month
To get started, see Setup and Getting Started.
Setup
Installation
Install Canon from crates.io:
cargo install canon-archive
This installs the canon binary.
From Source
Alternatively, build from source:
git clone https://github.com/robklg/canon.git
cd canon
cargo install --path .
Canon Home Directory
Canon stores all state in a single directory called the canon home. The default location is ~/.canon/.
It contains:
| File | Purpose |
|---|---|
canon.db | SQLite database (roots, sources, objects, facts) |
aliases.toml | Filter aliases (optional — see Aliases) |
The directory is created automatically on first use.
Overriding the Location
You can relocate canon home with the CANON_HOME environment variable or the --canon-home flag:
# Via environment variable
export CANON_HOME=/mnt/archive/.canon
canon scan /photos
# Via flag (takes precedence over environment variable)
canon --canon-home /tmp/test-canon scan /photos
Precedence: --canon-home flag > CANON_HOME env var > ~/.canon/
Verify Installation
canon --help
You should see the list of available commands. Continue with Getting Started.
Getting Started
This guide walks through a typical Canon workflow: scanning files, enriching with metadata, querying, orienting, archiving, and resolving what remains.
Scanning
First, index your source files and existing archive:
# Add source roots (files you want to organize)
canon scan --add --role source /path/to/photos
canon scan --add --role source /path/to/backup-drive/photos
canon scan --add --role source --comment "Old backup, possibly duplicates" /Volumes/OldDrive
# Add an archive root (your organized destination)
canon scan --add --role archive /Volumes/Archive
By default, Canon computes content hashes during scanning. This enables deduplication and archive tracking.
Enriching
Use external tools to extract metadata. The example below uses exiftool to extract EXIF data including GPS-based geolocation:
canon worklist --where 'source.ext|lowercase IN (jpg, jpeg, heic, mov, mp4)' \
| ./scripts/exif-worklist.sh \
| canon import-facts
See Enriching for details on the worklist/import pipeline.
Querying
Discover what facts are available and explore your files:
# See all available facts
canon facts
# Check value distribution for a specific fact
canon facts --key content.geo.region # Where were photos taken?
canon facts --key "content.DateTimeOriginal|year" # Which years?
# List files matching filters
canon ls --where 'content.geo.city=Bletchley'
# Preview files (macOS)
canon ls -0 --where 'content.geo.city=Bletchley' | xargs -0 open -a Preview
Orienting
Survey a location to see how it relates to everything else you’ve indexed:
canon survey /Volumes/OldDrive/photos
The summary shows what’s archived, which other locations share content, and how much exists only here. When you don’t know where to work next, sweep ranks the places where one decision resolves the most:
canon sweep
See survey and sweep for reading the output.
Archiving
When you find a collection worth archiving, create a manifest:
canon cluster generate \
--where 'content.DateTimeOriginal|year=2023' \
--where 'content.geo.region="North Holland"' \
--dest /Volumes/Archive/Trips/2023-Amsterdam
This creates manifest.toml with the query parameters and a manifest.lock with matching sources.
Edit manifest.toml to customize the output pattern:
[output]
pattern = "{content.DateTimeOriginal|date}/{filename}"
base_dir = "/Volumes/Archive/Trips/2023-Amsterdam"
Preview and apply:
canon apply manifest.toml --dry-run # Preview what will happen
canon apply manifest.toml # Execute the copy
Files are copied to the archive with paths like:
/Volumes/Archive/Trips/2023-Amsterdam/2023-06-16/IMG_001.jpg
Resolving
Content that isn’t worth keeping is excluded rather than deleted: nothing is destroyed, and the decision is recorded.
# This folder's content is verified in the archive; dismiss the redundant copies
canon exclude set /Volumes/OldDrive/photos --reason "verified archived, originals redundant"
Every action is recorded. Read a place’s history back at any time:
canon trail /Volumes/OldDrive/photos
When everything on a root is archived or excluded, retire it: its complete story is bound into a plain-text book at the archive, and the storage is free to go.
Next Steps
- Learn about Concepts to understand how Canon models your files
- Explore the full Commands reference
- See Filters for advanced query syntax
Concepts
The core concepts the rest of the book builds on:
- Roots: Storage locations that Canon tracks
- Sources: Files discovered on disk
- Objects: Unique content identified by hash
- Sources vs. Objects: How files relate to content
- Facts: Metadata attached to sources or objects
- Exclusion: Dismissing content from consideration, reversibly
- Resolution: The goal of the work, and the standings on the way there
- Decision Provenance: How Canon records what you did, to what, and why
- Retirement, the Book, and the Shelf: How a resolved root leaves the index
Roots
A root is a directory on a storage device that Canon tracks. Each root is identified by its absolute path and assigned a role.
Roles
Canon distinguishes two root roles:
Source roots contain assets you want to explore, reconcile, or archive. They may be unstructured, incomplete, or contain duplicates. Examples: old backup drives, phone exports, download folders.
Archive roots hold an intentional structure that you maintain. Files archived by Canon are placed here. Examples: your organized photo library, music collection, document archive.
Rules
- Roots may not overlap (one root cannot be inside another)
- A root can be any directory, not just a drive or mount point
- You can have multiple roots of each type
- Roots can be suspended to temporarily hide them from operations
Typical Setup
Source roots:
/Volumes/OldBackup (unorganized photos from 2015)
/Volumes/PhoneExport (recent phone backup)
~/Downloads/Photos (miscellaneous downloads)
Archive roots:
/Volumes/Archive/Photos (canonical photo library)
/Volumes/Archive/Music (canonical music library)
Offline Access
Query commands (ls, facts, coverage, worklist, compare, cluster generate, exclude, roots) work even when the underlying storage is detached. Canon resolves path arguments against known roots in the database, so you can explore sources, check coverage, and generate manifests without the storage being physically attached.
Commands that access file contents (scan, apply) still require the storage to be online.
Source
A source is a file discovered on disk during scanning. Canon tracks:
- Location: Root path + relative path within the root
- Identity: Device ID and inode for move detection
- Metadata: Size and modification time
- Integrity: Partial hash (first + last 8KB) for validation during transfers
- State: A
basis_revcounter that increments when size or mtime changes
Sources represent where files are found. Multiple sources can point to the same content (see Object) when files are duplicated across locations.
When a source is scanned with hashing enabled (the default), Canon computes its SHA-256 hash and links it to an object. This enables deduplication and archive tracking.
Exclusion
Sources can be marked as excluded to skip them during archiving. A source is considered excluded if:
- The source itself is marked excluded, OR
- The source’s linked object is marked excluded
Excluding an object therefore excludes every source with that content, regardless of where it appears.
Object
An object represents unique content identified by its SHA-256 hash. Objects are content-addressed: two files with identical bytes will have the same hash and thus reference the same object.
Objects enable:
- Deduplication: Multiple sources can point to the same object
- Archive tracking: When content exists in an archive, all sources with that hash are marked as archived
- Fact sharing: Metadata attached to an object is available on all sources with that content
Objects are created automatically when sources are hashed during scanning or enrichment.
Empty files are contentless
A zero-byte file has shape but no content. Every empty file shares the one
empty-content object, so its hash identifies nothing. Canon treats such
sources as contentless: they never count as covered or archived (any
empty file anywhere would otherwise cover them all), never count as
unresolved (there is no content to lose), and never block a
retirement. They are still ordinary files: ls finds them,
they can be excluded, and archive operations carry them with their folders,
so a verbatim folder copy keeps its empty files. Reports that mention them
say empty files and state the count; they are never silently omitted.
Source vs. Object
The relationship between sources and objects underlies Canon’s deduplication and archive tracking.
Sources Are Locations
When a root is scanned, Canon indexes every file it finds as a source. Each source represents a specific file at a specific path.
Objects Are Content
When sources are hashed, Canon creates or links them to objects. An object represents the underlying content, independent of where it was found.
Source A: /backup1/photos/IMG_001.jpg ─┐
Source B: /backup2/old/IMG_001.jpg ─┼─► Object (hash: abc123...)
Source C: /downloads/photo.jpg ─┘
All three sources above have identical content, so they reference the same object.
Fact Sharing
When a source is linked to an object:
- Content facts (like EXIF metadata) can be stored on the object and become available to all sources with that hash
- Source facts (like file path) remain specific to each source
Import a fact once, and it is available everywhere that content exists.
Archive Tracking
Canon uses the source-object relationship to track archiving progress:
- When you archive a file, Canon copies it to an archive root and records the object’s hash
- Any source with that same hash is now considered “archived”
- The
coveragecommand shows how many of your sources exist in an archive
Hashing
By default, Canon hashes all files during scanning. Since hashing can be time-consuming for large collections, you can:
- Use
--no-hashduring scan to skip hashing initially - Hash selectively via the enrichment pipeline, targeting specific file types
Unhashed sources cannot be linked to objects, so they cannot be deduplicated or tracked for archive coverage.
Facts
Facts are key-value metadata attached to sources or objects.
Types of Facts
Built-in facts are collected automatically during scanning:
source.ext- File extensionsource.size- File size in bytessource.mtime- Modification timestampcontent.hash.sha256- Content hash (when computed)
Imported facts come from external tools via the enrichment pipeline:
- EXIF metadata:
content.Make,content.Model,content.DateTimeOriginal - Geolocation:
content.geo.city,content.geo.country - Media info:
content.mime,content.duration - Any custom key-value pairs you choose to import
Namespaces
Facts are namespaced:
source.*- Facts about the file on disk (path, size, timestamps)content.*- Facts about the content itself (stored on objects when hashed)
When querying, the content. prefix is optional: --where 'Make=Apple' is equivalent to --where 'content.Make=Apple'.
Value Types
Canon stores facts as:
- Text: Strings like
"Apple"or"image/jpeg"; enables string matching (=,~glob) and string modifiers (|lowercase,|stem) - Numbers: Integers or decimals like
1024or3.14; enables numeric comparisons (>1000) and the|bucketmodifier - Timestamps: Unix timestamps; enables date modifiers (
|year,|month) and date comparisons (>=2024-01-01)
Type hints can be provided during import to ensure correct parsing. See Enriching for details.
Exclusion
Excluding dismisses content from consideration: a conscious decision that this
content does not need archiving. Nothing is deleted. The files stay on disk and in
the index; what changes is attention. Excluded sources are skipped by default in
queries and archiving operations, and --include excluded shows them again.
An exclusion can be cleared at any time, returning the content to consideration.
Clearing is itself recorded, as the restored transition. Nothing else lifts a
dismissal: the exclusion holds through edits, moves, replacement, and a file
reappearing after an absence.
Redundancy beside the archive is a common reason to let go, not the only one: corrupted files, known junk, and content that simply is not worth keeping are all exclusion’s territory.
Two levels
- Source-level exclusion dismisses a file at its path. Other copies of the same content are unaffected.
- Object-level exclusion dismisses content by hash. It is universal: it affects every source sharing that content, in source roots and archive roots alike, and any copy scanned later.
A source counts as excluded when either level applies: it is excluded itself, or its content is.
The record
Every exclusion is recorded as a decision with your optional
--reason, and leaves a durable receipt. Exclusion is one of the three recorded
fates, beside archived and deleted: the dismissal is part of the story, not a
silent disappearance.
See canon exclude for the command surface.
Resolution
Everything Canon indexes, across all your roots, is your universe. Resolving it is the goal of the work: deciding, for all of it, that content worth keeping is archived and content not worth keeping is excluded. The universe shrinks from both directions, and what remains is the work still to do.
Coverage is evidence
Covered content is verified present in the archive by content identity: the same hash stands at an archive root. Coverage is a precise claim about content and nothing more. It does not say the archive copy is arranged the way you want, and it does not say you chose it; content can be covered by a copy nobody deliberately placed.
Coverage is evidence. The judgment that content is resolved stays yours: Canon states what it can verify and leaves the conclusion to you.
Standings
A present source is in exactly one standing:
- excluded: consciously dismissed (see Exclusion)
- contentless: empty, nothing for content identity to verify (see empty files are contentless)
- archived from here: the still-standing original of content archived out of this place by a copy
- covered: content verified present in the archive, though not archived from here
- unresolved: none of the above; no resolution evidence
When several could apply, the earlier in this list wins: exclusion is a judgment and covers the content whatever else is true of it, and an empty file is contentless before any identity test. A source that was never hashed counts as unresolved, because its coverage cannot be verified; empty files are the exception, since there is no content to lose.
The verdict is asymmetric
Canon can prove content unresolved: present, no coverage evidence, not excluded. It never certifies the opposite. The retirement review says NOT READY while unresolved sources stand; when nothing blocks, it reports that no blockers were found and leaves the verdict to you.
Where this vocabulary appears
canon coverage counts covered content,
canon survey reads a location’s archive overlap,
canon roots story maps a root’s standings by place,
and the retirement review states them as counts
before a root is released.
Decision Provenance
Canon silently records every effectful action you take: scans, exclusions, applies, and more. Each decision leaves two linked artifacts: a queryable record in the database and a durable receipt file on disk. Together they build a trail of what happened, when, optionally why, and which files specifically.
What Gets Recorded
Every command that changes state writes a decision record:
| Command | What it records |
|---|---|
scan | Directory indexing; files gone missing (deletion) |
apply | File archiving |
exclude set/clear/duplicates | Source triage |
exclude set-object/clear-object | Object-level triage |
cluster generate/refresh | Manifest creation |
roots rm/suspend/unsuspend | Structural changes |
import-facts | Enrichment |
prune | Data cleanup |
facts delete | Fact removal |
note clear | Note deletion |
Read-only commands (ls, facts, coverage, survey, compare, worklist) do not record.
What a Record Contains
Each decision captures:
- Command — stable identifier (e.g.,
exclude_set,apply) - Scope — paths the command operated on
- Command line — the full command as typed
- Reason — optional user annotation (via
--reason) - Status —
started,completed,partial, orinterrupted - Counts — attempted, completed, failed, skipped
- Summary — the completion message you saw
- Canon version — which version produced the record
- Timestamp — when the command started
Two-Phase Recording
Recording happens in two phases:
- Start: A “started” record is written after you confirm (or just before execution for commands without confirmation)
- Complete: The record is updated with the outcome after execution finishes
If Canon is interrupted (Ctrl+C, crash, power loss), the “started” record survives as a durable trace that the operation was attempted.
Records vs. Receipts
A decision has two artifacts:
- The record — a row in Canon’s database (everything above). It answers what happened, when, and why, and it is queryable.
- The receipt — a durable TOML file written to a
.canon-ledger/directory on disk, capturing the per-item detail the record only summarizes: every file the decision touched, with its content hash, size, and modification time.
The record is the index; the receipt is the evidence. Together they mean a file that reached one of its three terminal fates (archived, excluded, or deleted) can always be traced back to the decision that put it in that state, even years later, even from the files alone.
Receipts
Receipts live in a .canon-ledger/ directory under a root. Each is named for the decision that produced it, so the id in the filename links it straight back to the record:
.canon-ledger/000042-exclude_set.toml
.canon-ledger/Media/2016/000041-apply.toml
.canon-ledger/000043-scan.toml
A receipt sits at the locus of the action’s effect:
- Apply receipts are targeted: they mirror the destination path under the archive root’s
.canon-ledger/, sitting alongside the content they describe. - Exclusion receipts are flat: they land directly in the archive ledger root’s
.canon-ledger/. An exclusion is a judgment that must outlive the source root it helps clear, so its receipt lives on the archive side; with no destination path to mirror, it sits flat. - Deletion receipts are source-local: they land in the
.canon-ledger/of the source root where the files were lost, on the medium itself, so the record of the loss travels with it. A single scan that detects deletions across several roots writes one receipt per affected root, all under the one decision.
Each receipt records, per item: the source root and relative path, content hash, size, and modification time. Variants carry the shape of their decision: exclude duplicates groups items by content hash, recording which copy was kept versus excluded; object-level exclusions list every source sharing the content; a deletion receipt lists exactly the sources that went missing.
Anatomy of a receipt
Every receipt’s [meta] block states, in its own text, what happened, to what, and where. A reader without Canon (a person finding the receipt years later, an external tool, an older binary) never has to infer semantics from the receipt’s body shape or from a command name that may have been renamed since:
[meta]
receipt_version = 1
decision_id = 142
command = "scan"
transition = "deleted"
posture = "observed"
status = "completed"
# ...summary, canon_version, command_line...
[meta.locus]
path = "/mnt/old-drive/photos"
id = 3
transition— the what, in fixed vocabulary:archived,excluded,restored(an exclusion undone), ordeleted. This is the same wordcanon trailuses for the same action; the trail and the receipt tell one story in one vocabulary.posture— whether Canon performed the change or observed one the world made. A scan-detected deletion isobserved: Canon witnessed a loss, it did not cause one. Every other receipt today isperformed.[meta.locus]— the identity of the root the receipt is anchored to, making its placement into data. Locus is the receipt’s where, not the action’s: an exclusion run in a source folder has itsscopethere and its per-itemroots there, but its locus is the archive ledger root, because that is where the receipt itself lives.pathis the root’s canonical path captured at write time — authoritative for a human and for rebuilding an index from disk, and still meaningful after a drive is remounted elsewhere or a receipt is copied off its root.idis the join key against a live database. Both are always present.origin_disposition— apply receipts only:retained(a copy; the content now lives in two places) orrelocated(a move; the origin no longer holds the file).
The granularity rule. Subjects that can span roots always carry their own per-item root identity: apply items keep their source_root; exclusion and object entries keep their root. The locus root is always meta-level. Receipt-level-only identity (no per-item root, as in a deletion receipt) is valid exactly where single-root-ness is guaranteed by construction: a deletion receipt is coalesced to one root, so its items inherit the meta locus.
These fields are additive: receipts written before they existed remain valid, and every reader tolerates their absence.
The provenance chain
Every source carries a decision_id: the decision that last changed its state. When a decision changes a file a previous decision already touched, the receipt records that predecessor as previous_decision_id. Because the predecessor’s id is also its receipt’s filename, you can walk the chain backwards from the files on disk alone, no database required.
Recording Modes
What Canon writes is controlled by ledger.recording in $CANON_HOME/config.toml:
| Mode | Database record | Receipt file |
|---|---|---|
Full (default) | ✓ | ✓ |
Records | ✓ | — |
Off | — | — |
ledger.layout controls where targeted (apply) receipts sit: Central (default) collects them under the archive root’s .canon-ledger/; Alongside places them in a .canon-ledger/ beside each destination directory. Layout does not affect exclusion or deletion receipts; those are always flat at their own .canon-ledger/ root (the archive ledger root for exclusions, the source root for deletions).
[ledger]
recording = "Full" # Full | Records | Off
layout = "Central" # Central | Alongside
A decision’s recorded receipt location is settled at the decision’s last act, so a finished row names a file that exists. The location is reserved when the run starts, before the receipt is written; if the receipt never appears — nothing transitioned, the write failed, or the run refused before moving anything — the reservation is withdrawn rather than left pointing at nothing, and trail show states why. The counts carry the reason: a receipt records per-item transitions, so a run with none has nothing to receipt. A run killed outright never reaches its last act; its row stays started, which is the state to look for when recovering from a crash.
If no archive root is configured, exclusion decisions are still recorded, but no receipt can be written; Canon warns you so the gap is visible rather than silent. Deletion receipts have no such dependency: they live on the source root, which always exists, so deletions from a root that was never archived are still recorded in full.
Annotating Decisions with --reason
Attach a short reason to explain why you’re taking an action:
canon exclude set --where 'source.ext=dll' --reason "OS system files, no personal value"
canon apply manifest.toml --reason "Italy 2016 — assembled from three drives"
canon scan /mnt/old-laptop --reason "Deleted duplicate movies, originals confirmed in archive"
--reason is available on: exclude set, exclude clear, exclude duplicates, exclude set-object, apply, scan, roots rm.
When not provided, no reason is stored and you are not prompted. When provided, the reason is written into both the decision record and the receipt’s [meta], so it travels with the durable artifact.
For apply, manifest notes (from the # === Notes === section) automatically become the reason when --reason is not explicitly provided.
Suppressing Receipts with --no-receipt
To record a decision in the database but skip the receipt file for a single invocation:
canon exclude set --where 'source.ext=dll' --no-receipt
--no-receipt is a global flag, per-invocation only, not a persistent setting. Database recording still happens (per the recording mode above); only the receipt file is suppressed. To turn recording off entirely, set recording = "Off" in config.toml.
When Recording Does Not Happen
- Dry-run (
--dry-run): No side effects occurred, so nothing to record - Declined confirmation: User said “n” at the prompt
- Validation failure: Command failed before any work began
recording = "Off": Recording disabled inconfig.toml
Reading the Trail
canon trail reads the record back: what happened at a place (canon trail), the day’s story (canon trail --today), and any single decision in full with its receipt locations (canon trail show <id>). Notes interleave as the thinking between the actions.
The Extraction Ledger — the Trail’s Outbound Direction
Standing at a source location, the decisions above tell only half the story: deletions and exclusions, not what was archived out of the place. The extraction ledger is the outbound half. It is an aggregate index, decision_extractions, of what each apply drew from each source root: how many files, how many bytes, where they went, and whether the originals remain (copied) or are gone from here (moved).
It is deliberately aggregate-only: one row per (decision, source root, origin directory, destination directory), never a per-item copy. Per-item detail already lives in the apply receipt on disk; the ledger rows exist so canon trail can answer “what left from here?” without re-reading every receipt on every scoped view.
Every row makes one uniform claim: all its files lie under its recorded origin location, and were placed under its recorded destination location. That claim is exactly what the trail matches: a row surfaces at the views that contain its locations and nowhere else, and wherever it surfaces, its counts are exact. This is deliberately a different rule from a decision’s acted-on scope, which matches in both directions (acting on a parent folder acts on its children too): a scope declares “I acted on this subtree”, while a placement records where files demonstrably are; a location above your view implies the first, never the second. Rows recorded before Canon kept directory precision hold coarse common prefixes; they make the same claim less tightly, so they match conservatively (at their recorded prefix and above, silent below it) until reindexed.
The recorded paths are write-time snapshots, not live lookups: a row keeps telling its story after the source root has been removed from Canon. canon trail show marks such roots (root removed), so a snapshot path never silently reads as a live, visitable location.
Disk is truth, the database is a rebuildable index
This is the same principle that governs the rest of provenance: the database is a projection over receipts, not a second source of truth. If decision_extractions is ever lost (a fresh database, a restored backup missing recent rows, a manual mistake), it can be rebuilt from the receipts still sitting on disk:
canon ledger reindex
See ledger reindex for the full command. It walks every apply decision, reads its receipt (tolerating older receipts that predate today’s self-describing fields), and rebuilds the same aggregate rows the forward apply path writes. A backfilled row is indistinguishable from a forward-recorded one, by construction: both go through the same aggregation. The same run upgrades pre-precision rows to directory precision: the receipt holds the per-item paths, so a rebuilt row is as tight as a forward-recorded one, and a coarse row is replaced, never left standing beside its precise successors.
Gaps are reported, never inferred: a decision with no receipt on disk (recording was off, or --no-receipt was used) is not something reindex can recover, and it says so rather than guessing.
The Composition Card — State, Not Events
Everything above answers “what happened?”: the trail is an event log. The composition card, shown at the bottom of a scoped canon trail view (see Reading the Trail), answers a different question: “what is this place made of, right now?” It is a present-tense read of the surviving sources’ own stamps, not a replay of the events that produced them.
The two can honestly disagree. A location’s “Arrived here” total is an event count; it never shrinks, because a decision that happened stays happened. Its “Standing here” total is a state count; it can be smaller, when some of what arrived was later deleted or moved elsewhere.
Origin, on the card, means where content entered Canon’s custody from: the source root an apply drew it from. It is attributed at the same decision-level granularity as the extraction ledger above, since that ledger is the card’s only source of origin data; it is not a per-item lineage.
Crossing In, Crossing Out, Staying Put
State versus events is one axis. There is a second, cutting across both the trail’s rollups and the card: whether content crossed the boundary of the place you are asking about.
A decision has two endpoints: where content was drawn from, and where it landed. The scope you are viewing draws a boundary between them, or fails to:
| Origin | Destination | From this view |
|---|---|---|
| inside | outside | left — content was archived out of here |
| outside | inside | arrived — content came in |
| inside | inside | rearranged — content moved, but crossed nothing |
Because roots cannot nest, a decision with both endpoints inside one view is always an apply within a single archive root: a curation pass re-placing content that was already in custody. It is not a filesystem rename; those are observed by scan and never write an extraction row at all.
Counting such a decision as both a departure and an arrival would double it, with both counterparty counts naming the very place you are standing; dropping it silently would hide real work. So it gets its own line, and its own word.
The boundary moves when you move. Standing at an archive root, a curation pass reads as a rearrangement; standing in the destination folder, the same pass reads as an arrival, because from there the origin genuinely is elsewhere. Each view answers its own question. What is guaranteed is that within any one view, the categories are disjoint: no file is counted twice.
A movement that does cross the boundary is a crossing: a recorded movement across the boundary of the place in view, with an origin end and a destination end. It is the unit canon trail crossings reads. A rearrangement is not one, because it crossed nothing.
A crossing binds two places by what moved between them, when, and why. That is a different relation from the sweep’s counterpart, which binds places by where matching content currently is: two folders can hold the same bytes with nothing ever having moved between them, and a crossing can be recorded between two places that share nothing today. The trail answers by record; the sweep and survey answer by content.
The word rearranged states an observable fact about two paths and one boundary. It deliberately avoids relocated, which already means something narrower in Canon: whether an apply’s originals moved or were copied (see origin_disposition above). An intra-archive apply can be either.
Retirement, the Book, and the Shelf
Canon’s goal for old storage is getting it out of your life. Working through a root resolves what’s on it; retirement closes it out so the storage can go.
Retiring binds the root’s story into a book that stands on the shelf: plain text, readable without Canon, without the database, and without the storage it came from. An old drive is usually hard to discard because you can’t say what’s on it any more. The book answers that after it’s gone.
Retirement
Retiring a root means this root is resolved and its index may be removed.
What counts as resolved is your judgment. Canon says NOT READY when sources are
neither archived nor excluded. When nothing blocks, it reports that it found no
blockers and leaves the verdict to you. --allow unresolved retires past a NOT READY
verdict, on the record. Canon can know a story is unfinished; only you can know it is
finished.
The judgment doesn’t wait for the ceremony.
canon roots story renders the same map live, at any
point in the triage, and records nothing.
Discarding the storage is yours to do. The ceremony’s guarantee is the record, not the judgment: whatever you decided, the book holds every file’s fate.
Retirement operates on a root, at whatever size you drew it. A whole drive may be one root, or several that each retire on their own.
canon roots retire runs the ceremony in four steps:
the readiness review, the bind (compile, place, verify), an inspection window, and
the release of the index. At every failure point, either the root is fully intact or
the book is fully placed. Never both partial.
The foreword is optional: leave that section of the composed story untouched and it
drops out of the bound page. And if you don’t want a book at all,
canon roots rm deletes the root’s index outright. The
decisions stay in the trail, and the archive’s own
ledger keeps its per-file apply and exclusion receipts, but nothing gathers them into
one story: the notes are deleted, content no decision touched loses its account, and
the storage’s deletion receipts stay on the storage, discarded with it. Retiring
needs a registered archive root because the book needs a shelf. Removal doesn’t.
The book
The book is the bound story of a retired root: what it held, the fate of every
file, the decisions and their reasons, the notes, and the receipts the storage itself
kept. It is a directory of plain text. Open its README.md and start reading.
The book’s contents and format are a public, Canon-independent contract. See the book format.
The shelf
The shelf is the retired/ directory at your archive’s ledger root, where the
books stand. On first use Canon writes a README there explaining what the directory
holds.
canon roots retired lists what’s on it. Keep the shelf with your archive. Deleting
a book deletes the only readable story of a root that is already gone.
What survives what
- Removal costs the index only. The root’s sources, facts, and notes are deleted from the database. The complete per-file story is bound in the book.
- The shelf is read from disk.
canon roots retiredreads the shelf itself, so losing the database costs only the enrichment it adds: retirement dates and reasons. The books and the count of them survive it. - Receipts follow the surviving content. The root’s drive-local receipts are gathered into the book verbatim, filenames preserved, so decision chains stay walkable from disk into the book. Receipts that live at the archive (apply, exclusion) stay in the archive’s live ledger, which survives because the archive does.
Canon Commands
Common Options
Most commands that operate on sources share these options:
Path scope: Limit a command to a specific directory by passing a path:
canon ls /path/to/photos
canon facts /path/to/photos
canon coverage /path/to/photos
A path that is not under any known root is an error. A path that is under a known root but holds no known sources depends on how many paths you gave:
| Paths given | Behavior |
|---|---|
| One, no known sources | Error: no sources known at <path> |
| Several, at least one with sources | The others are skipped and named; the command runs on the rest and exits 0 |
| Several, none with sources | Error naming every path |
A skipped path is always stated, never silently dropped:
no sources known at /path/to/photos/2012 — skipped
Where the line appears follows the command’s own scope channel: stdout for
report commands (facts, coverage, survey), stderr for list commands
(ls, worklist), and in the ceremony before any confirmation for commands
that change state (exclude set/clear/set-object, cluster generate,
facts delete) — including under --yes and --dry-run. Display modes that
render a bare stream and carry no scope header of their own
(coverage --compact, survey --detail unique) state it on stderr, so what
is on stdout stays exactly what was asked for. A skipped path never appears
in the decision record.
Four commands never skip, because a location they name is load-bearing to the
question rather than one more place to look: both sides of compare, the
scope and --prefer paths of exclude duplicates, and survey --other.
These error as a single path does.
Paths match the index whichever Unicode normalization form you type. Canon stores the form the disk gave it and matches your argument against that.
Filters: Select sources using --where with boolean expressions:
canon ls --where 'source.ext=jpg'
canon facts --where 'source.size > 1000000'
canon cluster generate --where 'geo.country=Netherlands' --dest /archive
Multiple --where flags are combined with AND. See Filters for the full syntax.
--include: By default, query commands (ls, facts, coverage, worklist, compare) show sources from active source roots, hiding excluded and archived sources. Use --include to expand what you see:
canon ls --include excluded # Also show excluded sources
canon ls --include archived # Also show sources from archive roots
canon facts --include all # Show everything
--include only changes what’s displayed; it never modifies anything.
--allow: Commands that change state (cluster generate, apply, import-facts) skip certain sources by default (e.g., sources already in an archive). Use --allow to acknowledge you want to include them:
canon cluster generate --allow archived # Include sources from archive roots
canon cluster generate --allow duplicates # Include content already archived elsewhere
canon import-facts --allow archived # Import facts for archive sources
The available --allow values are specific to each command. See individual command pages for details.
Command Reference
- Managing Roots: Add and manage storage locations
- scan: Scan existing or new roots
- roots: List, suspend, or remove roots
- roots story: Read a root’s resolution story as a map of places
- roots retire: Bind a resolved root’s story into a book and release the root
- Enriching: Import metadata from external tools
- worklist: Output sources for external processing
- import-facts: Import processor output
- Writing Processors: Build custom extractors
- Querying: Explore your indexed files
- Managing Sources: Control which sources are processed
- Archiving: Organize files into your canonical archive
- Maintenance: Clean up and maintain the database
- facts delete: Remove incorrect or unwanted metadata
- prune: Clean up stale, orphaned, or excluded data
- ledger reindex: Rebuild the extraction ledger from receipts
Managing Roots
To track files in Canon, first you add and scan roots. This makes these sources available for further enrichment or archive operations. You can suspend roots to temporarily mask them from Canon commands.
Adding new roots, or scanning existing is performed through the scan command.
Managing roots, such as suspending or listing them is done with canon roots.
canon roots story reads a root’s resolution story between triage
passes: where you acted and why, and what no decision ever touched. When you judge it
resolved, canon roots retire binds that story into a book and releases
the root from the index.
Scan
Scan directories and index files.
When you scan a root, Canon walks the directory tree starting at the given path(s). A path that names a single file is observed on its own, without a walk. For each file it collects basic metadata, such as last modification time and size, and by default computes the content hash. After scanning, Canon knows about the existence of all sources in that root; hashed sources are linked to objects.
Collections of files that belong together can be scanned as separate roots. Each root can be given a comment, to recall what it contains or to note what you discovered there.
To have Canon treat an already organized location as your canonical archive, scan it with --role archive from the start. The role is set when the root is added; to change it, remove the root and re-add it with the new role.
You can add multiple archive roots, for instance one for a music collection and another for eBooks.
When to run scan
Re-scan a root after its contents change, so Canon detects the changes and no files are missed for archiving. When archiving, Canon always checks the validity of the files to be archived.
Scan also serves periodic integrity verification of your archives: --verify recomputes hashes to detect corruption, and Canon exits with a non-zero status if any mismatches are found, making it suitable for cron jobs that alert on failure.
Examples
# Add a new root and scan it (--add and --role required for new roots)
canon scan --add --role source /path/to/photos
# Scan multiple new roots
canon scan --add --role source /path/to/photos /path/to/more/photos
# Add with a descriptive comment
canon scan --add --role source --comment "Photos from 2020 trip" /path/to/photos
# Add as an archive root (for tracking already-organized files)
canon scan --add --role archive /path/to/archive
# Re-scan an existing root (--role optional, validated against existing)
canon scan /path/to/photos
# Scan just a subtree within an existing root
canon scan /path/to/photos/2024
# Scan a single file within an existing root
canon scan /path/to/photos/2024/img_0042.jpg
# Scan without computing hashes (just index files)
canon scan --no-hash /path/to/photos
# Verify archive integrity by recomputing all hashes (good for cron jobs)
canon scan --verify /Volumes/Archive
# Mark sources under a deleted folder as not present
canon scan --missing /path/to/deleted/folder
Hash computation: By default, Canon computes content hashes for new and changed files during scan; hashes enable deduplication and archive tracking. Hashing can take long. Use --no-hash to index files without hashing, either for speed or when you intend to hash only certain kinds of files. Sources left without a hash are reported at the end of the scan and hashed by the next scan that hashes (see Hash debt).
Integrity verification: --verify recomputes hashes for all files, even unchanged ones. If a file’s hash changes without its mtime changing, Canon warns about possible corruption and exits with an error.
Discovering untracked directories: Use --candidates to find directories with files that aren’t yet under any root, for instance when exploring a drive or backup to see what could be added:
# Find candidate roots to add under a path
canon scan --candidates /Volumes/Backup
Candidate roots to add:
/Volumes/Backup/photos (3 directories with files)
/Volumes/Backup/imports (1 directory with files)
Directories under existing roots are skipped. When multiple subdirectories share a common ancestor that could be added as a single root, they’re rolled up (unless that ancestor contains an existing root).
Scanning single files: A path argument names a place, and a place can be a single file. Canon observes that file the way it observes a subtree of one: metadata read, the index updated, the content hashed. Nothing else under the root is looked at, so bringing a handful of changed files current costs a handful of reads rather than a walk:
# Re-observe two files that changed
canon scan /Volumes/Photos/2024/img_0042.jpg /Volumes/Photos/2024/img_0043.jpg
Scanned 2 files: 0 new, 2 updated, 0 moved, 0 unchanged, 0 missing
Hashed 2 files
The decision is recorded against the paths it was aimed at, so canon trail shows it at those files.
A named file that is gone is skipped with a warning, never recorded as deleted: one look cannot tell a deleted file from an unmounted or a mistyped one. The warning says where to make that assertion:
Warning: skipping /Volumes/Photos/2024/img_0042.jpg: No such file or directory (os error 2)
If it is gone for good, record it with: canon scan --missing /Volumes/Photos/2024/img_0042.jpg
The pointer to --missing appears only where asserting a deletion would be sound: inside a live root, below its top, and with the root’s own path answering on disk. Storage that is not currently there makes everything under it read as gone, at every depth, so a path Canon cannot reach gets the warning alone. --missing works at file grain with the same recording as a folder, described below.
A root is a folder, so --add and --candidates refuse a file argument and name the directory to use instead.
Marking deleted paths as missing: When you delete a folder that was under a scanned root, Canon still considers those files present. Re-scanning the parent would let Canon discover they’re gone, but that can be expensive when the parent holds many other files. Use --missing to tell Canon directly that a path no longer exists:
# Deleted a backup folder: mark its 140 sources as not present
canon scan --missing /Volumes/Backup/old-phone
# Works with any path under a known root, including the root itself
canon scan --missing /Volumes/Backup
The sources are marked as not present but remain in the database with their hashes and metadata intact. If the path reappears later (e.g., storage remounted), a normal scan will reconcile them back. Cannot be combined with --all or --add.
The decision is recorded against the folder it was aimed at, so canon trail shows it at that location rather than as a global decision.
Deletions are recorded. Whether Canon infers a deletion by re-scanning a parent (files that were present but weren’t seen this time) or you mark one directly with --missing, the disappearance is captured as decision provenance: each vanished source is linked to the scan decision, and a source-local receipt listing exactly what was lost is written to .canon-ledger/ on the affected storage. Add --reason to say why; the reason travels into both the record and the receipt:
canon scan --missing /Volumes/Backup/old-phone \
--reason "Phone backed up to archive, originals confirmed"
Deletion is a recorded fate alongside archiving and exclusion: what the storage held, what you kept, released, or discarded, and why, stays reconstructible from the files alone. A deletion is recorded even when no archive root exists. To suppress the receipt for one run use --no-receipt; to disable recording entirely set recording = "Off" (see Decision Provenance).
Absolute paths need no current directory. A shell whose working directory has been deleted (a root retired or unmounted in another window, for example) can still run canon scan /some/absolute/path. A relative path does need one, and says so:
Error: cannot resolve relative path './photos': the current directory is unavailable
Output shows what was found:
Scanned 1234 files: 100 new, 5 updated, 2 moved, 1127 unchanged, 0 missing
Hashed 105 files
Reading the counts: new counts paths the index has not held before. updated counts files whose content changed at a path already indexed, whichever way the application saved them: written in place, or written to a temporary file and renamed over the path. A file whose content is recreated exactly as it was, by a restore or a deduplication pass, is neither new nor updated: it counts as unchanged, and the scan records where the file now sits.
moved counts files found at a new path that Canon can tie to a path it already knew. A move is reported only when the content matches and the old path is confirmed gone. When either test fails, the file counts as new and the old path counts as missing: two accurate records rather than one guess. Rescanning storage that was remounted, or whose filesystem hands out fresh internal identifiers each session, reports nothing at all.
Hardlink companions. A file can occupy several paths at once through hardlinks. Each path is its own source, sharing content with the others. A path that appears alongside an already-indexed file counts as new, and the summary states how many of the new paths are companions:
Scanned 31892 files: 27753 new (27751 hardlink companions of already-indexed files), 0 updated, 0 moved, 4139 unchanged, 0 missing
The first scan after upgrading reports this once, for every companion path in the library, and the counts can be large. The scan is otherwise ordinary: it can be interrupted and re-run, and the next scan reports nothing.
Unverified moves. Checking whether a file moved means checking whether its old path is gone from the storage that recorded it. Two things make that check impossible: the root holding the old path cannot be read at all, or its directory is readable but its storage is not currently mounted, so everything under it would read as gone whether it is or not. Either way the summary says so rather than assuming an answer:
Scanned 12 files: 3 new, 0 updated, 0 moved, 9 unchanged, 0 missing, 2 possible moves could not be verified
The files count as new, and stay that way: the old path keeps its own source until the root holding it is scanned again, which reports it missing. Canon does not join the two records afterwards.
The same line appears for one scan after a root is remounted, because the remount renumbers the storage and the recorded identifiers have not caught up. Scanning that root refreshes them.
Skipped entries. Only regular files become sources. Symlinks are skipped, and never followed. Named pipes, sockets and devices are skipped too, counted separately as special files. Both counts reach the summary, so a path visible on disk and absent from the index is accounted for:
Scanned 1043 files: 1043 new, 0 updated, 0 moved, 0 unchanged, 0 missing, skipped 214 symlinks
The counts say what the walk saw, so they repeat on every scan of the same directory, where new and moved say what changed.
Some network clients, SMB shares in particular, present a symlink to the operating system as an ordinary file. Canon indexes what the operating system presents, so such a link becomes a source and is not counted here. Its content is the target’s content, so both paths resolve to the same object.
Hash debt
A source with no content hash is invisible to everything that reads content: coverage, duplicate detection, and cluster selection all pass over it. Canon states how many sources a scan leaves in that state:
Scanned 4820 files: 4820 new, 0 updated, 0 moved, 0 unchanged, 0 missing
4820 sources remain unhashed
The count covers the paths this scan walked, and appears after any scan that leaves sources unhashed. When some of that debt is content the scan tried to read and could not, the line says how much:
Scanned 3 files: 0 new, 0 updated, 0 moved, 3 unchanged, 0 missing
Hashed 2 files (2 from backlog)
1 sources remain unhashed (1 could not be read)
The qualifier counts what is still in debt when the scan ends, so it is always part of the number in front of it, and it covers only files that hold no hash at all. Two neighbouring cases are reported elsewhere: a file that cannot be read during the walk never becomes a source, and appears as skipped (read errors) on the first line; and a file that --verify cannot re-read keeps the hash it already had, so it is not in debt and is reported only as a warning.
Files that could not be read do not change the exit status: the non-zero exit is reserved for hash mismatches, which say something about the content rather than about access to it.
The next scan that hashes reads them, whatever else it finds: a file Canon has never read is hashed even when nothing about it changed. The summary separates that backlog from work this scan caused, so a large pay-down is readable:
Scanned 4820 files: 0 new, 0 updated, 0 moved, 4820 unchanged, 0 missing
Hashed 4820 files (4820 from backlog)
Clearing a root indexed with --no-hash can take as long as hashing it the first time would have. The pass can be interrupted: what remains unhashed is reported again, and the next scan continues from there. A file that could not be read this time is warned about individually, counted in the line above, and stays in debt until a later scan reads it.
--verify re-reads every file regardless, so it clears debt as a side effect and reports no backlog count.
Keeping continuity across a move
Canon follows files that move within or between roots, provided it sees the destination:
# Index where the files are now
canon scan /Volumes/Photos
# Reorganize on disk
mv /Volumes/Photos/inbox/trip /Volumes/Photos/2024/trip
# Scan again: the sources keep their history at the new paths
canon scan /Volumes/Photos
Scanning a subtree is enough, as long as the destination is inside it, and so is naming the moved file’s new path on its own: Canon checks the old path directly rather than needing to have walked it. Moving files to a different root works the same way, and only the destination root needs scanning for the move to be recognized.
Two cases are not followed. Edit the files and move them in the same step, and Canon reports new plus missing instead: nothing ties the two paths together once both the location and the content have changed. And a move out of a suspended root is not followed, because a suspended root’s contents keep the standing they had; unsuspend it and scan again.
In both cases the records stay: the new path is indexed, the old path is reported missing when its root is scanned, and the old path’s deletion receipt names the decision that preceded it.
canon roots
List and manage registered roots.
Roots are added via scan and managed with the roots command. You can list, suspend/unsuspend, add comments, or remove roots.
Important notes:
- Removing a root also removes its sources, facts, and notes from the database
- Removing a root does not delete any files on disk
- If you re-add a removed root, you’ll need to re-enrich it
# List all roots with file counts and last scan time
canon roots
# List roots at or beneath a specific path
canon roots /path/to/photos
# List only suspended roots
canon roots --suspended
# Set a comment on a root (omit text to clear)
canon roots comment id:1 "Old backup, possibly duplicates"
canon roots comment id:1
# Suspend a root (hides from all operations without deleting data)
canon roots suspend id:1
canon roots suspend path:/path/to/photos
# Unsuspend a root (make visible again)
canon roots unsuspend id:1
# Remove a root by ID (files on disk are NOT deleted)
canon roots rm id:1
# Remove a root by path
canon roots rm path:/path/to/photos
# Skip confirmation prompt
canon roots rm id:1 --yes
Example output:
ID ROLE FILES LAST SCAN PATH
1 source 16635 2h ago /path/to/photos
2 archive 169941 5d ago /path/to/archive
3 source 1234 never /path/to/backup (Old backup, possibly duplicates)
Suspending Roots
Suspended roots are hidden from listings, excluded from scan --all, and their sources are excluded from all queries (ls, facts, coverage, worklist, etc.). Suspended roots still prevent overlapping (you cannot add a new root at a suspended root’s path). Use --suspended to list only suspended roots.
Removing Roots
When removing a root, Canon shows how many sources are “in archive” (same content exists in an archive) vs “not in archive”, and suggests using canon ls <path> to preview which sources will be forgotten.
The confirmation also states what removal means for the root’s story. If no retirement artifact exists, Canon states that removal deletes the root’s inventory, notes, and recorded fates, leaving the story unreviewable, and points at canon roots retire as the way to bind it first. If the root was already retired, the line instead points at where its story is bound. The line never blocks: removal proceeds through the normal confirmation either way. See what survives removal.
Removal is itself recorded as a decision; add --reason to say why the root is going. The root’s sources, facts, and notes leave the database, but its recorded history survives: receipts already written to the root’s .canon-ledger/ stay on the storage, and past decisions keep rendering in canon trail. An apply that drew content from the root still shows its path in trail show, marked (root removed), because those records are write-time snapshots.
Root Specs
Several commands accept root specifications in two formats:
| Format | Example | Description |
|---|---|---|
id:N | id:1 | By database ID (shown in canon roots output) |
path:/... | path:/path/to/photos | By exact path |
canon roots suspend id:1
canon roots suspend path:/path/to/photos
canon roots story
What’s resolved on this root, and where did it end up? The trail answers in events and the retirement review answers in counts. The story answers in places: where you acted and why, and what no decision ever touched.
canon roots story <id:N|path:/path> [--limit N | --all]
Each run reads the index fresh and prints the map.
The map of places
Story: /mnt/old-disk
role source
comment old laptop backup, 2014–2016
first indexed 2026-03-14
last scan 2026-07-28 (5d ago)
The places
(root)
archived 5 files, 130.0 KB → /archive/exports/old-disk #66 · "final export before the disk goes"
→ canon trail /mnt/old-disk
pictures
archived 4,102 files, 61.0 GB → /archive/media/rest #51 · "rest of the pictures, mechanical"
→ canon trail /mnt/old-disk/pictures
pictures/italy
archived 640 files, 18.4 GB → /archive/media/2016-italy #42 · "the Italy trip"
archived 3 files, 2.1 MB → /archive/exports/old-disk/pictures/italy #66
→ canon trail /mnt/old-disk/pictures/italy
minecraft-worlds
no decision here
3,412 covered — copies stand in /archive/staging-2019 (3,401), /archive/games (11)
→ canon trail /mnt/old-disk/minecraft-worlds
system-cache · across 214 folders
excluded 12,006 files, 1.2 GB #58
→ canon trail /mnt/old-disk/system-cache
downloads · across 61 folders
excluded 4,890 files across 3 decisions
· "installer junk" #57, #61
· #63 — no reason given
deleted 1,204 files (scan-observed) #64
35 unresolved (19 never hashed — cannot be content-verified)
→ canon trail /mnt/old-disk/downloads
Standing: 20,911 sources — 3,980 covered · 16,896 excluded · 35 unresolved (19 never hashed)
Whether this story is complete is yours to judge.
For the readiness gate: canon roots retire path:/mnt/old-disk --dry-run
Reading a place
Each place shows what you did there, what stands there now, and a handoff to the trail.
Act lines carry the transition word, the counts, the destination you chose (→),
the decision id, and your reason. The transitions are archived, excluded, and
deleted; a deletion a scan observed is marked scan-observed, so it never reads as
your act. Repeated decisions at one place aggregate, though acts that went to
different destinations never merge.
One decision touching several places renders as slices: partial counts at each
place, all carrying the same #id, none of them the decision’s total. #66 above is
two slices. A reason is quoted in full at its first slice and cited as a bare #id
after that.
Standing lines say what is there now, whether or not a decision touched it:
| Line | Meaning |
|---|---|
covered | content verified present in the archive; copies stand in says where |
archived from here | archived from this root with the copy left standing (a copy-mode apply) |
excluded | shown when it says something the act lines don’t |
unresolved | neither archived nor excluded; any never-hashed count is called out |
empty files (no content to cover) | contentless, outside coverage, never blocking retirement |
→ always means sent there by your act. copies stand in always means observed
there today. The two never mix.
A place nobody decided on is marked no decision here, and its covered content is
worth a second look: coverage is content identity alone, so “covered in a staging
folder you never picked” is exactly what that line exists to catch.
Notes render verbatim at their place, and a noted place always gets its own line however uniform its surroundings.
Every place ends with → canon trail <path>, which tells that place’s full event
story. The story shows the shape; the trail shows the sequence.
Why some folders aren’t listed
The map is path-ordered and lists only places worth a look: those whose standing mix,
act mix, or covered-copy locations differ from their surroundings, and those carrying
a note or a decision you gave a reason. Everything else merges into the nearest
listed place, with · across N folders showing the breadth. Uniformly resolved
territory is one line however vast it is.
The gate
The story renders no verdict, neither NOT READY nor ready. It closes with the
standing totals and hands the gate to canon roots retire --dry-run,
whose review states the same totals as counts.
At retirement this same map is written again for a future reader and bound into the
book as story.md, with plain fate words,
no handoffs, and a beginning and a last page around it.
Edge cases
#63 — no reason givenis a real recorded decision that had no--reasonattached. The id renders so the line reads as a decision without a reason rather than as a missing decision.N excluded (no recorded decision)is the opposite gap: excluded content whose deciding record is absent, either excluded before provenance existed or with recording off.- A noted place whose content has all moved on says
nothing stands here now. - The excluded standing line is omitted when it would exactly restate what the act lines already say. Covered, unresolved, and missing lines are never omitted.
empty filesreports what stands there now. Whether a past archive pass carried those files is the trail’s and the receipts’ story; passes made before the contentless rule skipped empty files as “already archived”.
Flags
--limit N— cap the number of place lines (default 50). Omissions are counted, never silent.--all— every place line.
Requirements
- The target is a source root — an archive root’s places are served by
canon trailand its composition card. - A suspended or unreachable root reads fine — the story as last observed, stated in the header.
canon roots retire
Retiring a root compiles its whole story into a book, places the book on the shelf, and then removes the root from the index. The ceremony has four steps: the readiness review, the bind (compile, place, verify), the inspection window, and the release. A confirmation comes before anything is written, and another before anything is removed.
canon roots retire <id:N|path:/path> [--dry-run] [--allow unresolved] [--reason <text>] [--yes]
The load-bearing safety invariant: at every failure point, either the root is fully intact or the book is fully placed. Never both partial. The removal step is structurally unreachable until the placed book has passed verification.
The readiness review
The review states the root’s whole story in counts before anything happens:
Retirement review: /mnt/photos-backup
role source
comment old laptop backup, 2014–2016
suspended no
first indexed 2026-03-14
last scan 2026-07-28 (5d ago)
Resolution account
ever indexed here 14,215 sources
the story so far
archived from here 9,847 files, 214.6 GB (6,102 moved, 3,745 copied)
deleted 3,891 sources (scan-observed)
missing, unexplained 12 sources
standing here now 4,210 sources
covered 3,980 (content verified present in the archive)
excluded 195
unresolved 35 (19 unhashed — listed by name only)
Facts to weigh
12 sources are missing without a recorded deletion.
19 present sources were never hashed — they cannot be content-verified.
2 cluster-generate decisions on this root have no subsequent apply — possible open intentions.
NOT READY for retirement — 35 sources are neither archived nor excluded.
To retire anyway: canon roots retire path:/mnt/photos-backup --allow unresolved
To read the story behind these counts: canon roots story path:/mnt/photos-backup
The review is the gate’s counts. The substance behind them, the map of places and the
acts with their reasons, is canon roots story, and the review points
there on both verdicts.
The account has two registers, deliberately not reconciled. The story so far
counts whole-history events: what was archived from here (both moves and copies, as
the extraction ledger recorded them), what a scan observed deleted, and what is
missing without a recorded deletion. Standing here now partitions the sources
presently there: archived from here (the still-standing originals of copy-mode
applies), covered (content verified present in the archive), excluded, empty files
(contentless, never
blocking), and unresolved. A file copied to the archive appears in both registers,
and the (moved, copied) split is what keeps that readable.
first indexed is row evidence, the time the earliest surviving source was first
indexed rather than a scan-decision date, so it stays honest on roots older than
decision recording. The first recorded scan opens the book’s timeline.
Facts to weigh never block retirement. They are: sources missing without a recorded deletion, never-hashed sources, an unreachable path (“retirement would bind the story as last observed”), and cluster-generate decisions with no subsequent apply.
The verdict is asymmetric
When present sources are neither archived nor excluded, Canon states plainly: NOT READY for retirement. Sources that were never hashed count as unresolved: they cannot be content-verified against the archive, and forgetting to hash is exactly the mistake this catches.
When nothing blocks, Canon reports “No blockers found. Whether this story is complete is yours to judge.” and never claims “ready”. Canon can know a story is incomplete; only you can know it is finished.
Binding the book
After the review and the verdict gate, Canon names where the book will stand,
retired/<name>-<date>/ on the shelf at the archive ledger root, and asks the first
confirmation. On yes:
- The story is composed: the same reading
canon roots storyrenders, written for the book (see the book format), and offered once to your editor. - The book is compiled into a temporary directory beside the shelf, the story bound
inside as
story.md. - The compile is verified (structure, per-fate counts, gathered ledger, the claimed story) before anything standing is touched.
- The verified book is placed by rename (same filesystem, atomic). On first use the shelf is created with a README explaining what it holds.
The book is at /archive/retired/photos-backup-2026-08-02
14,215 entries bound; 41 receipts gathered
story.md — the story as told
The story and your foreword
Before the compile, Canon asks once:
Edit the story before it is written into the book? [y/N]
Yes opens $VISUAL/$EDITOR on the composed page. The draft opens with a suggested
title (<name> — <comment>) and a Foreword section awaiting your words: a
reflection on the whole place, signed however you wish, bound verbatim above Canon’s
narration. Left exactly as it is, the foreword section drops out of the bound page.
Everything else you reshape binds as you leave it; the inventory and meta beside it
remain the machine-verified record, and a hand-refined story is marked as such in the
book’s meta.
Answering no, or having no editor set, binds the story as composed. An editor failure
or an emptied page never aborts the ceremony: the choice simply re-opens. --yes
never asks and binds the composed story.
Any gaps the compile recorded (unreadable receipts, an ungatherable drive-local ledger) are printed and bound inside the book. See the book format for what the book contains.
If a book for the same root already stands at that name, from a previous run aborted after binding, the ceremony says so up front and replaces it with the fresh compile. A book for a different root with the same name is never touched; the new book takes a numbered sibling name. Nothing on the shelf is ever silently overwritten.
The release
Between the two confirmations is an inspection window: the book is placed and verified, the root is untouched. Open the book, read it, take your time. Then:
Remove the root from the index? Aborting keeps both the root and the book.
- Aborting is free. The root stays indexed, the book stays on the shelf, and the retirement decision records that the story is bound but the root remains. A later re-run recompiles fresh and converges.
- Confirming releases the root: sources, facts, notes, and the root row are
removed in one transaction, and the retirement decision, with your
--reasonand a durable reference to the book, completes the trail.
Before removing, Canon re-checks that the world hasn’t moved since the review. If another process scanned, applied, or excluded on this root in the meantime, the release stops (root intact, book standing) and asks to be re-run.
The closing summary states the guarantee:
Retired /mnt/photos-backup: 14,215 sources released; the story is bound at /archive/retired/photos-backup-2026-08-02
The storage is yours to discard.
The trail keeps rendering the retired root’s history afterwards: canon trail at the old path
states the retirement and points at the book, and receipt pointers follow the
gathered ledger into it.
The shelf listing
canon roots retired lists the books on the shelf:
The retired fleet: 2 books on the shelf (/Volumes/Archive/retired)
2026-08-02 /Volumes/Backup/icloud-export — 3,980 entries → icloud-export-2026-08-02
2026-09-14 /Volumes/old-laptop — 12,404 entries → old-laptop-2026-09-14 · "sold it"
The listing reads the shelf itself, so a book bound under recording = Off appears
too, marked (not indexed). Each line is enriched from its decision row with the
retirement date and your reason. Where the two sides disagree the listing says so
rather than dropping the line: a recorded retirement whose book no longer stands
lists as exactly that, and a directory on the shelf that cannot be identified as a
book is counted rather than skipped. When the shelf isn’t reachable, with the archive
unmounted, the listing falls back to the index and says so. An empty shelf is stated
plainly.
Identification is not verification: the listing reads each book’s meta.toml
identity and counts, nothing more. A book of a future format version still lists, and
nothing about its contents is checked or claimed.
Recording modes
Full and Records behave identically here: retirement writes no receipt file,
because the book is the decision’s durable artifact, referenced from the decision
row. Under recording = Off the ceremony still binds the book and releases the root
but leaves no index entry: the trail and the rm-guard won’t know of it, and the shelf
listing shows the book from disk alone, marked (not indexed). Canon states this at
the first confirmation. --no-receipt never suppresses the book, which is the
command’s deliverable rather than a provenance side-channel.
Flags
--dry-run— the review only; always exits 0 (it is a report).--allow unresolved— acknowledges retiring despite unresolved sources (“I’m aware, proceed”). Without it, a NOT READY verdict ends the ceremony with a non-zero exit. Never implied by--yes— skipping prompts and acknowledging unresolved content are different decisions.--reason <text>— recorded with the retirement decision and on the book’s identity page.--yes— skips both confirmations. The ordering and the verification stay structural:--yescan never place an unverified book or remove a root before its book stands.
Requirements
- The target must be a source root — an archive root is not retired; the archive is where the books live.
- An archive root must be registered — the book needs a shelf. Removing a root
without binding its story remains available as
canon roots rm. - A suspended or unreachable root can be retired — surfaced as retiring on faith: the story is bound as last observed, and the drive-local ledger’s absence is recorded in the book as a gap.
Enriching
Add metadata to indexed files using external processors.
Canon uses a pipeline model: worklist outputs sources as JSONL, an external processor extracts metadata, then import-facts stores the results.
canon worklist → processor → canon import-facts
A processor can be any CLI tool or script that extracts information from files: exiftool for EXIF data, file for MIME types, ffprobe for media info, or custom scripts.
Basic Usage
Extract EXIF metadata from images:
canon worklist --where 'source.ext|lowercase IN (jpg, jpeg, heic)' \
| ./scripts/exif-worklist.sh \
| canon import-facts
Limit the worklist with --where to files the processor can handle.
Detect MIME types for all files:
canon worklist | canonargs --fact mime -- file -b --mime-type {} | canon import-facts
After enrichment, the imported facts become available for filtering and querying (see Facts).
Provided Processors
Canon includes ready-to-use processors:
| Processor | Purpose | Requires |
|---|---|---|
scripts/exif-worklist.sh | EXIF, GPS, and media metadata | exiftool, jq |
scripts/hash-worklist.sh | SHA-256 content hashes | jq |
scripts/tag-worklist.sh | macOS Finder tags | jq, python3 |
canonargs --fact mime -- file -b --mime-type {} | MIME type detection | canonargs |
Install canonargs with: cargo install canonargs
Going Deeper
worklist- Full options for generating worklistsimport-facts- Input format and type hints- Writing Processors - Build your own enrichment scripts
Tip: Selective Hashing
Content hashing normally happens during scan. To hash only specific file types, use --no-hash during scan and hash selectively via the pipeline:
canon scan --no-hash --add --role source /path/to/mixed-files
canon worklist --where 'mime~image/* OR mime~video/*' \
| ./scripts/hash-worklist.sh \
| canon import-facts
canon worklist
Output sources as JSONL for processing by external tools.
# Sources in current directory (when inside a root)
canon worklist
# All sources across all roots
canon worklist --global
# Only sources missing a content hash
canon worklist --where 'NOT content.hash.sha256?'
# Only JPG files
canon worklist --where 'source.ext=jpg'
# Scope to a specific directory
canon worklist /path/to/photos
# Include sources from archive roots (for backfilling facts)
canon worklist --include archived
# Include excluded sources
canon worklist --include excluded
# Include both
canon worklist --include all
# Include existing facts in output (for chained enrichment)
canon worklist --emit content.geo.lat --emit content.geo.lon
Choosing a --where gate that a repeated pass converges on is covered in Writing Processors.
Output Format
Each line is a JSON object with source metadata:
{"source_id":123,"path":"/full/path/to/file.jpg","root_id":1,"size":1024,"mtime":1703980800,"basis_rev":0}
| Field | Description |
|---|---|
source_id | Database ID (pass through to import-facts) |
path | Full absolute path to the file |
root_id | ID of the root containing this source |
size | File size in bytes |
mtime | Modification time (Unix timestamp) |
basis_rev | Revision counter for staleness detection |
Emitting Existing Facts
With --emit, requested facts are included in the output (null if absent):
canon worklist --emit content.geo.lat --emit content.geo.lon
{"source_id":123,"path":"/...","basis_rev":0,"facts":{"content.geo.lat":52.37,"content.geo.lon":4.89}}
{"source_id":124,"path":"/...","basis_rev":0,"facts":{"content.geo.lat":null,"content.geo.lon":null}}
--emit takes the key as stored — write it in full. Unlike --where, it does not add the
optional content. prefix for you: --emit geo.lat looks for a fact whose key is literally
geo.lat and emits null, even where --where 'geo.lat?' on the same command line matches.
Check how a key is stored with canon facts. Built-in source.* keys are
never emittable — no fact is ever stored under that namespace (import-facts
refuses it), and what they would say is already in the entry’s own fields.
This enables processors to build on previous enrichment:
- Dependent enrichment: Use extracted coordinates to look up location names
- Fact combination: Merge data from multiple sources into derived facts
Example: reverse geocoding files that have coordinates but no city name:
canon worklist --emit content.geo.lat --emit content.geo.lon --where 'geo.lat? AND NOT geo.city?' \
| ./scripts/reverse-geocode.sh \
| canon import-facts
Staleness Detection
The worklist is a snapshot of sources at a point in time. Each entry includes basis_rev which tracks file changes. Processors should pass this through to import-facts, which will skip the import if the file changed since the worklist was generated.
The size and mtime fields allow processors to verify a file hasn’t changed before extracting facts.
canon import-facts
Import facts from JSONL on stdin. Receives output from a processor that consumed a worklist.
canon worklist | some-processor | canon import-facts
# Allow importing facts for sources in archive roots
canon worklist --include archived | some-processor | canon import-facts --allow archived
Input Format
Each line must be a JSON object with source_id, basis_rev, and facts:
{"source_id":123,"basis_rev":0,"facts":{"hash.sha256":"abc123...","mime":"image/jpeg"}}
| Field | Description |
|---|---|
source_id | Source ID from the worklist (required) |
basis_rev | Revision from the worklist for staleness check (required) |
facts | Object mapping fact keys to values |
The processor must pass through source_id and basis_rev from the worklist entry. If basis_rev doesn’t match the source’s current value, the import is skipped (the file changed since the worklist was generated).
Fact Namespacing
Facts are automatically namespaced under content.*: mime becomes content.mime. See Namespaces.
The special key hash.sha256 creates or links an object, enabling deduplication and archive tracking.
Type Hints
Canon stores facts as text, numbers, or timestamps; the stored type determines which operations work on a fact (see Value Types for the mapping).
If a datetime like "2024:07:23 11:06:32" is stored as text instead of a timestamp, queries like --where 'DateTimeOriginal|year=2024' won’t work: the modifier expects a timestamp.
Providing Type Hints
Wrap values in an object with value and type:
{"source_id":123,"basis_rev":0,"facts":{
"DateTimeOriginal": {"value": "2024:07:23 11:06:32", "type": "datetime"},
"duration": {"value": "1:23:45", "type": "duration"},
"rating": 5
}}
| Type | Parses | Stored As |
|---|---|---|
datetime | ISO dates, EXIF format, plain years (2024) | Unix timestamp |
duration | "1:23:45", "5:30", or seconds as number | Seconds (number) |
| (none) | Strings as text, numbers as numbers | As-is |
Common Pitfalls
Dates as strings: EXIF dates from tools like exiftool come as strings ("2024:07:23 11:06:32"). Without a type hint, they’re stored as text and time modifiers won’t work. Always use "type": "datetime" for date fields.
Mixed types: A fact key must have a consistent type across all sources. You cannot store DateTimeOriginal as text for some files and as a timestamp for others. If you initially imported facts with the wrong type and need to re-import with the correct type, first delete the existing entries:
# Delete all DateTimeOriginal facts that were stored as text
canon facts delete --key content.DateTimeOriginal --type text
Then re-run your processor with proper type hints.
Archive Sources
By default, importing facts for sources in archive roots is skipped. Use --allow archived to enable this (useful for backfilling metadata on already-archived files).
Writing Processors
Processors are scripts or programs that read worklist entries, extract metadata from files, and output facts for import.
Input and Output
A processor reads JSONL from worklist and writes JSONL for import-facts.
Input (from worklist):
{"source_id":123,"path":"/photos/IMG_001.jpg","basis_rev":0,"size":1024,"mtime":1703980800}
Output (for import-facts):
{"source_id":123,"basis_rev":0,"facts":{"Make":"Apple","Model":"iPhone 12"}}
The processor must pass through source_id and basis_rev unchanged.
Custom Processors
Read JSONL from stdin, extract facts from each file, output JSONL to stdout:
#!/bin/bash
while IFS= read -r line; do
source_id=$(echo "$line" | jq -r '.source_id')
basis_rev=$(echo "$line" | jq -r '.basis_rev')
path=$(echo "$line" | jq -r '.path')
# Extract facts (example: EXIF data)
facts=$(exiftool -json -Make -Model "$path" 2>/dev/null | jq '.[0]')
jq -nc \
--argjson source_id "$source_id" \
--argjson basis_rev "$basis_rev" \
--argjson facts "$facts" \
'{source_id: $source_id, basis_rev: $basis_rev, facts: $facts}'
done
The canonargs Helper
canonargs handles the JSONL parsing and output formatting; you provide a command that extracts data from a single file.
Installation
cargo install canonargs
Single Fact Mode
When your command outputs a single value:
canon worklist | canonargs --fact mime -- file -b --mime-type {} | canon import-facts
The {} is replaced with the file path. The command’s stdout becomes the fact value.
Default behavior: Values are stored as text. To specify a type, add --type:
# Store as datetime (enables |year, |month modifiers)
canon worklist | canonargs --fact DateTimeOriginal --type datetime -- exiftool -DateTimeOriginal -s3 {} | canon import-facts
# Store image width as number (using ImageMagick's identify)
canon worklist | canonargs --fact width --type number -- identify -format '%w' {} | canon import-facts
Valid types: datetime, duration, number
Key-Value Mode
When your command outputs key=value pairs (one per line):
canon worklist | canonargs --kv -- my-extractor {} | canon import-facts
Default behavior: All values are stored as text. To specify types, use key:type=value syntax:
width:number=1920
height:number=1080
DateTimeOriginal:datetime=2024:07:23 14:30:00
codec=h264
JSON Mode
When your command outputs a JSON object:
canon worklist | canonargs --json -- exiftool -json {} | canon import-facts
Example extractor output:
{"Make": "Apple", "Model": "iPhone 12", "DateTimeOriginal": "2024:07:23 14:30:00"}
JSON mode auto-detects numbers. If your command outputs "width": 1920 (a JSON number), it’s stored as a number. If it outputs "width": "1920" (a quoted string), it’s stored as text.
For datetime fields, you still need to use the typed hint format:
{"DateTimeOriginal": {"value": "2024:07:23 14:30:00", "type": "datetime"}}
Chaining
Processors can be chained since canonargs passes through the worklist entry and merges facts:
canon worklist \
| canonargs --fact mime -- file -b --mime-type {} \
| canonargs --json -- exiftool -json {} \
| canon import-facts
Using Existing Facts
Processors can access previously imported facts via the --emit flag on worklist. See Emitting Existing Facts for details.
Resuming a Pass
A --where gate decides which sources a pass still needs. Gate on a fact the processor emits for every file it handles, not on the fact you are after.
A processor that emits a capture time only when the file carries one leaves every other file matching NOT content.media.capture_datetime? permanently, so each pass reprocesses them:
# Reprocesses files that have no capture time, every run
canon worklist --where 'NOT content.media.capture_datetime?'
# Converges: this processor emits media.width for every file it reads
canon worklist --where 'NOT content.media.width?'
Gates are per fact vocabulary, so a processor covering several kinds of file needs one branch per kind:
canon worklist --global --include archived --where '
((mime ~ "image/*" OR mime ~ "video/*") AND NOT content.media.width?)
OR (mime ~ "audio/*" AND NOT content.audio.duration?)
'
When no output is unconditional, emit a marker for every file handled, including files nothing was found in:
{"source_id":123,"basis_rev":0,"facts":{"exif.scanned":"13.55"}}
NOT content.exif.scanned? then selects exactly the unprocessed sources, and storing the tool version lets a later pass reselect on it.
The enriched? predicate does not serve as a gate: it is true as soon as a source has any fact beyond the content hash, including facts from a different processor.
Type Hints
The stored type of a fact determines what operations work on it: timestamps enable date modifiers and comparisons, numbers enable numeric comparisons and |bucket, text enables string matching and string modifiers. If your processor outputs dates as strings, or numbers as quoted strings, add type hints; without them, queries like --where 'DateTimeOriginal|year=2024' or --where 'width>1000' won’t work.
See import-facts for the hint format and full details.
Tagging Files with Finder Tags (macOS)
While browsing files during archiving work, you can assign macOS Finder tags to classify them on the spot. Canon can then import those tags as facts, making them queryable and usable for clustering.
The Workflow
-
Browse and tag in Finder. Right-click files (or select multiple) and assign tags such as “vacation”, “kids”, or “junk”.
-
Import tags into Canon:
canon worklist Photos/2011 | ./scripts/tag-worklist.sh | canon import-facts -
Query by tags:
canon ls --where 'tag.vacation?' # files tagged "vacation" canon ls --where 'tag.vacation? AND tag.kids?' # both tags canon ls --where 'tag.vacation? AND NOT tag.kids?' # vacation without kids canon facts # see all tag.* keys with counts -
Cluster and archive by tag:
canon cluster generate --where 'tag.vacation?' --dest /Archive/Media/2011/Vacation ...
How It Works
The tag-worklist.sh script reads macOS extended attributes (com.apple.metadata:_kMDItemUserTags) from each file. Each Finder tag becomes a fact key like tag.vacation or tag.kids. The tag name is normalized to lowercase with special characters replaced by underscores.
Tags are presence-based: query them with the ? (exists) operator, not by value. tag.vacation? matches files tagged “vacation”, and composes with AND/OR/NOT like any other filter expression.
Why This Matters
A folder of mixed content often belongs in different places in the archive. Tags let you classify files while previewing them in Finder; the imported tags then drive --where filters and clustering to route each part to its destination.
Tips
- Always pass through
source_idandbasis_revunchanged - Use
jq -cfor compact JSON output (one object per line) - Handle errors gracefully—skip files that can’t be processed
- Use type hints for datetime fields so modifiers work correctly
- Ensure numbers are actual JSON numbers, not quoted strings
Querying
After scanning and enriching, you can explore your indexed files.
ls- List sources matching filter expressionsfacts- Discover available facts and check coveragecompare- Compare directories to find overlapsurvey- Survey a selection for archive status, related locations, and unique contentsweep- Rank reduction opportunities across all roots
All query commands except sweep support path scoping (limit to a subdirectory) and --where filters; sweep always operates across all roots.
Scope defaulting: When no paths are given, query commands scope to the current directory if it’s inside a known root. If the current directory is not inside any root, commands operate globally across all roots. Use --global to force global scope while inside a root.
canon ls
List sources matching filters. Useful for quick inspection and piping to other tools.
# List sources in current directory (default when inside a root)
canon ls
# List sources matching a filter
canon ls --where 'source.ext=jpg'
# Filter by source ID
canon ls --where 'source.id=12345'
# Filter by archive status using status predicates
canon ls --where 'archived?'
canon ls --where 'NOT archived?'
canon ls --where 'NOT archived? AND hashed?'
# Filter by hash status
canon ls --where 'NOT hashed?'
# Show duplicate files (same content hash), grouped by hash
canon ls --duplicates
# View excluded sources (requires --include for visibility)
canon ls --include excluded --where 'excluded?'
# Include sources from archive roots (automatic when scope is in an archive)
canon ls --include archived
# Include excluded sources in results
canon ls --include excluded
# Include both archived and excluded sources
canon ls --include all
# Query all roots, ignoring current directory scope
canon ls --global --where 'source.ext=jpg'
# Long format with size and date
canon ls -l
# Null-delimited output for xargs (handles spaces in paths, macOS)
canon ls -0 --where 'source.ext=jpg' | xargs -0 open -a Preview
# Combine status predicates with fact filters
canon ls --where 'NOT archived? AND mime~image/*'
Status predicates (archived?, hashed?, excluded?, enriched?) compose freely with other --where expressions. See Filter Syntax for details.
--duplicates is a display mode (changes output format to grouped by hash), not a filter. It can be combined with --where.
Status column in long format: When --include is used, ls -l shows a status column indicating source state: E (source-level exclusion), X (object-level exclusion), A (source in an archive root), or blank.
Scope display: When scoped (via CWD or explicit path), ls prints scope: /path to stderr. When global, no scope line is printed.
Path display:
- CWD-scoped (no explicit path, inside a root) → relative output paths
- Explicit absolute path or
--global→ absolute output paths
Output is one path per line (stdout), with a count printed to stderr:
scope: /Volumes/old-drive/photos
vacation/img001.jpg
vacation/img002.jpg
work/doc.pdf
3 sources
canon facts
Discover what facts you have and check coverage.
# Overview of all facts (scoped to current directory when inside a root)
canon facts
# Scoped to a specific directory
canon facts /path/to/photos
# Global overview across all roots
canon facts --global
# With filters
canon facts --where 'source.ext=jpg'
# Value distribution for a specific fact
canon facts --key content.Make
# With modifiers: group mtime by year-month
canon facts --key 'source.mtime|yearmonth'
# With accessors: distribution by top-level directory
canon facts --key source.rel_path[0]
# Combine accessor and modifier: distribution by filename extension
canon facts --key 'source.rel_path[-1]|ext'
# Show hidden built-in facts
canon facts --all
# Unlimited results (default is 50)
canon facts --key content.hash.sha256 --limit 0
# Include sources from archive roots
canon facts --include archived
# Include excluded sources
canon facts --include excluded
# Include both
canon facts --include all
# Show source count per root (which roots have matching content?)
canon facts --by-root
canon facts --where '@image' --by-root
# Group fact values by root (which roots contribute to each value?)
canon facts --key source.ext --by-root
# Group by any fact key (with modifiers)
canon facts --key source.ext --group-by 'source.mtime|year'
# Compound grouping (root + another fact)
canon facts --key source.ext --by-root --group-by 'content.Make'
The output begins with a scope header showing what’s being queried (Facts: /path or Facts: all roots). It is printed whether or not anything matched, so a report of nothing says where Canon looked:
Facts: /mnt/old-drive/photos
No sources match the given filters.
Example output:
Facts: all roots
Sources matching filters: 34692
Fact Count Coverage
────────────────────────────────────────────────────
source.ext 34692 100.0% (built-in)
source.size 34692 100.0% (built-in)
source.mtime 34692 100.0% (built-in)
source.path 34692 100.0% (built-in)
content.hash.sha256 34692 100.0%
content.mime 34692 100.0%
content.Model 7935 22.9%
content.Make 7935 22.9%
...
Example grouped output (--by-root):
source.ext (by root)
jpg (total: 12,500, 36.0%)
id:1 ...OldBackup/Pictures 8,000 64.0%
id:2 ...laptop/photos-backup 4,500 36.0%
png (total: 8,200, 23.6%)
id:1 ...OldBackup/Pictures 5,000 61.0%
id:3 ...laptop-import/media 3,200 39.0%
See also: facts delete for removing incorrect metadata, prune for cleaning up stale or orphaned data.
canon compare
Compare two folders by content hash. Useful for verifying backups or finding differences between directories.
# Compare current directory against another location
canon compare /path/to/folder_b
# Compare two explicit directories
canon compare /path/to/folder_a /path/to/folder_b
# With filters
canon compare /path/to/folder_a /path/to/folder_b --where 'source.ext=jpg'
# Include excluded sources in comparison
canon compare /path/to/folder_a /path/to/folder_b --include excluded
# Show file paths for differences
canon compare /path/to/folder_a /path/to/folder_b --verbose
With one path argument, the current directory is used as side A and the argument as side B. With two paths, they are used as A and B explicitly. The current directory must be inside a known root when used as side A.
Output shows:
- Files only in A (by content)
- Files only in B (by content)
- Files in both (matching content hash)
Unhashed files are skipped and counted on stderr. Empty files are skipped and
counted the same way (Skipped N empty files (no content to compare)): they
are contentless.
Compare reports on content; whether two folders correspond file-by-file,
empty files included, is a question it deliberately does not answer.
Exit code is 0 if identical, 1 if differences found.
canon survey
Survey a location to understand what’s here, where it connects, and what’s unique. The default output is an orientation map: what’s archived, which other locations share content, and how much exists only here. Use it as a starting point when arriving at a new folder, an old drive, or any scope you want to understand.
# Survey current directory
canon survey
# Survey a specific path
canon survey /mnt/old-drive/photos
# Survey with filters
canon survey /mnt/old-drive/photos --where "@image AND source.mtime|year=2016"
# See which of your files overlap with related locations
canon survey /mnt/old-drive/photos --detail overlap
# See content that exists nowhere else
canon survey /mnt/old-drive/photos --detail unique
# Pipe unique paths for further processing
canon survey /mnt/old-drive/photos --detail unique -0 | xargs -0 open
# See what's NOT at a reference location
canon survey /mnt/old-drive/photos --detail residual --other /mnt/backup/vacation/
# Add affinity columns to understand related locations deeper (requires --where)
canon survey /mnt/old-drive/photos --where "@image" --affinity
# See complementary content at related locations (requires --where)
canon survey /mnt/old-drive/photos --where "@image" --detail complement
# Compare against specific locations instead of discovering them
canon survey /mnt/old-drive/photos --other /mnt/backup/vacation/
# Filter archive section to a specific archive
canon survey /mnt/old-drive/photos --archive path:/archive/photos
# Include excluded sources in the selection
canon survey /mnt/old-drive/photos --include excluded
# Survey all roots globally (when inside a root but want the full picture)
canon survey --global
Options
| Flag | Description |
|---|---|
--where <EXPR> | Filter expression (repeatable). Narrows the selection. |
--affinity | Enable affinity columns (+N more, unique count, classification). Requires --where. |
--detail <MODE> | archived, complement, unique, overlap, or residual. Replaces the summary view. |
--archive <SPEC> | Filter archive section to a specific archive root (id:N or path:/...). |
--include <VALUE> | Include additional sources: excluded. |
--global | Survey all roots, ignoring current directory scope. |
--other <PATH> | Compare against specific locations (repeatable). Bypasses scope discovery. |
--brief | Skip per-location affinity computation when --affinity is active. |
--verbose | Show all locations (summary) or all paths per location (detail views). |
-0 | Null-delimited output for --detail unique, --detail overlap, or --detail residual. |
Surveying an archive location
Survey reads source-side selections. Archive content is its outward side, always visible in the archive sections, so an archive location has no selection of its own to survey. A scope that lies entirely inside archive roots is stated rather than surveyed, and the command exits non-zero:
Survey: /archive/media/2014-holiday
This place is inside the archive root /archive.
Survey reads source-side selections; archive content is its outward
side, always visible — nothing here has a source-side selection to survey.
For what stands here and where it came from: canon trail
For a listing of what's here: canon ls
When several archive places are named, each root is listed with the places asked for under it, and the frame is still stated once:
Survey: /archive/media, /archive2/old
inside archive root /archive: /archive/media
inside archive root /archive2: /archive2/old
Survey reads source-side selections; archive content is its outward
side, always visible — nothing here has a source-side selection to survey.
For what stands here and where it came from: canon trail
For a listing of what's here: canon ls
Under -0 with a machine-rendering detail view the statement goes to stderr,
leaving the requested stream on stdout empty; the non-zero exit still carries
the refusal.
This is decided from the roles of the containing roots, not from an empty result: a source-side scope that genuinely selects nothing still shows the ordinary summary with zero counts. The statement never lists what stands in the archive location.
A scope that is partly source-side proceeds on the source side and names what it set aside, one line per archive root. The reason is stated once for the view, however many roots the places fall under:
Survey: /mnt/old-drive/exports
set aside — inside archive root /archive: /archive/media
set aside — inside archive root /archive2: /archive2/old
(survey reads source-side selections; archive content is its outward side, always visible; for these places see canon trail or canon ls)
517 sources here (0 unhashed, 517 hashed)
--include archived is refused for the same reason:
Error: --include archived is not valid for survey.
Survey reads source-side selections; archive content is its outward
side, always visible.
For what stands in an archive location: canon trail
For a listing of what's there: canon ls
Reading the output
Summary view (default)
The default output is the orientation view.
Survey: /mnt/old-drive/exports
517 sources here (0 unhashed, 517 hashed)
264 unique here
Archived: 201 of 517 (38.9%)
/archive/media/2019/holiday 41
/archive/media/2019/kids 35
/archive/media/2019/home 43
/archive/media/2020/kids 22
...
Related locations:
/mnt/backup/pictures/phone/ 161 of 517 overlap (18,057 total)
/mnt/sandisk-export/camera-roll/2019/dec 82 of 517 overlap (370 total)
/mnt/sandisk-export/camera-roll/2020/jan 40 of 517 overlap (211 total)
/mnt/backup/phone/2019-W48 37 of 517 overlap (115 total)
... and 6 more locations (use --verbose to show all)
The output has three sections:
Survey header: Shows your scope, any active filters, and source counts. The unhashed/hashed split tells you how many files can participate in content comparison; unhashed files can’t be matched. When the selection holds empty files, a counted line states them (N empty files (no content to compare)): they are contentless, set aside from overlap, coverage, and uniqueness entirely, and always counted. “Unique here” is the count of content that exists nowhere else in Canon’s universe.
Archived: How many of your files have copies in an archive. The archive paths show where in the archive this content lives. Use --detail overlap --other <archive-path> to see which specific files are archived at a given location.
Related locations: Other places in Canon’s universe that share content with your selection. Each line shows:
- N of M overlap: How many of your files also exist at this location
- (T total): How many files are at this location overall; this tells you the location’s scale relative to the overlap
Use --detail overlap to see which of your files appear at each location. Locations are sorted by overlap count, highest first.
Notes
If you’ve annotated locations with canon note, those notes surface in survey output between the scope header and the statistics. Survey shows notes from the surveyed scope and its descendants (the subtree), capped at the 5 most recent:
Survey: /mnt/old-drive/exports
Notes:
2026-03-20 . confirmed: 95% archived, 12 unique remain
2026-03-18 vacation/ interesting sunset photos, check originals
2026-03-15 . phone backup from 2019, mostly photos
(2 earlier notes across 1 location)
517 sources here (0 unhashed, 517 hashed)
...
Each note shows a date, a relative path indicator (. for the scope itself, subfolder/ for descendant locations), and the note text.
Use --verbose to show all notes instead of capping at 5.
When no notes exist in the subtree but ancestor scopes have notes, a summary line appears:
(3 ancestral notes)
Related locations also show note count indicators when they have notes:
Related locations:
/mnt/backup/pictures/phone/ 161 of 517 overlap (18,057 total) (2 notes)
/mnt/sandisk-export/camera-roll/2019/dec 82 of 517 overlap (370 total)
Adding filters
The summary works without any --where filters. Filters narrow what you’re looking at:
# Only the images
canon survey /mnt/old-drive/exports --where "@image"
# Content from a specific period
canon survey /mnt/old-drive/exports --where "source.mtime|year=2019"
The same related locations may appear with different overlap counts, because the overlap is computed against your filtered selection.
Affinity mode (--affinity)
When you have a --where filter and want to understand what related locations have beyond the overlap, --affinity adds classification columns:
Related locations:
/mnt/backup-2022/photos/italy/ ≥ 380 of 388 overlap (420 total) +95 more (31 unique)
/mnt/partner-laptop/DCIM/vacation > 45 of 388 overlap (225 total) +180 more (42 unique)
/mnt/backup-2022/photos/misc/ ⊆ 30 of 388 overlap (30 total)
The additional columns:
- +N more: Files at this location that match your filters but have different content from your selection; what you’d find if you went there
- (K unique): Of those, how many exist nowhere else
- Classification symbol: How this location relates to your selection (see below)
The four dispositions
With --affinity, each related location is classified:
- Superset (≥) — Has nearly everything you have, plus more matching content. A more complete version of what you’re looking at.
- Lead (>) — Has complementary content with partial overlap. A related collection with additional material.
- Subset (⊆) — High overlap, no complementary content, and most of the location’s own content overlaps with yours. A smaller copy.
- Mirror (=) — Overlap but no complementary content, and the location has significant other content outside your filter. A partial copy within a larger collection.
Locations are sorted by classification: supersets first, then leads, then subsets, then mirrors. Within each group, sorted by complementary count descending, then overlap count descending.
Detail views
Detail views replace the summary with specific file listings. Each answers a question the summary raises.
| Summary signal | Question | Detail view |
|---|---|---|
| “201 of 517 archived (38.9%)” | Which files are archived, and where? | --detail archived |
| “264 unique here” | What content exists only here? | --detail unique |
| “161 of 517 overlap” | Which of my files are at that location? | --detail overlap |
| “+95 more” (affinity) | What matching content is over there? | --detail complement |
| — | What’s here that’s NOT at a specific location? | --detail residual |
Archived (--detail archived)
Shows which of your files are archived, grouped by archive location, with counterpart paths showing where each file lives in the archive:
Archived files (201 sources across 6 locations):
Archived at /archive/media/2019/home (43 files):
exports/photos/IMG_0001.jpg
→ media/2019/home/IMG_0001.jpg
exports/photos/IMG_0002.jpg
→ media/2019/home/IMG_0002.jpg
... and 38 more
Archived at /archive/media/2019/holiday (41 files):
exports/vacation/DSC_0100.jpg
→ media/2019/holiday/DSC_0100.jpg
...
Locations are sorted by file count (most files first). When results are small (20 or fewer per location), all paths are shown; otherwise capped at 5. Use --verbose to see all. With -0, output is flat, deduplicated selection-side paths only (for piping to xargs -0).
Use --archive to filter to a specific archive root.
Unique (--detail unique)
Outputs paths of files whose content exists nowhere else:
photos/2016-07-14/IMG_4201.jpg
photos/2016-07-14/IMG_4202.jpg
photos/2016-07-18/DSC_0891.jpg
Paths are relative when the scope is under the current directory, absolute otherwise. Use -0 for null-delimited absolute paths (for xargs -0).
Overlap (--detail overlap)
Shows which of your files have copies at each related location, along with the counterpart paths at that location:
Overlapping with related locations (overlap):
/mnt/backup/phone-export/ (4 of 135 overlap):
recordings/morning-walk.m4a
→ audio/2020/morning-walk.m4a
recordings/evening-notes.m4a
→ audio/misc/recording-001.mp3
photos/IMG_0042.JPG
→ DCIM/2020-W48/IMG_0042.JPG
→ DCIM/2020-W48/IMG_0042 2.JPG
Each → line shows where the matching content lives at the other location. Multiple counterparts appear when the same content exists more than once (e.g., OS-generated duplicates like IMG_0042 2.JPG). Counterpart paths are relative to the location.
When results are small (20 or fewer), all paths are shown. For larger results, paths are capped at 5 per location; use --verbose to see all. With -0, output is flat and deduplicated selection-side paths only (no counterpart data), for piping to xargs -0.
Complement (--detail complement)
Requires --where. Shows files at related locations that match your filters but have different content from your selection. Implies affinity computation.
Complementary content at related locations:
/mnt/backup-2022/photos/italy/ (+95, 31 unique):
week3/IMG_4501.jpg
week3/IMG_4502.jpg
week3/IMG_4503.jpg
week4/IMG_4601.jpg
week4/IMG_4602.jpg
... and 90 more
Paths are relative to the location. When results are small (20 or fewer), all paths are shown; otherwise capped at 5 per location. Use --verbose to see all.
Residual (--detail residual)
Requires --other. Shows which of your files are NOT shared with the reference location:
Not at /mnt/backup/vacation/ (residual):
photos/IMG_4201.jpg
photos/IMG_4202.jpg
photos/IMG_4203.raw
Unhashed files are always included in residual output: without a hash, their presence at the reference location can’t be confirmed. Empty files are never included, for the opposite reason — there is no content whose absence could be claimed; they stay counted in the header. Use -0 for flat output. With multiple --other locations, each gets a separate listing.
Directed comparison (--other)
By default, survey discovers related locations by searching Canon’s full universe for content overlap. --other lets you specify locations directly:
canon survey /mnt/old-drive/photos \
--other /mnt/backup/vacation_italy/ \
--other /mnt/partner-laptop/DCIM/
Differences from default mode:
- Header reads “Comparing with:” instead of “Related locations:”
- Locations are displayed in user-specified order (not sorted)
- In
--detail complement, mirrors are shown with a note rather than omitted
Archive status and unique counts are always computed against the full universe regardless of --other.
How exploration typically flows
Orientation: Survey a location and read the summary. From there, scope down to a subfolder, add --where filters, or drill into a detail view.
Following a thread: Survey a related location directly (canon survey <that-path>) to understand it. Use --detail overlap to see which files connect the two places, and canon facts to see what metadata is available before refining with --where.
Assessing coverage: Use --affinity with a --where filter to see which locations have more matching content. Drill into --detail complement to list it, and --detail residual --other <location> to see what is not covered.
Acting on results: Pipe --detail unique -0 or --detail overlap -0 to downstream tools (xargs -0 open for inspection, xargs -0 ls -la for sizes, or further processing).
canon sweep
Sweep the whole universe for reduction opportunities: the ranked places where one dismissal decision resolves the most. Every other query command asks you to say where; the sweep answers that question itself. The sweep finds places, survey judges one, you decide, and the decision is recorded with canon exclude.
# The leaderboard: the ten best reduction opportunities, ranked
canon sweep
# More entries
canon sweep --limit 25
# Everything: all entries, all members, findings below the emit floors
canon sweep --all
The sweep takes no paths and no filters; it is universe-wide, computed fresh from current database state on every run. Acting on a finding (excluding, archiving) removes it: the next run reflects the new state, and the top slot always holds the current best move.
Options
| Flag | Description |
|---|---|
--limit <N> | Show up to N leaderboard entries (default: 10). |
--all | No cap: every entry, every member of a multi-place entry, and findings below the emit floors. |
Reading a finding
#1 /Volumes/OldBackup/ARCHIVED/Super8
96% inside /Volumes/Archive/Media/Super8 (by size · 89% by count)
counterpart: archived, scanned 5d ago · subject scanned 2d ago
gain: 1,204 files · 33.7 GB residual: 1 file · 3.9 GB nowhere else
→ canon survey . --other /Volumes/Archive/Media/Super8
- The subject (the full path on the first line) is the place the finding is about, the side you might dismiss. The other side is the counterpart: where the copies live. A single finding’s headline is its subject; a hub’s headline is the shared counterpart (the hub’s own “shared counterpart” line states this).
- The relation states how the subject’s content connects elsewhere, in survey’s vocabulary: a subset sits inside a counterpart that holds more; a mirror matches its counterpart in both directions. Both percentages matter: a large gap between “by size” and “by count” means many small files carry little weight.
- The counterpart line states the counterpart’s standing (
archivedorpresent), which is what makes acting on the finding safe or not. The wording is declarative (“inside X”, never “keep X”): the relation implies no preferred side; even for a subset, the smaller side can still be the better copy. Both sides carry their scan age; the claim rests on the last scan. - Gain is what acting on the finding resolves. Residual is content existing nowhere else in the universe.
residual: nonemeans a clean dismissal; a small residual often means one rescue away from a clean one. - The
→handoff is the ready-to-run judging command, written as if youcdinto the subject first. The sweep only ever hands off to judgment, never to a ready-made exclusion.
When the subject is not fully hashed, the finding says so (compared on 92% by size): unhashed content is unverified, never silently omitted. Notes you’ve left on the subject or counterpart (canon note) surface beside the finding.
A subject that itself stands on an archive root is marked (in the archive) and ranks below an equivalent place on a source root: its content is already resolved, so it does not compete for triage attention, and the real opportunity usually sits on the counterpart side. It is demoted, not removed, and the relation is stated anyway; the sweep compares any location to any other. A hub headlined by an archive counterpart is untouched by this: its members are the subjects, and live source members keep the hub competing at full weight.
A subject that is a whole root, rather than a folder inside one, is marked (whole root). Both markers appear where both apply.
Scattered findings
When no single counterpart concentrates the match, the finding states the spread:
#4 /Volumes/Backup/laptop-import/mixed
94% exists elsewhere, across 7 locations (2 archived · 3 suspended)
scattered; consolidation candidate · subject scanned 12d ago
Scattered content with nothing archived ranks last, but it stays visible: scattered redundancy is a consolidation candidate.
The parenthetical counts how many of those locations are archive roots, and how many stand on suspended roots. The suspended count is omitted when it is zero. No other number moves: a location behind a closed door is still a location, and the content is still there.
Hubs
Many places pointing into one counterpart render as a single leaderboard entry:
#2 /Volumes/Archive/Media/iphone-backup
shared counterpart — 36 places hold copies inside it · archived, scanned 5d ago
total gain: 7,820 files · 41.2 GB
/Volumes/OldBackup/iphone-2019 98% inside · 402 files · 2.1 GB
/Volumes/old-disk/dumps/phone 97% inside · 371 files · 1.9 GB
… 34 more (--all)
→ canon survey /Volumes/Archive/Media/iphone-backup
The hub occupies one leaderboard slot and shares one handoff: surveying the counterpart shows every member as a related location.
Roots close to done
A source root with few unresolved sources left takes one leaderboard slot of its own, headlined by the root and carrying its places as members:
#1 /Volumes/oldmac (whole root)
3 unresolved sources remain
6 places, up to 1,204 files · 41.3 GB
/Volumes/oldmac/Pictures/2019 mirrors · 100% · archived · 612 files · 22.1 GB
/Volumes/oldmac/Pictures/2020 98% inside · archived · 431 files · 14.8 GB
… 4 more (--all)
→ canon roots retire path:/Volumes/oldmac --dry-run
The count is the same remainder the retirement readiness review measures: sources on the root that are neither archived, nor covered, nor excluded, nor empty. It is a fact about the root and not a verdict about it; the review the handoff names is what judges whether the root is ready, and --dry-run reports that verdict rather than acting on it. A zero remainder reads no unresolved sources remain.
The figure is an upper bound (up to), not a total. Places on one root can be each other’s evidence: two folders holding copies of each other both report those bytes, and only one of them can be let go, so what acting would actually resolve is never more than the figure shown.
Each member states its own counterpart standing, which is what makes acting on it safe; hub members take theirs from the hub’s headline instead. Members are capped like hub members, with the omission counted and --all revealing the rest. A qualifying root with only one place forms no such entry: one place is already one slot. Archive roots never form one; they are not retired.
Sibling folders under one parent
When several places under one parent each pair with their own counterpart, they take one leaderboard slot headlined by the parent, carrying the places as members:
#1 /Volumes/Backup/photos
10 places under here · 88% of this folder
up to 4,102 files · 61.4 GB
/Volumes/Backup/photos/set-01 mirrors · 100% · archived · 402 files · 6.1 GB
/Volumes/Backup/photos/set-02 98% inside · archived · 388 files · 5.9 GB
… 8 more (--all)
→ canon survey /Volumes/Backup/photos
The percentage on the second line is the share of the parent’s own sources that lie under the members. It is what the entry claims: that the parent is where one decision covers the whole situation. Sources under the parent that no member accounts for are the rest of that figure, and the handoff surveys the whole parent rather than the members, so what the members leave out stays visible.
Where places could group either way, the parent claims them: a hub groups places by the
counterpart they share, a parent groups them by the one decision that covers them. Places under
different roots never group together, whatever they hold in common. The parent may be a root’s own
top, when the places sit directly on it; the entry then reads as a bare root path and hands off to
canon survey at that root.
A parent whose members account for less than 60% of it forms no entry, and its places compete individually as they otherwise would. Nothing is hidden either way. Grouping applies at the immediate parent only and never recurses, so a folder and its own child can each headline an entry.
The members share one root, so the entry carries that root’s remainder line where nearness is in play, above the figure, exactly as a single finding does.
The figure is an upper bound (up to), not a total, for the same reason a root entry’s is: two
places under one parent can be each other’s evidence, and only one of them can be let go. Each
member states its own counterpart standing. Members are
capped like hub members, with the omission counted and --all revealing the rest. Two places
under one parent are enough to group; one place is already one slot.
Places that mirror each other
Two places can each mirror a folder inside the other, which is one overlap stated from both ends. They take one slot, and the entry that keeps it says so:
#7 /Volumes/Backup/downloads/tools
mirrors /Volumes/Backup/tools/vendor/app (100% by size · 100% by count)
counterpart: present, scanned today · subject scanned today
also mirrored by /Volumes/Backup/tools — one decision resolves both
gain: 812 files · 3.2 GB residual: none
→ canon survey . --other /Volumes/Backup/tools/vendor/app
Which of the two keeps the slot is decided by path, so an unchanged database always shows the same one. A place inside a second, with that second inside a third, is not this shape: those are two situations and both keep their slots.
Ranking
There is no composite score: every ranking factor is visible on the finding, in this order:
- Cleanliness: ready-to-assess findings (at or above the lifting tolerance) above consolidation-grade overlap.
- Archive standing: a place standing on a source root above an equivalent place standing in the archive.
- Root nearness: a place on a source root with little left unresolved above a place on a root barely started. Archive roots carry no nearness and tie here.
- Weight: resolution gain, size-led (counts always shown beside sizes).
- Counterpart standing: archived above merely-present; scattered content with nothing archived last.
- Residual burden: content existing nowhere else penalizes; a clean dismissal outranks one that needs a rescue first.
Nearness sorts ahead of weight, so a small place on a root close to done outranks a large one elsewhere. Where it applies, the finding says so on its own line:
#1 /Volumes/oldmac/Pictures/2019
mirrors /Volumes/Archive/Media/2019 (100% by size · 100% by count)
counterpart: archived, scanned today · subject scanned today
20 unresolved sources remain on /Volumes/oldmac
gain: 30 files · 61.4 KB residual: none
→ canon survey . --other /Volumes/Archive/Media/2019
The line appears only where nearness is in play; its absence means the order rests on the other factors. Nearness separates entries only among roots close enough to done for the board to say so; above that, roots tie on it and weight leads. The remainder is bucketed by order of magnitude, so it takes a tenfold change to move a place on the board.
Two runs against an unchanged database produce identical output.
Suspended roots
Places on a suspended root are not ranked, and neither are places whose counterpart stands on one. Each suspended root that kept places off the board gets a footer line naming both causes, what each is worth at most, and the way back:
/Volumes/OldBackup suspended — not ranked: 8 places on it (up to 185.3 GB), 4 with copies on it (up to 12.1 GB) · canon roots unsuspend path:/Volumes/OldBackup
Places on the root stand there; places with copies on it stand elsewhere and rest their claim on content behind the closed door. Each cause carries its own figure, and each is an upper bound: parked places can be each other’s evidence, so what unsuspending would actually resolve is never more than the figure shown. Whether the suspended root is a source or an archive root makes no difference. Above three suspended roots the lines collapse to one, counting roots rather than naming them, with canon roots list --suspended as the way back.
Suspended roots stay in the computation: their copies still count as gain rather than residual, so a folder duplicated entirely inside a suspended root does not read as unique. What changes is position, not existence. --all does not reveal these places; canon roots unsuspend brings them back, and inspecting one parked place is canon survey <path>.
The below-floor footer count includes places that are not ranked, so --all reveals fewer entries than that count names.
Honesty rules
- The header declares every omission: ubiquitous objects (present in too many places to signal anything) and empty files (zero-byte content is contentless: it never creates overlap, never counts in percentages, never blocks a residual).
- Excluded content is resolution, not overlap: it neither creates findings nor blocks dismissal, and surfaces as context where substantial (
3,000 sources here already excluded). - Floors trim output, never existence: small findings are counted in the footer (
12 more below the emit floors (--all)) and reachable with--all. - A board that changes without you acting on it explains itself: suspending a root changes what the sweep ranks, and the suspended-root footer lines state the count, the mass, and the way back on every run where places were not ranked.
- An empty leaderboard is an answer, never a false one: the board says there is no folder-level redundancy worth attention only when nothing was withheld from it. Where suspension or
--limitemptied it, the line says that instead. An unscanned or unhashed universe gets a pointer, not an empty list.
The journey
Run the sweep, read the top finding, cd there, run the handoff survey, judge, then record the decision with canon exclude and a reason. Declining to act leaves no trace, unless you leave a note, which comes back beside the finding on the next run.
canon trail
Read the decision trail. Canon records every effectful action (decision provenance); trail reads that record back as a timeline of what happened, with your notes interleaved.
One command, three ways to ask:
- The scope lens — standing in a folder: what did I do here? Decisions touching this place, as a timeline ending at now.
- The time lens (
--today,--since,--on) — what did I do today? The day’s decisions as a story, with a rollup of what was deleted, archived, and excluded. - The counterpart door (
trail crossings) — what moved between here and there? The relation between two places: what this one gave another, or took from it.
# What happened here?
canon trail
# What happened in a specific folder?
canon trail /mnt/old-drive/photos
# Today's story, across all roots
canon trail --today --global
# Everything since Saturday
canon trail --since saturday
# One specific day
canon trail --on 2026-05-12
# One decision in full
canon trail show 61
# Expand the rollups by place
canon trail crossings
# Everything one drive ever delivered here
canon trail crossings --origin /Volumes/old-backup
# Full paths, for copying
canon trail -l
trail is a pure query command; it never changes anything.
The scope lens
With no time flags, trail lists the decisions that touched the current scope, oldest to newest, ending at the most recent:
Decision trail: /mnt/old-drive/photos
#42 2026-05-12 14:03 archived . Applied italy-2016: 47 copied, 0 errors
#57 2026-07-11 15:10 scan . Scanned 4,120 files: 12 new, 1,350 missing · "verified duplicates"
#61 2026-07-11 16:42 excluded misc Excluded 210 duplicates (kept 105) · "redundant backup"
2026-07-11 16:50 italy ~ unsure about the RAW files — revisit
12 earlier decisions not shown (--limit N or --all; showing 20).
2 global decisions not shown (--global).
A decision touches the scope in either direction: a decision on a parent folder happened to this folder too, and a decision on a subfolder is activity here. Sibling folders’ decisions don’t appear. That rule applies to a decision’s acted-on scope. The extraction and arrival lines below follow recorded placements instead, which appear only in views that contain them (see the extraction ledger).
Each line carries the decision id, timestamp, the act, the place, the completion summary, and your --reason (quoted). Decisions that did not complete cleanly are marked ([partial], [interrupted], [started]).
The act is the registered transition word where the decision has one (archived, excluded, restored, deleted) and the stored command identifier otherwise (scan, cluster_generate). Notes carry no act; the ~ marks them.
The place is the one of the decision’s recorded scopes that brought it into this view, with +N for its other places. Where several of its scopes match, the deepest is named: a scope inside the view says more about where the act was than an ancestor of it does. Decisions recorded without a scope show global.
The place is rendered relative to what you’re viewing (. is the viewed folder itself). A place elsewhere in the same root is measured from that root and carries a leading /; when the listing contains one, a line under the header names the root:
Decision trail: /archive/2016
Places are relative to this folder; a leading / is relative to /archive.
#71 2026-08-02 10:56 scan / Scanned 99,801 files: 97,746 new, 2,049 unchanged
#84 2026-08-02 11:31 archived /2020 Applied curation-2020: 412 copied, 0 errors
Views spanning several roots, and global views, render full paths, capped from the left. -l renders every place in full, absolute and uncapped (see Full paths).
The listing is capped at the 20 most recent decisions; the footer tells you what’s beyond the cap (--limit N or --all to widen). Global decisions can’t be attributed to any folder, so scoped views count them in a footer rather than hiding them.
The trail is the sequence view: what happened here, in order. Its shape-first counterpart is canon roots story, which renders a whole root as a map of places and hands each place back to the trail for its full event story.
Places with no history
A folder holding no sources still has a story when something records it: a file that once stood there, a note, an apply that drew from or placed into it, or a decision scoped there or inside it. Those places render normally, which is what keeps a folder emptied by a move-mode apply from disappearing from its own history.
A path that none of those record is stated rather than rendered, and the command exits non-zero:
No history known at /mnt/old-drive/191 — no sources, notes, or decisions record this place.
(Did you mean 'canon trail show 191'?)
The second line appears only when the argument is all digits, where trail show <id> is the likely intent. A decision scoped at a parent folder does not make a path beneath it a known place.
The same answer comes back whether you name the place or stand in it: a bare canon trail scoped to the current directory is held to the same test. A root’s own top is exempt, and --global is unaffected.
The outbound direction: what left from here
Standing at a source location, an apply that drew content out of this scope shows up too, even though the apply’s own selection scope may have been global or elsewhere. It renders in the extraction aspect, replacing the usual summary line:
#42 2026-05-12 14:02 archived 2016/italy → 47 files (3.9 GB) to /Archive/Media/2016/Italy (copied) · "italy assembly"
The place cell is the drawn-from location, not the destination; the disposition states the recorded act — copied or moved. It says what the apply did, not what is at the origin now: that place may be long since cleared, or on a drive Canon can no longer see. A decision appears once per view, never as both a selection line and an extraction line.
The ledger records an apply per directory it drew from, so a view shows only what actually left it: an apply that drew from two sibling folders never surfaces at a third, and standing inside one of them you see that folder’s share of the draw, not the apply-wide total.
Scoped scope-lens views end with a whole-history rollup, independent of the --limit cap. It answers “where do I stand with this place?”, not “what happened recently?”:
Archived from here: 1,251 files (22.1 GB) → 2 destinations.
Omitted when nothing has ever been drawn from here. Sizes are omitted, not guessed, if any contributing decision’s bytes can’t be determined. Global views carry no single “here” to roll up, so neither the rollup nor extraction lines appear there; an apply still counts toward the “not shown” footer at any view it doesn’t touch.
The inbound direction: what arrived here
Standing at a destination, the same apply shows up too: files it placed inside this scope are enough, regardless of where its source root sits. It renders in the arrival aspect:
#42 2026-05-12 14:03 archived . ← 47 files (3.9 GB) from /Volumes/old-laptop/photos/2016/italy (copied in) · "italy assembly"
The place cell is the destination this time, view-relative (. for the viewed folder itself); the wording mirrors the outbound direction (copied in / moved in). A source root the live index no longer knows renders with (root removed) appended, matching trail show’s drew from: lines.
When a decision’s origin and destination both sit inside the view (content rearranged entirely within one scope), it renders once, not twice: the extraction-aspect line, with the destination shown view-relative instead of absolute. Both endpoints stay visible in that one line.
A placement matches only where the view contains it, at its recorded precision. Deliveries are recorded per destination directory, so an apply that delivered to 2016/01 and 2016/02 never appears at 2016/03, and an arrival line’s counts are what landed inside the view you’re standing in, never the apply-wide total. Applies recorded before Canon kept directory precision are known only to a coarse common prefix of where their files landed: they surface at that prefix and above, and stay silent below it rather than guessing. canon ledger reindex rebuilds directory precision from the receipts on disk and closes that gap wherever a receipt exists.
The matching whole-history rollup:
Arrived here: 2 files (14 B) from 1 origin.
What a rollup counts
A rollup counts boundary crossings, and the view defines the boundary. “Archived from here” is content that left this place; “Arrived here” is content that entered it. Content that moved within the view crossed nothing, so it belongs to neither; it gets a third line:
Archived from here: 1,251 files (22.1 GB) → 2 destinations.
Arrived here: 340 files (8.2 GB) from 3 origins.
Rearranged here: 47 files (3.9 GB).
Crossings are stated first, then what stayed inside. The hint sits between them, because it expands the two crossing lines and not the third:
Archived from here: 1,251 files (22.1 GB) → 2 destinations.
Arrived here: 340 files (8.2 GB) from 3 origins.
`canon trail crossings` to list the places behind these totals
Rearranged here: 47 files (3.9 GB).
Any combination of the three can appear: a location can draw content out, receive content in, rearrange content within itself, all of these, or none. A view whose only rollup is Rearranged here carries no hint — nothing crossed there, so the door has no places to list.
Rearranged here carries no counterparty clause, unlike its two siblings: rearranged content stayed here, so there is no other place to name.
The same decision reads differently from different scopes (Crossing In, Crossing Out, Staying Put). An apply that moved content from /archive/2016 to /archive/2020:
- Viewed at
/archive, both endpoints are inside. Nothing crossed → Rearranged here. - Viewed at
/archive/2020, the origin is outside. Content crossed in → Arrived here.
Each view answers its own question. Classification is per row, not per decision, so a single apply that drew from inside the view and from outside it contributes to Rearranged here and Arrived here at once.
Sizes are all-or-omitted per rollup, computed over that rollup’s own rows: an unknown-size crossing never suppresses a fully known rearrangement total.
The composition card: what’s standing here
Below the rollups, a scoped scope-lens view ends with a present-tense statement of what the location is made of right now, read from its surviving sources’ stamps rather than from the trail’s events (see the composition card):
Standing here: 3 files (21 B)
arrived from /Volumes/old-laptop/photos/2016/italy
3 files (21 B) · decision #12 · 2026-05-12
`canon trail crossings --origin <path>` to list the folders behind an origin
“Arrived here” is an event total and never shrinks; “Standing here” is a state total and can be smaller. Arrived here: 5 files next to Standing here: 3 files means some of what arrived was later deleted or moved elsewhere; neither number is wrong.
An origin line takes three lines at most — the path, then its marker where it has one, then its counts — the same shape the counterpart door uses for the same facts. The path ends its own line and is never elided or wrapped, because it is what you copy into the next command. Where a line’s counts name exactly one decision, they name the decision itself (decision #12) rather than telling you there is one; two or more stay a count.
Origin lines come first, busiest first: a single-origin root that fed this location across one or more applies merges into one arrived from <root> entry, carrying how many decisions are behind it and the date range they span (open them with trail crossings --origin <root>); an apply that drew from several roots in a single decision gets its own via apply #N from M origins line, since its content isn’t merge-worthy with anything else. After origins come standings: what present content here was last touched by, one line per transition (excluded: 28,412 files (19.2 GB)), merged across every decision that produced it. A standing is a statement about this place now, so it carries no decision id; the decisions behind it are the timeline above. Then a first indexed here bucket for content this location saw first via a scan, and an arrival unrecorded bucket for content carrying no stamp at all. That content is tracked — it is indexed, present, and counted in the header above; what is missing is the record of how it arrived, and the row cannot say why, so the line names the absence and stops there.
Where the record has a gap, the line names the one decision it is about, after the standings: archived (origin unknown) here (#88) for an apply the extraction ledger cannot attribute, transition unrecorded here (#404) for a stamp whose decision row no longer exists. A gap must read as a gap, so it is never merged away.
Long lists are capped with an explicit remainder line, never a silent truncation — and a capped card carries one invitation, not two, because the remainder absorbs the hint:
… and 2 more origins — `canon trail crossings --origin <path>` to list the folders behind an origin
… and 3 more gaps.
An origin line names a place other than this one. Two cases follow from that, both mirroring the boundary rule above:
Standing here: 500 files (12.9 GB)
arrived from /Volumes/old-laptop
300 files (7.1 GB) · 2 decisions · 2026-03-02 – 2026-05-01
arrived from elsewhere in /archive
47 files (3.9 GB) · decision #57 · 2026-05-12
`canon trail crossings --origin <path>` to list the folders behind an origin
rearranged: 12 files (800.0 MB)
first indexed here: 141 files (1.1 GB)
arrived from elsewhere in <root> means the content genuinely arrived (its origin sits outside the viewed scope) while the origin root contains where you’re standing. Origin lines are anchored on the root, so a bare /archive while standing in /archive/2020 would name the place you are already in. The root is still named rather than left implicit, because a view can span several roots.
rearranged means the content didn’t arrive at all: every row of the applies behind it was drawn from inside this view, so there is no elsewhere to name. Unlike the rollups, the card classifies per decision rather than per row: a source’s stamp records which decision last touched it, not which row of that decision, so for an apply spanning several origins the card cannot tell which surviving files came from which side. Any row from outside keeps the origin line, rather than claiming a rearrangement the index can’t substantiate.
Origin attribution is root-level throughout: arrived from /Volumes/old-laptop, not the subfolder within it. The card merges applies across time, and the root is the stable unit; for the exact subfolder of any one decision, canon trail show <id> lists it under drew from:.
Exclusion doesn’t remove standing: an excluded-but-present source still counts. Renaming a file later doesn’t erase its origin either, since attribution follows the decision that stamped it, not the file’s current name. The card only appears when it has something to say: a location whose content is entirely first-indexed-here or untracked renders no card at all. It never appears in global views, the time lens, or --jsonl output; it is a scoped, present-tense reading, and JSONL’s extractions field already covers the machine-readable side of provenance.
The time lens
--today, --since <when>, or --on <when> switch to the day-grouped story view, chronological, so it reads forward:
Decision trail: all roots — today
Saturday 2026-07-12 — deleted 1,350 files (35.0 GB), archived 47 files (3.9 GB), excluded 210 files — and 2 other actions
#63 09:14 scan /mnt/old-disk Scanned 4,120 files: 12 new, 1,350 missing · "verified duplicates"
09:40 /mnt/old-disk/photos ~ unsure about the RAW files — revisit
#64 11:02 archived ...ive/photos/italy Applied italy-2016: 47 copied, 0 errors
#65 11:30 excluded /mnt/old-disk/misc Excluded 210 duplicates (kept 105) · "redundant backup"
<when> accepts today, yesterday, a weekday name (the most recent one, today included), or a date (YYYY-MM-DD). Days follow your local timezone.
Each day opens with a rollup by fate: deleted (deletions a scan observed), archived (apply), excluded, plus a count of other actions (scans that deleted nothing, manifest generation, imports, and so on). Sizes are computed from the index and shown when reliable; for older decisions whose files have since been touched by newer decisions, the size is omitted rather than guessed.
Scope still applies: canon trail --today inside a root shows that folder’s day; add --global for the whole story.
Notes in the timeline
Notes (canon note) interleave with decisions by default, marked with ~ and carrying no id, act, counts, or status: a thought never reads as an action. The trail holds actions (“what did I do?”); notes hold thoughts (“what did I think?”). Use --no-notes for decisions only.
Full paths: -l
The place column is capped, which is the wrong shape when what you want is the path itself. -l (or --long) renders each entry over several lines instead, with the full absolute path, uncapped:
$ canon trail -l
#71 2026-08-02 10:56 scan
/mnt/old-drive/photo library/imported 2007-2010 (+30 other places)
Scanned 99,801 files: 97,746 new, 2,049 unchanged
2026-08-02 15:02
/mnt/old-drive/photo library/imported 2007-2010/raw
~ this should probably just be bulk-transferred
Paths are absolute in this mode wherever you run it, scoped views included: relative rendering is a convenience for reading, and this mode exists to be copied from. -l changes only how an event renders, never which events appear, and has no effect under --jsonl.
The counterpart door: trail crossings
canon trail crossings reads the relation between two places: what this one gave another, or took from it. It takes the paths the trail’s own output prints.
The bare view
$ canon trail crossings
Crossings: /archive
Archived from here: 1,251 files (22.1 GB) → 2 destinations.
/archive/Media/2016
904 files (18.2 GB) · 3 decisions · 2026-07-11 · 44 folders
/archive/Documents/scans
347 files (3.9 GB) · decision #57 · 2026-08-09
Arrived here: 36,412 files (498.2 GB) from 10 origins.
/Volumes/old-backup/archived
(root retired — the book: /archive/books/2026-08-11-backup-archived)
8,398 files (201.4 GB) · 15 decisions · 2026-08-02 – 2026-08-09
… and 9 more origins.
`canon trail crossings --origin <path>` or `--destination <path>` to list the folders behind an entry
The closing line names the flag that opens the entries above it: an outbound section lists destinations, so --destination opens them; an inbound section lists origins, so --origin does. A view showing both sections names both.
The section headers are the rollup lines from canon trail, in the same order and the same form. The entries beneath itemize them by counterpart: the number of entries is the rollup’s counterparty count, and their counts sum to its total.
Where an entry’s counts name exactly one decision, they name the decision itself (decision #57) — a fully-determined answer is a handle you can pass to canon trail show, not a statistic to look up.
Outbound entries name places, not folders
Deliveries are recorded per destination directory, and a manifest pattern can spread one apply across a directory per day. Listed at that precision the outbound section answers which places? with a list of generated date folders, so it groups them instead — at a key derived from the destinations in view, coarser than the recorded folder and, wherever the archive’s own arrangement leaves room for one, below its root:
Archived from here: 1,582 files (33.7 GB) → 3 destinations.
/archive/Media/2016/03
1,383 files (30.1 GB) · 4 decisions · 2026-07-19 · 44 folders
/archive/Media/2016/an-event
146 files (918.2 MB) · decision #61 · 2026-07-11 · 3 folders
/archive/Media/2016/another-event
53 files (2.7 GB) · decision #61 · 2026-07-11
44 folders is the coverage count: how many recorded destination folders that entry stands for. An entry standing for exactly one omits it, because the path above it is that folder.
The grouping is a display key and never a loss of reach: naming a grouped entry with --destination opens it at the recorded precision. Nor is the count a second arithmetic — canon trail’s → N destinations runs the same grouping over the same key, so wherever both surfaces speak they count the same way. (They can still fall silent differently, for the reason given above: they select by different evidence.)
The inbound section is unchanged: its counterpart is the origin root, which is already a place you would name.
An archive whose destinations sit directly under its root has no directory between the root and the leaf to key on, so every destination stays its own entry. One level of nesting is the whole difference; the grouping cannot invent a place the archive does not have.
The two surfaces select by different evidence, so they can part. A source root removed and added again at the same path carries a new id: canon trail’s rollup, which matches on that id, falls silent, while crossings, which matches on the path each decision recorded, still answers. Where they both speak they agree; where they differ, the crossings answer is the one read from the record.
Counterpart paths render whole, on their own line, never elided. A counterpart whose root the live index no longer knows is marked (root removed); a retired one points at its book instead, in the wording trail show’s drew from: lines use.
A section with nothing in it does not print. Standing at a source location, only the outbound section appears:
$ canon trail crossings
Crossings: /Volumes/camera-card/2019
Archived from here: 1,551 files (44.2 GB) → 1 destination.
/archive/Media/2019
1,551 files (44.2 GB) · 3 decisions · 2026-07-14 – 2026-08-01 · 6 folders
`canon trail crossings --destination <path>` to list the folders behind an entry
Naming a counterpart
--origin <path> narrows to content drawn from at or under that place; --destination <path> to content placed at or under it. Both take a path at any depth, and both compose. Naming one drops that section to per-decision detail at row precision:
$ canon trail crossings --origin /Volumes/old-backup/archived
Crossings: /archive
Arrived here: 8,398 files (201.4 GB) from /Volumes/old-backup/archived
(root retired — the book: /archive/books/2026-08-11-backup-archived).
#48 2026-08-02 1,204 files (31.2 GB) moved in
Photos/2016 → Media/2016
Photos/2017 → Media/2017
"italy trip + the 2017 backlog"
#50 2026-08-02 847 files (12.8 GB) copied in
Photos/2018 → Media/2018
… and 13 more decisions.
Standing here: 8,151 of the 8,398 files delivered — 15 decisions stand behind them; 17 delivered.
Each end of a place line is measured from its own anchor: the named counterpart on one side, the viewed scope on the other. The counterpart path in the header stays whole. A named section carries no drill-down hint — you have already stepped through that door — but naming one counterpart leaves the other section listing counterparts as usual, and that half is still taught.
A flag can also narrow the section on the side you are standing rather than the side you asked about. Standing at a source location, --origin <subfolder> narrows what left, while what it left for is still unnamed, so that section keeps the counterpart listing and names the place it narrowed to instead of saying “here”:
Archived from /Volumes/camera-card/2019/raw: 812 files (22.4 GB) → 1 destination.
The counts are then smaller than the same sentence in canon trail, and the header says why.
The Standing here line appears when the view is scoped, --destination is not in play, and the composition card carries an origin line for exactly this root. It states two counts on each of two axes: how much that origin delivered and how much of it stands here now, in files and in the decisions behind them. The second clause appears only when the decision counts differ, and they can, without either being wrong: the card counts decisions that stamped surviving sources, this door counts decisions holding delivery records.
Where the two match, they sit side by side and you can see it without doing the subtraction:
Standing here: 229 files stand; 229 were delivered.
Matching counts are not a statement that these are the same files, and the line does not make one. Content can be moved into a place by a later act that keeps its original stamp, so a file can leave and another arrive carrying the same delivery’s mark — which is also how more can stand here than this door records as delivered:
Standing here: 8,300 files stand; 8,199 were delivered.
Only where fewer stand than were delivered do the numbers license a proportion, and only there does the line state one: 8,151 of the 8,199 files delivered.
A named destination narrows the delivered count while the card still answers for the whole location, so the two would no longer be counts of the same content; the line is omitted rather than shown as a comparison that does not hold. The gap between them is not decomposed, on either axis. Content can leave an archive location by deletion, by a later apply, or by a transition recorded in place, and these records cannot tell those apart.
What counts as a crossing
A crossing is a movement across the boundary of the place in view. Content that moved within the view crossed nothing and appears in neither section. When that is all there is, the command says so:
Nothing crossed this boundary. 47 files (3.9 GB) were rearranged within it.
Where you named a counterpart, the answer names it back, so it is clear which relation came back empty:
$ canon trail crossings --origin /archive/retired/archived-2026-08-08
Crossings: /archive
Nothing has crossed between here and /archive/retired/archived-2026-08-08.
A place can be known to Canon — indexed, noted, recorded — and still be no delivery’s endpoint. Naming one is not an error and does not read as one; the answer is simply that nothing moved between the two places.
A named counterpart matches at or below the path given, never above it. Asking about /archive/2016 does not surface a delivery whose recorded destination is /archive: a common prefix says nothing about a particular folder beneath it. Matching is on literal bytes, so _ and % in a folder name mean themselves.
Counterparts match on the paths recorded when each decision ran, so a removed root, or one removed and re-added, keeps its link.
--global
--global borrows the counterpart named as its boundary:
$ canon trail crossings --global --origin /Volumes/old-backup/archived
reads everything that drive ever delivered, wherever it went. This is the same computation as standing at the drive. --global therefore requires --origin or --destination; on its own there is nothing to measure against, and the command errors.
The same refusal applies without the flag. Running canon trail crossings from a directory no open root contains resolves to every root, which is the same boundless state, and it errors for the same reason: a view with no boundary cannot report a crossing, because every place is inside it. Two such directories answer instead of erroring — a retired root’s old path, which states its retirement, and a directory inside a suspended root, whose message names the suspension and the way back.
When --global names both, the boundary is the deeper of the two where one contains the other, and the origin otherwise. Where the two paths do not nest, both choices select the same records and only the section header differs. Where one contains the other they select differently, and the deeper path is the one that reads the movement between them as a crossing rather than as a rearrangement inside the wider place.
Exits, caps and machine output
A counterpart Canon has no record of is stated, exit non-zero, no Error: prefix, stdout clean, matching the scope-lens miss. A counterpart Canon knows across which nothing crossed is answered, exit 0.
--limit N (default 20) caps each section independently, always with an explicit remainder; --all uncaps. Place listings inside one delivery cap at 5, the value drew from: uses, and --all uncaps those too — every remainder this command prints has an invocation that opens it.
--jsonl emits the same decision events the timeline emits, over the decisions carrying a crossing in view. No field is added or dropped, and each decision carries its full row set, so a decision serializes identically wherever it was surfaced from.
crossings is read-only and records no decision.
| Flag | Meaning |
|---|---|
--origin <path> | Narrow to content drawn from at or under this path |
--destination <path> | Narrow to content placed at or under this path |
--global | All roots; requires --origin or --destination |
--limit N | At most N entries per section (default 20) |
--all | No cap |
--jsonl | Machine output (JSONL on stdout) |
Counterparts are named by path. A root spec (id:N, path:...) is refused: it cannot name a location below a root, and a removed root’s id is gone with the root.
Inspecting one decision: trail show
The id on every line drills down:
$ canon trail show 61
Decision #61 — exclude_duplicates
when: 2026-07-11 16:42
status: completed
counts: attempted 315, completed 210, failed 0, skipped 105
reason: "redundant backup"
command: canon exclude duplicates /mnt/old-drive/photos --prefer /archive ...
scope: /mnt/old-drive/photos (here)
version: 0.5.2
summary: Excluded 210 duplicates (kept 105)
receipts:
/archive/.canon-ledger/000061-exclude_duplicates.toml
For an apply decision, a drew from: section lists what it took from each source root: path, files, and size. The path is a snapshot recorded at apply time, so it renders even after the root itself is gone from Canon. A root Canon no longer indexes ends its line with (root removed): the path stays primary (it is the answer to “where did this come from?”), and the marker states that the path is history, not a place you can visit. For a copy, the originals may still exist at the origin, but they are no longer part of Canon’s universe:
drew from:
/Volumes/old-laptop/photos/2016/italy — 47 files (3.9 GB)
/Volumes/nikon-sd/dcim — 12 files (401 MB) (root removed)
When one root’s draw fanned out across directories, they are listed beneath its summary line with their own shares, capped at five, with an explicit … and N more directories remainder, never a silent truncation:
drew from:
/Volumes/nikon-sd/dcim — 245 files (2.4 GB)
dcim/100nikon — 105 files (1.0 GB)
dcim/101nikon — 140 files (1.4 GB)
The marker follows the recorded root, not the path. If you remove a root and later re-add the same path, old extractions still show (root removed): they belong to the root that was removed; the re-added one is a new root that happens to share its path.
A removed origin that left through canon roots retire points at its book instead: (root retired — the book: /archive/retired/old-drive). The book is the root’s complete story, openable without Canon. Only a plain roots rm (no bound story to point at) keeps the bare (root removed).
No section when the decision drew from nowhere (every other decision kind).
show lists where the decision’s receipts live on disk, including one receipt per source root for deletions. It does not print receipt contents; open the file to see the per-item record. When there is no receipt, the reason is stated (no receipt (--no-receipt), no receipt (nothing transferred) for a run that completed no transfer, or no receipt recorded); absence is never silent. A finished decision’s receipt pointer names a file that exists: a run whose receipt was never written carries no pointer rather than a dangling one. A receipt pointer whose root has since been removed renders as root #N (removed)/…: the receipt was written, but the file now lives on storage Canon no longer indexes.
show’s scope list
A decision can name many places. show lists them one per line, capped at five with an explicit remainder, and puts the ones bearing on where you are standing first:
scope: /mnt/old-drive/photos (here)
/mnt/old-drive/photos/2016 (within here)
/mnt/old-drive/admin
/mnt/old-drive/misc
/mnt/old-drive/scratch
… and 26 more places
(here) marks a scope that is the current directory or contains it; (within here) marks one the current directory contains. trail show <id> therefore reads differently depending on where you run it — the same scopes are always listed, only their order and these markers change. Run from outside every scope, or where the working directory cannot be resolved, the list is in recorded order with no markers.
The markers use the same rule that decides whether a decision appears in a scoped canon trail at all, so a scope marked (here) is the reason that decision surfaces where you are standing.
After retirement: the trail stays whole
When a root leaves through canon roots retire, its history keeps rendering, in two places.
Receipt pointers follow the gathered ledger. A deletion receipt was written at the source root itself; retirement gathered a copy into the book’s ledger/, filenames preserved. trail show on such a decision renders the pointer as a relocation:
receipts:
/Volumes/old-drive/.canon-ledger/000057-scan.toml
(root retired — gathered into the book at /archive/retired/old-drive/ledger/000057-scan.toml)
The first line is where the receipt was written; the second is where it lives now, a path you can open without Canon. Two other states exist: if the book holds no gathered copy (a root retired on faith, unreachable at binding), the line says so and defers to the book, which records the gap (not gathered into the book; the book at <path> records why); if the book’s own location isn’t reachable right now (the archive is unmounted), the line states where the story is bound without claiming what’s inside (the story is bound at <path>, not reachable now). Canon checks only that the files exist; it never reads the book to answer a trail query.
A scoped trail at a retired root’s old path states the retirement. Asking canon trail /Volumes/old-drive, or running canon trail while standing inside the old mount path, answers with the retirement itself instead of an error or a silently global view:
This place is retired: /Volumes/old-drive — retired 2026-08-02, "drive failing".
The story is bound at /archive/retired/old-drive (decision #61).
The statement answers the question asked, so the command exits 0. A path that was never retired keeps the normal behavior: an explicit unknown path is still an error, and a working directory outside every root still falls back to the global view. (A root removed with plain roots rm has no bound story to point at; its decisions still render with snapshot paths, but there is no retirement to state.) Under --jsonl the statement is one JSON object ("type": "retired_scope", with root_path, retired_at, reason when recorded, book, decision_id); stdout stays machine-clean on this path too.
Machine output
--jsonl emits one JSON object per timeline event, with a type field ("decision" or "note"), the raw command identifier, timestamps, counts, reason, scope, summary, and receipt location. An apply event additionally carries extractions: one entry per recorded placement, a source root’s origin directory paired with the destination directory it fed (root, rel_prefix, files, bytes, destination, disposition). It is populated regardless of view, including --global, so machine consumers never have to re-derive it from a scoped run. (Rows recorded before directory precision are one per source root, with common-prefix locations: the same fields, coarser values.) The field is absent (not []) for decisions that drew from nothing. The scope header moves to stderr so stdout stays clean:
canon trail --today --global --jsonl | jq -r 'select(.type=="decision") | .summary'
Flags
| Flag | Meaning |
|---|---|
--global | All roots, ignoring current-directory scope |
--today | Time lens: today (sugar for --since today) |
--since <when> | Time lens: from a day onward |
--on <when> | Time lens: one day |
--limit N | Show at most N decisions (default 20) |
--all | No cap |
--no-notes | Decisions only |
-l, --long | Multi-line entries with each place’s full absolute path |
--jsonl | Machine output (JSONL on stdout) |
Managing Sources
After scanning and enriching, these commands control which sources archiving operations consider, and annotate locations along the way.
exclude marks sources to skip during cluster generate and apply: temporary or system files, known duplicates beside a preferred copy, files below a size threshold. Excluding deletes nothing, and exclusions can be cleared at any time.
note annotates locations with timestamped observations. Notes surface automatically in survey output when you revisit a location.
canon exclude
Manage source exclusions. Excluded sources are skipped by most commands.
# Mark sources as excluded (e.g., small files, temp files)
canon exclude set --where 'source.size<1000'
canon exclude set /path/to/photos --where 'source.ext=tmp'
# Exclude a specific file by path
canon exclude set /path/to/photos/unwanted.jpg
# Exclude by source ID (shown in ls --duplicates output)
canon exclude set --id 12345
# Preview what would be excluded
canon exclude set --where 'source.ext=bak' --dry-run
# Skip confirmation prompt (for scripting)
canon exclude set --where 'source.ext=bak' --yes
# View excluded sources
canon ls --include excluded --where 'excluded?'
canon ls --include excluded --where 'excluded?' /path/to/photos
# Remove exclusions
canon exclude clear
canon exclude clear --where 'source.ext=tmp'
# Preview what would be cleared
canon exclude clear --where 'source.ext=tmp' --dry-run
# Skip confirmation prompt
canon exclude clear --yes
When excluding or clearing more than one source, a confirmation prompt shows the count, root spread, and (for exclude set) archive coverage before proceeding. Use --yes to skip the prompt, or --dry-run to preview without executing.
Given several paths, one that holds no known sources (an empty folder matched by a shell glob, say) is skipped and named before the ceremony, and the rest proceed:
$ canon exclude set /photos/2011 /photos/2012 --dry-run
no sources known at /photos/2012 — skipped
Would exclude 1 sources:
/photos/2011/a.jpg
The line appears before any confirmation, under --yes and --dry-run alike. A skipped path never enters the decision record. See Path scope for the rule in full.
canon exclude duplicates
Automatically exclude duplicate files while keeping copies in a preferred location.
# Exclude duplicates, keeping files under /preferred/path
canon exclude duplicates /scope/path --prefer /preferred/path
# Preview what would be excluded
canon exclude duplicates /scope/path --prefer /preferred/path --dry-run
# Skip confirmation prompt
canon exclude duplicates /scope/path --prefer /preferred/path --yes
# With filters
canon exclude duplicates /scope/path --prefer /preferred/path --where 'source.ext=jpg'
When excluding more than one source, a confirmation prompt shows the count, number of duplicate groups, and skip statistics before proceeding. Use --yes to skip the prompt.
canon exclude set-object / clear-object
Exclude content by hash rather than by path. Object-level exclusion is universal: it affects every source sharing that content, in source roots and archive roots alike. Use it when content is unwanted wherever it turns up: corrupted files, known junk, the same clip scattered across storage.
# Exclude by a file's content (looks up its hash)
canon exclude set-object /path/to/junk.bin --yes
# Exclude by content across a scope, with filters
canon exclude set-object /scope --where 'content.mime=application/octet-stream' --yes
# Exclude by hash directly (excluding empty content requires this explicit form)
canon exclude set-object --hash <content-hash> --yes
# Restore a content-level exclusion (hash as shown by `exclude list-objects`)
canon exclude clear-object <content-hash>
# List content-level exclusions
canon exclude list-objects
exclude set-object defaults to a dry-run; pass --yes to execute. See Objects for how content-level exclusion differs from path-level source exclusion.
How exclusions affect other commands:
| Command | Default behavior | Override |
|---|---|---|
ls | Skips excluded | --include excluded or --excluded filter mode |
worklist | Skips excluded | --include excluded |
facts | Skips excluded, shows count | --include excluded |
coverage | Stats on included only | --include excluded shows excluded dimension |
cluster generate | Always skips excluded | No override (hard gate) |
apply | Blocks if manifest has excluded | No override (hard gate) |
Exclusions are stored directly on sources and objects in the database.
Provenance
Every exclusion is recorded as a decision. When an archive root is configured, a receipt listing the affected sources lands flat in the archive ledger root’s .canon-ledger/. exclude set, clear, duplicates, and set-object accept --reason to annotate why; the global --no-receipt flag skips the receipt file for one invocation. See Receipts for placement and per-item detail.
canon note
Annotate locations with timestamped notes. A note is a quick observation about a directory scope: “interesting photos from 2016 trip”, “possible duplicates of archive set”, “needs review”. Notes surface automatically in survey output.
# Add a note to the current directory
canon note -m "lots of unsorted vacation photos here"
# Add a note to a specific path
canon note /mnt/old-drive/exports -m "overlaps with 2019 backup, check survey"
# View notes at the current scope
canon note
# View notes at a specific path
canon note /mnt/old-drive/exports
# List recent notes across all roots (temporal, capped at 10)
canon note --global
# List recent notes recursively under a scope
canon note -r
canon note -r /mnt/old-drive
# Show the spatial map — one line per noted location
canon note --global --by-scope
canon note -r --by-scope
# Show more entries (or all)
canon note --global --limit 20
canon note --global --limit 0
# Clear notes at the current scope
canon note --clear
# Clear all notes under a scope (with confirmation)
canon note --clear -r /mnt/old-drive
# Skip confirmation prompt for recursive clear
canon note --clear -r --yes
Options
| Flag | Description |
|---|---|
-m <TEXT> | Add a note with the given text. |
-r, --recursive | List or clear notes for scope and all descendants. |
--global | List all notes across all roots. |
--by-scope | Group by location, show most recent note per location (spatial view). |
--limit <N> | Maximum entries to display (default: 10, 0 = unlimited). |
--clear | Clear notes at the scope (or subtree with -r). |
--yes | Skip confirmation prompt (recursive clear only). |
The journal model
Notes use an append-only journal model. Each -m call adds a new timestamped entry; notes are never replaced or edited in place. Multiple notes can exist at the same scope, forming a chronological log of observations. Clearing is the only way to remove notes.
This is deliberate: notes capture evolving understanding. “Check for duplicates” and “confirmed: 80% overlap with backup” are two entries in the same log.
Modes
Add (-m)
Adds a note at the resolved scope. Prints confirmation to stderr.
$ canon note -m "phone backup from 2019, mostly photos"
Note added: /mnt/old-drive/phone-export
View (default)
Shows notes at the exact scope, with spatial context indicators showing how many notes exist above (on parent scopes) and below (on descendant scopes).
$ canon note
/mnt/old-drive/phone-export:
2026-03-15 phone backup from 2019, mostly photos
2026-03-20 confirmed: 95% archived, 12 unique files remain
2 noted locations below
When there are no notes at the scope but notes exist nearby, the spatial indicators appear alone:
1 note on parent scopes · 3 noted locations below
When CWD is not under any known root, view mode falls back to the global temporal list.
Temporal listing (--global, -r)
Shows the most recent notes ordered by date: oldest at top, most recent at the bottom (closest to the prompt). Capped at 10 entries by default.
$ canon note --global
Photos/2011 2026-03-10 mixed bag, vacation + school stuff, worth sorting
old-laptop/Desktop 2026-03-10 raket project — Daniel's, check with him
old-laptop/Music 2026-03-12 check for unique .flac files
Photos/2011 2026-03-15 tagged vacation photos, school stuff still needs triage
Photos/2011/vacation 2026-03-20 subset tagged and clustered
(14 more notes, 6 more locations)
The footer (on stderr) shows how many more notes and locations exist beyond the cap. Use --limit to see more:
$ canon note --global --limit 20 # show 20 entries
$ canon note --global --limit 0 # show all entries
Recursive listing (-r) is the same but scoped to a subtree:
$ cd /mnt/old-drive
$ canon note -r
phone-export 2026-03-15 phone backup from 2019, mostly photos
phone-export 2026-03-20 confirmed: 95% archived, 12 unique files remain
phone-export/vacation 2026-03-22 unique sunset photos here
Spatial listing (--by-scope)
Shows one line per location: the most recent note and the total note count for that location. Locations ordered by their most recent note date, capped at 10.
$ canon note --global --by-scope
old-laptop/Music (1) 2026-03-12 check for unique .flac files
Photos/2012/vacation (2) 2026-03-14 need to check overlap with phone backup
old-laptop/Desktop (2) 2026-03-25 raket — checked with Daniel, archive
Photos/2011/vacation (4) 2026-03-28 beach photos assembled, ready to cluster
(6 more locations with notes)
The note count shows which locations have longer histories; view one in full with canon note <path>.
Inside a root, --by-scope without --global or -r implies -r, a spatial map of the subtree:
$ cd /mnt/old-drive
$ canon note --by-scope
phone-export (3) 2026-03-20 confirmed: 95% archived, 12 unique files remain
phone-export/vacation (1) 2026-03-22 unique sunset photos here
Clear (--clear)
Without -r, clears notes at the exact scope only; no confirmation needed.
$ canon note --clear
Cleared 2 notes at /mnt/old-drive/phone-export
With -r, clears all notes in the subtree. Shows a plan and prompts for confirmation:
$ canon note --clear -r /mnt/old-drive
Clear 5 notes across 3 locations under /mnt/old-drive?
Proceed? [y/N] y
Cleared 5 notes
CWD defaulting
When no path argument is given, canon note uses the current working directory, the same pattern as other Canon commands.
- CWD inside a root: scope resolves to
(root_id, rel_path)for that location - CWD not in any root: view mode falls back to global temporal list; add and clear modes error
The directional model
The listing modes look in different directions:
- View (default): looks at this level — notes attached to the exact scope, with counts pointing up and down
- Recursive (
-r): looks down — notes at this scope and everything below it - Global (
--global): looks at everything — all notes across all roots
Both temporal and spatial modes work with either --global or -r. Temporal (by date) is the default; --by-scope switches to spatial (by location).
Notes in survey
Notes surface automatically in survey output, appearing after the scope header. Survey shows notes from the scope and its descendants (the subtree), capped at 5 most recent entries. See the survey documentation for details.
Notes and the decision trail
Notes hold thoughts (“what did I think about this?”); the decision trail holds actions (“what did I do?”). Don’t write notes to record actions: effectful commands record themselves, and --reason attaches your why. canon trail shows both as one timeline, with notes visually distinct.
Distinction from other annotations
Canon has several annotation mechanisms, each serving a different purpose:
- Notes (
canon note): Location-level observations during exploration. Timestamped journal. Surface in survey. - Root comments (
canon roots comment): A single descriptive label on a root. Shown incanon rootslistings. - Manifest notes (
# === Notes ===in manifest files): Free-form text in a specific manifest. Preserved acrosscluster refresh. - Facts (
canon import-facts): Structured key-value metadata on files or content. Used in filters and patterns.
Archiving
When you find a collection of files to archive, Canon uses a two-step process:
- Generate a manifest with
cluster- select files and define the destination - Apply the manifest with
apply- copy or move files to the archive
This workflow lets you review and customize the output before committing to any file operations.
coverage- Check how much has been archivedcluster- Generate a manifest for a set of filesapply- Execute the manifest to copy/move files
canon coverage
Show archive coverage statistics: how many sources are hashed and how many are archived.
# Coverage for current directory (when inside a root)
canon coverage
# Scoped to a specific directory
canon coverage /path/to/photos
# Global overview of all source roots
canon coverage --global
# With filters
canon coverage --where 'source.ext=jpg'
# Coverage relative to a specific archive root
canon coverage --archive id:1
canon coverage --archive path:/path/to/archive
# Include archive roots in analysis
canon coverage --include archived
# Include excluded sources
canon coverage --include excluded
# Include both
canon coverage --include all
The output begins with a scope header (Coverage: /path or Coverage: all roots).
Example output (global):
Coverage: all roots
Root: /path/to/backup1 (source)
Total sources: 1,234
Hashed: 1,100 (89.1%)
Empty files: 40 (no content to cover)
Archived: 850 (80.2% of 1,060 with content)
Unarchived: 210
Root: /path/to/backup2 (source)
Total sources: 567
Hashed: 500 (88.2%)
Archived: 400 (80.0% of 500 with content)
Unarchived: 100
────────────────────────────────────────
Overall:
Total sources: 1,801
Hashed: 1,600 (88.8%)
Empty files: 40 (no content to cover)
Archived: 1,250 (80.1% of 1,560 with content)
Unarchived: 310
- Hashed: Sources with a content hash (ready for archiving)
- Empty files: Zero-byte sources, shown when present. They are
contentless, so
coverage counts them in neither
ArchivednorUnarchived; the lines add up as hashed = empty files + with-content, and with-content = archived + unarchived - Archived: Sources whose content exists in an archive root. The
percentage names its denominator (
of N with content), so a fully-covered selection reads 100% even when it contains empty files; the remainder is always exactlyUnarchived - With
--archive: Shows “In this archive” vs “Not in archive” for that specific archive
canon cluster generate
Generate a manifest of files matching filters. The --dest flag specifies where files will be copied and must be inside a registered archive root.
# All photos to an archive (unhashed sources are automatically skipped)
canon cluster generate --where 'source.ext IN (jpg, png, heic)' --dest /Volumes/Archive/Photos
# Destination can be a subdirectory within an archive
canon cluster generate --where 'source.ext IN (jpg, png, heic)' --dest /Volumes/Archive/Photos/2024
# Scope to a specific path
canon cluster generate /path/to/photos --dest /Volumes/Archive
# Custom output file
canon cluster generate --where 'source.ext=jpg' --dest /Volumes/Archive -o my-manifest.toml
# Allow sources from archive roots
canon cluster generate --where 'source.ext=jpg' --dest /Volumes/Archive --allow archived
# Allow duplicate content (same hash already in an archive)
canon cluster generate --where 'source.ext=jpg' --dest /Volumes/Archive --allow duplicates
# Show which files were excluded (already archived)
canon cluster generate --where 'source.ext=jpg' --dest /Volumes/Archive --show-archived
# Overwrite existing manifest file
canon cluster generate --where 'source.ext=jpg' --dest /Volumes/Archive --force
The command generates two files: a manifest (.toml) that you edit, and a lock file (.lock) containing the source list.
Typical workflow:
canon cluster generate --where 'source.ext IN (jpg, png, heic)' --dest /Volumes/Archive
# Edit manifest.toml to customize the output pattern
canon apply manifest.toml --dry-run # Preview
canon apply manifest.toml # Execute
Output:
After generating, the command prints a summary showing root breakdown and archive coverage:
Generated manifest: manifest.toml (1,234 sources in manifest.lock)
From 2 roots:
/Volumes/Drive1 (800)
/Volumes/Drive2 (434)
1,234 have no archived copy
Empty files are never skipped as “already archived”. A zero-byte file is contentless, so archive detection ignores it and the manifest carries it with its folder.
Manifest structure:
The generated manifest includes a cluster summary, a notes section for your own annotations, and comments listing available pattern variables:
# === Cluster Summary ===
# 1,234 sources from 2 roots:
# /Volumes/Drive1 (800)
# /Volumes/Drive2 (434)
# 1,234 have no archived copy
# === Notes ===
#
[meta]
version = 2
query = ["source.ext IN ('jpg', 'png', 'heic')"]
scope = ["/path/to/photos"]
generated_at = "2026-02-28T12:00:00Z"
lock_hash = "abc123..."
[options]
allow = [] # e.g. ["archived", "duplicates"]
[output]
pattern = "{scope.rel_path}" # ← Edit this to customize organization
base_dir = "/Volumes/Archive"
archive_root_id = 2
# Available facts for pattern (100% coverage on 1234 sources):
# ...
-
Cluster Summary is regenerated on each
cluster refresh, showing current source counts, root breakdown, and archive coverage. -
Notes section is preserved across refreshes — add your own comments here.
-
patternstarts at{scope.rel_path}when the generation was scoped to any path at all, and at{source.rel_path}when it was not: files keep the folder structure they were found in. Edit it to organize them differently. An existing manifest keeps the pattern it recorded.Writing the manifest inside the destination is fine, but a manifest whose own path is a directory the pattern needs blocks every file below it. Generate and refresh warn when that is the case, naming both paths;
applyrefuses the run. This is easy to hit by naming a manifest after a folder whose name contains a dot:-o/-Oappend.tomlonly when the name has no extension, so-O photos.2024writes exactly that name. -
versionfield tracks the manifest format version. -
[options]records which--allowflags were used during generation.cluster refreshreads them, because it re-selects sources from the same query.applyreads onlyduplicates, which speaks to the content it is about to transfer;archivedacknowledged a selection that has already happened, and apply selects nothing. What apply needs acknowledged, it asks for on its own flags.
Common output patterns:
# Structure below the scoped path (default for a scoped generate)
pattern = "{scope.rel_path}"
# Structure below each source's root (default when unscoped)
pattern = "{source.rel_path}"
# Flat - all files in base_dir
pattern = "{filename}"
# By EXIF date
pattern = "{content.DateTimeOriginal|year}/{content.DateTimeOriginal|month}/{filename}"
# By EXIF date with hash prefix (avoids collisions)
pattern = "{content.DateTimeOriginal|year}/{content.DateTimeOriginal|month}/{hash_short}_{filename}"
# By camera model
pattern = "{content.Make}/{content.Model}/{filename}"
# By file type
pattern = "{source.ext}/{filename}"
See Pattern Expressions for the full syntax reference, including modifiers, path accessors, and aliases.
Refreshing the Lock File
Use canon cluster refresh to update the lock file if sources have changed since the manifest was generated:
# Re-query and update the lock file
canon cluster refresh manifest.toml
# Edit the manifest first, then re-query from what was saved
canon cluster refresh manifest.toml --edit
This re-runs the manifest’s query and updates manifest.lock with the current matching sources. The manifest settings ([options], [output]) remain unchanged.
On refresh:
- The Cluster Summary is regenerated with current counts
- The Notes section is preserved verbatim
- The same root breakdown and archive coverage summary is printed to stdout
--edit opens the manifest in $VISUAL/$EDITOR before the re-query, so an edited query is the query that runs. The manifest is edited in place. If the editor exits with a failure status, or the saved manifest does not parse, the refresh stops: neither the manifest nor the lock file is written, and the file holds exactly what was saved. Nothing is parsed before the editor opens, so a manifest that no longer parses can be repaired this way.
When the query matches nothing, the lock file is removed and lock_hash is emptied. The manifest is rewritten in full, with the Cluster Summary stating the zero match and the Notes section preserved as on any other refresh.
canon apply
Apply a manifest to copy/move files. Copied files are automatically registered in the database with the same content hash, so they’re immediately recognized as archived (no separate scan needed).
# Preview what would happen (fast - skips source existence checks)
canon apply manifest.toml --dry-run
# Copy files (default mode, preserves mtime/permissions on Unix)
canon apply manifest.toml
# Show per-file progress during transfer
canon apply manifest.toml --verbose
# Resume a previously interrupted apply
canon apply manifest.toml --resume
# Rename files instead of copying (Unix only, fails on cross-device)
canon apply manifest.toml --rename
# Move files: rename if same device, copy+delete if cross-device
canon apply manifest.toml --move
# Only apply sources from specific roots
canon apply manifest.toml --root id:1 --root id:2
canon apply manifest.toml --root path:/path/to/source
# Allow duplicates within the destination archive
canon apply manifest.toml --allow duplicates
# Allow duplicates across archives (but not within destination)
canon apply manifest.toml --allow cross-archive-duplicates
Transfer modes:
| Flag | Behavior |
|---|---|
| (default) | Copy + preserve mtime/permissions (Unix) |
--rename | Atomic rename; fails if cross-device (Unix only) |
--move | Try rename; fallback to copy+delete on cross-device (Unix only) |
All modes use noclobber semantics: if a destination file exists, apply aborts with an error.
For --rename and --move, the confirmation summary shows which source roots will lose files:
Mode: rename (sources will be relocated)
Files: 150
Sources from:
/Volumes/Drive1 (100 files)
/Volumes/Drive2 (50 files)
Confirmation summary:
The summary previews the directory files actually enter, which is the manifest’s
base_dir plus whatever literal directories the pattern begins with:
Destination: /Volumes/Archive
Pattern: 2024/{filename}
...
Destination current contents (/Volumes/Archive/2024):
(will be created)
A pattern whose directories come from content has no single placement directory. The preview shows the directory they all sit under and says so:
Pattern: sorted/{source.rel_path}
...
Destination current contents (/Volumes/Archive/sorted):
(placements fan out under this directory by pattern)
2023/
2024/
Progress before anything moves:
Apply reads every source in the manifest before it transfers the first file: once while planning, then twice more before the transfer loop. On a network volume each pass is a round-trip per source, so each one names itself and counts:
Running preflight checks...
100% (1234/1234)
Checking destination write permissions...
Checking 1,234 sources can be read...
100% (1234/1234)
Verifying 1,234 sources against the lock file (reading file heads)...
100% (1234/1234)
The two passes before the transfer loop stay separate: an unreadable source is refused
before any file’s content is read. --dry-run returns after planning, so it never runs
them.
Resume mode (--resume):
Use --resume to continue a previously interrupted apply. This is useful when:
- Apply was interrupted (Ctrl+C, system crash, disk full)
- Some files failed to transfer due to errors
Resume mode classifies each destination into one of:
- Already archived - Registered in database, skipped
- Resumed - File exists on disk but not in database, skipped (needs
scanto register) - To transfer - Not in database, not on disk, will be copied
# Resume an interrupted apply
canon apply manifest.toml --resume
# Preview what --resume would do
canon apply manifest.toml --resume --dry-run
If --resume reports “resumed” files, run canon scan on the affected paths to register them:
# Scan only the destination directory that was being written to
canon scan /path/to/archive/2024
If --resume detects files with size mismatches (partial copies from interrupted transfers), it will error and ask you to delete those files before continuing.
Integrity validation:
During transfer, Canon validates each source file’s partial hash (first 8KB + last 8KB) to detect file corruption or modification since the manifest was generated. If validation fails, the transfer is aborted.
Root filtering:
Use --root to apply only a subset of sources from the manifest. Useful for staged application when sources are on different drives.
--root id:N- Filter by root ID (shown in manifest asroot_id)--root path:/path- Filter by root path (must match exactly)
Pre-flight checks (mandatory):
-
Blocked destination directories - If a file stands where a destination directory has to go, apply refuses the whole run before transferring anything, naming the file and the destinations it blocks. This check also runs with
--resume: a file in the way is not evidence of an earlier run’s progress. Move or rename the file, or edit the pattern. -
Destination collisions - If multiple sources would map to the same destination path (e.g., using
{filename}when sources have duplicate names), apply aborts with an error showing which files conflict. -
Destination path conflicts - In regular mode (without
--resume), checks if any destination paths are already occupied (registered in the database or existing on disk). If conflicts are found, apply suggests using--resumeto skip already-copied files. -
Stale destination records - If the database shows files as present in the archive but they’re missing from disk, apply aborts. Run
canon scan <archive>to update the database before retrying. -
Archive conflicts - Checks if files already exist in the destination archive or other archives. Empty files are exempt: they are contentless, so an empty file being applied never conflicts with empty files already in the archive.
-
Excluded sources - Blocks if any sources in the manifest are marked as excluded.
-
Stale sources - If a source changed since the manifest was generated, apply refuses before transferring anything and names what changed. When the refusal names the stale files in full, it hands them back as the command to re-observe just those:
Error: 2 sources have changed since manifest was generated: /Volumes/Photos/2024/img_0042.jpg: size: 4211 → 4230, mtime: 1787482715 → 1787482733 /Volumes/Photos/2024/img_0043.jpg: mtime: 1787482715 → 1787482733 Re-observe just these files, then refresh: canon scan /Volumes/Photos/2024/img_0042.jpg /Volumes/Photos/2024/img_0043.jpg canon cluster refresh trip.toml If more than these has changed, run `canon scan` then `cluster refresh` to regenerate the lock file. Error: Aborting due to stale sources in manifestPast ten stale files the listing truncates and the whole-root remedy is offered alone. The same two lines answer both staleness conditions: when only the lock is behind,
cluster refreshdoes the work and the scan finds nothing to do; when the files changed on disk since the last scan, the scan is what brings the database current for the refresh to read. A stale file that was deleted meanwhile is skipped bycanon scanwith a warning; assert the deletion withcanon scan --missingand refresh, and it leaves the lock.
Edit the manifest’s [output] section to customize the destination:
[output]
pattern = "{content.DateTimeOriginal|year}/{content.DateTimeOriginal|month}/{filename}"
base_dir = "/path/to/archive"
Pattern variables use fact keys with optional modifiers (see Pattern Expressions for the full syntax):
{filename},{stem},{ext}- Filename aliases{hash},{hash_short}- Content hash aliases{source.mtime|year},{source.mtime|month}- File modification date{content.DateTimeOriginal|year}- EXIF date with modifier{content.Make},{content.Model}- Any fact key
Recovering from interrupted apply:
If apply is interrupted or encounters errors:
- Fix any reported errors (permissions, disk space, etc.)
- Delete any partial files in the archive (files with wrong sizes from interrupted copies)
- Re-run with
--resume:canon apply manifest.toml --resume
Resume mode’s classification and its handling of “resumed” and partial files are described above.
If source files changed during apply, refresh the manifest first:
canon scan <source-paths>
canon cluster refresh manifest.toml
canon apply manifest.toml
Provenance
Every apply is recorded as a decision, with a receipt listing every file transferred, written under the archive root’s .canon-ledger/ (placement and per-item contents: Receipts). Add --reason to record why; when you don’t, the manifest’s # === Notes === section becomes the reason automatically. The global --no-receipt flag skips the receipt file for one invocation.
An apply is also indexed by what it drew out of each source root: the extraction ledger. At a source location afterwards, canon trail shows what was archived out of that place and whether the originals remain; canon trail show <id> gives the full per-root breakdown and the receipt’s location on disk.
Maintenance
Commands for cleaning up and maintaining Canon’s database.
facts delete and prune delete data from the database (never from disk) and are
dry-run by default; use --yes to execute. ledger reindex writes by default: it
rebuilds an index by writing rows back from receipts on disk; use --dry-run to
preview instead.
facts delete- Remove incorrect or unwanted metadataprune- Clean up stale, orphaned, or excluded dataledger reindex- Rebuild the extraction ledger from receipts on disk
canon facts delete
Delete facts by key. Useful for removing incorrect or unwanted metadata.
# Preview deletion (dry-run by default)
canon facts delete content.mime --on object
canon facts delete content.Make --on source /path/to/photos --where 'source.ext=jpg'
# Execute deletion
canon facts delete content.mime --on object --yes
--on sourceor--on objectis required to specify entity type- Protected namespaces (
source.*) cannot be deleted - Dry-run by default; use
--yesto execute - The population is the one a matching
canon lswould list at the same scope: excluded sources and archive copies are not reached, so a deletion never goes past what you can preview. Inside an archive root, archive sources are in view and are reached, the same way a read there sees them
canon prune
Clean up orphaned or stale data from the database.
# Preview stale facts (file changed since fact was recorded)
canon prune --stale-facts
# Preview orphaned objects (no present sources reference them)
canon prune --orphaned-objects
# Preview facts for excluded sources/objects
canon prune --excluded-facts
canon prune --excluded-facts=source # Only source facts
canon prune --excluded-facts=object # Only object facts
# Execute deletion
canon prune --stale-facts --yes
canon prune --orphaned-objects --yes
canon prune --excluded-facts --yes
Stale facts are facts whose observed_basis_rev no longer matches the source’s
current basis_rev (the file was modified after the fact was imported).
Orphaned objects are content entries with no remaining present sources. This can happen when files are deleted. They can serve as a historical record; pruning them frees database space.
Excluded facts are metadata for sources or objects marked as excluded. Pruning them frees database space.
All prune operations are dry-run by default. Add --yes to execute.
canon ledger reindex
Rebuild the extraction ledger from apply receipts already on disk. The ledger is the aggregate index behind canon trail’s “what left from here?” lines.
# Preview what would be indexed
canon ledger reindex --dry-run
# Rebuild
canon ledger reindex
When to run it
- After upgrading to a Canon that records placements at directory precision: applies indexed by an older Canon are known only to a coarse common prefix (visible at that prefix and above, silent in deeper views). One reindex rebuilds them at full precision from their receipts.
- After restoring a database from an older backup: recent
applydecisions may be missing their extraction rows even though their receipts survived on disk. - After manually clearing or losing rows in
decision_extractions. - As a periodic check: it is idempotent and safe to run anytime; decisions already indexed converge to the same rows rather than duplicating.
It never touches receipts, decision records, or any other table; only decision_extractions. It writes no decision row of its own: rebuilding an index is not a content decision, so the printed report is the only record of the run.
What it does
reindex walks every apply decision and, for each one, tries to read its receipt:
- The decision records no receipt location (
--no-receipt, receipts off for that run, or a receipt whose write never completed) → reported as no receipt: nothing to recover from the row. The reason states what the row shows, not why — the row cannot tell those cases apart. - A receipt was recorded but isn’t reachable right now (its root is gone, offline, or the file itself is missing) → reported as unreachable, distinct from “no receipt”: nothing is concluded from not being able to check today, and it is retried on the next run.
- The receipt reads but fails an integrity check (bad TOML, a decision id that doesn’t match) → reported as malformed, skipped.
- The receipt reads cleanly → its items are aggregated into extraction rows, the same way a live
applydoes. If some item’s source root is no longer recognized, that root is reported separately as a partial-index gap rather than silently dropped.
Every decision lands in exactly one bucket; the report is never silent about what it couldn’t do.
$ canon ledger reindex
Ledger reindex: extraction index
Scanned 214 apply decisions.
indexed: 182 decisions (317 rows)
already current: 24
no receipt: 6
#12 no receipt location recorded
#31 --no-receipt
unreachable: 2
#87 root path not present (offline?): /Volumes/archive-b
#103 destination root #9 no longer known
malformed: 0
Unreachable receipts are retried on the next run.
--dry-run prints the same report with “would index” phrasing and writes nothing.
Exit status
Exits nonzero only when nothing at all could be processed: every scanned decision landed in no receipt/unreachable/malformed and nothing was indexed. A run that indexes at least one decision, even alongside gaps, exits 0; gaps are expected and self-explaining, not failure.
Facts Reference
Facts are key-value metadata. See Concepts: Facts for an overview.
Namespaces
| Namespace | Description |
|---|---|
source.* | Facts about the file on disk (path, size, mtime) |
content.* | Facts about the content (hash, EXIF, mime type) |
object.* | Object-level properties |
The content. prefix is optional when querying. For example, Make=Apple is equivalent to content.Make=Apple.
Values
Facts can hold three value types:
| Type | Examples | Notes |
|---|---|---|
| Text | "Apple", "image/jpeg" | Strings; quote if contains spaces |
| Number | 1024, 3.14, -5 | Integers or decimals |
| Timestamp | 1704067200 | Unix timestamps; enable date modifiers |
Modifiers
Transform values using | syntax:
Time Modifiers
For timestamp values (like source.mtime or EXIF dates):
| Modifier | Output | Example |
|---|---|---|
year | 4-digit year | 2024 |
month | 2-digit month | 07 |
day | 2-digit day | 23 |
hour | 2-digit hour (24h) | 14 |
minute | 2-digit minute | 30 |
second | 2-digit second | 45 |
date | ISO date | 2024-07-23 |
time | ISO time | 14:30:45 |
datetime | ISO datetime | 2024-07-23T14:30:45 |
yearmonth | Year-month | 2024-07 |
week | ISO week number | 30 |
weekday | Day of week (Mon=1) | 2 |
quarter | Quarter (1-4) | 3 |
String Modifiers
| Modifier | Description | Example |
|---|---|---|
lowercase | Convert to lowercase | JPG → jpg |
uppercase | Convert to uppercase | jpg → JPG |
capitalize | Capitalize first letter | apple → Apple |
stem | Filename without extension | photo.jpg → photo |
ext | File extension | photo.jpg → jpg |
short | First 8 characters | abc123def456 → abc123de |
Numeric Modifiers
| Modifier | Description |
|---|---|
bucket | Group into ranges (1-10, 10-100, etc.) |
bucket(a,b,c) | Custom ranges (<a, a-b, b-c, >c) |
Example: source.size|bucket groups file sizes into human-readable ranges.
Path Accessors
Python-style indexing for path values:
| Syntax | Meaning |
|---|---|
key[-1] | Last segment (filename) |
key[0] | First segment |
key[1:3] | Slice segments 1 and 2 |
key[:-1] | All but last segment |
Accessors can be combined with modifiers:
source.rel_path[-1] → IMG_001.jpg
source.rel_path[-1]|stem → IMG_001
source.rel_path[0] → photos
See Also
- Built-in Facts - Complete list of automatic facts
- Filters - Using facts in queries
- Pattern Expressions - Using facts in archive patterns
Built-in Facts Reference
These facts are automatically available for all sources without enrichment.
Source Facts
| Fact | Type | Description |
|---|---|---|
source.id | num | Database ID (hidden*) |
source.ext | text | File extension (lowercase, no dot) |
source.size | num | File size in bytes |
source.mtime | time | Modification timestamp |
source.path | path | Full absolute path |
source.root | path | Root directory path (hidden) |
source.rel_path | path | Path relative to root (hidden) |
source.device | num | Device ID (hidden) |
source.inode | num | Inode number (hidden) |
Content Facts
| Fact | Type | Description |
|---|---|---|
content.hash.sha256 | text | SHA-256 content hash |
Pattern Aliases
These aliases are available in pattern expressions:
| Alias | Expands To |
|---|---|
filename | source.rel_path[-1] |
stem | source.rel_path[-1]|stem |
ext | source.rel_path[-1]|ext |
hash | content.hash.sha256 |
hash_short | content.hash.sha256|short |
id | source.id |
*Hidden facts are not shown in canon facts by default. Use --all to include them.
Filter Syntax
Filters select sources based on facts using a boolean expression language. Most commands accept --where to filter which sources they operate on. Multiple --where flags are combined with AND.
Operators
Basic
| Syntax | Meaning |
|---|---|
key? | Fact exists |
key=value | Fact equals value (case-sensitive) |
key!=value | Fact doesn’t equal value (case-sensitive) |
key~pattern | Glob pattern match (case-sensitive) |
key!~pattern | Glob pattern doesn’t match |
key>value | Greater than (numbers/dates) |
key>=value | Greater or equal |
key<value | Less than |
key<=value | Less or equal |
key IN (v1, v2, ...) | Fact matches any value in list |
key NOT IN (v1, v2, ...) | Fact doesn’t match any value in list |
Glob Patterns
The ~ operator supports shell-style glob patterns:
| Pattern | Meaning |
|---|---|
* | Match zero or more characters |
? | Match exactly one character |
[abc] | Match any character in set |
[a-z] | Match character range |
[!abc] | Match any character NOT in set |
\* | Literal asterisk (escape) |
# Files starting with IMG_
--where 'filename~IMG_*'
# Files with 3-letter extension
--where 'source.ext~???'
# Files in a year subdirectory
--where 'source.rel_path~*/2024/*'
# Exclude temp files
--where 'filename!~*.tmp'
Values after operators like ~, =, != accept most characters without quoting, including /, -, ?, *, [, ]. Quoting (single or double) is still supported for values containing spaces or parentheses.
Status Predicates
Status predicates check computed state: whether a source is in a particular condition, rather than the value of a stored fact. They use the same syntax as fact-existence checks (key?) but evaluate differently.
| Predicate | True when |
|---|---|
archived? | Content exists in at least one archive root |
hashed? | Content hash has been computed |
excluded? | Source or object is excluded |
enriched? | Has any stored metadata beyond the content hash |
Status predicates are boolean-only: they work with ? and NOT ... ? but not with comparison operators.
# What still needs archiving?
canon facts --key mime --where 'NOT archived?'
# Survey unresolved content
canon survey --where 'NOT archived?'
# Unidentified files
canon worklist --where 'NOT hashed?'
# Combine status predicates with fact filters
canon ls --where 'NOT archived? AND hashed? AND mime~image/*'
# View excluded sources (requires --include)
canon ls --include excluded --where 'excluded?'
Key distinctions:
archived?vscontent.hash.sha256?:archived?asks whether the content is in an archive;content.hash.sha256?asks whether that fact value exists.archived?is the canonical way to check archive status.hashed?vscontent.hash.sha256?: Equivalent results, different paths.hashed?is the idiomatic form.NOT archived?includes unhashed sources (they are not archived). UseNOT archived? AND hashed?to exclude unhashed sources.- The set of status predicates is closed: these four, not extensible by users.
Boolean Operators
| Syntax | Meaning |
|---|---|
expr AND expr | Both conditions must match |
expr OR expr | Either condition matches |
NOT expr | Negates the condition |
(expr) | Grouping for precedence |
Operator precedence (highest to lowest): NOT, AND, OR. Use parentheses to override.
Aliases
You can define named aliases in $CANON_HOME/aliases.toml (by default ~/.canon/aliases.toml). There are two kinds of aliases; Canon classifies each automatically by parsing its value:
Expression Aliases
Shorthand for complete filter predicates. These are values that contain an operator (like =, >, IN, etc.):
image = "content.mime IN ('image/jpeg', 'image/png', 'image/gif', 'image/tiff', 'image/webp', 'image/heic')"
video = "content.mime IN ('video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska')"
tens = "source.mtime|year >= 2010 AND source.mtime|year < 2020"
large = "source.size > 10000000"
Expression aliases are wrapped in parentheses when expanded, so boolean logic inside them composes safely:
canon ls --where '@image AND @tens'
# Expands to: (content.mime IN (...)) AND (source.mtime|year >= 2010 AND source.mtime|year < 2020)
Key Aliases
Shorthand for verbose key paths: accessors, modifiers, and namespaces. These are values that are just a key (no operator):
filename = "source.rel_path[-1]"
parent = "source.rel_path[-2]"
ext = "source.ext|lowercase"
year = "source.mtime|year"
taken = "content.DateTimeOriginal"
yearmonth = "content.DateTimeOriginal|yearmonth"
Key aliases are substituted literally and used with operators in your filter:
canon ls --where '@filename = "photo.jpg"'
# Expands to: source.rel_path[-1] = "photo.jpg"
canon ls --where '@yearmonth >= 202301'
# Expands to: content.DateTimeOriginal|yearmonth >= 202301
canon ls --where '@ext = "jpg" AND @year >= 2020'
# Expands to: source.ext|lowercase = "jpg" AND source.mtime|year >= 2020
Using Aliases
Reference aliases with @name in any --where expression:
# Expression alias standalone
canon ls --where '@image'
# Compose expression aliases
canon ls --where '@image OR @video'
# Key alias with operator
canon ls --where '@filename ~ "IMG_*"'
# Mix both kinds
canon ls --where '@image AND @year >= 2020'
# Negate an expression alias
canon ls --where 'NOT @large'
How Classification Works
Canon determines whether each alias is a key or an expression by parsing the value. If the value is a valid filter expression (contains an operator), it’s an expression alias and gets wrapped in parentheses. If not (it’s just a key path), it’s a key alias and gets substituted literally.
Rules:
- Alias names must start with a letter and can contain letters, digits, underscores, and hyphens
@inside quoted strings is treated as a literal character, not an alias reference- Nested aliases are not supported (
@in alias values is literal) - The aliases file is only loaded when
@appears in a--whereargument - If the file doesn’t exist and no
@aliases are used, no error is raised
Using Modifiers
Modifiers can be applied to fact keys using the | syntax. See Facts for the complete list.
# Files from 2024
--where 'source.mtime|year=2024'
# January photos
--where 'content.DateTimeOriginal|month=1'
# Case-insensitive extension matching
--where 'source.ext|lowercase=jpg'
# Case-insensitive glob
--where 'filename|lowercase~img_*'
Examples
# Files with a content hash
--where 'content.hash.sha256?'
# Files missing a content hash
--where 'NOT content.hash.sha256?'
# JPG files only
--where 'source.ext=jpg'
# JPG or PNG files
--where 'source.ext=jpg OR source.ext=png'
# Common image formats
--where 'source.ext IN (jpg, png, gif, webp)'
# Exclude certain extensions
--where 'source.ext NOT IN (tmp, bak, log)'
# Not temporary files
--where 'NOT source.ext=tmp'
# iPhone photos (content. prefix is optional)
--where 'Make=Apple'
# Files larger than 1MB
--where 'source.size>1000000'
# Files modified in 2024 or later
--where 'source.mtime>=2024-01-01'
# Large images (combining with parentheses)
--where '(source.ext=jpg OR source.ext=png) AND source.size>1000000'
# Multiple --where flags combine with AND
--where 'source.ext=jpg' --where 'content.Make=Apple'
Pattern Expressions
Pattern expressions define how files are organized in archives. They use {expr} syntax to insert dynamic values based on facts.
Patterns are used in the pattern field of cluster manifests. When you run canon cluster generate, it creates a manifest with a default pattern = "{filename}" that you can customize.
Basic Syntax
Patterns consist of literal path segments and expressions in curly braces:
{content.DateTimeOriginal|year}/{content.DateTimeOriginal|month}/{filename}
This produces paths like: 2024/07/IMG_001.jpg
Fact Keys
Any fact key can be used in a pattern:
{source.ext}- File extension{source.mtime}- Modification time{content.Make}- Camera manufacturer (from EXIF){content.hash.sha256}- Content hash
The content. prefix is optional for content facts, so {Make} is equivalent to {content.Make}.
{scope.rel_path} is not a fact: it is the source’s path below the vantage — the deepest directory containing every scope the manifest records that lies in that source’s own root. With one scope the vantage is that scope. With several, it is the directory they share, so each scope’s own name survives in the result. Scopes in different roots each get their own vantage. cluster generate records the paths it was scoped to; where the manifest records no scope, or none in a source’s root, the pattern is refused rather than guessed.
Modifiers
Transform values using the | syntax. See Facts for the complete list.
{source.mtime|year} → 2024
{source.mtime|yearmonth} → 2024-07
{content.hash.sha256|short} → a1b2c3d4
{source.ext|uppercase} → JPG
Multiple modifiers can be chained:
{filename|stem|lowercase} → img_001
Path Accessors
Extract segments from path values using Python-style indexing:
| Syntax | Meaning |
|---|---|
key[-1] | Last segment (filename) |
key[0] | First segment |
key[1:3] | Slice segments 1 and 2 |
key[:-1] | All but last segment |
Examples with source.rel_path = "photos/2024/vacation/IMG_001.jpg":
{source.rel_path[-1]} → IMG_001.jpg
{source.rel_path[0]} → photos
{source.rel_path[1:-1]} → 2024/vacation
{source.rel_path[-1]|stem} → IMG_001
Aliases
Aliases provide shorthand for common expressions. Use canon facts --show-aliases to see all available aliases.
| Alias | Expands To |
|---|---|
filename | source.rel_path[-1] |
stem | source.rel_path[-1]|stem |
ext | source.rel_path[-1]|ext |
hash | content.hash.sha256 |
hash_short | content.hash.sha256|short |
id | source.id |
Example using aliases:
{hash_short}_{filename} → a1b2c3d4_IMG_001.jpg
Missing Values
Canon requires all facts used in a pattern to have values for every source. If any source is missing a required fact, canon apply refuses to proceed and reports which facts are missing.
When you run canon cluster generate, the manifest includes comments listing all facts with 100% coverage. These are safe to use in your pattern.
If sources are missing required facts, you can:
- Filter them out during generation:
--where 'DateTimeOriginal?' - Import the missing facts via the enrichment pipeline
Common Patterns
# Preserve structure below the manifest's scope
pattern = "{scope.rel_path}"
# Preserve structure below each source's root
pattern = "{source.rel_path}"
# Flat (all files in one directory)
pattern = "{filename}"
# By EXIF capture date
pattern = "{content.DateTimeOriginal|year}/{content.DateTimeOriginal|month}/{filename}"
# By date with hash prefix (collision-safe)
pattern = "{content.DateTimeOriginal|date}/{hash_short}_{filename}"
# By camera
pattern = "{content.Make}/{content.Model}/{filename}"
# By file type and year
pattern = "{source.ext}/{source.mtime|year}/{filename}"
The Book Format
When a root is retired, its complete story is compiled
into the book: a directory designed to outlive Canon. Everything in it is plain
text in stable formats, so you can read a book decades later with ls, a text
editor, and nothing else. This page is the format contract.
A book directory contains:
photos-backup-2026-08-02/
├── README.md the human entry point — start here
├── story.md the story as told, written at the retirement
├── inventory.jsonl every source the root ever had, with fates
├── timeline.md every decision that touched the root, with reasons
├── notes.md the notes, bound beside the timeline
├── ledger/ the receipts that lived on the drive (absent when it kept none)
└── meta.toml identity, account, counts, gaps — machine-readable
README.md
The rendered summary a person can just read: the root’s identity (path, role, comment, scan history, the retirement reason), the resolution account, the verification posture, a guide to the other files, and the gaps, meaning anything this book should hold but doesn’t.
It names story.md as the way in, and carries the mapping from the story’s plain
words to Canon’s own:
| In the story | In Canon |
|---|---|
| chosen for the archive | archived |
| let go | excluded |
| preserved by copies in the archive | covered |
| no known copy in the archive | unresolved |
| empty file | contentless |
| returned to consideration | restored |
story.md
The story as told: the same reading canon roots story renders live, written for someone with no Canon
and no memory of the place. What was on it, what was chosen for the archive and where
it lives now, what was let go and why, in plain words.
The page runs in a fixed order:
- An opening that orients the reader: what this document is, and its date.
- A short explanation of how to read the entries.
- The places, in full. A bound story is never capped.
- A tally of where everything went.
- The gaps, stated in prose: what was left open on purpose, seen and weighed and accepted.
- A last page.
The tally’s lines can sum past its total, because a file copied to the archive and later dismissed here belongs to two of them. Where that happens the story states by how much, so the tally reads as overlapping registers rather than as a partition.
The story names itself one telling of the record. Another telling could be drawn from the facts beside it; this is the one written at the retirement.
The ceremony invites a foreword: your own words about the whole place, bound
verbatim above Canon’s narration. It also offers the entire page to your editor
before it binds. A hand-refined telling is marked in meta.toml (hand_edited); the
inventory and counts beside it stay the machine-verified record either way.
inventory.jsonl
One JSON object per line, one line per source, sorted by path. The sort order is the tree structure, so a future reader or tool can browse the retired root without any index. Fields:
| Field | Presence | Meaning |
|---|---|---|
path | always | Path relative to the root |
size | always | Bytes |
mtime | usually | Modification time, ISO-8601 UTC (2015-06-12T09:30:00Z); absent only on entries recovered from receipts that predate per-item mtimes |
hash | where known | Content hash with algorithm prefix (sha256:…) |
fate | always | What happened to this source; see the vocabulary below |
decision | where recorded | The fate-determining decision: for archived, the apply; for excluded/deleted, the decision that stamped it; for the standings, the source’s most recent recorded transition. Cross-references the timeline’s #N and the NNNNNN-command.toml receipt filenames, in the gathered ledger/ for drive-local receipts and in the archive’s live ledger for apply and exclusion receipts |
verification | always | content_verified (hashed) or name_only (listed by name, never content-verified) |
disposition | archived only | moved or copied; absent when the record predates the vocabulary — omitted, never guessed |
destination | archived only | The recorded destination of the apply, readable without Canon |
locations | where known | Archive paths holding this content at compile time (the live tier; destination is the recorded fallback). Zero-byte sources carry no location lists: every empty file shares the one empty-content object, so the list would answer nothing about this file |
reason | where recorded | The user’s reason on the excluding or deleting decision |
Fate vocabulary
archived— archived from here: an apply receipt names this path as an origin. Carries the recordeddestinationand, where resolvable, currentlocations. Sources moved into the archive keep an inventory entry even though the root no longer holds a record of them; these entries are recovered from the apply receipts.covered— content verified present in the archive (by hash), archived from elsewhere, or archived from here when no receipt survives to say so (recorded as a gap).excluded— consciously dismissed, with the recorded reason. When the content is also archived, the archive locations appear as context, so both truths are carried.deleted— a scan observed the loss; the recorded reason where present.present— present at retirement, with none of the above recorded.contentless— empty at retirement (zero bytes). An empty file has no content to identify, so the entry claims neither covered nor unresolved. (Added within version 1, 2026-08-04; books bound earlier contain no such entries and recorded empty files ascovered.)missing_unexplained— absent without a recorded deletion, carried as its own fate rather than folded into another.
An entry without a hash never carries content_verified. For a root that was indexed
but never hashed, every entry is name_only.
timeline.md
Every decision that touched the root, oldest first: date, decision id, command, the decision’s summary as Canon printed it at the time, and the user’s reason beneath. Global decisions, which touch the whole universe rather than this root specifically, are counted at the end rather than listed. The retirement’s own in-flight decision is absent, because the book is compiled before the release completes and that decision has nothing to report yet; the retirement’s facts live on the identity page instead. A prior retirement attempt, bound but not released, is history and is listed.
notes.md
Every note on the root, oldest first, with its location: the thinking between the actions. Removal deletes notes from the index, and binding them here is what keeps them.
ledger/
The receipts that lived on the drive itself: the root’s own .canon-ledger/, copied
verbatim with filenames and timestamps preserved, so previous_decision_id chains
inside the receipts remain walkable from disk into the book, without Canon. By the
receipt placement principle a source root’s ledger only ever holds deletion
receipts, the record of what was lost there. The receipts behind archiving and
letting-go decisions live in the archive’s own ledger (.canon-ledger/ at the
archive root), beside the content they concern; the book points there rather than
copying them, since the story and timeline already carry every decision in full.
When the drive kept no receipts of its own, no ledger/ directory is written and the
README states the absence. If the drive was unreachable at compile time, the
directory is likewise absent and the gap is recorded in meta.toml and the README.
meta.toml
The machine-readable half, version = 1:
gaps— every self-explaining gap: unreadable receipts (per-item origin degraded tocovered), an ungathered ledger, and so on. An empty list asserts that nothing this book should hold is missing from it.[identity]— path, role, comment, suspension,first_indexed(when the earliest surviving row was first indexed — row evidence, honest on roots older than decision recording), last scan,compiled_at, the user’s reason,decision_id(the retirement decision that bound this book — the id the trail and the index reference this retirement by, readable from the book alone; absent when the ceremony ran with recording off — omitted, never guessed), and the Canon version that wrote the book.[account]— the resolution account in counts: the story so far (archived files and bytes with the moved/copied split, deleted, unexplained missing) and the standing at binding (archived_standing— archived from here with the copy still standing,covered,excluded,contentless,unresolved; the first and the two additions arrived within version 1, 2026-08-04). Bytes and derived totals are omitted when the record cannot support them — never guessed.[posture]—scan_verifiedoron_faith(with the reason: suspended, unreachable, or never scanned) and the last scan time.[counts]— entry totals per fate (includingcontentless, added within version 1; absent in earlier books, read as zero). These are the verification anchor: Canon’s structural check recounts the inventory and compares against them before any removal proceeds.[ledger]— whether the drive-local ledger was gathered, and how many files.[story]— the telling’s claim: its file (story.md), whether it was hand-refined at the binding (hand_edited), and the reading settings that shaped it (the place-map calibration constants). Verification requires the claimed file to exist and hold text; the prose itself is never recounted, and the inventory and[counts]remain the verification anchor. Absent in books bound before the telling (added within version 1, 2026-08-05); such books verify unchanged.
Versioning
version in meta.toml identifies the format. This page describes version 1. Fields
may be added within a version; existing fields keep their meaning. A future Canon
refuses to verify a book of a newer version than it knows. The book itself stays
readable either way.