Documentation
AFTRIMGE architecture
This document describes what exists. Nothing here is aspirational; if a behaviour is not implemented it is either absent from this document or explicitly marked as not implemented.
1. The shape of the thing
The valuable asset is the local temporal engine. The interface is a view over it and holds no domain logic: it cannot read a file, resolve a path, or decide what a filesystem event means.
watched folder
│
┌──────────────────────┼──────────────────────────────────────────┐
│ watcher/ ▼ │
│ manager OS watcher (notify), one thread per project │
│ normalizer platform events → internal signals │
│ ignore scratch, lock and tooling files dropped │
│ rename_pairing two halves of a rename joined by cookie │
│ debouncer wait until the file stops changing │
├──────────────────────┼──────────────────────────────────────────┤
│ store/ ▼ │
│ hasher SHA-256 of the settled content │
├──────────────────────┼──────────────────────────────────────────┤
│ analysis/ ▼ │
│ ingest did anything actually change? what is it? │
├──────────────────────┼──────────────────────────────────────────┤
│ store/ ▼ │
│ object_store content-addressed, deduplicated bytes │
├──────────────────────┼──────────────────────────────────────────┤
│ database/ ▼ │
│ projects files snapshots events objects (SQLite, WAL) │
├──────────────────────┼──────────────────────────────────────────┤
│ replay/ ▼ │
│ timeline state export read-only views over the record │
├──────────────────────┼──────────────────────────────────────────┤
│ ipc/ ▼ │
│ commands state Tauri commands + pushed events │
└──────────────────────┼──────────────────────────────────────────┘
▼
React
Dependency arrows point one way only. core/ sits underneath everything and depends on nothing inside AFTRIMGE. ipc/ sits on top and is the only module that knows Tauri exists. The engine talks outward through a trait (core::ActivityEmitter), which is why the entire pipeline is testable without a desktop app (see tests/acceptance.rs, which drives the real OS watcher with a plain struct in place of Tauri).
Module map
| Path | Responsibility |
|---|---|
core/models.rs | The domain vocabulary. No filesystem or SQL types. |
core/config.rs | Engine tuning in one place, not scattered constants. |
core/paths.rs | Data directory layout; project-relative path keys. |
core/emitter.rs | The outbound port the engine notifies through. |
error.rs | One error type, serialisable across IPC, with a stable code. |
store/hasher.rs | Streaming SHA-256. |
store/object_store.rs | Content-addressed storage and deduplication. |
database/ | Connection, migrations, and one module per table. |
watcher/ | Everything between the OS and a settled fact. |
analysis/ingest.rs | The snapshot engine: decide and record. |
replay/ | Timeline, past-state reconstruction, media, preview and export. |
replay/media.rs | What a snapshot is, and thumbnail generation. |
replay/evolution.rs | Lanes, metrics and the relationship graph. |
analysis/image.rs | Deterministic image measurement and diff rendering. |
analysis/text.rs | Deterministic line diffing. |
analysis/compare.rs | Resolves a snapshot pair and caches the answer. |
analysis/thresholds.rs | Every number that turns a measurement into a word. |
core/safety.rs | The vocabulary of returning safely: plans, conflicts, policies. |
restore/plan.rs | What a restore would do. Writes nothing. |
restore/apply.rs | The only code that writes into a user's folder. |
maintenance/retention.rs | What stops being retained. Never deletes history. |
maintenance/gc.rs | Reclaiming content nothing points at. |
replay/search.rs | Finding a file by any name it has ever had. |
ipc/ | Command surface, the Tauri emitter, and the media scheme. |
ipc/protocol.rs | The aftrimge:// scheme that carries past bytes. |
2. Watcher flow
2.1 Threads
One thread per watched project, spawned by watcher::manager::WatcherManager. The notify callback does nothing except forward the event down a channel, because a slow callback drops events on every platform. All real work (stat, hash, copy, SQL) happens on the driver thread, never on the UI thread.
notify callback ──channel──▶ driver thread ──▶ ingest ──▶ emitter ──▶ React
(must be fast) (owns tracker, pairer, does the work)
The driver's wait is bounded by the soonest pending deadline, clamped to 20–250 ms, so it neither spins nor oversleeps past a due snapshot.
2.2 Normalisation
watcher/normalizer.rs flattens notify::Event into six signals:
| Signal | Produced by |
|---|---|
Touched(path) | create, data/metadata modify, undirected name change, unknown kinds |
Removed(path) | remove |
RenamedPair{from,to} | Modify(Name(Both)), both halves in one event |
RenamedFrom{path,tracker} | Modify(Name(From)) |
RenamedTo{path,tracker} | Modify(Name(To)) |
Rescan | the watcher's overflow flag: events were dropped |
Raw notify types never leave this module. Anything the OS could not disambiguate becomes a Touched, which the stability layer resolves by actually looking at the disk: the honest answer rather than a guess.
Access(_) events are dropped: reading a file is not history.
2.3 Ignore rules
watcher/ignore.rs operates on the project-relative, forward-slashed key, so behaviour is identical on all platforms. It drops:
- hidden entries at any depth (
.git/,.vscode/,.hidden.png) - tooling directories (
node_modules,target,__pycache__,$RECYCLE.BIN, …) - OS noise (
.DS_Store,Thumbs.db,desktop.ini) - lock and scratch prefixes (
~$Office,.~LibreOffice,.#Emacs) - in-progress suffixes (
.tmp,.part,.crdownload,.swp,~) - Adobe-style recovery scratch (
*.sb-*)
2.4 The stability layer
Filesystem events are noisy. One save in a creative application typically looks like:
MODIFY MODIFY MODIFY CREATE tmp RENAME DELETE tmp MODIFY
Snapshotting each of those would produce six versions of one save, several of a half-written file. Instead (watcher/debouncer.rs):
change detected
│
▼
mark path pending, deadline = now + stability window (900 ms)
│
├── another signal that changes size or mtime → reset the deadline
│
├── another signal that changes nothing → deadline stands
│ (a chatty watcher must not defer a snapshot forever)
│
▼
deadline elapses → probe the path once more
│
├── same size and mtime as last seen → STABLE, hand to ingest
├── different → reset the deadline, keep waiting
└── gone → fall through to the deletion hold
A deletion is held for delete_settle (700 ms) before being believed, because an atomic save and a rename both begin by making a path disappear. If the path comes back within that window it is a modification, not a deletion.
A file that is genuinely still being written, a long video export, stays pending until it stops growing. Waiting is correct: nothing is lost, and a snapshot of a half-written render would be a lie.
StabilityTracker holds no filesystem or database handles. It is driven by Instant values supplied by the caller, which is why the timing behaviour is covered by ten deterministic unit tests instead of sleep-based guesswork.
2.5 Renames
Three mechanisms, in descending order of certainty. None of them guess.
RenamedPair: the OS gave both halves in one event. Definitive.- Cookie pairing (
watcher/rename_pairing.rs): Linux and Windows report a rename as two events linked by a tracker id. Each half is held briefly and matched on that id. Halves with no tracker id are never paired with each other, however suggestive the timing; they expire into a plain removal and a plain touch. - Content match (
analysis/ingest.rs): a deletion is not written immediately. It is staged forrename_correlation(3 s). If byte-identical content appears at a new path inside that window, the file keeps its identity and the event is recorded with evidenceCONTENT_MATCH, which the interface labelsINFERRED. This is the macOS path, and the cross-directory-move path everywhere.
If nothing claims a staged departure, it becomes a real DELETE, but only after one final check that the path has not reappeared.
A directory rename relocates every identity beneath it in one pass (files::list_active_under).
2.6 Reconciliation
A full tree walk, run when a watcher starts and whenever the OS reports it dropped events. It:
- records files that are new or changed since the database last saw them,
- stages deletions for tracked files that are no longer on disk,
- stamps everything with evidence
SCAN.
This is what makes history survive the app being closed. Because the moment of a scan-discovered change was not witnessed, its timeline position is the file's own mtime (clamped to now), and the SCAN label tells the user the timing is the filesystem's claim rather than an observation.
3. Snapshot flow
settled path
│
▼
stat: size + mtime
│
├── size and mtime both match the last snapshot? ──▶ STOP. Nothing changed.
│ (this is why a 4 GB file is not re-read on every metadata event)
▼
stream SHA-256 over the file (64 KiB buffer)
│
├── hash matches the last snapshot? ──▶ STOP. No version, no event.
│ (this is what makes twenty identical saves cost nothing)
▼
put bytes in the object store
│ ├── object already exists → deduplicated, nothing written
│ └── otherwise → staged in objects/.tmp, then renamed into place
│ (bytes are committed before the row, so a crash can only leave an
│ unreferenced object, never a snapshot pointing at nothing)
▼
one transaction:
upsert objects ledger · insert snapshot · mark file seen · insert event
▼
emit to the interface
Files above max_object_bytes (256 MB) are still hashed and still produce a snapshot row, with stored = 0. The record stays truthful about what the content was; the bytes simply are not retained, and the interface says so rather than offering an export that would fail.
4. Object store
objects/
ab/cdef0123… first two hex characters shard, remaining 62 name the file
91/2233445566…
.tmp/ staging for atomic writes
- The full SHA-256 is the address. Identical content has one address, therefore one file on disk, therefore is stored once no matter how many snapshots point at it.
- Writes are staged then renamed, so a crash mid-copy cannot leave a truncated file at a valid content address.
- A rename collision means another writer committed identical content first. Identical content is identical, so that is a dedup hit, not an error.
usage()walks the store rather than trusting a counter, so the figure in the status bar is the real disk footprint and cannot drift.- Hash strings are validated before use as paths, so a malformed hash cannot escape the store directory.
Deleting a project does not delete objects: another project may reference the same content. The objects.ref_count ledger is maintained for the garbage collection that Phase 2 will add.
5. Database design
SQLite, WAL journal, synchronous = NORMAL, foreign keys on, 5 s busy timeout. A single connection behind a mutex rather than a pool: write volume is a handful of rows per human action, and one connection removes a whole class of lock-contention problems. Migrations are forward-only, tracked in SQLite's own user_version, each applied in its own transaction.
projects ──┬── files ──┬── snapshots ──▶ objects
│ │ ▲
└── events ─┴────────┘
projects
id, name, root_path (unique, canonicalised), created_at, updated_at, last_scan_at.
files: persistent logical identity
id, project_id, current_path, original_path, extension, created_at, last_seen_at, status (ACTIVE | DELETED).
A file is not identified by its path. It keeps its id across renames and moves; current_path is only where it happens to live now, and original_path records where it entered the project's history. Paths are stored relative to the root with forward slashes, so a project folder can be moved wholesale and a history recorded on Windows reads correctly on macOS.
A partial unique index enforces that two live files cannot occupy one path, while allowing a path to be reused after its previous occupant is deleted.
snapshots: a known version of some content
id, file_id, content_hash, size, mime_type, extension, stored, captured_at (source mtime), created_at.
created_at is the version's position on the timeline. For witnessed changes that is the moment of recording; for scan-discovered changes it is the file's mtime, and the corresponding event is marked SCAN.
objects: the content ledger
content_hash (primary key), size, ref_count, stored, created_at. Upserted, never duplicated. ref_count is what will make future garbage collection safe.
events: the temporal record
id, project_id, file_id, event_type (CREATE | MODIFY | DELETE | RENAME), timestamp, previous_path, new_path, previous_snapshot_id, snapshot_id, evidence.
previous_snapshot_id is what makes the record a chain rather than a pile: a MODIFY names the version it replaced, and a DELETE names the last version that existed.
Indexes cover the three real access patterns: a project's events by time, a file's events by time, and a file's snapshots by time.
Reading history back
replay/state.rs answers "what did this folder look like at time T" entirely from recorded facts, in one query:
- existence: the last event at or before T, excluded if it was a
DELETE - name: the last
RENAMEat or before T, elseoriginal_path - content: the last snapshot at or before T
Nothing is interpolated, and files deleted after T still appear, which is the entire point.
6. IPC boundary
The frontend never sees a filesystem API. It sends ids and receives domain types. Folder selection happens in Rust so path handling never crosses into JavaScript.
Commands
| Command | Purpose |
|---|---|
select_folder | Native folder picker, returns a path string |
create_project | Create and immediately start watching |
list_projects / get_project / rename_project / delete_project | Project management |
start_watching / stop_watching | Watcher control |
get_watch_status / list_watch_statuses | Watcher state |
rescan_project | Force reconciliation now |
get_project_events | Activity log, paged |
get_timeline | Span, events and density buckets in one round trip |
get_project_files | Tracked identities |
get_file_history | One file's snapshots and events |
get_project_state_at | The playhead: the folder as it was |
get_snapshot / get_snapshot_preview | Version detail and bounded preview |
export_snapshot | Write past bytes to a path the user picks |
get_project_stats / get_storage_stats / get_engine_info | Diagnostics |
Pushed events
| Channel | Payload |
|---|---|
aftrimge://activity | { project_id, events: TimelineEvent[] } |
aftrimge://watch-status | WatchStatus |
filesystem change → engine records it → SQLite → ActivityEmitter
→ Tauri event → React prepends to the log
→ debounced refresh of timeline, stats and reconstruction
The interface never polls. ipc::state::TauriEmitter is the only place in the codebase where the engine touches Tauri.
Errors
EngineError serialises as { code, message } with a stable machine-readable code (NOT_FOUND, CONFLICT, OBJECT_MISSING, IO, …). Nothing is silently swallowed: every path that chooses to continue rather than propagate logs why, to the rolling file in the data directory's logs/.
7. Decisions worth knowing about
Deletions are deferred, not immediate. Three seconds of latency on a DELETE buys a clean single RENAME event for every cross-directory move on platforms that do not pair rename halves. Two events describing one action would be worse than a slightly late one.
Scan-derived events use the file's mtime, not the scan time. Stacking a folder's entire import at one instant would be less true than placing each file where the filesystem says it was last touched. The SCAN evidence label is what keeps that honest.
One connection, not a pool. See §5.
ref_count is maintained but nothing is collected yet. Building the ledger now means Phase 2's garbage collection is a policy decision rather than a migration.
The size limit records without retaining. A 40 GB render should not silently consume the disk, and pretending the change never happened would be worse than recording it and admitting the bytes are gone.
8. Temporal scrubbing
Phase 1 could answer "what did this folder look like at time T". Phase 2 makes that the central interaction, which turns a correctness problem into a performance one: a drag fires pointer events at screen refresh rate, and each one is a candidate query.
8.1 The key insight
A project's state only changes at an event. Between two events, nothing about the reconstruction can differ. So every playhead position is snapped to its state key, the timestamp of the last event at or before it, and that snapped value, not the raw position, is what the engine is asked about.
playhead ──▶ stateKeyAt(timestamps, at) ──▶ cache lookup ──▶ (maybe) query
raw binary search memoised get_project_state_at
Dragging across an afternoon in which nothing happened costs zero queries. Dragging across a whole project costs exactly one query per event, ever, because results are memoised under the key.
This is why get_event_timestamps exists: a bare Vec<i64> of every event time, small enough to hold in memory. It also answers "where is the next event" for step navigation and "how long is this dead stretch" for replay.
The implementation is src/lib/temporal.ts (pure, and unit tested) plus src/hooks/useProjectState.ts (caching, request sequencing, IPC).
8.2 Not blanking
While a new state loads, the previous one stays on screen and merely recedes. A scrub that blanked the view on every step would read as broken even if it were fast. Stale responses are discarded by sequence number, so a slow earlier request can never overwrite a newer answer.
8.3 Identity, not paths
The stage keys its plates by file_id, never by path. That is what makes a rename read as one plate changing its label rather than one plate vanishing and another appearing. It is only possible because the engine maintains a persistent identity across renames.
9. Local image previews
The frontend must render real pixels out of the object store without being handed filesystem paths and without being able to request arbitrary content.
9.1 Why not IPC
Returning bytes over invoke would base64-encode a 30 MB export, copy it through a JSON string, and decode it again, on every scrub. Instead there is a custom URI scheme:
aftrimge://localhost/snapshot/<snapshot_id> full recorded bytes
aftrimge://localhost/thumb/<size>/<snapshot_id> downscaled preview
(On Windows the platform maps this to http://aftrimge.localhost/…. Rust reports the correct origin in EngineInfo.media_base_url so there is no platform detection in TypeScript.)
The webview streams these like any other resource, and gets to cache them.
9.2 The authorisation model
The only addressable thing is a snapshot id. Every request is resolved through the database; if no snapshot row has that id, nothing is served. The object store is never addressed directly, so a content hash the user was never shown cannot be fished out of it, and no path ever crosses the boundary. The route parser accepts exactly two shapes and rejects everything else.
9.3 Caching
A snapshot's bytes can never change (the id names one immutable version of one file), so responses carry Cache-Control: immutable and an ETag of the content hash. Scrubbing back and forth over a range re-renders from the webview's own cache without touching Rust at all.
9.4 Thumbnails
A folder of 40-megapixel exports must not decode half a gigabyte in the webview. replay::media::Thumbnailer decodes and downscales to a closed set of sizes (card, strip) and caches the result on disk keyed by content hash, so an identical image referenced by twenty snapshots is thumbnailed once. Images smaller than the target are never upscaled: inventing pixels would misrepresent the recorded content.
Decoding happens on a spawned thread, never on the webview's, so a large JPEG cannot stall the interface.
9.5 Honest failure
classify() decides what a snapshot is, raster, vector, text or opaque, and the interface renders accordingly. A .psd gets its recorded metadata and a plain statement that it cannot be previewed, not a generic icon implying the content is beyond reach. A missing object returns 410 Gone; an undecodable image returns 415. Neither produces a blank frame that could be mistaken for an empty file.
10. Replay
There is no separate animation timeline. Replay moves the same playhead the user drags, through the same recorded instants. Everything downstream updates because the playhead moved, exactly as it does under a hand.
10.1 Compressed, not real, time
Replaying in real time would take as long as the project took. So the recorded span is compressed to a fixed viewing duration (22 s at 1×, halved at 2×, quartered at 4×). The playhead still visits genuine instants; only its rate is chosen.
10.2 Skipping dead air
Real projects are mostly silence: an afternoon's work inside a fortnight of calendar time. Naive compression makes replay a stationary playhead punctuated by bursts. So when the stretch ahead contains no events for more than ~900 ms of playback, the playhead jumps to shortly before the next event.
It never lands past the event it is hurrying toward (the user must see the moment, not just the aftermath), and it always travels through it on a subsequent frame. Both properties are unit tested.
10.3 Yielding
Any manual interaction (a drag, a wheel zoom, a click on an event, a step button) stops playback immediately. The user's hand always wins.
Frame deltas are clamped to 64 ms so a window that was frozen in the background does not resume by leaping most of the way through the history.
11. Reduced motion
prefers-reduced-motion is honoured in three places, and nothing is disabled by it; it is only stilled:
- CSS collapses every transition and animation.
- The plate materialisation animation is not applied.
- Replay refuses to auto-animate. Scrubbing, event stepping and version navigation all still work, so no functionality is behind the preference.
12. What Phase 2 changed
Deliberately little of the engine.
| Untouched | core/ · store/ · watcher/ · analysis/ingest · database/ schema |
|---|---|
| Extended | replay/timeline.rs gained a Window; database/events.rs gained timestamps_for_project; DataPaths gained thumbnails/ |
| Added | replay/media.rs · ipc/protocol.rs · three commands (get_event_timestamps, get_snapshot_media, windowed get_timeline) |
| Rebuilt | The entire frontend above lib/ipc.ts |
state_at, the query the whole product now rests on, was not modified. It was already correct at arbitrary timestamps; Phase 2 just started asking it the right questions, often enough, and cheaply enough.
One engine-level bug was found and fixed while building this: the timeline's minimum-span guard was a full second, which silently widened any zoom narrower than that. Someone zooming into a burst of saves milliseconds apart would have been shown the wrong window. It is now one millisecond, purely as a divide-by-zero guard, as it should always have been.
13. Evolution
Phase 1 recorded what happened. Phase 2 let you stand at any moment in it. Phase 3 answers the question those two make possible: how did this become this?
Nothing in this layer writes, and nothing in it guesses. It is measurement over bytes the engine already holds, plus a small number of relationships the engine can actually justify.
13.1 Per-file lanes
replay::evolution::project_lanes returns every logical file with its events, its metrics and its aliases. Deliberately one command, not one per file: it is built from three queries plus an in-memory grouping, so a hundred-file project is one round trip rather than a hundred.
Lanes are ordered by event count, so the files that were actually worked on rise to the top and the fingerprint reads at a glance.
The interface draws each lane against the timeline's own x-axis, with a lifespan bar from first sighting to last change, stopping at the deletion for files that died. Two independent caps keep it honest at scale: rows are windowed (only lanes near the viewport exist in the DOM), and within a lane, once more than MARK_BUDGET events would land, individual marks give way to a density strip. The information survives; only its resolution drops.
Event kinds get distinct shapes, not just colours: a filled square for a creation, a tick for a modification, a diamond for a rename, a terminator for a deletion. The pattern is legible without relying on hue.
13.2 The Afterimage Trail
A file's whole life as one object. Versions overlap on a receding diagonal, oldest at the back, each one dimmer and less saturated than the one in front of it. The version in force at the playhead comes forward, sharpens and takes the signal colour.
It is bound to the global playhead in both directions: scrubbing the timeline walks the lit frame along the trail, and picking a frame moves the playhead to the moment that version was recorded.
Frames use the trail thumbnail size, which the engine caches by content hash. A twenty-version trail is twenty small cached PNGs; a version whose content appeared before is fetched once. Nothing is retained in React state; the webview's own immutable cache is the cache.
A history longer than MAX_FRAMES is sampled evenly, always keeping the first and last, and the number dropped is stated rather than hidden.
13.3 Image analysis
analysis::image measures three independent things, because no single one is honest on its own:
| Measurement | Answers | Method |
|---|---|---|
| Perceptual distance | did this become a different picture? | 64-bit dHash over an 8×9 luminance grid, compared by Hamming distance |
| Pixel change | how much of the frame moved? | both sides resampled to a common working edge, compared per pixel against a noise floor |
| Histogram intersection | did the colours move? | 32 bins per channel, normalised |
A global brightness nudge moves every pixel slightly while barely touching the perceptual hash; swapping a small logo does the opposite. So the headline 0–100 score is a documented weighted blend of the first two (65% pixel change, 35% perceptual), and the derivation is displayed beneath it. The score is never shown alone.
Alpha is composited over mid grey before comparison, so a region going transparent registers as a change instead of vanishing.
Two versions with the same content hash short-circuit entirely: no measurement is performed, because the bytes are provably identical and measuring would be theatre.
13.4 Thresholds
Every number that turns a measurement into a word lives in analysis::thresholds: one file, documented, and reported to the interface so the panel showing a verdict can also show the scale that produced it. The band boundaries are drawn on the gauge itself.
0– 2 NEGLIGIBLE below the noise floor of resampling and re-encoding
3–15 MINIMAL
16–40 MINOR
41–70 SUBSTANTIAL
71–100 MAJOR
Changing a threshold does not invalidate cached measurements: thresholds are applied at read time, not baked in. Changing what a measurement means requires bumping ANALYSIS_VERSION, which orphans every cached result.
13.5 The image diff
A real rendered difference, not an overlay. Unchanged regions ghost as a dark monochrome version of the later frame, so the picture stays readable; changed regions light up in the signal colour with intensity proportional to how far each pixel actually moved.
It is served over the existing aftrimge:// scheme as /diff/<before_id>/<after_id>, both ids resolved through the database like any other request, so the route opens no new surface. Rendering happens on a spawned thread and the PNG is cached on disk under the pair's cache key.
The measurements are symmetric; the rendered diff is not. "How different are these" has no direction, but the diff ghosts the later version, so the two orderings are different artefacts. The cache key therefore includes order, and both properties are asserted in tests.
13.6 Text evolution
analysis::text runs a Myers line diff (via similar) over any snapshot the media classifier calls text. It reports lines added, removed and replaced: a removed run meeting an inserted run at the same point is one line being rewritten, and saying so is both more useful and still exactly true.
Non-UTF-8 content is refused rather than lossily decoded: a diff of mangled text would be a diff of something the user never wrote.
13.7 Caching, and how it is invalidated
Both derived caches key on
sha256( content_hash_a | content_hash_b | ANALYSIS_VERSION )
- Content-addressed, not id-addressed. Two snapshots of two different files holding the same bytes share an entry, because the answer is the same.
- Never stale. A snapshot's bytes cannot change, so an entry cannot go wrong. There is no expiry and nothing to purge on write.
- Versioned. Raising
ANALYSIS_VERSIONorphans everything, so a change to what a number means can never leave old figures being compared with new.
Measurements go to SQLite (analysis_cache, schema v2; small, queryable, survives restart). Rendered diff images go to diffs/ on disk beside the thumbnails, and like the thumbnails they can be deleted at any time.
14. The evidence model
This is the product's central rule, and it is now a type rather than a convention.
14.1 Two axes, deliberately separate
core::Evidence grades events: how the engine came to know a change happened. It has existed since Phase 1.
replay::evolution::RelationGrade grades relationships: how much a stated connection between files is worth. It is new, and it is derived from the first rather than duplicating it.
| Grade | Means | Used for |
|---|---|---|
FACT | Proven directly by the OS or by a cryptographic hash. No reasoning involved. | OS-reported renames; two files holding byte-identical content |
INFERRED | Deduced by deterministic scoring from real but indirect evidence. Reproducible, still a deduction. | a move recovered by content match when the OS did not report one |
TEMPORAL | Known only from sequence or timing. Says when, never why. | one file appearing shortly after another changed; a deleted name being reused |
A SAME_IDENTITY relationship takes its grade straight from the underlying event's Evidence: WATCHER → FACT, CONTENT_MATCH → INFERRED. That mapping is asserted directly in the chaos test.
14.2 Kept distinct in the interface
Three visual languages, differing in weight and dash as well as colour, so the distinction survives being read without hue:
FACT ━━━━━━━━ solid, full weight
INFERRED ┄┄┄┄┄┄┄┄ dashed
TEMPORAL ········ dotted, dimmed
Relationships are grouped by grade first, never mixed into one list, and each group carries a plain statement of what its grade permits. Every strand also shows a sentence written by the engine, verbatim. That wording is where the care about not overstating actually lives, which is why the interface is not allowed to compose it.
In the lanes, an event whose evidence is anything other than WATCHER carries a small underline, so a reading of the fingerprint never mistakes an inference for something witnessed.
14.3 What Phase 3 refused to claim
There is no "derived from" relationship, no name-similarity scoring, and no attempt to link final.png to final2.png on the strength of their names. The evidence for that does not exist yet, so the claim is not made.
15. An identity bug the chaos test found
Phase 1 revived a deleted file's identity whenever anything reappeared at its path. A user who deleted final.png and later saved a completely different final.png would silently inherit the deleted file's entire lineage: an inference presented as a fact, in the one place the product least allows it.
The rule is now: a returning file continues a lineage only when its content matches a version that lineage actually held. That is cryptographic proof the same work came back. Anything else starts a fresh identity, and the fact that the path was previously occupied is surfaced as a REOCCUPIED_PATH relationship, graded TEMPORAL, saying exactly that and no more.
Fixing it exposed a second, worse bug. A file returning with unchanged content produced no new version (correctly, since nothing had changed) and therefore recorded no event at all. The last thing the record said about it stayed DELETE, so every reconstruction after that instant insisted the file was not there, while it sat on disk.
Existence is a separate fact from content. record_version_inner now takes a restored flag: a returning file always gets a CREATE event, pointing at the version it already had, even when there is nothing new to store.
Both behaviours are now asserted in tests/chaos.rs and tests/engine.rs.
16. Safe return
Phases 1–3 only ever read the user's folders. Phase 4 is the point at which AFTRIMGE starts writing into them, so it is built with more suspicion than anything else in the codebase.
The governing rule: a restore adds to history, it never rewrites it.
16.1 The write pipeline
restore::apply never writes directly at a target. Every restore goes:
read the object → and re-hash it; a corrupt store must never be
written into a user's folder
re-check the target → against the hash recorded in the plan, so a file
edited between preview and confirmation is caught
stage beside the target → same directory, so the rename is within one
filesystem and stays a rename
flush and fsync → a power loss cannot leave a directory entry
pointing at an empty file
hash the staged file → never trust that a copy worked
rename over the target
hash the result → never trust that a rename worked
record a RESTORE event
The staging file is named .<target>.<pid>.aftrimge.tmp, hidden and .tmp-suffixed, so the watcher's existing ignore rules skip it, and stamped with the process id so two restores cannot collide. A test asserts both that it shares a directory with its target and that ignore::is_ignored_file rejects it.
16.2 Atomicity, stated precisely
The replacement is std::fs::rename.
- Unix:
rename(2), which POSIX requires to be atomic. An observer sees either the old file or the new one. - Windows:
MoveFileExWwithMOVEFILE_REPLACE_EXISTING. This replaces the directory entry in one operation, but Microsoft does not contractually document it as atomic, and it fails outright if another process holds the target open.
So the honest claim is: the content is never partially written on either platform, because the bytes are fully materialised and verified before the rename happens. What differs between platforms is the strength of the guarantee about the instant of replacement, and the code says so rather than claiming atomicity everywhere.
16.3 Conflicts
Detected by hashing what is actually on disk and comparing it with the record, not by inspecting mtime, which lies.
| Conflict | Meaning | Restorable? |
|---|---|---|
NONE | disk matches the newest recorded version | yes |
TARGET_MISSING | nothing there; restoring creates it | yes |
TARGET_MODIFIED | disk matches no recorded version, edited outside AFTRIMGE | force only |
PATH_REOCCUPIED | a different logical file owns this path now | force only |
TARGET_NOT_A_FILE | something else occupies the path | force only |
CONTENT_UNAVAILABLE | the bytes were released or never retained | never |
The last one is not a conflict to override, it is an impossibility, and the type system distinguishes the two: is_recoverable() is false only for it, and the interface never offers a force button in that case.
The plan carries current_hash, and apply re-checks it. A file edited between the preview and the confirmation is refused with a message saying exactly that, because the gap between deciding and acting is where a naive implementation quietly destroys work.
16.4 Restore as copy
Writes beside the original under a free name, touching nothing. apply refuses outright if the destination exists, whatever mode was requested, because a copy that could clobber would defeat its own purpose. The suggested name is checked against the disk at planning time and again at apply time.
A copy adds no version to the restored file's own history: the new file is its own thing, and the watcher picks it up as an ordinary creation.
16.5 Deleted files, and the Phase 3 rule
Restoring a deleted file revives its identity: the file genuinely comes back. But if a different file has taken the path in the meantime, that is PATH_REOCCUPIED and the restore is refused. The Phase 3 correction (identity is only revived with content evidence) is defended at this boundary too: no amount of restoring merges two histories because two files shared a name.
16.6 Watcher coordination
AFTRIMGE writes; the watcher sees the write and reports it. Without coordination that becomes a MODIFY sitting on top of the RESTORE, claiming the user changed something they did not.
The fix is a self-write expectation: before touching the disk, apply registers (project, path, content_hash) with the ingestor. When a settled change matches all three, it is recognised as ours and no event is written.
Matching on path and content hash together is what makes this safe. The blunter alternatives (pausing the watcher, or ignoring a path for a window) would swallow a genuine edit the user made in the same moment. This consumes exactly one settling of exactly the bytes we wrote; anything else is recorded normally. Expectations expire after 20 seconds, so one that is never claimed cannot suppress a real edit later.
Two tests pin this down: the echo of a restore records nothing, and an edit made immediately afterwards is still recorded in full.
16.7 What a restore records
A RESTORE event, which is a distinct EventType (schema v3 rebuilt the events table to admit it) with its own Evidence::Restore grade: the strongest grade there is, because the engine did not observe or deduce the change, it made it and verified the result.
The event carries three snapshot references, which is what makes the record complete:
source_snapshot_id: the historical version that was put backprevious_snapshot_id: the version it replacedsnapshot_id: the new version the restore created
Restoring v4 over v19 leaves v5–v19 exactly where they are and adds v20. The content is the same bytes as v4, so content addressing means it costs no additional storage. Asserted directly in tests/restore.rs.
16.8 Project-state restore
preview_project_restore categorises every file: RESTORE, CREATE, UNCHANGED, CONFLICT, or EXISTS_ONLY_NOW.
Nothing is ever proposed for deletion. A file that exists now but did not exist at the chosen moment is reported as EXISTS_ONLY_NOW and left alone. Making the present match the past by removing the user's newer work would be the single most surprising thing this product could do.
Applying carries out only the entries marked RESTORE or CREATE, re-planning each one immediately before writing it. Conflicts are skipped and counted; whole-project forcing is deliberately not offered, because "overwrite twelve things I have not looked at" is not a decision an interface should make easy. Each conflict can still be resolved individually.
17. Retention and collection
17.1 What retention actually does
Retention never deletes history. Events, snapshots, file identities and rename chains are kept for ever. What a policy releases is stored content (the bytes of old versions) by clearing snapshots.stored, which is exactly the mechanism the engine has always used for files too large to keep.
The consequence: the timeline stays complete whatever the policy. You can always see that logo.png had eleven versions and when each was recorded; what you may lose is the ability to open the bytes of the oldest. The interface already knows how to say that, because over-size files have behaved this way since Phase 1.
RetentionPreview.events_removed exists and is always zero. It is there so the guarantee is visible in the interface rather than only in this document.
Two things are never released, at any age:
- the newest version of every file, so the present is always restorable;
- content that a retained snapshot still points at, since deduplication means an old version and a current one can be the same bytes.
A corrupted or unreadable policy setting parses as KeepEverything. That fallback direction is the important one: damage must never be read as permission to delete.
17.2 Why ref_count is not the source of truth
The objects ledger carries a ref_count, incremented when a snapshot is inserted. It is never decremented. Deleting a project cascades its snapshots away without touching the counter.
This is not theoretical. Measured on a real AFTRIMGE database during the Phase 4 audit:
ledger ref_count vs actual snapshot references
8fa8e23908 claims 1 actual 0 <-- MISMATCH
e8a8649c15 claims 2 actual 1 <-- MISMATCH
... 9 of 13 objects over-counted
A collector trusting that counter would leak for ever. One trusting a counter that had drifted the other way would delete live content.
So collection ignores ref_count entirely when deciding what to delete and derives the answer from the snapshots table, which is the actual record of what points at what. The counter is then repaired to match reality, turning it from a lie into a useful diagnostic. Two tests pin this: a counter set to 99 and a counter set to 0 both leave referenced content untouched.
17.3 Collection safety
Reconciliation compares three sources (what snapshots reference, what the ledger claims, and what is on disk) and reports four categories:
| referenced | a retained snapshot points at it; never touched |
| unreferenced | in the ledger, pointed at by nothing; reclaimable |
| orphaned | on disk with no ledger row (an interrupted write); reclaimable |
| missing | in the ledger but gone from disk; reported, never silently repaired, because deleting the row would erase the evidence that content was lost |
Interruption safety:
- deletion is idempotent; a missing object is success, since the goal state is that it is gone;
- each object is deleted individually, so an interrupted run leaves the rest for next time;
- the reference check is repeated immediately before each delete, because a restore or an ingest between the listing and the deletion may have created a snapshot pointing at those exact bytes;
- the ledger row is removed only after the file, so a crash between the two leaves an orphan, which the next audit finds;
- a file that cannot be deleted (locked by another process) is recorded as a failure and retried next run rather than aborting the sweep.
Nothing in collection ever removes a snapshot row, an event, or a file identity.
18. Historical search
Literal substring search over four recorded fields: current paths, the path a file entered under, every path a rename event recorded, and extensions.
Every hit reports which field matched and, for a historical name, when that name applied, so a file called something entirely different today explains why it came back for the term typed. Results are one row per file: the best-ranked match wins, because listing the same file four times because its name matched in four places is noise.
% and _ are escaped, so searching for 100%_final looks for that text rather than treating it as a pattern.
There are no embeddings and no semantic matching, deliberately. This is an archive index, and it answers only what the record can prove.
19. Schema v3
The events table was rebuilt, not altered: its CHECK constraint enumerates the event types and SQLite cannot change a constraint in place. Nothing references events, so recreating it is safe with foreign keys left on, and the copy preserves every existing row. A migration test creates a v2 database with real rows, migrates it, and asserts the rows survived, the new column defaults to NULL, RESTORE is now accepted, nonsense is still rejected, and all three indexes were recreated.
v3 also adds a settings table. Deliberately a table rather than a config file: a setting that governs whether stored content may be released belongs with the data it governs, so copying the data directory carries its own retention policy rather than inheriting whatever the new machine has.
20. Observed history and reconstructed history
This is the most important distinction in AFTRIMGE, and Phase 5 is the phase that made it necessary to state precisely. Everything before it dealt only in the first kind.
OBSERVED HISTORY RECONSTRUCTED HISTORY
AFTRIMGE watched a folder. AFTRIMGE was handed a file.
It saw A.png become B.png. It measured what B.png is.
Source: the operating system Source: arithmetic over bytes
Grade: FACT Grade: INFERRED / POSSIBLE / UNKNOWN
Stored: snapshots, events Stored: artifacts, artifact_relations
Shown: the timeline Shown: the archaeology view
Both are real knowledge. They are not the same kind of knowledge, and the entire value of the first depends on never letting it be confused with the second.
Concretely
Observed. A file was watched. AFTRIMGE has its content hash at a series of instants, the events that separated them, and the evidence grade for each. When the timeline says a file was renamed at 14:02, the operating system said so. Nothing was deduced.
logo.png→logo-final.pngat 14:02:11 Evidence: WATCHER: the operating system reported this as a rename.
Reconstructed. A file arrived. AFTRIMGE has its bytes now, and nothing else. It can measure the bytes and compare them against other bytes, and from that it can say what transformations are consistent with what it sees.
poster-final.jpgcorresponds to a region ofbanner.png. Evidence: INFERRED: 92.4% correlation at a 400x300 alignment; the residual falls from 66.3 to 3.3 levels out of 255 once the geometry and tone are normalised. AFTRIMGE did not watch this happen.
How the separation is enforced
Not by convention but by structure, in five places:
- Separate tables.
artifactsandartifact_relationsshare no foreign key withsnapshotsoreventsexcept one deliberate, one-directional bridge (below). An artifact cannot become a snapshot. - Separate commands. The archaeology IPC surface cannot write into a project's history, and none of the timeline commands read an artifact.
- Separate grades.
RelationGradegainedPossibleandUnknownfor Phase 5. Nothing archaeology produces is everFactunless the evidence is a cryptographic hash or a decoded pixel. - Separate mode. Archaeology replaces the whole interface rather than sitting inside a project. Different room, different grid, different type scale, so a deduction about a JPEG cannot be mistaken at a glance for a recorded event.
- A first sentence that says which it is. Every investigation opens with what AFTRIMGE did not see:
> AFTRIMGE has no recorded history for this image. It was not watched being > made or changed, so everything below is reconstructed from its bytes and > nothing below is a witnessed fact.
The one bridge, and its direction
A watched snapshot can be brought into an investigation as an artifact (Investigator::from_snapshot), so that deterministic analysis can run against content AFTRIMGE genuinely observed. The resulting artifact records the snapshot it came from.
Nothing flows the other way. An artifact never becomes a snapshot; archaeology never writes an event; no conclusion appears in a timeline. An integration test asserts that importing images leaves events, snapshots, files and projects all empty.
21. Image archaeology
The question
An image arrives from a chat, an email, a client, a USB drive. AFTRIMGE never watched it. What can still be established?
The answer is: quite a lot, deterministically, and rather less than a person would hope. Both halves matter.
The pipeline
a file on disk
|
container::describe structure and metadata, from a bounded prefix
| -- no decoder, no dependency, every field traceable
| to an offset
exif::parse the TIFF directories inside that metadata
|
fingerprint::compute aHash, dHash, pHash, colour histogram,
| edge histogram, alpha, chroma, luminance
|
+--> survey::sweep for each file in the chosen folders:
| hash, header, fingerprint, cheap prefilter
|
transform::investigate the hypothesis cascade, per surviving pair
|
align::locate one picture inside another, by ZNCC
|
trail::build the survivors, in a sequence, with their limits
The cascade
Not one similarity number. A series of specific, falsifiable questions, each of which is reported whether it passed or failed:
RAW the frames as they are
RESIZE NORMALISED forced to one geometry
GEOMETRY each flip and quarter turn tried
ALIGNED the best sub-region correspondence
TONE NORMALISED after a least-squares tone fit
A raw difference across 94% of the frame that collapses to 3.3 levels out of 255 under alignment and tone normalisation is the finding. The final number alone is not, and the interface draws all five stages for that reason. A stage that could not run reports why rather than reporting a zero, because "not measured" and "measured no difference" are opposite results and must not look alike.
What it can detect
| Transformation | Detectable | How |
|---|---|---|
| Identical | yes, FACT | SHA-256 |
| Re-encoded | yes | residual at shared geometry |
| Heavily re-encoded | yes | wider residual band, gated on the damage being evenly spread |
| Resized | yes | aspect match plus residual after resampling; the scale is named as a ratio when it is one |
| Cropped | yes | ZNCC search across a scale ladder |
| Rotated (90/180/270) | yes | applied and measured |
| Flipped | yes | applied and measured |
| Border or canvas added | yes | uniform-border trim plus surround uniformity |
| Composite / partial | yes | alignment covering part of the frame |
| Greyscale | yes | chroma spread, with structure preserved |
| Brightness / contrast | yes | least-squares tone fit with an R² |
| Saturation | yes | chroma ratio with luminance held |
| Transparency | yes, FACT | decoded alpha |
| Arbitrary-angle rotation | no | not attempted |
| Perspective correction | no | not attempted |
| Local retouching | no | not attempted |
| Which application edited it | no | not knowable from pixels |
| The order of a chain | no | see below |
The bottom five are not oversights. A hypothesis that cannot be tested is not a hypothesis, and the engine says UNKNOWN rather than reaching.
The rules the engine keeps
Nothing is established by colour alone. A histogram can explain or contradict a match; it can never create one. Every hypothesis that reaches INFERRED passed a spatial test: a residual or a correlation. Two photographs of the same sunset share a histogram and share nothing else, and there is a test named after exactly that.
Featureless content proves nothing. align::Template::prepare declines a template with no variance. A solid grey square genuinely does sit inside every photograph containing a solid grey region, and calling that a crop would be the easiest way in the world to manufacture a false positive.
Metadata is not provenance. EXIF, XMP and text chunks are extracted, grouped by origin, and shown under a standing warning. They are claims the file makes, not facts about its history: trivially editable, routinely stripped, freely copied between files. Two files sharing a Software tag are not thereby related, and a test asserts it.
Direction is measured, not assumed. That an image is larger does not make it older. What makes it older is that enlarging cannot invent detail that downscaling discarded, and the engine tests both directions directly: resample the small one up and see whether it accounts for the large one; resample the large one down and see whether it accounts for the small one. Whichever direction reproduces the other is the one that happened. When both residuals are close (a lossy re-encode in the middle, or a picture with no fine detail), the answer is UNDETERMINED and the trail leaves the pair unordered.
Every number carries its derivation. EvidenceLine::derivation is not optional and is shown in the interface. The only percentage attached to a conclusion anywhere is a coefficient of determination, which is a real one.
Where the numbers live
Every threshold is in analysis/thresholds.rs, beside the Phase 3 ones, each with the reasoning for its value. ARCHAEOLOGY_VERSION is separate from ANALYSIS_VERSION because they are different algorithms answering different questions; a change to one must not silently invalidate, or silently preserve, results from the other.
Reproducibility
A conclusion is stored with the version that produced it. Raising ARCHAEOLOGY_VERSION leaves the old row in place, so a conclusion stays identifiable as having come from the algorithm that reached it. Analytical history is never silently rewritten.
Every comparison is cached on the ordered pair plus the version, because the comparison is directional: "is this a crop of that" and "is that a crop of this" are different questions with different answers.
Two bugs this phase produced, both worth recording
The quantisation tables were compared in the wrong order. A JPEG's DQT segment stores its 64 entries in zig-zag order; the IJG reference table is row-major. The two are the same multiset, so the table sum matched perfectly while every per-entry comparison was against the wrong coefficient. Every real file came back with no quality estimate except quality 100, where the table is all ones and order cannot matter, which is exactly the kind of partial success that hides a bug. With the zig-zag permutation applied, every quality from 10 to 100 is recovered exactly.
A NaN residual could not survive the cache. The measurement fields were f64 with f64::NAN standing for "this stage did not run". serde_json writes NaN as null and then refuses to read null back into an f64, so every report containing an inapplicable stage failed to deserialise, silently, with no error anywhere, showing the user no findings and never hitting the cache. They are Option<f64> now. A type that cannot represent "not measured" will invent a way to, and the invented way is always worse.
22. Artifact storage
An imported image may be ten kilobytes or two gigabytes. Taking permanent ownership of every file a user points at would be both rude and expensive, so an artifact has custody:
- ANALYSED: measured, described, fingerprinted; the bytes were read and let go. The file stays where it was and AFTRIMGE owns nothing. Everything needed to reason about the image is in the row.
- ARCHIVED: the user deliberately kept it. The bytes went into the same content-addressed object store as everything else.
The distinction is visible in the interface and is never implied by accident: importing analyses, and archiving is a separate act.
Archiving, in order
- The bytes go into the object store (content-addressed, so an image already held as a watched version costs nothing).
- The object is registered in the
objectsledger. Easy to forget, and forgetting it is worse than it looks: an object on disk with no ledger row reads to the garbage collector as residue from an interrupted write, and it would be swept away on the next collection. The user would have asked AFTRIMGE to keep something and watched it quietly delete it. - The row is marked ARCHIVED, which is what makes the collector treat it as a live reference.
Phase 4's collector derives live references from the snapshots table. Phase 5 extended that to a union with archived artifacts, because the snapshots table has never heard of them.
Bounded by construction
- Content hashing streams; a 4 GB file never lands in memory.
- Structure is read from a bounded prefix, so a twelve-gigabyte image costs one megabyte to describe.
- The pixel guard runs against the header's declared dimensions before anything is decoded. Refusing to decode a 300-megapixel image is only a protection if it happens before the allocation.
- Every correlation is computed over a bounded lattice of sample points, so the cost of one evaluation does not grow with the size of the images.
- A sweep is cancellable between files, reports progress, and counts everything it skipped.
23. The protocol, and why it is still not a file reader
An artifact row holds the path of a file on the user's disk. Serving that path by id would turn aftrimge:// into an arbitrary local file reader wearing an identifier as a disguise. It does not, because of two rules kept in ipc/protocol.rs rather than promised elsewhere:
aftrimge://localhost/artifact/<id> ARCHIVED artifacts only
aftrimge://localhost/artifact-thumb/<size>/<id> the thumbnail cache only
/artifact/<id>serves only ARCHIVED artifacts, whose bytes are in the object store because the user deliberately put them there. An analysed artifact returns 410, exactly as a snapshot released by retention does./artifact-thumbserves only from the thumbnail cache, which was written at import time from bytes that were already being read.
The consequence is the property that matters: no route in the scheme ever opens a file at a path. The only bytes reachable are ones AFTRIMGE already holds in its own storage, and source_path is never consulted by the protocol at all.
source_path is returned by the archaeology commands, because this is a desktop application investigating the user's own machine and telling them where their own file was found is the entire point of a survey. That is a different boundary from the one above, and the two are not confused.
24. Schema v4
CREATE TABLE artifacts (
id, content_hash, display_name, source_path, byte_size,
custody CHECK (custody IN ('ANALYSED','ARCHIVED')),
format, extension, width, height,
discovered_at, analysed_at, analysis_version,
fingerprint, -- serialised Fingerprint
container, -- serialised ContainerReport
snapshot_id -- the one bridge from observed history
);
CREATE TABLE artifact_relations (
id, subject_id, candidate_id,
relation, grade CHECK (grade IN ('FACT','TEMPORAL','INFERRED','POSSIBLE','UNKNOWN')),
direction, explanation,
evidence, -- the full serialised TransformReport
analysis_version, created_at,
UNIQUE (subject_id, candidate_id, analysis_version)
);
discovered_at is when AFTRIMGE was shown the file. It is never presented as when the image was made, which AFTRIMGE has no way to know.
The UNIQUE constraint is on the triple including the version, so re-running the same algorithm on the same pair replaces its row while a different version's answer sits alongside it.
ANALYSIS_VERSION was raised to 2 in this phase because ImageAnalysis gained region reporting: the cached payload's shape and meaning changed, which is exactly what the version is for.
25. The memory bridge
Phase 5 could investigate an image against folders you nominated. Phase 6 investigates it against everything AFTRIMGE has already recorded (years of snapshots across every watched project) and does it without decoding them all.
The two kinds of knowledge, and the seam between them
OBSERVED RECONSTRUCTED
Project A incoming.jpg
10:12 sketch.png ◄╌╌╌╌╌╌╌ arrived from somewhere
10:42 sketch.png modified POSSIBLE / INFERRED
11:07 final.png measured from pixels
AFTRIMGE watched these. AFTRIMGE deduced this.
No grade: a record is not Never better than INFERRED,
a claim about anything else. unless the bytes are identical.
An edge from an artifact to a snapshot never upgrades archaeology into history. However strong the spatial evidence, a measurement is a measurement. The only thing that makes a memory match FACT is that the two content hashes are equal, which is not an inference at all.
How the seam is enforced
Not by convention. By three mechanisms, in descending order of how much they can be relied on:
1. The types. memory::occurrence::HistoricalOccurrence has no grade field. Not a grade set to FACT, but no field at all. There is nowhere to write an inference onto a record, so nothing downstream can, and no serialisation can show one. An integration test asserts the serialised shape contains none of grade, confidence, relation, inferred or possible.
2. One function. memory::ranking::assess is the only place a memory grade is produced, and it takes the two content hashes rather than a caller's opinion of how they relate:
pub fn assess(subject: &str, candidate: &str, report: Option<&TransformReport>)
-> (RelationGrade, Basis)
| Evidence | Grade | Basis |
|---|---|---|
| hashes equal | FACT | ByteIdentity |
| hashes differ, pixels compared | whatever the cascade concluded | Measured |
| hashes differ, no pixels | UNKNOWN | Unverifiable |
A caller cannot claim byte identity that does not hold, because it cannot pass a basis, only hashes. The Measured branch additionally asserts, in debug, that the cascade did not return FACT; that invariant is separately tested against the real engine over resize, crop, grade, rotation and unrelated pairs.
3. Two fields. A MemoryMatch keeps occurrences / anchor / context apart from verdict, and the JSON the interface renders keeps them apart too. The frontend mirrors it: describeOccurrence takes an occurrence and cannot reach a grade; describeVerdict takes a verdict. A component that wants to draw a confidence has to reach into verdict, and that friction is the point.
The interface then repeats the distinction visually. RECORDED is a solid square on a solid rule; the artifact is a dashed ring off the rule; the connectors between them are dashed by grade. The RECORDED stamp looks identical whatever grade sits below it, because a record is not graded.
Content, and historical occurrence
CONTENT HISTORICAL OCCURRENCES
one row, one fingerprint Project A / logo.png / March 2
Project A / logo-final.png / March 3
Project B / exported.png / April 9
visual_index is keyed by content hash. Five hundred snapshots holding the same bytes are one row and one fingerprint computation. Their five hundred occurrences are not stored there at all: they are already snapshots ⋈ files ⋈ projects, and deriving them from the record rather than copying it is what stops the two disagreeing.
An occurrence reports the path the file held at that instant, recovered the same way replay::state recovers it. Showing today's name against a March timestamp would be quietly wrong.
The funnel
every indexed content hash e.g. 100,000 rows
│ SCALAR STAGE three u64 hash distances and four floats,
▼ read straight from columns; no JSON parsed
scalar survivors
│ SIGNAL STAGE the same prefilter a Phase 5 survey uses,
▼ on full fingerprints
candidates capped at 24
│ CASCADE STAGE transform::investigate: the only thing
▼ here that may conclude anything
matches capped at 20
The first two stages decide what to look at. They may pass something unrelated, which costs a comparison. They may never establish a relationship, and a candidate that survives them and fails the cascade is reported as unrelated exactly like one that never survived them.
The scalar stage is generous, because a false positive costs one JSON parse while a false negative loses a real finding silently. It is not a second fingerprint vocabulary: every scalar it reads is a field of the same Fingerprint the rest of the engine uses, duplicated into columns so a large corpus can be narrowed without parsing.
Generous is not the same as open
It was written as hash OR aspect OR tone, with all three bounds set wide, and measuring it showed what that actually does. A corpus of 241 recorded images (flat colour fields, panoramas, near-black frames, photographs of unrelated things) passed it entirely. Three loose predicates joined by OR is not a filter; each one finds some excuse.
The hash bound was the worst of it. A 64-bit perceptual hash puts unrelated images near 32 bits apart by construction, so a threshold of 34 is above the noise floor. Measured over 80 unrelated pairs, the smallest of the three hash distances came in at a median of 26, and 79 of the 80 fell under 34. The gate was open.
Two changes, both measured rather than guessed:
SCALAR_HASH_MAX34 → 22. Every transformation the hash route is responsible for (resize at any factor, JPEG re-encode, both together, brightening, tone curves) measures 0 to 2 bits. A border measures 14 and a centred crop 17. 22 keeps all of them with margin and turns away 95% of unrelated content instead of 1%.aspect OR tone→aspect AND tone, with aspect allowing the reciprocal so a quarter turn still matches. Rotation and reflection measure 27 bits and never came through the hash route anyway; they come through here, where they belong.
Result on the same 241-image corpus: 99 survive instead of 241.
The guard that makes this safe to do is memory_thresholds_admit_every_transformation_the_cascade_can_name, which re-measures seventeen transformations against the live constants and fails if any of them stops fitting through. Tightening a prefilter without that test is how a search quietly stops finding things.
What the funnel does not do
On a corpus that is genuinely homogeneous (251 photographs of the same dimensions and similar statistics), the cheap stages cut nothing, and the cascade cap does all of the bounding. The fingerprint stage passed all 99 survivors of the varied corpus too. So the honest statement is:
The cheap stages help when there is something cheap to notice. The cap is what bounds the cost.
Both numbers are in the accounting the interface shows, which is the point of showing them.
Every cap is reported. coverage_note says what was dropped and adds: anything not listed was not ruled out; it was not examined.
Ranking
Defined in one place, memory::ranking. Four keys in strict priority:
- Grade. A stronger kind of claim always outranks a weaker one, whatever the numbers say.
- Residual. The lowest figure the cascade reached.
- Reduction strength. The cheap signal. Only ever a tie-break.
- Content hash. Unique per match, so the order is total and a query run twice gives the same answer.
No score is produced. A blend of a grade, a residual and a fingerprint similarity is not a quantity, and printing one would invent a precision none of them has. What the interface gets instead is explain: the specific respects in which one candidate beat another: stronger evidence grade; a lower normalised residual, 3.26 against 41.9; its cascade survived five stages against three. When nothing measurable separates two candidates, the note says so and names the tie-break rather than implying a difference.
Incremental indexing
No startup scan. "What is missing" is a set difference the database answers, so a pass never looks at anything already done:
SELECT DISTINCT s.content_hash FROM snapshots s
WHERE s.stored = 1
AND NOT EXISTS (SELECT 1 FROM visual_index v
WHERE v.content_hash = s.content_hash
AND v.index_version = ?)
Two things drive it. The ingest pipeline calls a SnapshotObserver after every committed snapshot (a trait in the engine, on the same reasoning as ActivityEmitter), so analysis depends on nothing above it and stays testable with nothing wired in. And a bounded catch-up picks up whatever was missed: history recorded before this feature existed, an index version bump, a decode that failed.
The observer runs after the transaction commits, never inside it, and its failure is logged rather than propagated. The index is a convenience rebuilt from the record; the record is the thing that matters, and it is already committed by the time indexing runs.
Retention
A retention policy releases bytes. It does not release the record, and it does not release the measurement.
visual_indexhas no foreign key toobjects, deliberately. A cascade would take the index row with the ledger row, and the row is a measurement AFTRIMGE made rather than the content it was made from.- A released occurrence is still listed, still found by a query, and labelled
Basis::Unverifiable: "This historical occurrence is known, but its original bytes are no longer retained, so its pixels could not be compared." - Its grade is
UNKNOWN, and that is a different statement from a weak finding. Untested is not the same as tested and found wanting. - The surviving fingerprint is described as derived data, explicitly: enough to bring the occurrence to your attention, not enough to establish anything.
Rows orphaned from every snapshot are removed by explicit repair, never by a cascade.
Two questions that look like one
There is a state AFTRIMGE creates on purpose in which the bytes of some content survive while every snapshot of it has been released: an archived artifact is holding them, and the collector protects exactly that (§28). The bridge used to check store.contains() and then look up a retained snapshot to read the pixels from. Those are two different questions, and in this state they disagree: the store says yes, the snapshot query returns no row. The query errored, and one candidate in a state AFTRIMGE produces deliberately failed an entire search.
MemoryBridge::materialise now asks the question that actually matters, can this content be turned into something the cascade can read?, and answers it in two steps: a retained snapshot if there is one (the better answer, because from_snapshot records which version the pixels came from), otherwise the archived artifact that is already holding the bytes. Only when neither exists is the candidate unverifiable, and that is reported rather than raised.
The chaos scenario found this. It is pinned by content_an_artifact_still_holds_is_compared_rather_than_failing_the_search, which asserts the stronger property: not merely that the search survives, but that the candidate is measured, because the bytes were there to measure.
The same mistake one layer down
Looking for it elsewhere found it in Phase 5 code. Investigator::from_snapshot creates an ARCHIVED artifact (a watched version's bytes really are in the object store), but its reuse path matched on content alone, via find_by_content. So if an outside file with byte-identical content had already been imported as ANALYSED, investigating the snapshot handed back that ANALYSED row.
The same call then meant two different things. Cold: bytes AFTRIMGE owns. Warm: a path the user can move or delete. Deleting that outside file broke analysis of a version that was safely in the store the whole time.
artifacts::find_archived_by_content is the narrower lookup, and both the bridge and from_snapshot use it. The rule it encodes: a caller that needs bytes must not be handed a row that only holds a promise about someone else's file. a_recorded_version_never_borrows_custody_from_an_outside_file pins it by deleting the outside file and requiring the recorded version to stay readable.
Index repair
audit reports three things and changes nothing: entries missing for content that exists, entries left behind for content nothing references, and entries from a different index version. repair acts on them.
Repair touches the index and nothing else. It cannot delete a snapshot, an event, a file identity or a project, and RepairOutcome::history_removed is always zero: a field that exists so the guarantee is visible rather than merely promised, and which an integration test asserts.
It is not atomic, and is not described as such. It is a sequence of independent row writes. Cancelling stops it between rows and leaves a valid, smaller index whose remaining gaps the next pass will find, which is a different claim from all-or-nothing, and the honest one.
Both directions
Artifact → history is the search. History → artifact is MemoryBridge::artifact_for_snapshot, which materialises a recorded version as an artifact and stops. Whether to run anything expensive against it is the user's next decision, not the function's. A released version cannot cross at all: there is nothing to investigate.
Performance
Corpus size tested: 53 recorded images in the decoy suite, including smooth fields, flat colour and near-periodic structure, the shapes most likely to fool a cheap filter. The assertion is a relationship, not a number: expensive comparisons must be strictly fewer than the corpus, and the true match must survive.
Known scaling limits, stated rather than glossed:
- The scalar stage reads every index row at the current version. That is linear in the corpus, at roughly ten nanoseconds a row, and there is no index that would help: Hamming distance has no SQL operator. At a million images this is tens of milliseconds; well beyond that it would want an approximate-nearest-neighbour structure over the hashes, which does not exist here.
- A query holds the scalar projection in memory. A hundred thousand rows is a few megabytes; the full fingerprints, which include the histograms, would be hundreds, which is why they are read only for survivors.
- Indexing runs on the ingest thread. One decode per distinct image, so a folder of a thousand copies of one logo decodes once.
26. Cache namespacing
analysis_cache had one version column and two callers who meant different things by it: a pairwise comparison carried ANALYSIS_VERSION, a fingerprint carried ARCHAEOLOGY_VERSION. Reads were never wrong, because the version is mixed into every key. But prune_old_versions deleted by version alone, so a maintenance operation on one subsystem would silently throw away another's perfectly current work. Phase 5 documented the hazard and avoided it by never calling prune.
Phase 6 fixes it. Every row declares a Family (COMPARISON, FINGERPRINT, MEMORY), and every destructive operation is scoped to one:
prune_old_versions(conn, Family::Comparison, ANALYSIS_VERSION)
clear_family(conn, Family::Fingerprint)
count_family(conn, Family::Memory)
The invariant, stated so a test can be written against it: one analysis subsystem must never invalidate another subsystem's cache. Two unit tests and one integration test assert exactly that, including the specific historical case: pruning comparisons at version 2 while fingerprints sit at version 1.
The table was rebuilt rather than altered in migration v5. Adding the column with a default would have stamped every existing row as one family and mislabelled the other's. This table is a cache, and discarding a cache costs a recomputation, the one kind of loss AFTRIMGE is allowed to accept.
27. Schema v5
DROP TABLE analysis_cache;
CREATE TABLE analysis_cache (
cache_key TEXT PRIMARY KEY,
family TEXT NOT NULL, -- COMPARISON | FINGERPRINT | MEMORY
hash_a, hash_b, version, payload, created_at
);
CREATE INDEX idx_analysis_family ON analysis_cache(family, version);
CREATE TABLE visual_index (
content_hash TEXT PRIMARY KEY, -- no FK: derived data outlives content
index_version INTEGER NOT NULL,
indexed_at INTEGER NOT NULL,
-- read by the scalar stage, without parsing any JSON
width, height, aspect_ratio,
ahash, dhash, phash,
edge_density, chroma_spread, mean_luminance,
-- read only for rows that survive
fingerprint TEXT NOT NULL
);
CREATE INDEX idx_visual_index_version ON visual_index(index_version);
MEMORY_VERSION is separate from ANALYSIS_VERSION and ARCHAEOLOGY_VERSION for the same reason those are separate from each other: they version different algorithms, and a change to one must not silently invalidate, or silently preserve, results from another.
28. A collector race, found by reading
maintenance::gc::collect filters candidates with LIVE_REFERENCES: retained snapshots ∪ archived artifacts, the union Phase 5 introduced. But the re-check immediately before deletion queried snapshots alone:
// before
"SELECT COUNT(*) FROM snapshots WHERE content_hash = ?1 AND stored = 1"
Narrower than the filter that selected the object. An artifact archived between the two (a window that needs concurrency to open) would have had its bytes deleted, days after the user asked AFTRIMGE to keep them, with nothing in the outcome to say so.
The fix is a named predicate, gc::is_referenced_now, asking exactly the question LIVE_REFERENCES asks. Naming it is what made it testable: the race needs two threads, but the predicate does not, and three tests now assert it agrees with LIVE_REFERENCES for archived-only content, for a retained snapshot, for a released one, and for content nothing references. With the old query restored, two of them fail.
This mattered more after Phase 6, because the memory bridge materialises recorded versions as archived artifacts as a matter of routine.
29. The continuity engine
Phase 6 answers "have I seen anything related to this?": one hop, subject to recorded content. Phase 7 answers the larger question underneath it: where, if anywhere, does this image belong in everything AFTRIMGE has recorded?
That needs chains, and chains are where a system starts lying. Two individually reasonable measurements laid end to end produce a statement nobody measured.
29.1 Four kinds of knowledge
OBSERVED snapshots, events, paths, times.
AFTRIMGE watched these. `continuity` never writes to them.
ANCHORED the subject's bytes ARE content AFTRIMGE recorded.
A hash comparison. The only FACT the engine can produce.
MEASURED one archaeology cascade between two DISTINCT contents.
INFERRED or POSSIBLE. Never FACT (see §29.3).
COMPOSED a chain of measured edges.
Never stronger than its weakest hop, never given a
transformation label, never oriented by a clock.
Each is a different type, and the differences are load-bearing:
| grade field? | occurrences? | can be FACT? | |
|---|---|---|---|
Anchor | no: grade() is a const fn | yes | it is the fact |
ContentNode | no | yes | n/a: it is a record |
MeasuredEdge | yes | no | no |
ContinuityPath | yes | no | no |
ContentNode having no grade field is the same mechanism HistoricalOccurrence uses in §25: there is nowhere for an inference to be written onto a record, so no rendering path can turn one into the other by accident.
29.2 Why is this relationship not a fact?
The question every non-factual edge must be able to answer. It has one answer, and it is structural rather than a matter of thresholds:
A continuity edge exists only between two different content hashes.
FACTmeans the hashes are equal. So no edge can be one.
Traced through the code, in four steps:
RelationGrade::Factreaches aTransformReportonly throughTransformKind::Identical.transform::investigateraises that hypothesis only when itsidentical_bytesargument is true.Investigator::comparesetsidentical_bytesfromsubject.content_hash == candidate.content_hash.graph::MeasuredEdge::betweenrefuses to build an edge when the two hashes are equal, and returnsNone.
Byte identity has not been lost by this. It is simply not an edge: two snapshots with the same hash are the same content node, and the subject matching one is an Anchor, drawn as the one solid line on the map, in its own block above the findings, so it is never read as merely the best of them.
29.3 Grade composition
One function, compose::compose_grade, and it is subtractive in every branch.
compose(hops) = min(weakest(hops), CEILING) CEILING = INFERRED
INFERRED , INFERRED -> INFERRED
INFERRED , POSSIBLE -> POSSIBLE (the specified case)
POSSIBLE , POSSIBLE , POSSIBLE -> POSSIBLE (no length bonus)
anything , UNKNOWN -> UNKNOWN
FACT , FACT -> INFERRED (the ceiling)
[] -> UNKNOWN
There is deliberately no rule that adds anything. No length bonus, no agreement bonus, no "three weak links make a strong one". Composition in this engine can only lose information, because that is the only direction in which it is honest.
The CEILING is applied unconditionally rather than asserted, for two reasons. It holds in release builds. And it keeps composition associative. An earlier version downgraded only FACT, and that broke: [FACT, FACT, TEMPORAL] folded straight through to TEMPORAL, while folding the first two and then the third gave INFERRED. Two ways of grading one chain, disagreeing. A clamp is a min against a constant, and min is associative, so this version cannot develop that disagreement. It is asserted exhaustively over every ordered triple of the five grades.
TEMPORAL is above the ceiling too, and for its own reason: it means "known from sequence or timing", and a composed path is known from pixels. Letting one come out TEMPORAL would be exactly the timestamp laundering the engine exists to refuse.
29.4 Direction
A chain's orientation survives only unanimity.
earlier , earlier -> FAR_END_IS_EARLIER
earlier , undetermined -> UNDETERMINED (conflict: UNORIENTED)
earlier , later -> UNDETERMINED (conflict: CONTRADICTION)
earlier , earlier , later -> UNDETERMINED (there is no majority vote)
Two hops out of three is a chain AFTRIMGE guessed at, so it is not a direction. A contradiction outranks a silence as the explanation, because "these measurements disagree" is worth saying, but both give the same answer, which is that nothing is claimed.
Time is not an input. compose_direction takes &[Direction] and nothing else. There is no timestamp parameter and therefore no code path by which a clock could orient a chain. One adversarial test asserts this on the signature itself, so adding a recorded_at argument stops it compiling.
Walking an edge backwards reverses what it says (compose::walk), because that is a relabelling of one measurement, not a second one.
29.5 Composition never collapses into a transformation
A path of one hop is one measurement and keeps its SourceRelation. A path of two or more gets relation: None, always.
A resize followed by a re-encode is not evidence of "a resized and re-encoded version". It is two measurements with a shared endpoint, and saying the second thing would invent a history nobody measured.
So the path keeps its hops and says so, in as many words:
"3 separate measurements connect this image to that recorded content, through 2 intermediate images. AFTRIMGE is not claiming a single transformation took place: it is showing the chain it measured."
29.6 Ranking is not grading
Two questions that look alike and never share an answer:
- Grade: what is AFTRIMGE justified in claiming? Composed, epistemic.
- Rank: which should the user look at first? A sort order. Never shown as a number, never consulted when deciding a grade.
path::compare is total and deterministic, in strict priority: grade, then fewer hops, then a known direction above an unknown one, then the earliest recorded destination, then the destination hash.
Recorded time is the fourth key, reachable only when every epistemic key has tied, and it can only reorder, never regrade. Structurally: destination_earliest_at is None while the path is being graded, and is filled in afterwards.
RankingNote has two fields, and separating them is the point. reasons is AFTRIMGE's own rationale and must never contain a number that could be read as a score. evidence is a verbatim quotation of the measurement that set the grade, and it is full of numbers, and that is right, because those are measurements. They were one field at first; a test forbidding percentages in a ranking note then failed on a quoted cascade summary, which was the test noticing that one field cannot be held to two different standards.
29.7 Search and its bounds
subject content
│ SCALAR STAGE the same memory::scalar_pass, on the same rows,
▼ read once and reused for every expansion
scalar survivors
│ SIGNAL STAGE the same fingerprint::prefilter
▼
neighbours capped at 8 per node
│ CASCADE archaeology, cached in artifact_relations
▼
measured edges capped at 128; 48 comparisons per whole trace
│ ENUMERATE depth-first, cycle-guarded, ≤ 3 hops
▼
paths ≤ 3 per destination, ≤ 24 returned
Not a second funnel: the same scalar_pass and fingerprint::prefilter the memory bridge uses. If those thresholds move, continuity moves with them.
The corpus is read once per trace and reused for every frontier expansion. Reading it per node would multiply the only stage that touches every row by the number of nodes, for no new information.
Cycles. The enumerator keeps a visited set along the current walk. A node already on a walk cannot be stepped on again (a path that revisits content is not a longer path, it is the same path with a detour), so the longest possible walk is bounded by the node count, far above the depth cap. Every cycle declined is counted and reported.
Every prefix is a path. Reaching B on the way to C is a real finding about B, and recording only the far end would throw away the shorter, stronger statement.
No invisible caps. Every bound above is counted in SearchAccounting, and coverage_note prints every one that bit, ending: anything not listed was not ruled out; it was not examined. A unit test drives each field individually and fails if any produces no sentence.
29.8 Contradictions and ambiguity
Four kinds, all surfaced and none resolved:
| kind | what it means |
|---|---|
AMBIGUOUS_DIRECTION | the pair was measured both ways round and both say the other came first |
UNORIENTED_CHAIN | a chain's hops disagree about order |
MULTIPLE_ANCESTORS | more than one recorded content connects at INFERRED |
TEMPORAL_TENSION | the pixels and the recorded times point opposite ways |
TEMPORAL_TENSION is the one worth dwelling on. A file copied into a project years after it was made produces it honestly, and so does a mistaken measurement. The engine cannot tell those apart and does not pretend to. The recording time is a fact about when AFTRIMGE looked; the direction is a measurement of pixels; both can be true at once, and choosing between them would mean deciding that one kind of evidence outranks the other.
MULTIPLE_ANCESTORS says out loud that nothing is being picked: "the evidence supports each of them, and a single answer would be a choice rather than a finding."
29.9 Released content
A content node whose bytes retention released stays on the map. It keeps its occurrences, it keeps its place on the time axis, and it is drawn hollow. What it cannot do is grow a new edge, because growing one would mean measuring pixels that are gone. Those candidates are listed in unavailable_evidence with the reason, and counted in the accounting.
The near end of a comparison is never re-materialised (see §29.11).
29.10 Persistence: why there is no continuity table
An explicit decision, not an omission. Phase 7 adds no schema, no migration, no cache family and no new source of object liveness.
The expensive work in a trace is the archaeology cascade, and that is already persisted and versioned in artifact_relations, keyed by the pair and the analysis version. A second trace over the same neighbourhood re-walks the graph (cheap) and re-reads every comparison it needs (free). Measured: a second trace of the same subject performs 0 new investigations and reports its cache hits. The cache exists at the layer where a cached answer stays true.
A whole report cached against a subject would not stay true. Its candidate universe is every row of visual_index, which grows every time the user saves a file. A stored report would begin quietly hiding content recorded after it. One adversarial test drives exactly that sequence (trace, record new related content, trace again) and requires the new content to appear.
Because nothing is stored, there are no GC implications to audit: the engine holds no references, owns no bytes, and cannot keep an object alive or let one be collected.
29.11 Two questions that look like one, again
The Phase 6 note in §25 has a sibling here, and it was found the same way: by a test failing for the right reason.
The subject of a trace is an ANALYSED artifact: an image AFTRIMGE was shown, not one it holds. MemoryBridge::materialise refuses those deliberately, because reading an analysed artifact's pixels means reading a path the user can move. So asking it to materialise the subject returned None, and the walk measured nothing at all. Six tests failed with "found nothing", which is the most misleading way for a search engine to break.
The fix is not to loosen materialise. It is to stop asking: the near end of every comparison is an artifact the trace already has in its hand: the subject it was called with, or one it materialised on a previous hop. The engine now carries a content_hash -> artifact_id map, seeded with the subject, and only ever materialises the far end.
30. Selective retention
Derived knowledge may tell the user what is worth keeping. Only the user's preservation decision creates a retention reference.
Continuity can reconstruct relationships across a whole archive, which makes it tempting to let a strong finding protect its own content automatically. That would be the wrong system: a measurement would start deciding what survives, an algorithm change would silently change what is kept, and nobody could point at the moment a decision was made. Nothing in preservation runs on its own.
30.1 A third ontological category
Phases 1–7 had two kinds of knowledge on screen. Phase 8 adds a third, and it is not a third degree of certainty; it is a different kind of statement:
■ RECORDED solid AFTRIMGE watched this happen.
⋮ MEASURED dashed AFTRIMGE measured this relationship, and graded it.
▣ KEPT BY YOU double You chose to keep this content.
The third is a fact, in the strongest sense available (AFTRIMGE performed the action at the user's instruction and recorded it), but it is a fact about a person, not about an image. That content is preserved says nothing whatever about where it came from.
Double borders are reserved for preservation and used nowhere else. Glyph, border and label all differ across the three, so the categories survive being read without colour; preservation.test.ts asserts all three sets are of size three.
30.2 The persisted model
Two tables, and the split is the design.
preservation_sets -- one user action. The fact.
id, label, source_kind, source_version, subject_hash, evidence, created_at
preservation_members -- one content identity that action names.
preservation_id → preservation_sets(id) ON DELETE CASCADE
content_hash -- NO foreign key to objects
available_at_creation -- frozen: were the bytes here when they chose?
position
CREATE INDEX idx_preservation_members_hash ON preservation_members(content_hash);
Why a set rather than per-content rows. Both were viable and the audit decided it. A set gives one durable entity per user action, so withdrawal is one statement and the cascade does the rest; it lets the reason be stored once rather than repeated per hash; and it still gives the collector exactly what it wants, which is an indexed content_hash column. Per-content rows with an action id would have been the same design, normalised worse.
Why content_hash has no foreign key to objects. The same reason visual_index has none (§27). The bytes exist today and the user previously asked to preserve this content are different facts, and the second must survive the first becoming false. A preservation naming content that is already gone is a real, recordable decision, and the reference standing means that if the content is ever recorded again it is protected from that moment.
30.3 The evidence boundary, surviving persistence
The trap this phase had to avoid: persisting a preservation of
content A ──INFERRED──▶ content B ──POSSIBLE──▶ content C
must not turn into a stored claim that A caused B caused C.
Four mechanisms, and none of them is frontend wording:
PreservedMemberhas no grade field. Not a grade set to something cautious, but no field at all. A unit test serialises one and asserts the JSON contains nograde,verdict,relationorconfidencekey.PreservedRelationnames every field "recorded".recorded_grade,recorded_at_version,recorded_summary. A reader of the serialised form cannot mistake a quotation for a current finding, and a test asserts the bare names"grade"and"version"never appear.- The caller cannot supply evidence.
PathSelectioncarries content hashes and nothing else.Preserver::recover_evidencereadsartifact_relations(AFTRIMGE's own record of what its cascade concluded), so a route cannot arrive carrying grades somebody else chose. A pair with no stored relation contributes no testimony rather than a guess. - The version is frozen.
source_versionandrecorded_at_versionare written once. A later algorithm may trace the same images differently; that must not rewrite the historical statement on this date the user preserved these hashes based on this investigation, and nothing recomputes it.
Preservation also creates no snapshot, no event and no occurrence. There is no path from these tables into snapshots, events or files, and an integration test asserts the four history counts are unchanged across preserving and collecting.
30.4 The unified liveness rule
One string, and Phase 8's collector change is entirely contained in it:
const LIVE_REFERENCES: &str = "\
SELECT DISTINCT content_hash FROM snapshots WHERE stored = 1 \
UNION \
SELECT DISTINCT content_hash FROM artifacts WHERE custody = 'ARCHIVED' \
UNION \
SELECT DISTINCT content_hash FROM preservation_members";
It is read by all three places liveness is decided: audit, the collector's candidate filter (via referenced_set), and the final pre-deletion re-check (is_referenced_now). Adding preservation to it is the whole collector change: nothing downstream needed to learn what a preservation set is.
The collector does not know what continuity is, and must not. It asks one question (is anything pointing at this content right now?) and the answer is an indexed lookup on an identity. It never runs archaeology and never reconstructs a graph.
Phase 8 also removed the last duplicated copy of that query. referenced_set is now the only place it runs in bulk, so the audit and the candidate filter cannot drift apart the way the filter and the re-check did in Phase 6.
the_candidate_set_and_the_final_recheck_can_never_disagree asserts the two agree across the full matrix of holders (snapshot, artifact, preservation, nothing), which turns we made the strings match into they read the same string.
30.5 A preview that had been asking a narrower question
Found by the audit, before any preservation existed.
retention::preview computed reclaimable objects from snapshots alone. An object kept alive by an archived artifact therefore counted as reclaimable, and was then correctly kept by the sweep: a preview promising space it could never deliver. The numbers disagreed by exactly the archived set, silently, and adding preservation would have widened the gap by every preserved object.
The fix composes rather than duplicates: gc::NON_SNAPSHOT_REFERENCES is the half of the liveness rule a retention policy cannot release, and the preview's query excludes it. the_retention_preview_and_the_collector_agree_about_what_can_be_freed asserts preview.objects_reclaimable == outcome.objects_deleted over a fixture containing a preserved object.
30.6 Lifecycle
TRACE derived, transient, persists nothing
↓
user selects a route the only step that creates a reference
↓
PRESERVATION SET one action, durable
↓
member hashes one more branch of LIVE_REFERENCES
Preserve. Records the decision for every selected identity. Completeness::AvailableMembers (the default) records whatever the route names and reports honestly what it holds; RequireCompletePath refuses unless every identity's bytes are present, and writes nothing when it refuses. Bytes are never copied: preservation adds a reason to keep content already in the store, or records a decision about content that is not. There is deliberately no route by which it reaches out to a filesystem path or an outside artifact to fetch what is missing; that would be a custody transfer, and nothing here is authorised to perform one.
Withdraw. Removes one action's references and nothing else. Content still held by a snapshot, an archived artifact or another preservation set stays exactly as live as it was. Withdrawal deletes no bytes: collection is a separate, explicit sweep, so it is reversible right up until the user runs one. The interface says so, and says how many identities would be exposed (MemberState::other_holders) before anyone clicks.
Status is computed, never stored. available_at_creation is frozen: a fact about the moment of choosing. Whether the bytes are here now is read from the store at display time, because a stored copy would go stale in the one direction that matters: claiming content is secured when it is not.
30.7 Measured
| 200 preservation sets over 40 identities | live set 207 µs, 40 re-checks 2.14 ms |
| the live set's size | 40: identities, not reasons |
| releasing a 120-member set | 517 µs, one statement, cascade does the rest |
Liveness scales by indexed identity. The live set grew with the number of distinct content hashes, not with the number of reasons, which is the property that would break if a per-set walk had crept in.
31. The storage ledger
Three questions, and nothing else:
What content is known?
Why is each piece still here?
What would happen if one reason were withdrawn?
An inspection and hypothetical layer. It invents no liveness semantics and cannot: every answer comes from the same enumeration the collector reads.
31.1 What the audit found: five definitions of "live"
Phase 8 claimed one unified predicate. The repository had five:
| # | where | what it was |
|---|---|---|
| 1 | gc::LIVE_REFERENCES | the authority |
| 2 | gc::NON_SNAPSHOT_REFERENCES | textually duplicated inside #1 rather than composed into it |
| 3 | preservation::hydrate | a hand-written copy of all three branches, for other_holders |
| 4 | artifacts::archived_hashes | its own copy of the archived branch |
| 5 | retention::reclaimable_objects_sql | correctly composed from #2 |
Four of them agreed. That is precisely the state Phase 6's race grew from: the filter and the re-check agreed in every single-threaded run right up until they did not. Phase 8 fixed one drift and introduced another in the same commit.
So Phase 9 did not add a sixth. It deleted four.
31.2 One enumeration
pub enum HolderKind { Snapshot, ArchivedArtifact, Preservation }
impl HolderKind {
pub const ALL: [HolderKind; 3] = [...];
pub const fn source(&self) -> &'static str { /* one SELECT per kind */ }
}
pub fn union_of(kinds: &[HolderKind]) -> String
pub fn live_references() -> String // union_of(ALL)
pub fn non_snapshot_references() -> String // union_of([Archived, Preservation])
Everything is built from this: the collector's candidate filter, the final pre-deletion re-check, the audit, the retention preview, every ledger entry, every holder list and every hypothetical. Adding a holder means adding a variant. Nothing else has to be remembered.
union_of(&[]) returns SELECT NULL AS content_hash WHERE 0 rather than an empty string, because a hypothetical world with no holders must produce valid SQL that matches nothing, not a fragment a caller splices into NOT IN () and inverts.
31.3 The four states
Each corresponds to a distinction the schema can already draw. None was invented because it sounded useful; StoreAudit has reported the last two since Phase 4 as missing_objects and orphaned_files.
| ledger row | bytes on disk | holders | ||
|---|---|---|---|---|
HELD | yes | yes | ≥ 1 | something points at it |
RECLAIMABLE | yes | yes | 0 | a sweep may take it |
MISSING | yes | no | any | known, and the bytes are gone |
UNTRACKED | no | yes | 0 | bytes with no record |
The interesting cell is MISSING with a holder. A preservation recorded about content that has already been lost is a real decision (§30.2), so the holder is still listed, but the state is MISSING, not HELD, because calling it held would promise bytes AFTRIMGE does not have. classify() is a pure function and the whole table is asserted, including that cell.
RECLAIMABLE never means will be deleted. Collection stays a separate, explicit act, and the wording is tested for it.
31.4 The hypothetical
REMAINS HELD something else holds it
BECOMES EXPOSED this was the last reason
ALREADY RECLAIMABLE nothing held it before either
Not total_holders - 1. That is only equivalent when every holder is distinct, and they are not: one preservation naming a hash once and a snapshot naming it three times is four holder rows and two reasons. The forecast re-derives the holder list from the real tables with the nominated reason filtered out, so a second preservation naming the same content keeps it held, which a subtraction would get wrong.
Every identity that survives is explained individually, by name:
REMAINS HELD
hero.png → SNAPSHOT: hero.png in Brand Identity
logo.jpg → ARCHIVED ARTIFACT: logo.jpg
reference.webp → PRESERVATION: Campaign Continuity
The forecast writes nothing. a_forecast_writes_nothing_at_all fingerprints the object store, the ledger rows, the preservation sets and the member rows, runs every hypothetical the module offers, and requires the fingerprint unchanged.
31.5 Why the ledger and the collector cannot drift
Not "we keep them in step". They read one enumeration, and the holder matrix proves it by driving five independent entry points over every combination:
SNAPSHOT ARTIFACT PRESERVATION checked against
──────── ──────── ──────────── 1. ledger.build() whole-ledger state
0 0 0 2. ledger.inspect() single entry
1 0 0 3. gc::referenced_set bulk candidates
0 1 0 4. gc::is_referenced_now final re-check
0 0 1 5. gc::collect() the collector itself
1 1 0
1 0 1 ...and the ledger's prediction of what
0 1 1 would be collected is compared with
1 1 1 what actually was.
Eight combinations × five paths. Not string comparison, but the actual code paths, including a real sweep at the end.
preservations_other_holders_agrees_with_the_ledger closes the loop on the copy that was deleted: other_holders now asks the ledger, and the test asserts the two answers match member by member.
31.6 No migration
Phase 9 adds no schema. Every answer is derivable:
objects + the holder enumeration + existing holder metadata
Caching derived liveness would create the one thing this phase exists to prevent: a second stored opinion about what is live, able to go stale. Measured instead (see below), and the numbers did not justify it.
31.7 Measured
| 301 objects, 250 holders | |
|---|---|
| build the ledger | 37.5 ms |
| inspect one object | 1.25 ms |
| collection forecast | 34.3 ms |
| forecast withdrawal of 150 members | 95 ms |
| queries to build | 4: one for objects, one per holder kind |
The query count is asserted, not observed: 1 + HolderKind::ALL.len(), so an N+1 holder lookup fails the build rather than showing up as a slow screen.
Two costs were measured and then fixed, in that order. forecast_withdrawal called holders_of twice per member (once for "before", once for "after", the same question asked twice) and looked up each size individually. Reading the holders once and batching the sizes took the ledger from 100.7 ms to 37.5 ms and the collection forecast from 80.8 ms to 34.3 ms.
The remaining 95 ms for a 150-member forecast is three queries per member, which is inherent to needing a per-member holder list. It is a user-initiated action well inside an interactive budget, so it stands unoptimised until a measurement says otherwise.
31.8 Custody is not provenance
The ledger explains why bytes are on disk. It never carries an archaeology grade, a continuity grade or a confidence, and it cannot: Holder and LedgerEntry have no field for one. A test serialises a holder and asserts the JSON contains none of grade, confidence, relation, verdict or inferred; the frontend has the same test against its own vocabulary.
That separation is also visual. The ledger's states use double, dashed, dotted and absent rules: a fourth visual language, deliberately unlike the archaeology grades and unlike preservation's double-ruled KEPT BY YOU, so a storage screen can never be read as an evidentiary one.
32. Memory integrity and custody
32.1 Four questions, and the one nothing could ask
EXISTENCE is there a row, and a file? objects, StoreAudit
LIVENESS does anything still hold it? gc::HolderKind
PROVENANCE where did this image come from? archaeology
INTEGRITY are these bytes actually that ← nothing, until Phase 10
object?
They are not degrees of one thing. An object can be HELD by four reasons, carry a rich archaeology history, and be silently corrupt.
What the audit found. An object's filename is its claim: objects/ab/cdef… asserts that those bytes hash to abcdef…. Nothing verified it. put_bytes hashes what it writes, but put_file accepts a caller-supplied hash and never checks the copy, deliberately, to avoid reading large files twice. read, copy_to and contains never verify. The single exception was restore::apply, which refuses to write a corrupt object into a user's folder; that is a check at the point of use, not a way to ask the question.
So a modified object was undetectable until someone tried to restore it.
32.2 The six states
✓ VERIFIED bytes read in full, SHA-256 matched
⚠ CORRUPT bytes exist and hash to something else, both digests reported
✕ MISSING known object, bytes not on disk
⊗ UNREADABLE could not be read at all
· UNVERIFIED nobody has looked
? UNTRACKED bytes with no identity behind them
Two of these matter more than they look.
UNREADABLE is not CORRUPT. A locked file, a permission failure or a dying disk says nothing about whether the content is wrong. Conflating them would let a transient error look like corruption and invite a "repair" that overwrote perfectly good bytes. Repairer::plan refuses to touch an object it could not read, in as many words.
UNVERIFIED is not reassurance. It is the honest answer for anything outside the scope that ran, and the reason a scoped pass cannot be read as a clean bill of health. Both the engine's statement_for and the interface's wording are tested for it.
Nothing is persisted. Verification is a statement about bytes at a moment; a stored VERIFIED would be a claim about the past dressed as a claim about now, and it would go stale in the one direction that matters.
32.3 Streaming
Verifier::check calls hasher::hash_file, the same 64 KiB streaming reader the ingest pipeline uses. Nothing in the integrity path calls store.read(); that loads the object whole, which is exactly what must not happen. A four-gigabyte object is verified in constant memory.
Measured: 7.6 MB verified in 839 ms (9 MB/s) in a debug build, and a 200-object whole-store pass at 2 queries. The query count is asserted, not observed, so an N+1 fails the build rather than showing up as a slow screen.
32.4 Repair, and why a source is trustworthy
A repair source is trustworthy only because its bytes were read and hashed to the identity being repaired. Nothing else vouches for anything.
Not because it looks the same. Not because archaeology rates the two images INFERRED, not because a continuity route connects them. Those are evidence systems about pictures; this is about bytes. A visually identical reconstruction that hashes differently is not a valid source for that identity; it is a different object that happens to look alike.
Content addressing does the rest of the work: any file whose bytes hash to H is object H. Candidates are a retained snapshot's working file, or an ANALYSED artifact's source file. Notably absent: the object store itself. Repairing object H from object H is repairing corruption with itself, and a test asserts the store never offers itself.
The sequence, each step load-bearing:
verify the damage don't repair what isn't broken
verify the source the only thing that makes it a source
stage a copy never write over the target directly
verify the staged copy the copy could itself have gone wrong
atomic rename the store's own commit primitive
verify the target a rename can report success and lie
If any step fails: REFUSED, not best effort. And there is no force option, and the refusal wording is tested for the absence of one.
32.5 The repair invariant
After a successful repair:
SHA-256(object bytes) == object content hash ✓ asserted
liveness(before) == liveness(after) ✓ asserted
history(before) == history(after) ✓ asserted
archaeology(before) == archaeology(after) ✓ asserted
repair_changes_bytes_and_nothing_else snapshots the event count, snapshot rows with their stored flags, file identities, artifacts and artifact_relations, repairs a corrupt object held three different ways, and requires every one of them byte-for-byte unchanged.
32.6 Maintenance is not history
A repair creates no event, and event_type = 'REPAIR' would have been wrong. The events table describes observed history (what the user's work did to files in watched folders), and the whole timeline, replay, evolution and archaeology stack reads it that way. A repair row there would appear in replay, in the lanes, in the trail, and in every count of what happened to a file. It did not happen to the file. It happened to the store.
So maintenance_log (v7) is a separate table in a separate domain, read by nothing that reasons about history. A migration test asserts it has no foreign key to events, snapshots, files or objects, and that the events table's CHECK constraint never grew a REPAIR type.
Why it persists at all, when Phases 9 and 10 otherwise prefer derived state: a repair cannot be re-derived. Once the bytes are correct, nothing distinguishes an object that was always sound from one that was silently rebuilt last Tuesday. For a system whose premise is "I can tell you whether what I recorded is still trustworthy", rewriting a user's archive and keeping no account of it is the wrong answer.
32.7 Custody actions
Phase 9 left the ledger able to explain three holders and act on one. The audit found why: Preserver::release existed; snapshots could only be released by a bulk age-based policy; and archived artifacts had no unarchive operation at all: Phase 8 and 9 tests un-archived by raw SQL.
Two operations were added, each in its own domain:
| holder | operation | rule it keeps |
|---|---|---|
| Snapshot | retention::release_snapshot | refuses a file's newest version, so the present stays restorable |
| Archived artifact | artifacts::release_custody | the artifact and every measurement survive; it becomes ANALYSED |
| Preservation | Preserver::release | the decision record goes with it |
Withdrawable has one variant per holder kind and Ledger::withdraw dispatches to the owning domain. There is deliberately no generic DELETE HOLDER: those three mean different things, and collapsing them into one verb would erase the differences the rest of the system depends on. The ledger is the unified interface; the domain rules stay separate.
None of them deletes bytes. There is no path from withdraw to gc::collect.
32.8 Concurrency: the contract, stated rather than invented
The database is one Mutex<Connection> inside one process, WAL, with a 5-second busy timeout. There is no cross-process lock, and Phase 10 did not add one: the intended contract is single-instance, and inventing a locking scheme without a demonstrated need would be adding a mechanism to a problem nobody has shown.
Within a process, verification and collection can interleave, and that is safe by the same reasoning the collector already relies on:
- Repair racing GC. GC only deletes objects nothing references. If repair rebuilds one GC is about to take, the object is unreferenced either way and the outcome is the same. If GC deletes first and repair renames in after, the result is an unreferenced object the next sweep takes.
- Verification racing retention. Verification reads; retention flips
storedflags. A pass may report on an object released a moment later, which is a scope statement about a moment, which is exactly what a verification report is.
What is not claimed: two AFTRIMGE processes over one store. Nothing enforces that today, and Phase 10 does not pretend otherwise.
32.9 Measured
| verify 7.6 MB (debug) | 839 ms, ~9 MB/s, constant memory |
| whole store, 200 objects | 68 ms, 201,800 bytes hashed, 2 queries, 200 reads |
| held scope, 50 of 200 | 9.7 ms |
| single object | ~1 ms |
The query count for a whole-store pass is 2 regardless of object count, and the test asserts it.
33. Evidence provenance
33.1 What the audit found
Almost every ingredient of provenance was already here, and none of it could be asked for.
artifact_relations stores the entire serialised TransformReport beside every conclusion (the cascade, the residuals, both fingerprints, every EvidenceLine), plus the ARCHAEOLOGY_VERSION that produced it, in a table whose schema comment already says "analytical history is never silently rewritten". Continuity reports carry their content nodes and measured edges in full "so any hop can be opened". analysis_cache records both input hashes, its Family and its version. Four algorithm versions are separate constants with separate cache families, each documented against the mistake of one silently invalidating another.
What was missing was a way in. Every one of those structures is a by-product of running the operation that made it. You could see the evidence for a relation by re-running the survey that found it; you could not point at a stored conclusion and ask it to account for itself. The evidence existed; the question did not.
And one thing was genuinely absent: nothing joined a claim to Phase 10's integrity model, so nothing could distinguish
"we once had evidence" a record, still true
"we can still independently check it" a different statement
33.2 The four levels, and why only three are a type
RECORD AFTRIMGE observed or performed it. No reasoning involved.
MEASUREMENT a deterministic computation over known inputs.
DEDUCTION a deterministic rule applied to records and measurements.
─────────────────────────────────────────────────────────────────────
PRESENTATION what the interface says; deliberately not a variant.
provenance::level::EvidenceLevel has three variants. Presentation is not a kind of evidence; it is a rendering of one, and it happens in the frontend. Giving it a backend variant would create somewhere for a presentation decision to be stored, and the first thing anyone would store there is a sentence that outranks its own evidence. Both level.rs and provenance.ts are tested for its absence.
33.3 Two orthogonal questions, kept apart
REPRODUCIBILITY can the inputs still be produced as the objects the
claim needs? Answered by Phase 10's Verifier.
CURRENCY was this reached by the algorithm version running now?
Answered by the version recorded with the claim.
Folding these into one "staleness" word would make two unlike cases look alike. A superseded conclusion whose objects all survive is perfectly reproducible by the algorithm that produced it; a current conclusion whose inputs are gone cannot be reproduced at all. a_superseded_conclusion_is_still_reproducible_when_its_inputs_survive pins the distinction.
Neither is a grade. Reproducibility is tested for the absence of the grade vocabulary, and Currency::Superseded is tested for the absence of invalid, incorrect, wrong, outdated. A superseded conclusion is not a wrong one; it is one the current algorithm has not been asked to restate.
33.4 The reader owns no facts
provenance::Explainer writes to nothing. An explanation is a join over canonical records (artifacts, relations, snapshots, events, preservation sets, the object store, the verifier), composed at read time. There is no provenance table and no migration, because a stored explanation would be a derived value kept in a second place, which is the defect this codebase collapses rather than creates.
It is also not a second evidence authority. Grades come from where they always came from:
| authority | input | question |
|---|---|---|
transform::conclude | measurements over pixels | what transformation connects two images |
ranking::assess | two hashes + a report | what the memory and continuity graphs may claim |
replay::evolution | an Evidence value | how strongly observed history is known |
Three authorities over three different kinds of input, which is correct; collapsing them would mean one function deciding questions it has no evidence for. ranking::assess's own comment claiming to be "the one place a grade is decided anywhere in AFTRIMGE" was an overstatement, and is corrected.
What is genuinely single is the FACT rule: conclude reaches FACT only through TransformKind::Identical, which investigate produces only when the caller passes identical_bytes, which compare computes only as subject.content_hash == candidate.content_hash. assess takes the two hashes rather than a caller's claim about them, and MeasuredEdge::between refuses equal hashes outright. Nothing else in the repository can produce a relation- level FACT.
33.5 A claim is addressed by identity, never by path
pub enum Claim {
Relation { id: String },
Anchor { content_hash: String },
Occurrence { snapshot_id: String },
Preservation { id: String },
}
Paths rename, disappear, are reused and point at different bytes over time; a claim addressed by one would silently become a claim about something else. This is also what keeps the IPC surface safe: there is no way to phrase a request for an arbitrary file. RepairSource.path was #[serde(skip)] for the same reason in Phase 10, and Reachability::AnalysedSource carries a name here, never a location, as asserted by invariant_an_explanation_never_hands_out_a_filesystem_path.
33.6 The custody asymmetry, which was backwards
The sharpest defect the audit found. Investigator::pixels re-hashed an ANALYSED artifact's source file before believing its pixels (because AFTRIMGE does not own that file and it may have changed), while an ARCHIVED artifact's bytes came straight out of store.read() with no check at all.
So the one path AFTRIMGE owns was the one path that could feed corrupt pixels into a measurement and attribute the result to the identity those bytes no longer have: a real-looking INFERRED relation about content that is not there. Phase 10 proved the store's filename is a claim nothing was verifying; this is what that gap could produce.
Both paths verify now. The check is a SHA-256 over bytes already in memory against a decode that costs far more, so there was never a performance argument for the asymmetry either. a_corrupt_stored_object_never_produces_a_relation_about_the_identity_it_is_not asserts both the refusal and that no row is written.
33.7 A degenerate second authority, collapsed
ContentNode carried an Availability field with PRESENT and RELEASED, and RELEASED was never constructed: a node is inserted only once an edge has connected it, and building an edge requires reading pixels, so a node whose bytes are gone never exists. The field was a constant that read like a measurement, and the interface believed it. PreservePath counted "how much of this route AFTRIMGE could hold right now" by filtering on PRESENT, which matched every node, under a comment saying the number was "taken from the map rather than guessed".
UnavailableEvidence was the real authority all along: populated, naming the content, carrying the occurrences that survive it. So the field is gone, the list is the answer, and it now says why:
○ RELEASED nothing holds the bytes. Retention working; not a fault.
⚠ UNVERIFIABLE bytes exist under this identity and are not this content.
⊗ UNREADABLE could not be read at all. Says nothing about the content.
Exactly one of the three is an alarm, and the frontend test asserts which.
A related pre-existing bug surfaced while wiring this: measure propagated a comparison failure with ?, so a single unreadable artifact aborted an entire continuity trace with an error where a partial map was the right answer. Both outcomes now land in unavailable_evidence with the reason kept distinct, and the reason is established by Phase 10's Verifier rather than by a second opinion about what a byte mismatch means.
33.8 A version conflation, fixed additively
A preservation quoted a grade out of artifact_relations (where every row carries the ARCHAEOLOGY_VERSION that measured it) and stamped CONTINUITY_VERSION beside it. Two different algorithms, two different constants, one field. Both are 1 today, so nothing was visibly wrong, and nothing would have been until one of them moved.
PreservedRelation gains recorded_analysis_version: Option<u32> with #[serde(default)]. The evidence is a JSON column, so no migration was needed: quotations written before Phase 11 deserialise as None, which reads as "nobody wrote it down" and never as "current". Old quotations are not rewritten.
33.9 The evidence inspector
Seven questions in a fixed order, and the order is the argument:
WHAT AM I LOOKING AT? the claim, and what kind of statement it is
WHAT IS RECORDED? ▪ solid rule
WHAT WAS MEASURED? ▭ double rule
WHAT WAS DEDUCED? ◇ dashed rule
WHAT ARE THE INPUTS? object identities, each with Phase 10's verdict
CAN IT BE REPRODUCED? a separate question from whether it is true
WHICH VERSION SAID IT? a third, separate question
Empty sections are printed rather than hidden. An anchor has no measurements and no deductions, and an interface that quietly omitted those headings would let a bare record look like something that had been investigated.
The last two sit apart from the grade chip, because neither is a grade. The NOT REPRODUCIBLE box is marked (dashed, heavier) because it is worth noticing, and is not marked as an error, because the conclusion it describes was not withdrawn. The line under a claim whose inputs are gone says both halves in one breath, since either alone misleads:
Recorded deduced. The evidence behind it can no longer be independently checked. That does not unmake it.
33.10 Measured
| explain one relation | 5 queries, 2 objects verified, 195,006 bytes hashed, 6 ms |
| explain a 40-member preservation | 26 queries, 25 objects verified, 3 ms |
| explain an occurrence with missing bytes | 0 bytes hashed |
investigate with 2 findings | 4 round trips |
investigate with 5 findings | 4 round trips |
The relation figure is fixed (one relation row, two artifacts, two verifications) and does not grow with how much archaeology the archive holds. The preservation bound exists because verification reads bytes, and a set of ten thousand would otherwise turn one question into a whole-archive pass nobody asked for; MAX_EXPLAINED_INPUTS caps it at 25 and the explanation says how many it looked at.
The last two lines are the §27 rule, and they are the property rather than a budget: investigate called artifacts::get once per stored relation, so a list of N findings cost 1 + N round trips on a screen that is otherwise a pure read. Measured across two corpus sizes, because a fixed budget would pass by accident the moment someone raised the constant. With the batched read reverted the same test reports 5 round trips for 2 findings and 8 for 5, which is what makes it a regression test rather than a description.
Database::round_trips is what measures this: one relaxed atomic increment beside a mutex acquisition already being paid for, so "this screen does not cost a query per row" is assertable by code that has no accounting struct of its own.
33.11 Grade semantics, stated once
The five grades are the product's central honesty rule made into a type. They live in core::models rather than in any subsystem, because three subsystems speak them and a second vocabulary would be exactly the fracture the rule exists to prevent.
| grade | what produced it | what it entitles the interface to say |
|---|---|---|
FACT | equal SHA-256, an OS-reported rename, or an operation AFTRIMGE performed and verified | this is so; no reasoning was involved |
TEMPORAL | sequence or timing alone | when things happened relative to each other, and nothing about cause |
INFERRED | deterministic measurement over real but indirect evidence that passed the strong threshold | a reproducible deduction, not a proof |
POSSIBLE | real evidence that did not reach the strong threshold | a possibility; the honest answer to most archaeology |
UNKNOWN | a test ran and declined | the question was asked and the evidence does not answer it |
Two rules hold across all of them:
UNKNOWNis not "not asked". It means a test ran. An unrecognised or future grade parses toUNKNOWNrather than being promoted into a claim.- A chain is never stronger than its weakest justified hop, and
continuity::composeadditionally clamps every composed grade atINFERRED, so no walk over measured edges can produceFACTorTEMPORAL.
33.12 Four questions, now five
EXISTENCE is there a row, and a file? objects, StoreAudit
LIVENESS does anything still hold it? gc::HolderKind
INTEGRITY are these bytes actually that integrity::Verifier
object?
PROVENANCE where did this image come from? archaeology, continuity
─────────────────────────────────────────────────────────────────────────
ACCOUNTABILITY what exact records, measurements provenance::Explainer
and rules produced that answer,
and can any of it still be
checked?
The fifth is not a degree of the fourth. Provenance is a claim about where an image came from; accountability is a claim about the evidence for that claim, and it can be gone while the provenance record stands. That is the whole separation Phase 11 exists to hold:
- Integrity does not imply provenance. Verified bytes are not proof of a historical relationship.
invariant_a_verified_object_is_not_proof_of_a_relationship. - Provenance does not imply integrity. A recorded relationship says nothing about whether its objects' current bytes are sound.
invariant_a_recorded_relationship_says_nothing_about_current_bytes. - Evidence loss does not rewrite history. Collecting an object cannot make a recorded occurrence untrue.
invariant_evidence_loss_never_erases_a_recorded_occurrence. - Reasoning does not mutate records. Tracing continuity and explaining a claim are reads, twice over.
invariant_a_deduction_never_mutates_the_record_it_reads.
34. Object admission
34.1 The boundary Phase 11 named
Phase 10 established that AFTRIMGE could not tell whether stored bytes were still the object they claimed to be, and gave it a verifier. Phase 11 found that one write path still took a caller's word for an identity. This is that path:
// before
pub fn put_file(&self, source: impl AsRef<Path>, hash: &str) -> Result<PutOutcome> {
let target = self.object_path(hash)?;
if target.is_file() { return Ok(PutOutcome::Deduplicated); }
let staged = self.stage_path(hash);
let bytes = fs::copy(source, &staged)?; // never hashed
self.commit(staged, target, bytes) // committed anyway
}
The doc comment said "the caller must already have hashed the file". Both production callers had, and both hashed at a different moment than they copied.
34.2 What the audit found
Every path by which bytes can become a canonical object:
| ingress | input | who computes the hash | who verified the bytes | atomic | on failure |
|---|---|---|---|---|---|
ingest::record_version_inner → put_file | a watched file | hasher::hash_file, several DB round trips earlier | nobody | rename | abandon this version |
archaeology::archive → put_file | an imported file | hasher::hash_file, at import time, possibly days earlier | nobody | rename | propagated to the user |
integrity::repair (its own stage/rename) | a verified source | verify_path on the staged copy | itself | rename | REFUSED |
put_bytes | an in-memory buffer | itself | itself, trivially | rename | error |
restore::write_verified | an object | hash_file twice, before and after | itself | rename | error, and it writes into the user's folder, not the store |
Four findings:
put_bytes had no production callers at all. The safe primitive was used only by tests; the unsafe one carried all the traffic.
Repair was a second implementation of "stage and publish". It staged target.with_extension("repair-staging") (inside the shard directory), so an interrupted repair left a file that clean_staging never swept (it only sweeps .tmp), that list_objects refused to name (the filename is not valid hex), and that usage() counted anyway. An invisible, unreclaimable leak in the reported disk footprint.
The staging path collided. {hash}.{pid}.part is identical for two threads in one process admitting the same content. Measured: six concurrent admissions of one payload produced one success and five sharing violations on Windows; on Unix they would have interleaved into each other's bytes.
The identity race was real and reachable from both callers.
T0 hash the source -> H
T1 ...database round trips, or the user goes for lunch...
T2 copy the source -> bytes that may no longer be H
T3 commit them as H
archive has the wider window by far: an artifact is hashed at import and copied whenever the user later clicks Archive.
34.3 What put_file meant, and what it means now
It meant (B): store bytes the caller claims correspond to this hash. The ambiguity was load-bearing (the fast path depended on it), and it was preserved only because the callers happened to use it correctly most of the time.
It now means (A): store this file and verify it. The signature is unchanged, so no caller was broken; what changed is that the second argument became a proposal rather than an instruction.
A caller may propose an identity.
The object store never trusts that proposal.
The exact bytes staged are hashed as they are written.
Only if computed == proposed may they become that object.
34.4 The canonical chokepoint
put_file(path, expected) put_bytes(&[u8])
│ │
└────────── stage ─────────┘
│
one pass: buf[..n] ──┬──► Sha256::update
└──► BufWriter::write_all
│
sync_all
│
Staged { hash, bytes }
│
┌────────────┴────────────┐
│ │
commit_as(expected) commit_over_for_repair(expected)
│ │
hash == expected? hash == expected?
│ │
rename (dedup rename (replace:
if occupied) maintenance only)
Three functions rename to a content address and each compares first. commit is private and reachable only from Staged::commit_as. There is no fourth path, as asserted by a repository-wide search re-run after implementation, and by a test that reads the source tree and requires commit_over_for_repair to be mentioned in exactly two files.
34.5 Staged, and why it is a type
stage returns a value, not a path:
#[must_use = "a staged object is removed unless it is committed"]
pub(crate) struct Staged { path: PathBuf, hash: String, bytes: u64 }
impl Drop for Staged { /* removes the file */ }
Staged and canonical are different things in the type system rather than only in a comment. A Staged is not at an object address, nothing that reasons about objects can see it, it carries the identity its bytes established rather than one a caller proposed, and it cannot be published without an identity check. Every early return between staging and commit (a read failure, a write failure, the refusal itself, a panic) removes the file, so refusals leave no residue. That is asserted by running five refusals in a row and requiring .tmp to be empty afterwards.
34.6 One pass, and what it costs
The hasher and the writer are handed the same slice, buf[..n], in that order and nowhere else. There is no arrangement of reads in which the digest describes a different byte sequence than the file, which is what separates this from a "one-pass" implementation that hashes one stream while writing another.
The cost is real and was measured rather than characterised. Release build, Windows, best of three, only the admission timed:
| size | verified | fs::copy | ratio | throughput |
|---|---|---|---|---|
| 4 KB | 1.29 ms | 0.91 ms | 1.42× | 3 MB/s |
| 100 KB | 1.65 ms | 0.84 ms | 1.96× | 59 MB/s |
| 1 MB | 4.45 ms | 1.40 ms | 3.17× | 225 MB/s |
| 10 MB | 31.38 ms | 4.52 ms | 6.95× | 319 MB/s |
| 64 MB | 179.79 ms | 36.90 ms | 4.87× | 356 MB/s |
This is a genuine slowdown, not a rounding error. fs::copy hands the work to the operating system (CopyFileEx on Windows, copy_file_range on Linux) and never leaves the kernel. Streaming through user space to hash costs roughly five times that on large files.
Two things put it in proportion, and neither makes it disappear:
- The common case is unchanged. An occupied address short-circuits before the source is opened: a dedup hit on an 8 MB file measures 84 µs. A file saved repeatedly with the same content, or content already held, costs nothing new. That is the outcome the deduplication architecture is built around.
- On the ingest path the marginal cost is one extra hash, not 5×. Ingest already hashed the file to decide whether a snapshot was warranted. Admission now hashes again. Those two passes cannot be collapsed into one: the decision must be made before the write, and reusing the first digest for the second purpose is precisely the assumption this phase removed.
Memory is bounded and measured, not asserted: a counting global allocator in the test suite records the heap high-water mark across one admission. A 48 MB file grows the heap by 72 KB at peak: the read buffer and the BufWriter, independent of file size.
34.7 Source mutation
A source that changes between being hashed and being read is refused, with a Conflict naming both digests. Whether the change lands before, during, or after the read does not matter: the digest comes from what was staged, so the staged bytes are either that object or they are not published.
The honest form of the guarantee is not "the file did not change", because the store cannot know that. It is:
The bytes admitted under H hash to H.
Ingest treats the refusal the way it already treated an unreadable file: abandon this version and record nothing. A scan walks a whole tree, and one file the user happens to be saving must not end the walk; the watcher will see the new bytes as their own event, which is what they are.
Archaeology surfaces it, because the user asked:
poster.pnghas changed since AFTRIMGE measured it, so it was not archived. The measurements on record describe the earlier image; import it again to keep the version on disk now.
34.8 Duplicates, and existing corruption
Deduplicated means precisely an object already occupies this address and nothing was written. It is a statement about the store, not about the source, which is never opened on that path.
That has a consequence worth stating plainly: ordinary ingest does not repair a corrupt object, even when the incoming source is genuinely correct. The address is occupied, so nothing is read and nothing is written, and the corruption survives.
That is the chosen policy, not an oversight. Phase 10 drew the line between INGEST and REPAIR and this phase must not blur it: a repair is a deliberate decision, from a source verified for that purpose, recorded in maintenance_log. A file save is not that decision, has not verified anything, and would leave no account of having rewritten the user's archive. Detecting the corruption is the verifier's job; fixing it is repair's.
The alternative, hashing the existing object on every dedup hit, would put a full read on the most common ingest outcome in the product, to catch a condition Phase 10 already has an explicit operation for.
ordinary_ingest_never_silently_repairs_a_corrupt_object pins all of it: the outcome is Deduplicated, the damaged bytes are still there, the verifier still says CORRUPT, and maintenance_log is empty.
34.9 Interruption and atomicity
A staged file is in .tmp, is not at an object address, has a name containing no hash (a staged file has no identity until its bytes have been read to the end), and is removed when its Staged drops. list_objects cannot name it, clean_staging sweeps it, and usage() is the only thing that counts it, as staging.
sync_all runs on the staged file before the rename, so a crash between the rename and writeback cannot leave a correctly named object holding nothing.
The rename caveat from Phase 10 is unchanged and is not restated as a stronger guarantee than it is. What this phase establishes is not "every replacement is atomically interchangeable" but:
No unverified byte sequence becomes the canonical contents of an object identity.
commit still treats a rename failure onto an occupied target as a dedup hit, because another writer committing identical content between the check and the rename is not a failure.
34.10 Concurrency
Within the process, staging paths are now {pid}.{counter}.part with a process-wide atomic counter, so two admissions never share a staging file. Six threads admitting one payload produce one Stored, five Deduplicated, one correct object and an empty .tmp. Two threads admitting different bytes under one address produce either a correct object or no object, never the liar's bytes.
Phase 10's single-instance boundary is unchanged, and nothing here claims cross-process safety.
34.11 What did not change
- No migration. The schema is untouched; the live database stays at v7. Integrity remains derived, and no
verifiedoradmitted_atcolumn exists. - No new liveness holder, no new integrity authority, no provenance table.
- The ledger is unchanged. Admission is not a liveness state.
- Read paths are unchanged.
read,copy_to,containsandsize_ofstill do not verify, deliberately: Phase 10 made verification an explicit operation because doing it on every read has a cost nobody measured a need for. The model stays correct admission plus explicit verification. restore::write_verifiedis unchanged. It writes into the user's folder, so its staging must live in the user's directory, because a rename across filesystems is a copy, and loses everything the function exists to provide. It already hashes before and after. Sharing the object store's.tmpwould have been wrong, not tidier.- Legacy corruption is not scanned for. The new contract governs new admissions. Pre-existing corrupt objects remain until Phase 10's verifier finds them and Phase 10's repair fixes them, and the system continues to distinguish an object that was always sound from one that was rebuilt.
35. Canonical metadata
35.1 What the audit found
Phase 12 closed the byte boundary and left a narrower one: the store now knew the exact length of what it admitted, and objects.size was still whatever a caller had measured earlier.
The audit found that this was true, that it was already harmless for new rows, and that three other things were not.
objects.size has exactly one write path, snapshots::register_object, and its upsert deliberately never touches size on conflict, so whoever registers an object first establishes its length for ever. Two production callers reach it: snapshots::insert, forwarding a caller's number, and archaeology::archive, forwarding a measurement taken at import.
Both were transitively correct and structurally unenforced. Ingest hashes a file, then admits it; admission refuses unless the staged bytes hash to that identity; identical hash means identical bytes means identical length. So the number agreed by an argument, three modules apart, that nothing checked.
PutOutcome::Deduplicated carried no byte count. That is why callers kept their own: a caller that deduplicated had no admitted size to record and had to fall back on what it had measured. The store always knows (it either just wrote the object or it is looking at one), and it was not saying.
The ledger preferred the recorded number for figures that describe disk. Ledger::build loads the real sizes from store.list_objects() and the recorded ones from objects, and used the recorded one for held_bytes and reclaimable_bytes, a figure the interface labels as space that would become free. A row written before Phase 12 could disagree, so the archive could promise a quantity of disk it had no way to deliver, with the true number in the same scope.
objects.stored is written and never read, by anything, including tests.
gc::audit selected o.size and discarded it, which read as though a recorded number and a measured one were interchangeable, in the one function whose byte figures all come from the filesystem.
35.2 The canonicality map
| field | claims | owner | kind |
|---|---|---|---|
objects.content_hash | this object's identity | the admitted bytes (Phase 12) | canonical |
objects.size | that identity's byte length | the admitted bytes | canonical |
objects.ref_count | how many snapshots point here | derived from snapshots; repaired, never trusted | diagnostic |
objects.stored | n/a | nothing reads it | vestigial |
snapshots.size | how big this version was when recorded | the filesystem at that moment | historical observation |
snapshots.stored | this version's bytes are still retained | retention; read by gc::HolderKind | operational state |
snapshots.content_hash | which content this version held | ingest, verified by admission | historical + FK |
files.original_path | where it first appeared | the filesystem then | historical observation |
files.current_path | where it is now | the filesystem now | mutable observation |
artifacts.byte_size | how big the imported file was | hash_file at import | historical observation |
artifacts.width/height | measured dimensions | the decoder, at analysis_version | measurement |
artifacts.content_hash | which content was measured | hash_file at import | content identity, no FK |
analysis_cache.* | a measurement over two hashes | reproducible from the objects | cache |
visual_index.* | one image's fingerprint | reproducible from the object | cache |
preservation_members.content_hash | what the user chose to keep | the user's decision | content identity, no FK |
preservation_sets.evidence | what the engine said then | frozen quotation | historical record |
maintenance_log.* | what AFTRIMGE did to storage | the operation | historical record, not re-derivable |
35.3 The three things called "size"
snapshots.size how big a version was when it was recorded
objects.size the length of the canonical object's bytes
disk usage what the filesystem is actually holding
They coincide whenever the bytes were admitted, and they are not the same statement. A version too large for the object limit has a size and no object at all, so that number is the only surviving record of how big the content was, and it describes content the store never held.
So NewSnapshot now carries both, named for what they are:
pub struct NewSnapshot<'a> {
/// A historical observation: how big this version was when recorded.
pub size: i64,
/// What object admission established, when this version's bytes were
/// admitted. `None` for a version whose content was never retained.
pub object_size: Option<i64>,
...
}
insert forwards object_size to the ledger and falls back to the observation only for stored = false, where there is nothing else and the flag beside it says so. A debug assertion catches the combination that should not exist.
35.4 Canonical size is now established by the bytes
PutOutcome carries the byte count in both variants:
pub enum PutOutcome {
Stored { bytes: u64 },
Deduplicated { bytes: u64 },
}
impl PutOutcome {
/// Bytes newly committed. Zero for a dedup hit: work done, not object size.
pub fn bytes_written(&self) -> u64;
/// The length of the canonical object, however it got there.
pub fn canonical_bytes(&self) -> u64;
}
The dedup exit calls metadata() where it used to call is_file(): the same stat, now returning the number the caller needs. Measured: a dedup hit on a 16 MB object reports its size in 25 µs, so the number genuinely arrives for nothing.
Both production callers take the size from the outcome. archaeology::archive keeps artifacts.byte_size as the historical measurement of the file it was shown, and hands the ledger what admission established.
35.5 A wrong number is not damaged content
The hardest boundary in this phase, and the one most worth getting wrong-proof.
hash correct, metadata wrong the object is fine; a column is not
hash wrong, metadata correct Phase 10's CORRUPT
hash wrong, metadata wrong both, reported separately
An object whose bytes hash to its identity is that object however wrong a number beside it may be. Calling that CORRUPT would overload a word Phase 10 defined precisely, and would teach a reader that their archive is damaged when it is not, a reader who then goes looking for a way to "fix" content that was never broken.
So maintenance::consistency has its own vocabulary and shares none of integrity's:
MISSING_OBJECT ledger row, no bytes loss
BROKEN_REFERENCE history pointing at no ledger row loss
ORPHANED_OBJECT bytes, no ledger row noise
SIZE_METADATA_MISMATCH a wrong number beside right bytes noise
Finding::is_loss marks exactly the first two. The audit never hashes: that is integrity's question, it costs a whole-archive read, and asking it here under a different name would be a second integrity authority. Measured: 0 ms over a 24 MB store, and a test fails if it ever starts reading bytes.
It also never repairs. Running it twice produces the same findings, and a test asserts the sizes, the objects and the orphans are all exactly where they were.
35.6 Numbers that claim to be disk space
Ledger::build now prefers the disk:
bytes present -> the filesystem answers
bytes absent -> the recorded size, the last surviving statement
about how big the content was
No new query: the listing was already loaded. Measured: 4 round trips for a 30-object ledger, unchanged. Ledger::inspect follows the same rule with one size_of call replacing the contains call it already made, and its classify still asks whether the ledger knows the hash, which is a different fact from whether a size is available and is exactly the UNTRACKED state.
gc::audit stopped selecting the column it discarded, with a comment saying where its byte figures come from and why.
35.7 What was left alone, and why
- No migration. The schema can express all of this; §31's preference holds.
objects.storedstays. Removing a column means rebuilding the table, and a field nothing consults cannot make the archive say two things at once. It is documented at its write site as vestigial, and the consistency audit ignores it.ref_countstays. It is derived, repaired fromsnapshotsbyrepair_ledger, ignored by the collector, and reported asledger_drift. It counts snapshot references rather than holders, which is narrower than its name, but since nothing decides anything with it, that is a diagnostic imprecision rather than a second liveness authority.ProjectStats.physical_bytesstays derived fromsnapshots.size. It is a per-project figure and the store has no per-project view of disk. It is a logical quantity (distinct content, summed), and the audit's and the memory panel's disk figures come from the filesystem.- Read paths still do not verify. Unchanged from Phase 10.
- Frozen evidence stays frozen. Nothing here recomputes a preservation quotation, an analysis version, or a maintenance record.
35.8 Measured
| dedup hit reporting canonical size, 16 MB | 25 µs |
| consistency audit, 25 objects | 2 round trips |
| consistency audit over a 24 MB store | 0 ms (hashes nothing) |
| storage ledger, 30 objects | 4 round trips, 3 ms |
35.9 The shape that results
RECORD
│
├── historical observations snapshots.size, files.original_path,
│ artifacts.byte_size, kept as history
▼
OBJECT ADMISSION
│
├── content_hash established by the bytes (Phase 12)
├── size established by the same bytes
│
▼
CANONICAL OBJECT
│
├── liveness gc::HolderKind (Phase 9)
├── integrity integrity::Verifier (Phase 10)
├── provenance provenance::Explainer (Phase 11)
└── consistency maintenance::consistency (Phase 13, reports only)
The fourth is not a fifth dimension of the object; it is the question of whether the records about it agree with each other, and it has no vote in the other three.
36. Who is in the ledger
36.1 What the audit was asked
Phase 13 ended with two pressures. Both were audited repository-wide before any code moved. One was cleanup. One was a defect.
36.2 Pressure A, objects.ref_count and objects.stored: cleanup
The complete map, all languages, production and test:
| field | writers | readers | exposure |
|---|---|---|---|
objects.ref_count | snapshots::insert (+1, never −1), gc::repair_ledger (recompute) | one: gc::audit, which reads it only to report that it disagrees | StoreAudit.ledger_drift → MemoryReport → "COUNTER DRIFT" |
objects.stored | snapshots::register_object (MAX) | none, including tests | none |
ref_count is circular: its only consumer is its own drift detector. It diagnoses nothing but itself, drifts only after a project deletion, and the interface already tells the reader that "collection recomputes them and does not rely on them, so nothing is at risk."
objects.stored has no reader at all.
Neither was found authoritative anywhere. Removing either would change no behaviour, would require a table rebuild, and would buy no correctness. Per the schema rule (a migration needs a correctness benefit, not a tidier table), the schema is unchanged. Both remain documented as vestigial at their write sites.
36.3 Pressure B, non-FK content identities: one real defect
Each non-FK identity was traced for writers, readers, whether the object must exist at write time, and whether absence is detected:
| identity | object required at write? | dangling means | detected by |
|---|---|---|---|
artifacts.content_hash | no: an ANALYSED artifact retains no bytes | normal state | custody, and the ledger for ARCHIVED |
preservation_members.content_hash | no (Phase 8): a decision about content already gone is a real decision | valid history | PreservationState.unavailable |
maintenance_log.content_hash | no | valid history | nothing needs to |
analysis_cache.hash_a/b | yes at write, may go later | stale cache row | Family-scoped pruning |
visual_index.content_hash | yes at write, may go later | orphaned index row | visual_index::orphaned + explicit repair_memory_index |
Every one already had an owning subsystem that handles absence, and dangling is valid historical state by design. No foreign key was added, and no new finding type was invented.
But tracing the preservation row surfaced something else.
36.4 The defect: a holder the ledger could not see
Ledger::build drew its universe of hashes from two places:
objects table ∪ store listing
Holders were looked up per hash and never contributed one. So content held by a preservation, with no ledger row and no bytes, was absent from the screen that calls itself "every piece of content AFTRIMGE is storing, and why each one is still here", while gc::is_referenced_now answered true for the same hash.
Two subsystems, one hash, different answers about whether it is part of the archive's accounting. That is the shape every phase since 9 has been collapsing.
Reachable through ordinary use, not tampering: preserve a route whose bytes were already released. Completeness::AvailableMembers exists precisely to allow it, and Phase 8 documented it as deliberate.
The universe is now all three:
objects table ∪ store listing ∪ every hash a holder names
No new query: all_holders was already loaded before the universe was built.
36.5 The arm that became reachable
classify read:
(false, _) => LedgerState::Untracked, // "on disk, unknown to the ledger"
The wildcard was safe only while the store listing was the sole route to a hash with no ledger row: bytes_present was then always true. Once holders contribute, (false, false) is reachable, and UNTRACKED would be a lie, because that word means bytes nothing has a record of and there are no bytes at all.
(false, true) => LedgerState::Untracked, // bytes on disk, unknown to the ledger
(false, false) => LedgerState::Missing, // only a holder names it; the bytes are gone
MISSING for the same reason (true, false) gives it: something names content that is not there. A preserved route whose bytes were released is exactly the record of a decision outliving its subject, which is what preservation is for.
Missing content contributes no bytes to any space figure, so held_bytes and reclaimable_bytes are untouched.
36.6 What did not change
- No migration. Schema stays at v7.
- No foreign key added. The five non-FK identities keep their retention semantics.
- No new finding type. A preservation over released content is valid history, not an inconsistency, so
maintenance::consistencysays nothing about it. - No new liveness authority.
gc::HolderKindwas already right; the ledger now agrees with it. - No frontend change. The entry renders through the existing
MISSINGstate and the existing holder list. - No repair. Nothing deletes the preservation, recreates the object, or rewrites a row.
- No query added.
objects_examinedgrows by the number of holder-only hashes, which is the honest count.
37. Holder identity
37.1 The boundary Phase 14 promoted
Phase 14 made every hash a holder names part of the ledger's universe. That turned an invisible question into a visible one: what may a holder name?
Three holder kinds, three answers:
| holder | identity comes from | shape guaranteed by |
|---|---|---|
snapshots WHERE stored = 1 | ingest, after admission | hasher::hash_file, and a foreign key into objects |
artifacts WHERE custody = 'ARCHIVED' | hash_file at import; custody granted only after put_file and register_object | the hasher |
preservation_members | Vec<String> over IPC | nothing |
The first two cannot carry a malformed identity: their hashes are produced by the hasher, which emits 64 lowercase hex characters or nothing. The third took whatever it was handed.
37.2 Two absences that looked identical
At the write boundary, Preserver::record asked one question, store.contains(h), and it answers false for both of these:
well-formed, never admitted a real decision about content that is gone,
as Phase 8 built it deliberately
malformed not an identity; it can never be admitted,
verified, repaired, or collected, because
nothing can address it
store.contains calls object_path, which refuses a malformed hash, and the Result is discarded into false. So the two were indistinguishable and the row went in either way.
Since Phase 14 the consequence is visible: a string that is not an identity is listed in the storage ledger as MISSING content, in a table whose entire subject is content.
37.3 What is now refused, and what is not
Preserver::record, the single chokepoint both preserve_content and preserve_path pass through, validates the shape of every member and of subject_hash before anything is written:
for hash in hashes.iter().chain(subject_hash.as_ref()) {
if !hasher::is_valid_hash(hash) { return Err(EngineError::Invalid(..)) }
}
- Refused: empty, non-hex, 63 or 65 digits, uppercase, trailing space, path traversal.
EngineError::Invalid: malformed input, not a domain outcome, and deliberately notIncompleteSelection, which is about availability and is a legitimate answer. - Not refused: a well-formed hash nothing ever admitted. Availability is still not consulted. Refusing that would break the one thing preservation exists to allow.
- All or nothing: one malformed member refuses the whole selection. Partial acceptance would leave a preservation silently protecting less than the user chose.
The predicate is hasher::is_valid_hash, the same one ObjectStore::object_path has always applied. There is one shape rule in the codebase, not a second copy.
37.4 The holder state matrix
Every state the Phase 14 universe can produce, pinned in one archive:
| state | route | ledger |
|---|---|---|
| object row + bytes + holder | recorded and retained | HELD |
| object row + bytes, nothing holding | released, not yet swept | RECLAIMABLE |
| object row, no bytes | collected, or lost | MISSING |
| bytes, no object row | interrupted write | UNTRACKED |
| no row, no bytes, holder | preserved after release | MISSING |
And the transitions that must not move anything else: byte corruption while held changes integrity and not liveness; a metadata mismatch changes neither; two preservations over one hash are one row with several reasons and its bytes are counted once.
37.5 A row that survives collection, and why that is right
Worth stating because it looks like a leak. After the last holder is withdrawn and the sweep runs, the hash stays on the ledger as MISSING.
Retention clears snapshots.stored; it never deletes a version. And snapshots.content_hash is a foreign key into objects. So the collector removes the file and cannot remove the ledger row; it logs that and moves on. The surviving row is the record that this content existed and its bytes are gone, which is exactly what MISSING says. Deleting it would erase the evidence of the loss along with the loss.
37.6 objects.ref_count and objects.stored: audited again, left alone
| field | writers | readers | authoritative | decides anything | removable |
|---|---|---|---|---|---|
content_hash | register_object | everything | yes | yes | no |
size | register_object | ledger, integrity, retention | yes | yes | no |
ref_count | snapshots::insert (+1), gc::repair_ledger | one: gc::audit | no | no | yes, at a cost |
stored | register_object (MAX) | none | no | no | yes, at a cost |
ref_count is circular (its only consumer is its own drift detector), but it is not unexposed: StoreAudit.ledger_drift reaches the interface as "COUNTER DRIFT", GcOutcome.ledger_repaired as "Counters fixed", and gc has tests asserting both. Removing it would delete a working diagnostic and require a table rebuild, for no correctness gain.
objects.stored has no reader anywhere, production or test. Removing it is a table rebuild for tidiness alone.
Neither was removed. A migration needs a correctness benefit, not a smaller schema. Both remain documented as vestigial at their write sites, and maintenance::consistency ignores them.
38. Lifecycle accounting: the audit that changed nothing
38.1 Two hypotheses
Phase 15 ended with two pressures recorded as suspicions, not defects:
- (A)
gc::collectincrementsobjects_deletedeven when thesnapshotsforeign key blocks the row deletion, so the name may be describing something it does not do. - (B) The ledger grows monotonically, because collected content leaves a
MISSINGrow behind forever.
Both were investigated as hypotheses. Neither is a defect. No production behaviour changed in Phase 16, no migration was written, and the schema stays at user_version = 7.
38.2 The six quantities, kept apart
Most of the confusion in this area comes from one word doing six jobs. They are distinct, and the system reports them separately:
| quantity | owner | where it surfaces |
|---|---|---|
| bytes removed from disk | GcOutcome.bytes_freed | "Reclaimed" |
| known objects whose payload went | GcOutcome.objects_deleted | "Objects deleted" |
| untracked files removed | GcOutcome.orphans_deleted | "Orphans removed" |
objects rows still present | the table itself | ledger membership |
| content something still holds | gc::referenced_set | HELD |
| bytes currently on disk | ObjectStore::list_objects | HELD + RECLAIMABLE |
objects_deleted and orphans_deleted are disjoint: the collector classifies each file once, and a file with no objects row is never counted as a known object.
38.3 (A) The counter counts payloads, and that is what every reader asks
collect iterates the store, not the table. For each unreferenced file it removes the bytes, then attempts the row. The removal is what increments the counter; the DELETE is attempted afterwards and a failure is logged, never propagated.
That ordering is correct and cannot be reversed. Deleting the row first would leave a file with no record of what it is (the UNTRACKED state) if the process died between the two.
So objects_deleted means known objects whose payload was removed. Every reader in the codebase asks exactly that question: the memory panel and the ledger view report it next to reclaimed bytes as "how much content went away", and RetentionOutcome.objects_deleted forwards swept.objects_deleted unchanged rather than recomputing it: one number, one producer, several displays. There is no second authority to disagree with.
What was missing was the sentence saying so. It is now on the field.
38.4 (B) The row outlives the bytes on purpose
snapshots.content_hash is a foreign key into objects, and retention clears snapshots.stored without ever deleting a version. So content that was ever recorded keeps its objects row after collection and reads as MISSING.
That is not a leak; it is the archive working. MISSING means this content existed and its bytes are gone, which is true, and is precisely the fact an archival tool must not discard. Deleting the row would erase the evidence of the loss along with the loss, and would orphan the snapshot rows that name it.
Content that was never recorded (a holder naming a released hash, a file written straight into the store) has no row to keep, and the ledger does not invent one.
Re-admitting collected content revives the same row rather than creating a second: identity is the hash, and the hash did not change.
38.5 The one real defect: a doc comment that predated Phase 9
StoreAudit.referenced_objects was documented as "Distinct content referenced by at least one retained snapshot", and unreferenced_objects as "Objects on disk that no retained snapshot points at".
That wording is older than Phase 9, which collapsed liveness into gc::HolderKind. audit() has called referenced_set(), all three kinds, ever since. An archived artifact that no snapshot has ever named counts as a reference, and the comment said it did not.
the_store_audit_counts_every_holder_kind_as_a_reference pins the behaviour so the comment could be corrected against the code rather than the other way round.
The neighbouring RetentionPreview.objects_shared_and_kept says "retained snapshot" too, and there it is accurate: that field deliberately answers the snapshot-only question, and reclaimable_objects_sql() already excludes non-snapshot holders through gc::non_snapshot_references().
38.6 What the sweep costs
A sweep reads no file bytes and hashes nothing: it works from directory metadata, the reference set, and one re-check per candidate immediately before deletion. Its round-trip count is bounded by a constant plus one per candidate, not by the size of the store.
A dry run predicts exactly what the real sweep does, and writes nothing: neither rows nor files nor counters.
39. What survives contact with the outside
Phases 1–16 established that AFTRIMGE's internal model is coherent. This section records what a production/acquisition audit found when that model was pushed against the things a real machine does: a hostile caller, a downgrade, a sweep running at the wrong moment.
39.1 The webview is not an author of filesystem paths
ObjectStore::object_path refuses a traversal. ipc::protocol refuses one. to_relative_key refuses one, and says so in a comment about trust. But its inverse, from_relative_key, was a loop of PathBuf::push, and push is not a safe primitive:
key PathBuf::push produced
../../evil.txt <root>\..\..\evil.txt (resolves above root)
C:/evil.txt C:evil.txt (drive-relative; root gone)
\server\share\evil.txt C:\server\share\evil.txt (root replaced)
restore::apply is the only code in AFTRIMGE that writes into a folder the user owns, and its destination came from plan.target_path or copy_path, both strings chosen by the frontend and handed back over IPC. Neither was validated. A caller could write any archived content to any path.
from_relative_key now returns Option<PathBuf> and accepts a segment only if it is exactly one Component::Normal, with a containment check on the assembled path as a second line. Every caller treats None as a refusal: restore returns EngineError::Invalid, ingest logs and skips, the copy-name generator declines to offer the name. The ordinary destinations, including nested ones, still resolve, which is asserted alongside the refusals.
39.2 A future database is refused, not written to
Migrations are forward-only. A build that meets user_version higher than its own has no description of what that version means, so it cannot know which of its writes would violate the newer build's invariants.
It used to log a warning and continue. The damage that produces is silent, because every individual statement succeeds. It now returns EngineError::Invalid naming both versions. A downgrade costs the user a message rather than their history.
39.3 The admission window
Admission is two steps (rename the bytes into the store, then write the objects row), and between them the content is, by every definition the collector has, an orphan. Collection is a button and ingest is a thread, so a sweep is reachable inside that window:
put_bytes ─────────────► object on disk, no row
│
│ gc::collect sees an orphan and removes it
▼
register_object ──────► row written, describing bytes that are gone
The result was a snapshot reporting itself as stored, over nothing, with nothing logged. A file with no ledger row is now only collectable once it is older than gc::ORPHAN_GRACE (15 minutes), measured from its own mtime, with unknown age counting as young. The skipped files are reported as GcOutcome.orphans_retained rather than silently passed over, and they are still collected on a later sweep.
Age rather than a lock, because the same window exists between two AFTRIMGE processes, where no in-process lock would help.
39.4 A string from IPC is not a byte sequence you may index
Every short form of an identity in the engine was written &s[..s.len().min(12)]. That is a byte index. It is only safe when every character is one byte, which a real SHA-256 hex digest always is, and a string that arrived over IPC need not be. explain_claim (for an ANCHOR claim) and verify_integrity (for an Object scope) both formatted the caller's own string that way while reporting that it was not found, so a twelve-byte prefix ending inside a multibyte character panicked.
The release profile is panic = "abort". Driving the production binary through its real IPC channel with content_hash: "aaaaaaaaaaaé" terminated the process; the log contained no trace of it, and the only record anywhere was a Windows Error Reporting entry.
store::hasher::display_prefix counts characters rather than bytes, and all seven sites use it. tests/ipc_boundary.rs feeds sixteen hash- and id-taking engine entry points behind IPC commands sixteen hostile strings (empty, multibyte across bytes 2, 8 and 12, emoji, traversal, NUL, SQL and LIKE metacharacters, one megabyte) and requires an error rather than a panic, and no rows written.
39.5 A startup failure was indistinguishable from nothing
run() ended in .expect("AFTRIMGE failed to start"). In the release build (windows_subsystem = "windows", panic = "abort") that means no console, no window, no unwinding, and a non-blocking log writer that never flushes. Launching the production binary against a corrupt aftrimge.db produced exit code 0xC0000409 within seconds, no window, and a log whose only new line was "engine starting". The cause was never written anywhere.
That matters more after §39.2, not less: refusing a future database is only an improvement if the person is told why.
The setup hook now logs the cause, drops the log guard so the line reaches the disk, shows a native error dialog naming the cause and the archive's location (advising a copy before anything else is tried), and returns the error; run() exits with status 1 instead of panicking. The dialog comes from rfd, which was already in the tree through tauri-plugin-dialog with the same features; no crate was added to Cargo.lock.
39.6 What a killed process leaves behind
tests/crash_consistency.rs re-executes the test binary as a child, lets it work against a real SQLite file and a real object store, and terminates it with Child::kill (TerminateProcess on Windows) at fourteen points spread across a second. After each kill it does what startup does and checks that the database passes PRAGMA integrity_check, that PRAGMA foreign_key_check is empty, and that no snapshot claiming retained bytes lacks them.
| operation killed | result |
|---|---|
| admission (create and modify, 17 B to 3 MB) | consistent every round |
| release + collection | consistent every round; no kept version stranded |
restore in FORCE mode, 2.7–3 MB | target is always one whole version, never a mixture |
The one residue it found is left in place and documented rather than fixed: restore's staging file (.name.pid.aftrimge.tmp, beside the target, in the user's own folder) survives a kill, and nothing sweeps it: the object store's startup clean_staging covers only objects/.tmp. Four such files accumulated across fourteen kills. They are whole, verified copies of a version and harm nothing but disk space, and sweeping them would mean AFTRIMGE walking folders it does not own looking for files to delete, which is a worse trade.
A kill is not a power loss. These tests prove process-crash consistency; the sync_all before every rename is the argument for power loss, and no test here can make it.
39.7 What the audit did not change
Not defects, and left alone: objects.ref_count and objects.stored (§37.6); the ledger's monotonic growth (§38.4); objects_deleted counting payloads (§38.3). No migration was written; the schema stays at user_version = 7.
Known gaps that are not code defects and were not closed: the application and its installers are unsigned, and Smart App Control blocked the freshly built production binary on the audit machine; there is no update mechanism; there is no single-instance guard: launching the production binary a second time opened the same archive in a second, windowless background process that nothing surfaces to the person; the log directory grows daily without bound and records file paths and absolute project roots; there is no CI; a whole storage ledger is serialised over IPC even though the view renders at most 200 rows per group; cargo fmt --check reports drift in 78 files; two release builds of the same source differ in 24 bytes of linker timestamps, so the build is reproducible but not bit-for-bit; the engine's whole-archive integrity pass is not reachable from the interface; verify_object reports a malformed hash as the integrity state UNREADABLE rather than as invalid input; and a NOT_FOUND message can read "not found" twice. Each is product infrastructure or debt rather than a correction to the model.
40. Releases, portable mode and the website
40.1 What is released
| Platform | File | Package | Built on |
|---|---|---|---|
| Windows x64 | AFTRIMGE-<v>-Windows-x64-Installer.exe | NSIS, per-user, no elevation | Windows 11, MSVC |
| Windows x64 | AFTRIMGE-<v>-Windows-x64-Portable.zip | folder: executable, marker, README, notices | Windows 11, MSVC |
| Linux x64 | AFTRIMGE-<v>-Linux-x64.AppImage | AppImage, carries its WebKitGTK | Ubuntu 24.04 (glibc 2.39) |
| Linux x64 | AFTRIMGE-<v>-Linux-x64.deb | Debian package, system WebKitGTK | Ubuntu 24.04 |
They live in releeases/ (that spelling is deliberate). The binaries are not committed; SHA256SUMS.txt, release-manifest.json and release-facts.json are. The MSI and RPM formats Tauri can also produce are not released, because neither could be tested in the release environment: the MSI installs per-machine and needs elevation.
scripts/release-windows.mjs, scripts/release-linux.sh and scripts/release-manifest.mjs produce them. release-manifest.mjs --verify re-hashes every file against SHA256SUMS.txt.
40.2 Portable mode
core::portable decides it once, at the top of run(), from one fact: a file named aftrimge.portable beside the canonicalised executable. When present, the data root is AFTRIMGE Data beside the executable instead of the platform application-data directory, and WEBVIEW2_USER_DATA_FOLDER is set to AFTRIMGE Data/webview before any window exists, so the embedded browser's cache follows too. Nothing else in the engine knows or cares: DataPaths is constructed from a different root and everything downstream is unchanged.
Two rules came with it:
DataPaths::lies_withinrefuses a project root that contains the archive. The existingcontainsonly refused roots inside the archive, which was sufficient while the archive lived in%APPDATA%.- On Unix the archive root is created
0700. The default umask had produced a world-readable archive (0755/0644).
Project roots remain absolute paths, as they are in an installed copy. Relocating a portable folder relocates the archive, not the projects.
40.3 The website
website/build.mjs generates a static site from docs/*.md, docs/legal/*.md, docs/FAQ.md, THIRD_PARTY_NOTICES.txt and releeases/release-manifest.json. It has no dependencies beyond Node.
- The Markdown renderer escapes every character of source text before adding markup, so a document cannot inject HTML.
- Every page carries a Content-Security-Policy that allows only same-origin scripts and styles; the interactive preview writes no inline style attributes and sets geometry through the CSSOM.
- The download page is generated from the manifest, and the build hashes each artifact and fails if a file is missing or its checksum differs, so the page cannot link to a file that is absent or not the one described.
- The interactive preview (
website/assets/preview/) is a pure model over a fixed demonstration archive plus a view. The model implements the documented rules (ledger classification, holder kinds, retention protection, the orphan grace period, integrity states, restore gating) and is tested against them, so the preview cannot quietly demonstrate a different product. - No em dash appears in the built site; the renderer converts them in repository documents, and a test fails if one survives.
41. Operating it: one engine per archive, and what a failure leaves behind
Phase 17 hardened the parts of AFTRIMGE that only matter when something goes wrong or someone else has to ship it. The decisions, briefly; the reasons are in the module comments named.
41.1 One authority per question
Each operational question got exactly one answer in the code, so a second implementation cannot drift from the first:
| Question | Authority |
|---|---|
| Is this archive already open? | core::instance: an operating-system lock on aftrimge.lock (File::try_lock). Released by the OS however the process ends, so there is no stale-lock rule to get wrong. Per archive, not per application. |
| Which log files are kept? | logging::prune: 14 daily files, 64 MB besides today's, ordered by the date in the name. |
| Which database backups are kept? | database::backup: the newest 3, and only files it names. |
| What is the restore staging file called, and when is one abandoned? | core::residue: exact name, regular file, another process id, older than 15 minutes. |
| How large may an IPC input be? | ipc::bounds, applied in every command; a test reads commands.rs to prove it. |
| What version is this? | version in src-tauri/Cargo.toml. |
41.2 Startup order
The window is no longer created by Tauri before the setup hook ("create": false). Setup resolves the archive, creates its folders, takes the lock (a second launch shows a dialog and exits), installs the panic hook, starts logging, opens the database (backing it up first if its format is older), and only then creates the window. A second launch or a failed start therefore never shows an empty window or starts a WebView2 process on the archive.
41.3 Before a migration
VACUUM INTO from the connection about to migrate gives a consistent copy that includes pages still in the WAL. The copy is written as .partial, synced (through a writable handle, because Windows refuses to flush a read-only one, a defect the WAL test found), reopened, checked with integrity_check and user_version, and renamed. If any step fails the migration does not run.
41.4 Writes that can fail half way
core::fault is a cfg(test) seam: a named write site fails with ErrorKind::StorageFull after a byte budget. It found two defects: a restore whose write failed left its staging file in the person's folder, and export used fs::copy straight into the destination. core::atomic now writes exports and diagnostic reports beside the destination and renames them into place.
41.5 Paths that are not what they look like
from_relative_key is lexical, and lexically inside is not physically inside. A restore now walks the existing components of its destination and refuses a symbolic link or junction (paths::no_links_below). On Windows the resolver also refuses device names, alternate data streams, names Windows trims and forbidden characters (paths::windows_refusal, tested everywhere, applied on Windows). Before this, a restore copy through a junction wrote outside the project and CON was accepted and recorded as a successful restore.
41.6 Diagnostics without disclosure
diagnostics::sanitize_line is the rule for what a saved report may contain: time, level, module, the fixed message, field names, and field values only if they are plain numbers in an allowlisted field. A message that looks like it carries a path is redacted whole. A test records a real project with marker names and fails if any marker reaches the report.
41.7 Releases
The release scripts refuse a dirty tree, remap build-machine paths out of the binary (0.1.0 embedded the builder's account name 301 times), and link the application deterministically through build.rs rather than RUSTFLAGS (which also made every dependency build script byte-identical, so a machine that refused one unsigned build script refused it on every retry). Signing is wired through Tauri's signCommand to scripts/sign-windows.mjs and records every file it sees; with no certificate each is recorded UNSIGNED. See docs/acquisition/RELEASE_PROCESS.md.
Source: docs/ARCHITECTURE.md