First step of the shared-recording model. A recording is now stored as two
records: a PersistedMaterialization, which is the file on disk and the work
that produces it, and a PersistedLibraryEntry, which is one principal's link
to it. The materialization carries no owner and no visibility, because a
file that several users have asked for cannot belong to any one of them.
The mapping is still one entry per file, so no behaviour changes yet. What
changes is where the truth lives: reference_count is now a stored field with
one owner, and read_records() refuses any state where it disagrees with the
links pointing at the file, or where a link names a file that is not there.
A file deleted while still reachable, or kept forever after its last
reference went away, is not something a later pass can work out by guessing.
The DVR still works in whole tasks; split() and join() are the repository's
job precisely so the count has a single place to be maintained. join() is
lossless, and the link is authoritative for owner, visibility and quota
while the file is authoritative for everything physical.
Entry ids and materialization ids are separate namespaces from the start
(mat-<uuid>), so they cannot be used interchangeably in the interval before
one file legitimately serves several entries.
The schema name and database file change to recording_library, because the
previous shape stored the owner inside the task and cannot be expressed as a
one-to-one value migration into two records. The queue does not migrate — as
already documented — so the old database and history are left untouched
beside the new pair rather than failing the open.
The startup tests now discover the repository's artefacts instead of naming
them; hardcoding the filenames would have silently stopped testing the
fail-closed path the moment they changed, which is exactly what happened
here.
Playback could not find any recording. Two independent path computations
existed and disagreed:
writer RecordingTask::new -> <root>[/<subdir>]/<filename>
reader resolve_for_open -> <root>/users/<owner>/<filename>
resolve_recording_dir invented a users/<owner>/ or shared/ prefix that
nothing ever wrote, and relative_path stored only the bare filename while
the organised subdirectory lived in file_dir. Nothing covered the round
trip, so the mismatch survived.
recording_path.rs is now the only layout. It is pure, and deliberately
owner-independent: one physical file is shared by every user who requested
it, so keying its directory on an owner is wrong the moment a second user
attaches and would force the file to move when the first detaches.
resolve_recording_dir and its RecordingVisibility are deleted rather than
left as a second answer.
relative_path is now root-relative and carries the full layout, so the
writer's path and the reader's resolution are the same expression. Series
recordings gain the Season NN level they were documented to have but never
produced.
Component sanitisation is one function with a table test: separators,
control characters and the Windows-forbidden set collapse to _, leading and
trailing dots are stripped so a name cannot be hidden or silently aliased,
Windows device names fall back to a placeholder, and components are capped
at 255 bytes on a character boundary so a multi-byte title truncates to
valid UTF-8 rather than to a split character. A property test asserts that
no input the builder accepts can produce a path the containment check
rejects.
Collision suffixes now apply to the file component only, so reserving a
name no longer flattens the directory it belongs to.
The queue was a single JSON document rewritten in full on every mutation.
That cannot survive a torn write, and a change to the record shape had no
upgrade path other than refusing to load — load_from_disk fails closed on
an unknown schema_version, which would have stranded every operator on a
version bump.
Replace it with RecordingRepository: one record per task in a B+Tree, with
every write routed through BPlusTreeRecoveryJournal. A deleted or corrupt
database is now rebuilt from the recovery history instead of being lost,
and a future record-shape change becomes a migration step rather than a
refusal to start.
- backend/repository/src/recording_repository.rs owns the persisted shape
and RecordingRecoverySchema V1. It knows nothing about execution: the DVR
decides what a task means and hands over a set to commit atomically.
- commit() diffs the candidate against what is stored, so the recovery
journal stays proportional to what actually changed rather than to the
size of the queue.
- The partition (queued/scheduled/active/finished) is stored explicitly.
Deriving it from the state would be wrong: `active` is a distinct slot
and two tasks in the same state can sit in different partitions.
- Repository calls run on spawn_blocking; it fsyncs both the journal and
the B+Tree, so it must not run on a runtime worker thread.
RecordingTaskState moves to shared, along with the label() helper and the
TransferStatusDto conversion that were inherent impls in the DVR. The
repository crate cannot depend on the DVR, and the state is part of the
persisted contract either way.
Recovery generations are written under backup_dir, so they survive the loss
of the storage volume.
There is no migration from recordings_state.json: an existing queue is
discarded on upgrade.
The request body was declared twice: CreateRecordingTaskBody in the REST
handler and CreateRecordingTaskRequest in the frontend client. The two had
to agree by hand, and they had already drifted — the frontend typed
visibility as a String and built "private"/"shared" literally at three call
sites, so a typo compiled cleanly and only failed once the server rejected
the body.
Move both the request and its source identifiers into shared as
CreateRecordingRequest / RecordingSourceRequest and have each side use
them. visibility is now the RecordingVisibility enum end to end, so
visibility_to_wire returns a value the server can parse by construction.
Both types deny unknown fields: a client sending a field this build does
not understand has a different idea of what it is asking for, and
recording the wrong thing is worse than refusing. Tests cover the exact
minimal JSON, an injected "url" field, an unknown visibility and an
unknown source field.
Also widen the availability preflight to accept any recording permission.
It answers "is the DVR usable" and carries no recording data, so gating it
on read-or-create alone would have refused a manage-only principal.
A single write permission could not express the policy the DVR needs: a
user who may request a recording is not necessarily one who may cancel
someone else's, and neither implies the right to delete a file. Replace it
with recording.create, recording.manage and recording.delete, and map each
action onto the one it actually needs:
- create private/shared -> recording.create
- edit, cancel, manage rules -> recording.manage
- delete, system retention sweep -> recording.delete
The split renumbers every permission bit above recording.read, so
CURRENT_PERMISSION_SCHEMA_VERSION is bumped to 4 and tokens issued earlier
fail closed at the validator instead of having their bits reinterpreted.
The removed recording.write name now decodes to nothing, so a groups file
that still lists it loses the permission rather than silently gaining one
of the three replacements.
Permission bit values are frozen in a test: they are the wire format, and
reordering the enum would reinterpret every issued token.