Episode & Clip Lifecycle — App-Side Handling¶
How the application layer handles trimming, clipping, promoting, and deleting
episodes, and the exact database + S3 effects of each action. This is the
app-side companion to DATABASE.md: the schema
(schemas/episodes.ddl, schemas/clips.ddl) and the
lifecycle functions (queries/admin_lifecycle.sql) provide the primitives; this
doc defines the orchestration the app performs around them.
Golden rule — the database moves rows, the app moves bytes. Every function in
admin_lifecycle.sqlruns under RLS astobor_appand only ever touches DB state. It hands the app the S3 paths to act on; the app is responsible for all S3 / Glacier object movement and MP4 / parquet extraction. If the app crashes mid-flow, DB state and S3 state can diverge — so every flow below is written to be resumable and to change DB state only after the irreversible byte work is either done or safely deferred.
0. The pieces¶
| Store | Holds | Key columns |
|---|---|---|
episodes |
One row per full recording. Owns its own S3 objects. | mp4_s3_path, parquet_s3_path, frame_count, duration_seconds, aggregate_score, origin, promoted_from_clip_id, storage_state, deleted_at, purge_after |
clips |
Metadata only — a named [start_time_ms, end_time_ms) span into a source episode's video. Owns no S3 objects of its own; points at the parent's paths. |
episode_id, start_time_ms, end_time_ms, source_mp4_s3_path, source_parquet_s3_path, deleted_at |
upload_parts |
Multipart-upload ledger for an episode's MP4. Disposable once the object has landed. | episode_id |
Two structural facts drive everything below:
- A clip is a pointer, not a copy. It stores offsets + the parent's S3
paths. Deleting the parent's bytes would orphan every clip — so the
clips → episodesFK isRESTRICT(noON DELETE CASCADE): the database physically refuses to delete an episode while a live clip references it. Any flow that removes a parent must therefore deal with its clips first. storage_stateis where the bytes are, and it is the app's contract with itself.standard → glaciering → glacier → restoring → standard. The DB sets the intent (e.g.glaciering); the app performs the S3 transition and flips the state to the terminal value when the bytes have actually moved.
1. Trim & save — the destructive in-place edit¶
The client scrubs to a new [start, end] and chooses Save (trim the
episode). This mutates the episode in place: the same episodes.id
survives, but its video, parquet, and duration are permanently narrowed to the
selected span. This is not recoverable through the product.
1.1 The warning gate (required)¶
Because trim is destructive, the app must show a confirmation after the client has made all their edits and pressed Save — a blocking modal:
Trimming is permanent. Saving will replace this episode with the selected span and delete everything outside it. The original will be kept in cold storage for 7 days in case of accident, then permanently erased. This cannot be undone from the app. Trim and save? — [Cancel] / [Yes, trim]
Only on explicit Yes does the app proceed. On Cancel, nothing changes.
1.2 What happens on Yes¶
Let E be the episode, [s, e) the new span in ms.
- Extract the span from the stored MP4 (
E.mp4_s3_path) into a new object in the same S3 folder (e.g.…/<episode>/video-trimmed-<s>-<e>.mp4). Extract the matching parquet span too. - Archive the old original. Copy the pre-trim
E.mp4_s3_path(and parquet) to Glacier under a 7-day safety key, then remove the old standard-tier object. An S3 lifecycle rule hard-deletes the Glacier safety copy after 7 days — no product surface exposes it; it exists only for ops-level accident recovery. - Repoint the row in one transaction:
mp4_s3_path/parquet_s3_path→ the new objects. - Drop the stale ledger — delete
upload_partsforE(the old multipart ledger is meaningless for the new object). - Re-base and recount. The extracted parquet in step 1 is written with every
timestamp_msre-based to 0 (subtracts), so the trimmed episode starts at zero; updateframe_count,duration_seconds, and (if changed)fpsto match the new span. Re-basing keeps a trimmed episode indistinguishable from a freshly recorded one — important for any clip later taken from it. Resetaggregate_scoreto0as well: the score described the old span, and re-scoring the trimmed episode is a fresh run.
Ordering for resumability: do the irreversible S3 work (steps 1–2) first, then the DB repoint + recount (steps 3–5) in a single transaction. If the app dies between them, the new object exists but the row still points at the archived original — a retry re-extracts idempotently (same deterministic key) and completes the DB flip. Never delete the old objects before the new MP4 and parquet are durably written.
1.3 Clips on a trimmed episode¶
A trim narrows the parent, so a pre-existing clip's offsets may now fall partially or fully outside the new span. Trim is only offered when the episode has no live clips (or the app forces the client to resolve them first). Trimming an episode with clips is not supported — promote or delete the clips before trimming. (This restriction avoids silently corrupting clip offsets; if the product later needs trim-with-clips, define the re-clamping rule explicitly.)
2. Save as clip — the non-destructive path¶
The client scrubs to [start, end] and chooses Save as clip. Nothing about
the source episode changes.
- Insert one
clipsrow:episode_id= the source episode,start_time_ms,end_time_ms,session_id= the session the clip was created in (may differ from the episode's own session), andsource_mp4_s3_path/source_parquet_s3_pathcopied from the parent so the clip records exactly which bytes it references. - No S3 work. No new MP4. No parquet copy. The clip is pure metadata; play back is done by seeking into the parent's MP4 between the offsets.
- The original episode is never deleted or modified by clipping. Multiple clips can reference the same parent.
A clip is cheap and reversible: deleting a clip is a plain hard-delete of the one metadata row (it owns no bytes), and it never touches the parent.
3. Promote a clip to an episode¶
A client action Promote to episode turns a clip's referenced span into a first-class, self-owned episode. After promotion the clip's content lives on independently of its former parent.
Let C be the clip, P its parent episode.
- Extract
C's span ([C.start_time_ms, C.end_time_ms)) fromC.source_mp4_s3_pathinto a brand-new S3 location owned by the new episode (its own folder — not the parent's). Extract the parquet span too. - Create the new episode
N: origin = 'promoted',promoted_from_clip_id = C.id(a bare UUID, no FK — deliberately, so the id survivesCbeing deleted, mirroringaudit_logs.actor_id).mp4_s3_path/parquet_s3_path→ the new objects from step 1.robot_id,session_id,task(fromC.description/P.task),fps(fromP), timing, etc. carried fromC/Pas appropriate;duration_seconds/frame_countcomputed from the span; the extracted parquet'stimestamp_msre-based to 0 (as in §1.2);aggregate_scoreleft at its0default —Nis unscored until a scoring run assesses it, and inheritingP's score would be a claim about data nobody has evaluated.- Delete the clip row
C. It owned no bytes, so this is a plain delete of the metadata. - The parent
Pis left completely untouched — same bytes, same row. Promotion never modifies the parent. (If the client also wants the parent gone, that is a separate delete action — §4.)
Result: a promoted episode is indistinguishable from an original except that
origin='promoted' and promoted_from_clip_id records its lineage.
Do the S3 extraction and new-episode INSERT before deleting
C, so a crash leaves the clip intact and the promotion simply retries.
4. Deleting a whole episode (not trim)¶
When the client deletes an entire episode, the app offers two choices. Both
paths must first deal with any clips (§4.1), because the clips → episodes FK is
RESTRICT.
4.1 Clips are auto-promoted first (both paths)¶
If the episode has any live clips, the app promotes every one of them to its
own standalone episode before deleting the parent — running the §3 promote
flow per clip (extract each clip's own MP4/parquet into its own new folder,
create its origin='promoted' episode, delete the clip row). Only once
no clip references the parent does the app proceed to delete it; otherwise
the database would reject the delete.
This is an app-side orchestration, not a DB cascade. Its consequences, which the UI should make explicit before the client confirms:
- Each former clip becomes an independent episode with its own storage. They no longer depend on the parent's bytes.
- This detachment is permanent and is not reversed if the parent is later
restored from the bin (§4.2). Once promoted, a clip is its own episode
forever. (This intentionally differs from the DB's built-in
trash_episode/restore_episodeclip-cascade, which the app bypasses by emptying the clip set up front.)
Suggested confirmation copy when clips exist:
This episode has N clip(s). Deleting it will first save each clip as its own episode (with its own video), then remove this one. The saved clips will not come back into this episode even if you restore it. Continue?
4.2 Choice A — move to the bin (recoverable, → Glacier)¶
Calls trash_episode(episode_id[, retention_days]):
- Sets
deleted_at,deleted_by,purge_after, andstorage_state = 'glaciering'. Retention precedence: explicit arg → the org plan'sretention_days→ 30 days default. - The app then moves the episode's S3 bytes to Glacier and flips
storage_state = 'glacier'. upload_partsis hard-deleted immediately (a ledger for bytes that already landed, and that survive in Glacier — keeping it hot would buy nothing).- Because clips were already emptied in §4.1, the DB's clip-cascade in
trash_episodeis a no-op here.
Restore (in-window): restore_episode(episode_id) clears the trash
columns, sets storage_state = 'restoring', and returns the S3 paths. The app
thaws the Glacier objects, then flips storage_state = 'standard'.
Purge (retention elapsed): the scheduled purge_trashed() job
hard-deletes every episode past its purge_after, returning the S3 paths so the
caller can delete the Glacier objects. This is the point of no return for a
binned episode.
4.3 Choice B — delete permanently now¶
Calls delete_episode(episode_id) — an immediate hard delete: captures the
episode's set links onto a delete tombstone (for link preservation, see
tests/09_link_preservation.sql), drops upload_parts / link rows, then the
episode row. The app then deletes the standard-tier S3 objects. There is
no bin and no Glacier copy. Refuses on an already-trashed episode (restore it
first, or let purge_trashed() remove it).
4.4 The two choices at a glance¶
| Bin (§4.2) | Delete permanently (§4.3) | |
|---|---|---|
| DB call | trash_episode() |
delete_episode() |
| Row | kept (soft-deleted) | removed |
| S3 bytes | → Glacier, kept for retention window | deleted now |
upload_parts |
dropped | dropped |
| Recoverable? | Yes, until purge_after |
No |
| Clips | auto-promoted first (§4.1) | auto-promoted first (§4.1) |
5. Storage-state machine¶
episodes.storage_state is the single source of truth for where the bytes are
and what the app still owes:
trash_episode() app moves bytes
standard ───────────────────────► glaciering ──────────────► glacier
▲ │
│ app finishes the thaw, │ restore_episode()
│ flips state ▼
└──────────────────────────────── restoring ◄────────────────┘
(app thaws Glacier)
glacier ──(purge_after elapsed: purge_trashed())──► row + bytes deleted
A trim (§1) does not use these states — it stays standard throughout; its
7-day safety copy is managed by an S3 lifecycle rule, invisible to the
schema.
6. Invariants the app must preserve¶
- Never delete bytes the DB still points at, and never leave the DB pointing at bytes that are gone. Extract/write new objects before repointing rows; delete old objects after the row no longer references them.
- A live clip always has a live parent. Enforced by the
RESTRICTFK; the app must promote/reassign clips before removing a parent (§4.1). - The parquet is authoritative for frame-level data. There is no
framestable:episodes.parquet_s3_pathis the system of record, and any per-timestep read goes to it. Trim keeps the trimmed parquet; trash keeps the Glacier parquet; permanent delete removes it. - Trim and permanent-delete are irreversible from the product. The only safety nets are the 7-day trim Glacier copy (ops-only) and the bin retention window — both time-boxed.
- Promotion lineage is immutable.
origin='promoted'+promoted_from_clip_idare set once at creation and never rewritten, even after the source clip and/or parent are gone.