ADR 0007: Media operations journal¶
Status: Accepted (Console 2.0 P2c, issue #155). Migration 0044, the executor, the reconciler, and the library UI land in the same slice.
Context¶
Console 2.0 Track A adds file management: the operator can move a media file between folders, trash it, restore it from trash, and empty the trash to reclaim disk. These are the first operations that mutate or delete source bytes. A crash at any filesystem or database boundary can leave the system in a state where a file exists at neither its old nor its new location, or where the database pointer disagrees with the filesystem. Without a durable record of what was intended, recovery requires manual inspection.
The pins that constrain the design:
source_pathis the immutable acquisition identity, never rewritten (ADR 0001). Physical location is the mutablecurrent_path, seeded equal tosource_pathat ingest. Byte-opening reads switch tocurrent_pathin this slice.media_folder_idis a logical config scope (ADR 0002 P2b addendum), not a physical location tracker. A move updatescurrent_pathonly and never clobbers an explicit operator membership override (operator decision O1).- Single operator, but concurrency is real: two browser tabs, a reconciler beat, and a pipeline worker can all touch one media row at once.
- Production is Postgres only. Advisory locks and partial unique indexes are Postgres features; SQLite is a dev and test single-writer engine.
Decision¶
Every byte-touching operation is recorded in a media_operations journal table.
The journal row carries the full intent (operation type, origin path, destination
path, digests) and tracks progress through a state machine. A dedicated
reconciler beat drives interrupted rows to a consistent terminal state by
comparing the recorded intent against filesystem and database ground truth.
The load-bearing invariant: Voxint never unlinks the origin until it owns and has verified a durable destination. That is the invariant tests assert. An absolute "no file was ever lost" claim is unassertable (an external process can always delete a file), but the system never creates a window where its own operations leave both the origin absent and the destination unverified.
1. State machine¶
Five states, each with a durable invariant:
| State | Invariant | Transition |
|---|---|---|
planned |
Journal row committed. No operation-owned destination published yet. Origin bytes are at current_path (unchanged). |
On successful filesystem publication, advance to fs_applied. |
fs_applied |
The destination is durably published (fsynced to disk) and its digest verified. The origin disposition is known (still present for pre-unlink, or already removed by a same-device rename). This is the single filesystem crash window. | On successful current_path CAS update, advance to db_applied. For a same-device move, fs_applied and the pointer update commit in one transaction (no redundant database-only crash window). |
db_applied |
current_path has been updated by a CAS that required the expected prior path. The database and filesystem agree on the live location. Origin may still be present (pending unlink and cleanup). |
On cleanup completion (origin unlinked, temps removed, directory fsynced), advance to completed. |
completed |
Terminal. Cleanup finished: temp files removed, origin directory verified, purge child-file inventory fully resolved. | None. |
awaiting_retry |
Non-terminal. A transient error (permission, mount, I/O) occurred and the operation is waiting for a scheduled retry. The filesystem and database are at a consistent state. The row stays inside the partial unique index, so no new operation can be admitted for this item while a retry is pending. | When next_attempt_at has passed, the reconciler steals the lease and re-enters from the last durable state (planned or fs_applied). |
failed |
Terminal. An unrecoverable or operator-action-required stop. The filesystem and database are at a consistent state: either the operation was rolled back (origin intact, no destination) or rolled forward (destination published, pointer updated). | None. The operator must resolve the condition and issue a new operation. |
awaiting_retry carries an error_code enum, attempt_count,
last_attempt_at, and next_attempt_at, so a transient permission or mount
error schedules a retry with exponential backoff. The partial unique index
covers non-terminal states including awaiting_retry, so no second operation
or run admission can slip in while a retry is pending. failed is strictly
terminal and excluded from the index, freeing the item for a new operation.
Operations chain: a restore records restores_operation_id pointing at the
completed trash operation it reverses, so the reconciler can validate that the
restore's origin (the trash destination) is consistent. A stale operation whose
item has a newer non-terminal operation cannot complete: the CAS on
current_path rejects the stale expected-prior value.
2. Concurrency protocol¶
Transaction-scoped advisory and row locks release at each commit, so they cannot protect the filesystem gap between one commit and the next. Two requests or two reconcilers could otherwise race on the same row. The protocol uses four interlocking mechanisms:
2a. Partial unique index (at most one active operation per item).
A partial unique index on media_operations(media_id) filtered to non-terminal
states (state NOT IN ('completed', 'failed')) enforces at the database level
that a second concurrent request for the same item fails at insert. This covers
planned, fs_applied, db_applied, and awaiting_retry. The caller reports
"an operation is already in progress", never executes a second operation.
2b. Claim lease (stale executor detection).
Each operation carries claimed_by (a per-process or per-request token) and
lease_expires_at. The executor sets these at insert. Nothing renews the lease
during long-running filesystem work yet: a pass that outlives its lease is
stealable mid-flight, and adding renewal plus per-action rechecks is tracked in
issue #353. The reconciler steals an expired lease by a CAS on
claimed_by that also checks lease_expires_at < now(). A dead executor
(crashed process, killed request) does not wedge the row: once its lease
expires, the reconciler takes over. This mirrors the StageRun lease pattern
proven in test_cas_and_restart.py.
2c. Compare-and-set on state (no stale advancement).
Every state transition is a WHERE state = :expected_state AND claimed_by = :me
update. A stale actor whose CAS returns zero rows affected knows it lost the
race and aborts without further side effects. No actor can advance a row another
actor already moved past.
Lease fencing limitation and mitigation. The lease detects stale ownership
but does not fence filesystem side effects retroactively: a paused executor can
resume after its lease expires, perform a filesystem write (a no-clobber
publish or a temp-file create), and only discover at the subsequent CAS that it
lost ownership. No-clobber publication limits the damage (it cannot overwrite a
destination the new owner created), but a stale executor could create an orphaned
temp file. Mitigation: (a) the executor re-checks claimed_by and
lease_expires_at immediately before every destructive filesystem action
(origin unlink, temp cleanup), aborting if the lease is no longer held; (b) the
reconciler cleans operation-owned temp files (deterministic naming) as part of
its convergence pass; (c) origin unlinks happen only after the pointer CAS
succeeds, so a stale executor whose CAS fails never unlinks. This is weaker
than a session-level advisory lock held across the filesystem phase, but avoids
the connection-lifetime coupling that the plan explicitly rejected.
2d. Shared row lock on MediaItem (move-versus-run serialization).
Move, trash, restore, and purge take SELECT ... FOR UPDATE on the MediaItem
row and refuse to proceed if a non-terminal pipeline run exists for that item
(status NOT IN ('completed', 'failed', 'cancelled')). Run admission
(submit_media_item, submit_media_item_if_new, the library rerun action) takes
the same row lock and refuses to admit while a non-terminal operation exists, or
while the item is trashed or purged. The PREPARE stage defers (re-queues) rather
than decodes when an unresolved operation exists for its item. This closes both
the executor-versus-executor race and the move-versus-run race.
3. Filesystem publication (durable, no-clobber)¶
Preflight collision checks followed by os.replace are TOCTOU-prone, and
os.replace clobbers a destination created after the check. Checksum equality at
the destination does not prove this operation created it, so unlinking the origin
on a checksum match could destroy the operator's only copy.
Same-device sequence:
- Open the parent directory of the destination.
- Create the destination with no-clobber semantics. The preferred primitive is
os.link(hard link from origin to destination, atomic, fails if destination exists). If the filesystem does not support hard links across the relevant paths, userenameat2withRENAME_NOREPLACE(Linux 3.15+, atomic no-clobber rename). The implementation must not fall back to a check-then-rename sequence, which is TOCTOU-prone. os.fsyncthe destination directory.- For a move: unlink the origin, then
os.fsyncthe origin directory.
For a same-device move, steps 1 through 3 and the current_path CAS commit
happen in one transaction: the state advances directly from planned to
db_applied (skipping fs_applied), because no crash window exists between
publication and pointer update when they share a commit. Step 4 (origin unlink)
happens after the commit, and the state advances to completed on success.
Cross-device (EXDEV) sequence:
- Open the source file by descriptor (pinning its inode).
- Copy through the opened descriptor into an operation-owned temp path
(deterministic from the operation id:
.voxint-op-{op_id}.tmp), so recovery can find and clean it. os.fsyncthe temp file.- Verify the temp file's size and sha256 digest against the source.
- Publish no-clobber onto the final destination name (same as same-device step
2), then
os.fsyncthe destination directory. - Commit
state = 'fs_applied'. This is the EXDEV crash window: the destination is durable but the pointer has not moved. - CAS the pointer (
current_path = destination WHERE current_path = origin). Commitstate = 'db_applied'. - Unlink the origin,
os.fsyncthe origin directory. - Remove the operation-owned temp file (if distinct from the published destination).
- Commit
state = 'completed'.
The temp file name is deterministic so the reconciler can find and clean orphaned temps without a directory scan. A crash at any step is recoverable: the reconciler classifies the filesystem state (section 4) and drives to a terminal state.
4. Reconciliation decision table¶
The reconciler processes non-terminal media_operations rows on a beat
schedule. It never decides from two path-existence checks alone. For each row,
it classifies the filesystem reality and applies the decision table below.
Inputs to classification:
| Input | Source |
|---|---|
journal_state |
The state column of the media_operations row. |
current_path_class |
Where media_items.current_path points: origin (equals op.origin_path), destination (equals op.destination_path), or other (neither, meaning a later operation moved it). |
origin_exists |
Whether media_root / op.origin_path is a regular file. |
origin_digest |
sha256 of the origin file, if it exists. |
dest_exists |
Whether media_root / op.destination_path is a regular file. |
dest_digest |
sha256 of the destination file, if it exists. |
temp_exists |
Whether an operation-owned temp file (.voxint-op-{op_id}.tmp) exists in the destination directory. |
root_available |
Whether media_root and the relevant mount points are accessible. |
newer_op_exists |
Whether a newer non-terminal media_operations row exists for this media_id. |
Pre-check: root availability. If root_available is false, the reconciler
skips this row without changing its state (following the
MediaRootUnavailableError precedent). A long NAS outage must not cause the
reconciler to classify an absent file as lost.
Pre-check: newer operation. If newer_op_exists is true and the current
operation is non-terminal, the current operation is marked failed with
error_code = 'superseded' (not retriable). A stale operation must not
overwrite a later operation's pointer.
Decision table (after pre-checks pass):
| # | journal_state |
current_path_class |
origin_exists |
dest_exists |
temp_exists |
Action |
|---|---|---|---|---|---|---|
| R1 | planned |
origin |
yes | no | no | Normal: execute from the beginning. Acquire lease, proceed with filesystem publication. |
| R2 | planned |
origin |
yes | no | yes | Interrupted EXDEV copy. Delete the orphaned temp, then execute from the beginning. |
| R3 | planned |
origin |
yes | yes | any | Destination collision. If dest_digest matches origin_digest and the destination path is inside the operation-owned namespace (trash: _trash/{op_id}/; move: the recorded destination_path), this is a replay of a completed operation that lost its DB commit: CAS the pointer to destination, advance to completed (do NOT unlink origin until the CAS succeeds, then unlink). If digests differ or the destination is not operation-owned, mark failed with error_code = 'destination_exists' (operator action required). |
| R4 | planned |
origin |
no | no | no | Origin vanished before execution. Mark awaiting_retry with error_code = 'origin_missing' (the mount may return). |
| R4a | planned |
origin |
no | no | yes | Origin vanished and an incomplete EXDEV temp exists. Delete the orphaned temp, then mark awaiting_retry with error_code = 'origin_missing'. |
| R5 | planned |
origin |
no | yes | any | Origin gone, destination present with matching origin_digest. Roll forward: CAS the pointer to destination, advance to completed. If dest_digest differs from origin_digest, mark failed with error_code = 'ambiguous_state' (not retriable). |
| R6 | planned |
destination |
any | any | any | Pointer already at destination. Another actor or the executor completed the pointer update but not the state transition. Verify destination exists and digest matches, then advance to completed. If destination is absent or digest mismatches, mark failed with error_code = 'pointer_dangling'. |
| R7 | planned |
other |
any | any | any | Pointer moved by a later operation. Mark failed with error_code = 'superseded'. |
| R8 | fs_applied |
origin |
any | yes | any | Destination published but pointer not yet updated. Verify dest_digest matches origin_digest. If match: CAS the pointer to destination, then unlink origin (only after CAS succeeds), clean up temps, advance to completed. If mismatch: mark failed with error_code = 'digest_mismatch'. |
| R9 | fs_applied |
destination |
any | yes | any | Pointer already updated (state label lagged). Verify dest_digest matches origin_digest. Clean up origin (unlink if present, after digest verification) and temps, advance to completed. |
| R10 | fs_applied |
destination |
any | no | any | Pointer updated but destination is now absent. Mark failed with error_code = 'pointer_dangling' (not retriable). |
| R11 | fs_applied |
origin |
any | no | yes | Destination absent but temp exists. The fs_applied state means publication was committed, so the destination was removed after publication (external or concurrent). Verify temp digest matches origin_digest. If match: re-publish no-clobber from temp, then proceed as R8. If mismatch: delete temp and mark failed with error_code = 'temp_corrupt'. |
| R12 | fs_applied |
origin |
any | no | no | Destination lost after publication. If origin_exists with matching digest, roll back pointer to origin, mark awaiting_retry with error_code = 'destination_lost'. If origin also absent, mark failed with error_code = 'both_absent' (operator action required). |
| R13 | fs_applied |
other |
any | any | any | Superseded. Mark failed with error_code = 'superseded'. Clean up any temp. |
| R14 | db_applied |
destination |
any | yes | any | Normal post-pointer state. Clean up origin (unlink if present, fsync), remove temp, advance to completed. |
| R15 | db_applied |
destination |
any | no | any | Destination absent after pointer update. Mark failed with error_code = 'pointer_dangling'. |
| R16 | db_applied |
not destination |
any | any | any | Pointer diverged (superseded or rolled back externally). Mark failed with error_code = 'superseded'. |
Every pointer update in the table is a CAS: UPDATE media_items SET current_path = :dest WHERE id = :id AND current_path = :expected. A CAS that returns zero rows means another actor updated the pointer; the reconciler marks the row failed with error_code = 'cas_conflict'.
Purge reconciliation. The decision table above applies to move, trash, and restore operations (single origin, single destination). Purge follows a different flow driven by its child manifest. The reconciler handles purge operations by state:
| State | Action |
|---|---|
planned |
The manifest may or may not be built. If media_operation_files has no children for this operation, build the manifest and commit. Then attempt each pending child (unlink, mark done/missing/failed). |
fs_applied |
All children are resolved (done or missing). Delete artifact rows, set purged_at, advance to db_applied. |
db_applied |
Artifact rows deleted, purged_at set. Advance to completed. |
awaiting_retry |
Re-attempt failed children. If all children reach done or missing, advance to fs_applied. |
A purge operation transitions from planned to fs_applied only when every
child is done or missing. If any child is failed, it moves to
awaiting_retry. The partial unique index and lease protocol apply identically.
5. Trash, restore, purge¶
Trash is a journaled move whose destination is a managed subtree inside
media_root, named _trash/{op_id}/{filename} so each trash operation owns its
destination directory. current_path follows the file into the trash tree, so
existing playback (which serves the derived AudioArtifact preprocessed-WAV
bytes, not the source) keeps working while the item is trashed. The trash tree
name (_trash) is added to _RESERVED_TREES (setup_wizard.py), the single
choke point that excludes it from every watch-folder scan, so the watcher never
re-ingests trashed files. media_items.trashed_at is set when the trash
operation completes. A deletion deadline is recorded for display only; there is
no auto-purge.
Restore is a journaled move back to the recorded original location (the
trash operation's origin_path). If the destination (the original location) is
occupied, the restore refuses with an honest "destination occupied" error and
tells the operator which file is there, rather than overwriting. The restore
records restores_operation_id pointing at the completed trash operation so the
reconciler can validate the chain.
Purge (empty-trash, manual only) is designed separately from move because a
single origin-destination pair cannot track multi-file deletion. Purge builds a
durable per-file manifest using the media_operation_files child table,
enumerating the source file and every derived target across the item's runs
(AudioArtifact preprocessed WAV, AudioChunk clips, peaks files). The
sequence:
- Build the manifest: query the item's runs for all derived artifacts and
record each file path as a child row in
media_operation_fileswithstatus = 'pending'. Commit. - For each child row: attempt unlink, then mark the child row
done(file removed),missing(file was already absent), orfailed(permission or I/O error). Commit after each child. - Convergence check: only when every child row is
doneormissing(nopendingorfailedremaining) may the purge proceed to step 4. If any child isfailed, the parent operation moves toawaiting_retrywitherror_code = 'partial_purge', preserving the manifest for retry. The reconciler re-attempts failed children on the next pass. - Delete the artifact database rows (
AudioArtifact,AudioChunk, peaks metadata) and setmedia_items.purged_at. Commit. - Advance the parent operation to
completed.
The existing delete_run_derived_media is not reused as-is because it deletes
artifact rows before the best-effort unlink, which loses the retry inventory on
a crash. Purge reuses its file enumeration logic but defers the row deletion
until the manifest confirms convergence.
Run pages for purged items keep transcript text and run history. The source,
peaks, and clip endpoints return 410 Gone (not 404), with an honest capability
banner driven off purged_at.
6. Sidecar as a bundled secondary file¶
A sidecar YAML sits beside a media file and carries per-file configuration. It
belongs to the media bundle (operator decision O4), so a move, trash, and restore
relocate it with the file, and _reread_sidecar reads beside current_path, not
source_path.
Crash safety: two files cannot move atomically. The media file is the critical
file and is published first through the full no-clobber, fsync sequence. The
sidecar is then relocated best-effort, with its own origin and destination
recorded in the media_operation_files manifest (the same child table purge
uses), so the reconciler can complete or roll back the sidecar move
independently. A missing or unmovable sidecar is a warning, never a failure of
the media operation: the sidecar's effect is already frozen into any past run
snapshot, and a rerun that finds no sidecar behaves exactly as an unsidecar'd
file does today. This keeps the media-loss invariant strictly about the media
file.
7. Purged-media serving contract¶
When media_items.purged_at IS NOT NULL:
| Endpoint | Response |
|---|---|
GET /media/{run_id} (preprocessed WAV) |
410 Gone, X-Voxint-Purged: true header. |
GET /media/{run_id}/peaks |
410 Gone, X-Voxint-Purged: true header. |
GET /media/{run_id}/clips/{clip_id} |
410 Gone, X-Voxint-Purged: true header. |
| Run detail page | Renders with a capability banner: "Source media has been permanently deleted. Transcript text and run history are preserved." Audio player and waveform are hidden. |
| Library list (active view) | Row is hidden (purged items do not appear in the active library). |
| Library list (trash view) | Row is hidden (purged items have left trash). |
/runs list |
Row is visible. The run's transcript text and metadata are intact. A badge indicates the source is purged. |
The 410 responses use X-Voxint-Purged so the console islands can distinguish
"purged" from "broken" and render the appropriate banner without a second round
trip.
8. Operation-specific metadata transitions¶
Each operation type sets metadata atomically with its completion CAS:
| Operation | On completed |
On rollback |
|---|---|---|
| Trash | Set media_items.trashed_at = now(). |
Clear trashed_at (if the trash pointer CAS was never committed, trashed_at was never set). |
| Restore | Clear media_items.trashed_at. |
No change (item stays trashed). |
| Purge | Set media_items.purged_at = now(). Clear current_path (the file no longer exists). |
No change (item stays trashed, manifest preserved for retry). |
| Move | No metadata beyond the current_path CAS. |
No change. |
These writes are part of the completion CAS transaction, so they are atomic with the state transition and idempotent under reconciliation (the reconciler applies the same write if the state transition was interrupted).
Consequences¶
- Every byte-touching operation is recoverable from a crash at any boundary. The reconciler drives interrupted rows to a consistent terminal state using the decision table, which relies on digests and the recorded intent, not on path-existence checks alone.
- The concurrency protocol (partial unique index, claim lease, CAS transitions, shared row lock) closes both executor-versus-executor and move-versus-run races with mechanisms the codebase already tests, rather than inventing session-level advisory locks.
- Purge is irreversible but safe: the manifest-first design makes a partial purge recoverable and honest rather than silently orphaning files. The 410 contract gives the UI a clean seam to degrade gracefully.
- The trash tree is excluded from watch-folder scans by the single
_RESERVED_TREESchoke point, so no new exclusion wiring is needed. - The
current_pathsplit (ADR 0001) becomes load-bearing: byte-openers, the reclaim alias guard, and the sidecar read all switch tocurrent_pathin this slice. media_folder_idis untouched by a move (operator decision O1, honoring the ADR 0002 MUST-NOT-clobber rule). An explicit "move and reassign" is a separate operator action.
Rejected alternatives¶
- Database-first ordering (update
current_path, then move bytes). Rejected: a crash after the pointer update but before the move leaves the pointer aimed at a nonexistent destination, and existing byte-openers would fail. Filesystem-first keeps the pointer valid until the destination is durably published. - Trash as a pure database soft-delete flag (no physical move). Rejected: the issue requires bytes to leave the watched folders so the sweep stops re-emitting them and so disk is reclaimable.
- Kill-a-real-process crash testing. Rejected: the house pattern
(
test_cas_and_restart.py) constructs intermediate states deterministically and runs the recoverer, which is CI-friendly and reproducible. This slice extends that pattern with executor hooks at each filesystem boundary. os.replacefor destination publication. Rejected:os.replaceclobbers an existing destination, and a checksum match does not prove this operation created it.- Session-level advisory lock across the filesystem phase. Considered for lease fencing. Rejected: the lock lifetime is the database connection, and a connection drop during a long EXDEV copy would release the lock while the filesystem work is still in progress, recreating the same race. The claim-lease + CAS + pre-action recheck protocol is weaker but has no connection-lifetime coupling.
Review notes¶
A codex planner-role review (zen clink, 2026-08-26) examined the ADR for protocol correctness, decision-table completeness, and internal consistency. Eight findings were accepted and folded in:
-
Retriable
failedcontradiction (HIGH).failedwas simultaneously terminal (excluded from the unique index) and retriable. Added anawaiting_retrynon-terminal state, included in the unique index, so a pending retry blocks new operations and the reconciler can re-enter. -
Purge convergence with failed children (HIGH). The original wording let purge complete with
failedchild deletions, orphaning files. Convergence now requires every child to bedoneormissing;failedchildren move the parent toawaiting_retry. -
No purge reconciliation table (HIGH). R1-R16 assume a move (origin/destination). Added a purge-specific reconciliation table keyed on the manifest state.
-
Lease fencing limitation (HIGH). Documented that the lease detects stale ownership but does not fence filesystem side effects retroactively. Added mitigations: pre-action lease recheck, origin unlink only after CAS success, reconciler cleans orphaned temps.
-
Hard-link fallback TOCTOU (HIGH). Removed the check-then-rename fallback. Publication requires
os.linkorrenameat2(RENAME_NOREPLACE). -
R3 ownership rule (HIGH). R3 now requires the destination to be in the operation-owned namespace and re-verifies digest before origin unlink. R8/R9 now verify
dest_digestbefore origin cleanup. -
db_appliedreachability (MEDIUM). Clarified that same-device moves skipfs_appliedand go directly todb_applied; EXDEV moves commitfs_appliedafter publication anddb_appliedafter the pointer CAS, making both states reachable. -
Metadata transitions unspecified (MEDIUM). Added section 8 specifying
trashed_at/purged_at/current_pathwrites per operation type, atomic with the completion CAS.
Session-level advisory locks (finding 4 alternative) were considered and rejected (see "Rejected alternatives"). R4a (origin vanished + temp exists) was independently identified before the review arrived. R11 was reframed as a post-publication destination loss with temp recovery.