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

PathResponsibility
core/models.rsThe domain vocabulary. No filesystem or SQL types.
core/config.rsEngine tuning in one place, not scattered constants.
core/paths.rsData directory layout; project-relative path keys.
core/emitter.rsThe outbound port the engine notifies through.
error.rsOne error type, serialisable across IPC, with a stable code.
store/hasher.rsStreaming SHA-256.
store/object_store.rsContent-addressed storage and deduplication.
database/Connection, migrations, and one module per table.
watcher/Everything between the OS and a settled fact.
analysis/ingest.rsThe snapshot engine: decide and record.
replay/Timeline, past-state reconstruction, media, preview and export.
replay/media.rsWhat a snapshot is, and thumbnail generation.
replay/evolution.rsLanes, metrics and the relationship graph.
analysis/image.rsDeterministic image measurement and diff rendering.
analysis/text.rsDeterministic line diffing.
analysis/compare.rsResolves a snapshot pair and caches the answer.
analysis/thresholds.rsEvery number that turns a measurement into a word.
core/safety.rsThe vocabulary of returning safely: plans, conflicts, policies.
restore/plan.rsWhat a restore would do. Writes nothing.
restore/apply.rsThe only code that writes into a user's folder.
maintenance/retention.rsWhat stops being retained. Never deletes history.
maintenance/gc.rsReclaiming content nothing points at.
replay/search.rsFinding a file by any name it has ever had.
ipc/Command surface, the Tauri emitter, and the media scheme.
ipc/protocol.rsThe 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:

SignalProduced 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))
Rescanthe 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.

  1. RenamedPair: the OS gave both halves in one event. Definitive.
  2. 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.
  3. Content match (analysis/ingest.rs): a deletion is not written immediately. It is staged for rename_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 evidence CONTENT_MATCH, which the interface labels INFERRED. 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 RENAME at or before T, else original_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

CommandPurpose
select_folderNative folder picker, returns a path string
create_projectCreate and immediately start watching
list_projects / get_project / rename_project / delete_projectProject management
start_watching / stop_watchingWatcher control
get_watch_status / list_watch_statusesWatcher state
rescan_projectForce reconciliation now
get_project_eventsActivity log, paged
get_timelineSpan, events and density buckets in one round trip
get_project_filesTracked identities
get_file_historyOne file's snapshots and events
get_project_state_atThe playhead: the folder as it was
get_snapshot / get_snapshot_previewVersion detail and bounded preview
export_snapshotWrite past bytes to a path the user picks
get_project_stats / get_storage_stats / get_engine_infoDiagnostics

Pushed events

ChannelPayload
aftrimge://activity{ project_id, events: TimelineEvent[] }
aftrimge://watch-statusWatchStatus
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.

Untouchedcore/ · store/ · watcher/ · analysis/ingest · database/ schema
Extendedreplay/timeline.rs gained a Window; database/events.rs gained timestamps_for_project; DataPaths gained thumbnails/
Addedreplay/media.rs · ipc/protocol.rs · three commands (get_event_timestamps, get_snapshot_media, windowed get_timeline)
RebuiltThe 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:

MeasurementAnswersMethod
Perceptual distancedid this become a different picture?64-bit dHash over an 8×9 luminance grid, compared by Hamming distance
Pixel changehow much of the frame moved?both sides resampled to a common working edge, compared per pixel against a noise floor
Histogram intersectiondid 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_VERSION orphans 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.

GradeMeansUsed for
FACTProven directly by the OS or by a cryptographic hash. No reasoning involved.OS-reported renames; two files holding byte-identical content
INFERREDDeduced 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
TEMPORALKnown 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: WATCHERFACT, CONTENT_MATCHINFERRED. 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: MoveFileExW with MOVEFILE_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.

ConflictMeaningRestorable?
NONEdisk matches the newest recorded versionyes
TARGET_MISSINGnothing there; restoring creates ityes
TARGET_MODIFIEDdisk matches no recorded version, edited outside AFTRIMGEforce only
PATH_REOCCUPIEDa different logical file owns this path nowforce only
TARGET_NOT_A_FILEsomething else occupies the pathforce only
CONTENT_UNAVAILABLEthe bytes were released or never retainednever

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 back
  • previous_snapshot_id: the version it replaced
  • snapshot_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:

referenceda retained snapshot points at it; never touched
unreferencedin the ledger, pointed at by nothing; reclaimable
orphanedon disk with no ledger row (an interrupted write); reclaimable
missingin 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.


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.pnglogo-final.png at 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.jpg corresponds to a region of banner.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:

  1. Separate tables. artifacts and artifact_relations share no foreign key with snapshots or events except one deliberate, one-directional bridge (below). An artifact cannot become a snapshot.
  2. Separate commands. The archaeology IPC surface cannot write into a project's history, and none of the timeline commands read an artifact.
  3. Separate grades. RelationGrade gained Possible and Unknown for Phase 5. Nothing archaeology produces is ever Fact unless the evidence is a cryptographic hash or a decoded pixel.
  4. 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.
  5. 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

TransformationDetectableHow
Identicalyes, FACTSHA-256
Re-encodedyesresidual at shared geometry
Heavily re-encodedyeswider residual band, gated on the damage being evenly spread
Resizedyesaspect match plus residual after resampling; the scale is named as a ratio when it is one
CroppedyesZNCC search across a scale ladder
Rotated (90/180/270)yesapplied and measured
Flippedyesapplied and measured
Border or canvas addedyesuniform-border trim plus surround uniformity
Composite / partialyesalignment covering part of the frame
Greyscaleyeschroma spread, with structure preserved
Brightness / contrastyesleast-squares tone fit with an R²
Saturationyeschroma ratio with luminance held
Transparencyyes, FACTdecoded alpha
Arbitrary-angle rotationnonot attempted
Perspective correctionnonot attempted
Local retouchingnonot attempted
Which application edited itnonot knowable from pixels
The order of a chainnosee 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

  1. The bytes go into the object store (content-addressed, so an image already held as a watched version costs nothing).
  2. The object is registered in the objects ledger. 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.
  3. 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-thumb serves 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)
EvidenceGradeBasis
hashes equalFACTByteIdentity
hashes differ, pixels comparedwhatever the cascade concludedMeasured
hashes differ, no pixelsUNKNOWNUnverifiable

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_MAX 34 → 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 toneaspect 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:

  1. Grade. A stronger kind of claim always outranks a weaker one, whatever the numbers say.
  2. Residual. The lowest figure the cascade reached.
  3. Reduction strength. The cheap signal. Only ever a tie-break.
  4. 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_index has no foreign key to objects, 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?
Anchorno: grade() is a const fnyesit is the fact
ContentNodenoyesn/a: it is a record
MeasuredEdgeyesnono
ContinuityPathyesnono

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. FACT means the hashes are equal. So no edge can be one.

Traced through the code, in four steps:

  1. RelationGrade::Fact reaches a TransformReport only through TransformKind::Identical.
  2. transform::investigate raises that hypothesis only when its identical_bytes argument is true.
  3. Investigator::compare sets identical_bytes from subject.content_hash == candidate.content_hash.
  4. graph::MeasuredEdge::between refuses to build an edge when the two hashes are equal, and returns None.

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:

kindwhat it means
AMBIGUOUS_DIRECTIONthe pair was measured both ways round and both say the other came first
UNORIENTED_CHAINa chain's hops disagree about order
MULTIPLE_ANCESTORSmore than one recorded content connects at INFERRED
TEMPORAL_TENSIONthe 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:

  • PreservedMember has 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 no grade, verdict, relation or confidence key.
  • PreservedRelation names 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. PathSelection carries content hashes and nothing else. Preserver::recover_evidence reads artifact_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_version and recorded_at_version are 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 identitieslive set 207 µs, 40 re-checks 2.14 ms
the live set's size40: identities, not reasons
releasing a 120-member set517 µ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:

#wherewhat it was
1gc::LIVE_REFERENCESthe authority
2gc::NON_SNAPSHOT_REFERENCEStextually duplicated inside #1 rather than composed into it
3preservation::hydratea hand-written copy of all three branches, for other_holders
4artifacts::archived_hashesits own copy of the archived branch
5retention::reclaimable_objects_sqlcorrectly 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 rowbytes on diskholders
HELDyesyes≥ 1something points at it
RECLAIMABLEyesyes0a sweep may take it
MISSINGyesnoanyknown, and the bytes are gone
UNTRACKEDnoyes0bytes 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 ledger37.5 ms
inspect one object1.25 ms
collection forecast34.3 ms
forecast withdrawal of 150 members95 ms
queries to build4: 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:

holderoperationrule it keeps
Snapshotretention::release_snapshotrefuses a file's newest version, so the present stays restorable
Archived artifactartifacts::release_custodythe artifact and every measurement survive; it becomes ANALYSED
PreservationPreserver::releasethe 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 stored flags. 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 objects68 ms, 201,800 bytes hashed, 2 queries, 200 reads
held scope, 50 of 2009.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:

authorityinputquestion
transform::concludemeasurements over pixelswhat transformation connects two images
ranking::assesstwo hashes + a reportwhat the memory and continuity graphs may claim
replay::evolutionan Evidence valuehow 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 relation5 queries, 2 objects verified, 195,006 bytes hashed, 6 ms
explain a 40-member preservation26 queries, 25 objects verified, 3 ms
explain an occurrence with missing bytes0 bytes hashed
investigate with 2 findings4 round trips
investigate with 5 findings4 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.

gradewhat produced itwhat it entitles the interface to say
FACTequal SHA-256, an OS-reported rename, or an operation AFTRIMGE performed and verifiedthis is so; no reasoning was involved
TEMPORALsequence or timing alonewhen things happened relative to each other, and nothing about cause
INFERREDdeterministic measurement over real but indirect evidence that passed the strong thresholda reproducible deduction, not a proof
POSSIBLEreal evidence that did not reach the strong thresholda possibility; the honest answer to most archaeology
UNKNOWNa test ran and declinedthe question was asked and the evidence does not answer it

Two rules hold across all of them:

  • UNKNOWN is not "not asked". It means a test ran. An unrecognised or future grade parses to UNKNOWN rather than being promoted into a claim.
  • A chain is never stronger than its weakest justified hop, and continuity::compose additionally clamps every composed grade at INFERRED, so no walk over measured edges can produce FACT or TEMPORAL.

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:

ingressinputwho computes the hashwho verified the bytesatomicon failure
ingest::record_version_innerput_filea watched filehasher::hash_file, several DB round trips earliernobodyrenameabandon this version
archaeology::archiveput_filean imported filehasher::hash_file, at import time, possibly days earliernobodyrenamepropagated to the user
integrity::repair (its own stage/rename)a verified sourceverify_path on the staged copyitselfrenameREFUSED
put_bytesan in-memory bufferitselfitself, triviallyrenameerror
restore::write_verifiedan objecthash_file twice, before and afteritselfrenameerror, 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:

sizeverifiedfs::copyratiothroughput
4 KB1.29 ms0.91 ms1.42×3 MB/s
100 KB1.65 ms0.84 ms1.96×59 MB/s
1 MB4.45 ms1.40 ms3.17×225 MB/s
10 MB31.38 ms4.52 ms6.95×319 MB/s
64 MB179.79 ms36.90 ms4.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.png has 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 verified or admitted_at column 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, contains and size_of still 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_verified is 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 .tmp would 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

fieldclaimsownerkind
objects.content_hashthis object's identitythe admitted bytes (Phase 12)canonical
objects.sizethat identity's byte lengththe admitted bytescanonical
objects.ref_counthow many snapshots point herederived from snapshots; repaired, never trusteddiagnostic
objects.storedn/anothing reads itvestigial
snapshots.sizehow big this version was when recordedthe filesystem at that momenthistorical observation
snapshots.storedthis version's bytes are still retainedretention; read by gc::HolderKindoperational state
snapshots.content_hashwhich content this version heldingest, verified by admissionhistorical + FK
files.original_pathwhere it first appearedthe filesystem thenhistorical observation
files.current_pathwhere it is nowthe filesystem nowmutable observation
artifacts.byte_sizehow big the imported file washash_file at importhistorical observation
artifacts.width/heightmeasured dimensionsthe decoder, at analysis_versionmeasurement
artifacts.content_hashwhich content was measuredhash_file at importcontent identity, no FK
analysis_cache.*a measurement over two hashesreproducible from the objectscache
visual_index.*one image's fingerprintreproducible from the objectcache
preservation_members.content_hashwhat the user chose to keepthe user's decisioncontent identity, no FK
preservation_sets.evidencewhat the engine said thenfrozen quotationhistorical record
maintenance_log.*what AFTRIMGE did to storagethe operationhistorical 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.stored stays. 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_count stays. It is derived, repaired from snapshots by repair_ledger, ignored by the collector, and reported as ledger_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_bytes stays derived from snapshots.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 MB25 µs
consistency audit, 25 objects2 round trips
consistency audit over a 24 MB store0 ms (hashes nothing)
storage ledger, 30 objects4 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:

fieldwritersreadersexposure
objects.ref_countsnapshots::insert (+1, never −1), gc::repair_ledger (recompute)one: gc::audit, which reads it only to report that it disagreesStoreAudit.ledger_driftMemoryReport → "COUNTER DRIFT"
objects.storedsnapshots::register_object (MAX)none, including testsnone

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:

identityobject required at write?dangling meansdetected by
artifacts.content_hashno: an ANALYSED artifact retains no bytesnormal statecustody, and the ledger for ARCHIVED
preservation_members.content_hashno (Phase 8): a decision about content already gone is a real decisionvalid historyPreservationState.unavailable
maintenance_log.content_hashnovalid historynothing needs to
analysis_cache.hash_a/byes at write, may go laterstale cache rowFamily-scoped pruning
visual_index.content_hashyes at write, may go laterorphaned index rowvisual_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::consistency says nothing about it.
  • No new liveness authority. gc::HolderKind was already right; the ledger now agrees with it.
  • No frontend change. The entry renders through the existing MISSING state and the existing holder list.
  • No repair. Nothing deletes the preservation, recreates the object, or rewrites a row.
  • No query added. objects_examined grows 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:

holderidentity comes fromshape guaranteed by
snapshots WHERE stored = 1ingest, after admissionhasher::hash_file, and a foreign key into objects
artifacts WHERE custody = 'ARCHIVED'hash_file at import; custody granted only after put_file and register_objectthe hasher
preservation_membersVec<String> over IPCnothing

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 not IncompleteSelection, 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:

staterouteledger
object row + bytes + holderrecorded and retainedHELD
object row + bytes, nothing holdingreleased, not yet sweptRECLAIMABLE
object row, no bytescollected, or lostMISSING
bytes, no object rowinterrupted writeUNTRACKED
no row, no bytes, holderpreserved after releaseMISSING

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

fieldwritersreadersauthoritativedecides anythingremovable
content_hashregister_objecteverythingyesyesno
sizeregister_objectledger, integrity, retentionyesyesno
ref_countsnapshots::insert (+1), gc::repair_ledgerone: gc::auditnonoyes, at a cost
storedregister_object (MAX)nonenonoyes, 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::collect increments objects_deleted even when the snapshots foreign 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 MISSING row 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:

quantityownerwhere it surfaces
bytes removed from diskGcOutcome.bytes_freed"Reclaimed"
known objects whose payload wentGcOutcome.objects_deleted"Objects deleted"
untracked files removedGcOutcome.orphans_deleted"Orphans removed"
objects rows still presentthe table itselfledger membership
content something still holdsgc::referenced_setHELD
bytes currently on diskObjectStore::list_objectsHELD + 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 killedresult
admission (create and modify, 17 B to 3 MB)consistent every round
release + collectionconsistent every round; no kept version stranded
restore in FORCE mode, 2.7–3 MBtarget 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

PlatformFilePackageBuilt on
Windows x64AFTRIMGE-<v>-Windows-x64-Installer.exeNSIS, per-user, no elevationWindows 11, MSVC
Windows x64AFTRIMGE-<v>-Windows-x64-Portable.zipfolder: executable, marker, README, noticesWindows 11, MSVC
Linux x64AFTRIMGE-<v>-Linux-x64.AppImageAppImage, carries its WebKitGTKUbuntu 24.04 (glibc 2.39)
Linux x64AFTRIMGE-<v>-Linux-x64.debDebian package, system WebKitGTKUbuntu 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_within refuses a project root that contains the archive. The existing contains only 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:

QuestionAuthority
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