The editor document (EditTimeline v2)
The v2 editable document: a JSON timeline a client can open, edit op-by-op and save back under optimistic concurrency. This page is self-contained — the document model, the time model, the closed op catalogue, the sync protocol, the trust boundary and the four accepted engine deviations are all here, and you do not need any other page to work from it.
docs/video-editing.md is the other half of the feature and deliberately
covers different things: the v1 manifest (TimelineManifest, the read-only "Expanded Studio
Tracks" description a compiled render emits) and the VideoAnalyze → decision → VideoCompile
pipeline that produces it. A v2 timeline is seeded from a v1 manifest and then edited; the
pipeline that compiles one is documented there, not here.
Table of Contents
- What ships in this phase, and what does not
- The document model
- The time model
- The op catalogue: thirteen ops, closed
- The sync protocol
- The trust boundary
- Accepted engine deviations (contract, not defects)
- Storage, ownership and revision history
What ships in this phase, and what does not
No editor UI ships. There is no screen that opens a v2 timeline. What exists is the contract
(EditTimeline.schema.json + the C# records beside it), a TypeScript mirror of that contract, a
pure edit engine with no React dependency, and the four REST endpoints that persist a document.
The existing /projects/{id}/editor/... pages read the v1 GET /api/v1/projects/{id}/timelines
endpoint and play a compiled render; nothing in web/ calls the v2 endpoints or imports the edit
engine. Saying otherwise — implying an editable timeline screen exists — would be wrong.
| Piece | Location |
|---|---|
| Authoritative contract | inference/src/ReelBolt.Shared/Editing/EditTimeline.schema.json |
| C# document records | ReelBolt.Shared/Editing/ (EditTimeline.cs, Item.cs, Keyframed.cs, Provenance.cs) |
| Validator (server trust boundary) | ReelBolt.Shared/Editing/EditTimelineValidator.cs |
| v1 manifest → v2 document seeder | ReelBolt.Shared/Editing/EditTimelineSeeder.cs |
| REST surface | ReelBolt.Inference.Api/Controllers/EditTimelinesController.cs |
| TypeScript mirror + edit engine | web/lib/editing/ (types.ts, time.ts, engine/) |
The TypeScript mirror is hand-written, so web/lib/editing/__tests__/schema-drift.test.ts asserts
it against the JSON Schema — the item-type discriminator sets must match in both directions, and
the fixtures are validated with ajv. Never edit one side without the other.
The document model
The schema pins schemaVersion to const: 2, so a v1 document shape is not representable at all.
The C# EditTimeline record carries a [JsonConstructor] overload that always sets
SchemaVersion = 2; the field is not something a caller chooses.
Every enum in the document travels as a camelCase string, and only as a string. The five
contract enums — TrackKind, AspectPolicy, InterpKind, ProvenanceOrigin, AssetRefKind —
each carry a type-level [JsonConverter(typeof(EditTimelineEnumJsonConverter))]
(ReelBolt.Shared/Data/Models/Enums.cs:411, :424, :438, :457, :472), and that converter is
JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)
(EditTimelineEnumJsonConverter.cs:44). So "aspectPolicy": "fit" is the only accepted spelling,
matching EditTimeline.schema.json, and an integer body — "aspectPolicy": 0 — is a binding
failure: [ApiController] answers the endpoint's ordinary 400 problem response, the action is
never entered, and nothing is written. It is never a 500 and never a silent accept of an ambiguous
number. The declaration lives on the enum types rather than in a JsonSerializerOptions on purpose:
an options-level converter outranks the type-level attribute, and
EditTimelinesController.DocumentJsonOptions (EditTimelinesController.cs:143) deliberately carries
no converter — a naming-policy-less JsonStringEnumConverter there would silently override the
attribute and keep persisting PascalCase. One place defines the encoding.
This is a breaking change for a client written against the integer form. It is not breaking for
stored data: reading is still case-insensitive (JsonStringEnumConverter's own behaviour), so a
legacy row already stored as PascalCase — "kind": "Video" — still reads back and round-trips.
No migration is needed, and such a row simply converges on the contract the next time a PATCH
rewrites it.
Timeline and settings
| Field | Type | Notes |
|---|---|---|
schemaVersion | 2 | Fixed. |
id | uuid | The timeline's own identity. |
projectId | uuid | The owning project. Set from the route on create, never from a seed manifest. |
name | string | User-facing; required and non-blank on create. |
settings | EditTimelineSettings | See below. |
tracks | Track[] | Compositing order. |
markers | Marker[] | Frame-ordered. |
inOut | EditRange? | Optional { inFrame, outFrame } playback range. Default null. |
seed | EditTimelineSeed? | Optional { executionId, version } naming the workflow execution whose manifest produced this document. Default null. |
EditTimelineSettings is width, height, rate (a Rational), sampleRate,
pixelAspect (a Rational), aspectPolicy (fit | fill | stretch) and
startTimecodeFrames. A blank timeline (created without a seed) is 1920×1080, 30/1,
48 000 Hz, square pixels, fit, timecode starting at frame 0, with one empty Video track and one
empty Audio track.
The blank document's track ids come from EditTimelineSeeder.TrackId — the seeder's own
deterministic label → GUID function — so a blank timeline and a seeded one address their video track
by the same id. The labels are V1 (video) and A1 (audio); A1 can never collide with a seeded
audio track because the seeder only ever emits A2 (sfx) and A3 (music).
Tracks
A Track carries id, kind (video | audio), name, color, height, locked, enabled,
mute, solo, busId and items. Within a track, items are sorted by recordIn and never
overlap (previous.recordOut <= next.recordIn); gaps are legal and meaningful. A locked track
rejects every op that names it — directly by trackId, or indirectly through an item that lives on
it.
In a seeded document the six v1 track labels map to fixed names and kinds: V1 → Video (video),
V2 → Inserts (video), V3 → Graphics (video), S1 → Captions (video), A2 → SFX (audio),
A3 → Music (audio). The GUID is a name-based hash (SHA-256 over a fixed namespace plus the
label, first 16 bytes), so the same label yields the same id in every process and every run.
Items
Item is a JSON-polymorphic union discriminated on type. Every member carries id, name,
recordIn, recordOut and an optional provenance. Ids are opaque and unique document-wide.
type | Kind | Own fields |
|---|---|---|
mediaClip | MediaClip | assetRef (required), sourceIn (default 0), playbackRate (default 1.0, must be > 0), enabled, gainDb |
titleClip | TitleClip | text (required), fontSize (48), fontFamily (Arial), color (#FFFFFF), enabled |
generatorClip | GeneratorClip | generatorType (required), generatorConfig, enabled |
insertClip | InsertClip | assetRef (optional), quad (a keyframed QuadCorners), enabled |
caption | Caption | text (required), language (en), enabled |
transition | Transition | transitionType (required), easing (linear), enabled |
compoundClip | CompoundClip | stub — TODO(P8), no own fields |
multicamClip | MulticamClip | stub — TODO(P7), no own fields |
adjustmentClip | AdjustmentClip | stub — TODO(P8), no own fields |
The last three are the schema's documented stubs. Their type literal exists so a document
containing one round-trips through the contract unchanged, but nothing consumes their contents yet:
they are not a nested-item container, not an angle manager and not a layer effect. The engine
treats them accordingly — see the setProperty allowlist below, which gives them name only.
Keyframed values and keyframes
Keyframed<T> is { value?: T, keys?: Keyframe<T>[] }: value is the base/default used when there
are no keyframes or before the first one, and keys, when present, overrides it from each
keyframe's frame onward. A Keyframe<T> is { frame, value, interp } where interp is one of
linear, easeInQuad, easeOutQuad, easeInOutQuad, easeInCubic, easeOutCubic,
easeInOutCubic, step.
Two places use it today, both on mediaClip/insertClip:
MediaClip.gainDb— the clip gain in dB. The split is the one a DAW uses:valueis the clip's static gain,keysa time-varying envelope that overrides it. The seeder keeps the two independent — neither is derived from the other, and an item the manifest gives no gain keeps a nullgainDbrather than a defaulted one. The single forced exception:Keyframed<double>.Valuecannot express "absent", so a ducking envelope present with no static bed level is emitted with the identity0dB, which the envelope then overrides from its first key.InsertClip.quad— a keyframedQuadCorners(fourQuadPoints in source-frame coordinates), corner-pinning an insert onto a tracked region. v1 records corner positions only, so every seeded keyframe islinear.
Provenance
Provenance describes how an item came to exist: origin (manual | generated | imported |
analyzed), editedByUser (required), and the optional stepResultId, agentDefinitionId,
providerId, offeredId, rationale, agentName, providerName.
Ids and names are populated by different paths, and neither is derived from the other. The
three GUID fields are filled by v2-native creation paths, which hold the entities themselves. The
two display-name fields are filled by a v1 seed: a TimelineManifest records only the agent's
and the provider's names and no ids, and a pure seeder has no database in which to look an id up.
A name is therefore copied verbatim when the source carried one and left null when it did not —
never synthesized, and never resolved from the other field.
AssetRef
AssetRef is { kind, projectFileId?, stepResultId? } with kind of projectFile or
stepResult.
It is the one place a timeline names an entity that lives outside itself, which is exactly why
it is the thing the validator polices hardest (see the trust boundary).
mediaClip.assetRef is required; insertClip.assetRef is optional (an insert may be backed by a
composed asset rather than a file reference).
Markers and InOut
A Marker is { id, frame, name, color?, note?, durationFrames? }. The contract orders markers
by frame only — two markers sharing a frame have no canonical order. That single fact is what
forces addMarker's optional index (see the deviations).
inOut is an optional { inFrame, outFrame } playback range. seed is an optional
{ executionId, version } recording which workflow execution's manifest seeded the document.
The time model
All positions are integer frames on the OUTPUT clock — the record clock, so recordIn/
recordOut describe where an item lands in the finished program, not where it sits in its source.
The source offset is a separate field (mediaClip.sourceIn). There is no seconds field and no
floating-point position anywhere in the document.
Record spans are half-open: [recordIn, recordOut). An item ending at frame 100 and the next
starting at 100 are adjacent, not overlapping — which is precisely the adjacency the transition
rule and the ripple rules key off. The validator requires recordOut > recordIn on every item,
i.e. a strictly positive extent; a zero-length span is not a legal item.
The frame rate is a rational, Rational { num, den }. This is not decoration: the NTSC rates
29.97 and 59.94 are 30000/1001 and 60000/1001, and a double cannot represent them. The v1→v2
seeder resolves a manifest's fps against a table of exact well-known rates (23.976 → 24000/1001,
24, 25, 29.97 → 30000/1001, 30, 50, 59.94 → 60000/1001, 60, matched within ±0.01 so a rounded
23.98 still lands on its exact rational). A rate matching none of them falls back to
round(fps × 1000) / 1000 — deliberately not a continued-fraction approximation, which would
return an exact-looking rational (e.g. 2997/100) whose precision is implied rather than
measured. The fallback is recognisable: its denominator is always 1000.
Timecode conversion (web/lib/editing/time.ts)
framesToTimecode(frames, rate) and timecodeToFrames(tc, rate) are exact inverses for every
integer frame count n >= 0 at any rate, including fractional ones.
- Non-drop-frame only. The output is
HH:MM:SS:FF, counted at a constant integer frame base — the:before the frame field is the conventional mark of non-drop-frame. Drop-frame notation (HH:MM:SS;FF) is deliberately not supported: the timeline contract counts frames, so a dropped-frame label would be a second, disagreeing source of truth for the same value. - The base is
Math.round(rate.num / rate.den), the same conventionlib/utils/timecode.tsalready uses for itsfpsargument — so30000/1001(29.97) works on a 30-frame base. A rate that is missing, zero, negative or non-finite falls back to 30; the base is never below 1. - Malformed input clamps to 0; it never throws.
timecodeToFramesreturns0for a wrong segment count, a non-numeric segment, or a field outside its range (mm/ss> 59,ff >= base), rather than throwing or returning a partially parsed value.framesToTimecodetreats a non-finite or negative frame count as0and floors a fractional one, since a timecode label can only address whole frames.
The module is standalone on purpose: it does not import lib/utils/timecode.ts, which is
seconds-based and has no notion of the Rational contract.
The op catalogue: thirteen ops, closed
Every edit is one of these thirteen ops. There are thirteen, not twelve — the plan text says "exactly these twelve ops" and then names thirteen; the engine implements the thirteen it named, and the thirteen are what the contract is.
| Op | Payload | What it does |
|---|---|---|
insert | trackId, index, item | Splices item at its canonical array index, then ripples the following items when it lands flush at the next item's start. |
overwrite | itemId, item | Replaces the content of the addressed item. item.id must equal itemId. Any span is allowed as long as it overlaps nothing. |
lift | itemId | Removes the item and leaves the gap. |
extract | itemId | Ripple-delete: removes the item, then shifts the following items left by its duration when it was adjacent (gapAfter === 0) and not at all when it was already followed by a gap. |
trim | itemId, edge (in|out), mode (ripple|roll|slip|slide), deltaFrames | Moves one edge, clamped to the invariants. |
blade | itemId, atFrame, newItemId | Splits an item strictly inside its span. Left half keeps the id; right half takes newItemId and advances sourceIn when the kind carries one. |
deleteGap | trackId, atFrame | Deletes the gap containing atFrame by shifting every later item left by its length. |
closeGaps | trackId | Removes every interior gap, preserving item order and the first item's own recordIn. The trailing space after the last item is not an interior gap and is untouched. |
move | itemId, toTrackId, toRecordIn | Moves an item (keeping its duration) to another position and/or track, leaving a gap behind. |
setProperty | itemId, key, value | Sets one allowlisted property. |
addMarker | marker, index? | Adds a marker, spliced so the marker list stays frame-ordered. |
setMarker | markerId, patch | Patches a marker's fields. frame may only move within the slot its neighbours allow. |
removeMarker | markerId | Removes a marker by id. |
The union is closed
EditOp is a closed TypeScript union in web/lib/editing/engine/ops.ts. An op that is not a
member cannot reach the engine at all — TypeScript rejects it, and apply has an exhaustive switch
whose default arm is a never assignment. So "which edits exist" is answered in exactly one
place, and there is no half-implemented member to find.
Every op is pure: apply clones the document up front, never writes to the argument or to
anything reachable from it, and the inverse program it returns never aliases the returned document
(removed items are detached from the clone before being embedded in the inverse).
What is deferred
Deliberately absent, and therefore impossible to reach — not stubbed, not partially implemented: retime, keyframe ops, transition ops, track-structure ops, link/group, compound ops and applyProposal. A deferred op is a new ticket, not a variation of this one.
The inverse is a program, not an op
apply(doc, op) returns { doc, inverse: EditOp[] } — a program. Ten of the thirteen are exactly
self-inverse under one op, but three cannot be: blade must both drop the right half and restore
the left half's original span, and deleteGap/closeGaps translate an unbounded number of items.
Those three invert to a short program drawn only from the same thirteen named ops:
blade→liftthe right half, thenoverwritethe left half back to its original span (2 ops).deleteGap/closeGaps→ onemoveper affected item, emitted rightmost first, so every destination is already free when its move runs.
applyAll(apply(doc, op).doc, inverse) deep-equals doc for every op, and every intermediate
document satisfies assertInvariants — undo/redo and optimistic concurrency both rest on that.
The inverse is always non-empty except for closeGaps on a track that has no gaps, which genuinely
changed nothing and so has nothing to undo.
setProperty's allowlist
setProperty may address only these keys, per item kind:
| Item kind | Settable keys |
|---|---|
mediaClip | name, enabled, playbackRate, gainDb |
titleClip | name, enabled, text, fontSize, fontFamily, color |
generatorClip | name, enabled |
insertClip | name, enabled |
caption | name, enabled, text, language |
transition | name, enabled |
compoundClip, multicamClip, adjustmentClip | name only |
The three stubs carry no enabled field, so setting one there is rejected like any other unknown
key. undefined is a meaningful value, not a gap in the type: it removes the key, which is what
lets setProperty's inverse restore a property that was absent before (a Keyframed gain, say)
without a dedicated delete op in the catalogue.
But the allowlist is not the only check a clear has to pass. undefined on a key the document
invariants require is refused, before the write, with an EditEngineError whose op is
setProperty (apply.ts:958-964). The rule is stated by an exported set beside the allowlist —
REQUIRED_PROPERTY_KEYS (ops.ts:316) — which today holds name alone, and holds it because
assertItemShape refuses an item whose name is not a string and name is on every kind's
allowlist. Honouring the clear there would hand back a document the engine may no longer edit, so
the two ways to make the conflict go away — deleting the field, or loosening the invariant to admit a
nameless item — would each trade a loud refusal for a silently corrupt document. This is QA finding
F1 (medium) in .aiforge/initiatives/editor-v2-123/reports/T-513.yaml. Every other
allowlisted key (enabled, text, fontSize, fontFamily, color, language, playbackRate,
gainDb) is optional to the invariants, so clearing it stays supported and keeps the op invertible.
setMarker is held to the same shape invariant. A patch may set any marker field and undefined
clears it, so the post-patch marker is checked by assertMarkerShape (apply.ts:1072) before the op
returns. That is what makes setMarker { patch: { name: undefined } } a refusal rather than a
nameless marker — the marker contract has the same shape rule addMarker and assertInvariants
already enforce. That is QA finding F3 (low), same report. apply cloned the document up front,
so neither refusal can reach the caller's document.
Both rules serve one promise, the same one insert's partial-overlap refusal serves: an op cannot
introduce a violation (apply.ts:31). Both came out of QA's review of the engine and shipped
together in a single fix (6df773ce, "refuse to clear a required field, and validate markers"); each
is pinned by tests under web/lib/editing/engine/__tests__/.
What the engine refuses
The engine refuses an op when performing it would leave it holding a document it cannot honestly
undo. insert is one: it accepts exactly two positions relative to the following item, and
throws EditEngineError for anything in between — a partial overlap, where the inserted item
runs 0 < delta < duration past where the next item starts. See
deviation 2 for why. So are the
setProperty clear and the setMarker shape check described just above, and so is setMarker's
refusal of a frame move that would pass a neighbour
(deviation 4). Everything else the engine refuses
is the same error type: an unknown id, a locked track, a non-canonical index, a wrongly-typed
property, a document that violates an invariant. EditEngineError.op names the op that was being
executed, so a UI can point at the offending edit rather than just a message.
The sync protocol
Four endpoints, and nothing else. Everything the editing feature will eventually need — duplicate, history, render, export, import, media, proposals — is deliberately absent; adding one is a new ticket.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/v1/projects/{projectId}/edit-timelines | Owner | Lists the project's timelines, newest-updated-first, as lightweight summaries (id, name, revision, seedStepResultId, timestamps) — deliberately without the document. |
POST | /api/v1/projects/{projectId}/edit-timelines | Owner | Creates a timeline: blank, or seeded from a step result's timeline manifest. |
GET | /api/v1/projects/{projectId}/edit-timelines/{id} | Owner | Returns one timeline's current document and revision. |
PATCH | /api/v1/projects/{projectId}/edit-timelines/{id} | Owner | Applies one revision: replaces the document, advances the revision, appends the caller's ops verbatim. |
Ownership is the outer gate, project scoping is the inner one. Every endpoint loads the project
and returns 404 when it does not exist and 403 when it exists but belongs to somebody else.
Every {id} lookup additionally constrains ProjectId == projectId and inherits the soft-delete
query filter, so a timeline that is in another project, soft-deleted, or nonexistent is 404 in
all three cases and the body never distinguishes them.
PATCH carries baseRevision plus the full document
PATCH takes { baseRevision, ops, document }. The document is the complete post-edit
document, not a delta; ops is the client's edit operations and is opaque to the server —
serialized verbatim into edit_timeline_ops.ops_json and never applied server-side. The engine
lives in TypeScript on the client. An absent or null ops stores [].
A stale baseRevision is a 409 with currentRevision, and no write
If the stored Revision does not equal the request's baseRevision, the endpoint returns
409 Conflict with { "currentRevision": <n> } — the revision the caller must rebase onto — and
nothing is written: no entity is modified, no row is added, no transaction is opened.
The server is protected twice, on purpose. The explicit Revision != BaseRevision pre-check
catches the ordinary stale writer before any work happens; the DbUpdateConcurrencyException catch
behind it catches the writer that raced between that check and the commit (Revision is an EF Core
concurrency token, so the UPDATE carries WHERE id = @id AND revision = @original). Both paths
answer 409 with the same body shape and neither writes — a client treats them identically and
simply rebases onto currentRevision. On the race path the current revision is re-read with
AsNoTracking, precisely because the tracked entity still carries this request's rejected edit.
The order is the contract, not an implementation detail
The steps run in this order, and the order is observable:
- project lookup (
404) and timeline lookup (404) - whole-body size ceiling →
413 Payload Too Large, before the body is bound or parsed - serialized document or
opspast its own ceiling →413, no write - stale
baseRevision→409withcurrentRevision, no write - a null document →
400; thenEditTimelineValidator→400with every violation, no write - one transaction: document + revision + op row (+ snapshot), commit
DbUpdateConcurrencyExceptionfrom the racing writer →409
Both 413 steps run before the 409 check, and the null-document 400 runs after it — so a stale
caller is always told to rebase first, whatever else is wrong with their payload, while an oversized
ops blob is rejected before the stale-revision comparison rather than after it.
Three ceilings, and only one of them is a pre-parse control. MaxDocumentBytes is
8 * 1024 * 1024 (EditTimelinesController.cs:69) and MaxOpsBytes is 1024 * 1024 (:85); the
third, MaxRequestBodyBytes (:100), is their sum plus 64 KiB of JSON-envelope headroom. That
third one is the real bound, because [RequestSizeLimit] on the action (:358) sets Kestrel's
MaxRequestBodySize: Kestrel stops reading an over-limit body while it is still arriving and answers
413 before MVC binds, allocates or deserializes anything. The two per-part checks are post-parse
defence in depth — by the time either runs, the body has already been bound, so what they prevent
is persisting an oversized value, not reading it. They are what makes the transport ceiling a
bound on what the server actually accepts rather than a blunt byte count.
Each 413 body names the ceiling it exceeded — maxDocumentBytes (:384) or maxOpsBytes (:396)
— so a client can size its next attempt without guessing. The ops blob is serialized exactly once
(:377), so the bytes measured are the bytes written; re-serializing at the write would be a second,
unmeasured blob free to differ from the one the guard approved. A timeline document is a bounded
JSON structure (the validator caps it at 64 tracks × 5000 items), so anything past the ceiling is a
client defect or an attack, not an edit.
Creating a timeline
POST takes { name, seedStepResultId? }. name is required and non-blank (whitespace is
rejected, not trimmed to empty).
With no seedStepResultId, the blank document described above is created at revision 1.
With a seedStepResultId, the step result's TimelineStorageKey manifest is downloaded and mapped
through EditTimelineSeeder. The seeder is pure — no I/O, no database, no clock, no random — so
the same manifest and the same asset resolver always produce the same document. The route's project
id and the caller's name win over the manifest's own: a manifest cannot plant a document claiming to
belong somewhere else. Asset identifiers resolve through this project's ProjectFile rows indexed
by StorageKey and by OriginalFileName — the manifest references video by storage key and
audio by file name — and an identifier that does not resolve drops that item rather than inventing
a reference.
The seeded path's failure modes are deliberately distinguishable:
| Condition | Response |
|---|---|
| step result missing, or in another project | 404 (indistinguishable from nonexistent) |
step result carries no timeline manifest (timelineStorageKey null) | 400 |
| manifest cannot be fetched or read | 502 Bad Gateway — an upstream/storage failure, not a client error |
| the server-built document fails validation | 500, with the violations — the caller did nothing wrong |
The trust boundary
EditTimelineValidator is the only thing the server trusts. Nothing else in the write path
inspects the document: the controller parses it into the typed records, hands it to the validator,
and — if the validator is clean — persists it. The validator is pure: no I/O, no database, no clock.
It is given the caller-supplied lookups it needs as plain dictionaries, because ReelBolt.Shared
has no DbContext and must never query a database.
It returns every violation, in document order, never just the first. Each violation is a
(Path, Message) pair where Path is a precise dotted/indexed locator (e.g.
tracks[0].items[2].recordOut), so a 400 body tells the caller exactly which field to fix.
Every AssetRef must resolve inside the timeline's own project
This is the point of the whole type. An AssetRef is the one place a timeline names an entity that
lives outside itself, so it is the one place a caller could smuggle a reference to another project's
file or step result into their own timeline. Every ref must resolve, through the ownership maps, to
the timeline's own project and nowhere else. Getting it wrong is a cross-tenant data leak, not
merely a bad document — so it mirrors the trust boundary the VideoCompile artifact endpoint
already enforces.
The ownership maps are built without a project filter, and that is the point: the question being answered is "who really owns this id", not "does this project own it". An id belonging to another project must resolve to that other project so the validator can reject it; filtering the query by project would silently turn every foreign reference into "unknown" — still rejected, but for the wrong reason, and the maps would be blind to the very leak they exist to catch.
A ref is a violation when it is null, internally inconsistent (both projectFileId and
stepResultId set, or the id the kind does not call for missing), unknown to the ownership map,
owned by a different project, or carries an AssetRefKind the validator does not support
(EditTimelineValidator.cs:447). That last branch is unreachable through the endpoint today — an
integer kind fails model binding before the action runs (see
the document model), and the enum has only two values — but the validator
does not assume the endpoint is its only caller: it rejects an unsupported kind however the value
got there, rather than letting it fall through as valid.
Provenance.StepResultId is held to exactly the same rule. A provenance id is collected into
the same StepResultOwners map the asset refs use (EditTimelinesController.cs:665-668) — collecting
only the asset refs would leave every provenance id absent from the map, so the validator would
report each one as unknown instead of genuinely checking who owns it — and ValidateProvenance
(EditTimelineValidator.cs:317-344) applies the identical test: unknown to the map is a violation,
owned by another project is a violation, and because Provenance is optional, a null provenance or
a null stepResultId is simply not checked rather than defaulted into one.
AgentDefinitionId and ProviderId are deliberately NOT validated
(EditTimelineValidator.cs:302-314). They name rows in global, non-project-scoped catalogues —
agent_definitions and inference_providers carry no ProjectId column at all — so any project may
legitimately name any agent or provider and "does this id exist?" would be a referential-integrity
check with no cross-tenant boundary to protect. They are also write-only today: nothing on the server
reads either field. That is a property of the current consumers, not of the fields. A future
consumer that starts acting on either one must resolve-and-check it at its own point of use — where
it also gets to decide what "unknown" should mean — and a retention pruner that deletes step
results must revisit this validator's assumptions at the same time, since the ids it deliberately
leaves unchecked are exactly the ones that would dangle. OfferedId is the same kind of thing: an
opaque analysis-scoped token that is not a row id in any table.
Missing ownership is a violation; missing DURATION is not
The validator's two lookups answer different kinds of question and are treated asymmetrically on purpose:
- Ownership — a missing entry is a violation. Ownership is a security property held in a database the caller can always consult, and "unresolvable" must never be read as "trusted".
- Duration — a missing entry is not a violation. The source-extent check
(
sourceIn + (recordOut - recordIn) <= assetDurationFrames) can only ever prove an overrun; the absence of a duration means "not yet known", not "provably wrong", so the clip degrades to unvalidated rather than to a rejection.
The asymmetry is forced by what is available today: no caller in this phase can populate the
duration map at all. ProjectFile has no duration column, the MediaDerivative table has no writer
yet, and the Inference API has no ffprobe — so treating "unknown" as a violation would reject every
real MediaClip and make the whole validator unusable. The endpoint therefore passes an
intentionally empty duration map. A clip whose duration is supplied still gets the full
overrun check, so a future phase that starts populating durations re-arms the check with no change
to the validator.
In one line: an unresolvable asset identity is rejected; an unresolvable asset length merely goes unchecked.
The overrun check is skipped when the ref itself is untrustworthy: a foreign or unknown ref is already reported, and piling a second violation on top would bury the security-relevant one in noise.
Structural limits and ordering
| Rule | Bound |
|---|---|
| Tracks per document | at most 64 |
| Items per track | at most 5000 |
Keyframes per Keyframed<T> | at most 256 |
Per track, items must be sorted by recordIn and non-overlapping. A Transition is
special-cased: it must sit exactly at the boundary between its neighbours (recordIn equals the
predecessor's recordOut), which is checked instead of the overlap rule. Once a pair is out of
order the overlap test is skipped for that pair — adjacency is meaningless in an unsorted list, and
reporting both would fabricate a second violation for a defect already reported once.
Overlay text
TitleClip and Caption text must survive OverlayTextSanitizer.Sanitize with something
renderable left over, because the render path sanitizes again and drops an overlay whose text
sanitizes away — a clip whose text is null, whitespace-only, or composed entirely of characters the
allowlist strips is a document that cannot produce the output it claims.
Text that merely contains such characters (an apostrophe, %, :, an emoji) is not a
violation: real ASR captions contain apostrophes, and rejecting them would be a functional defect
rather than a hardening. There is deliberately no "text must equal its sanitized form" rule.
Disabled items are still validated
Every item is checked regardless of its Enabled flag. The document's geometry is the contract, and
a disabled item that has since been corrupted still has to survive a round trip through the editor
before it can be re-enabled.
Accepted engine deviations (contract, not defects)
Four places where the engine's implemented behaviour differs from the task text that specified it.
All four were raised with reasoning, accepted by the program lead, and are recorded in
decisions.md. They are the contract: QA treats a rejection as a rejection (not a failure), and
docs describe the behaviour below rather than the text that preceded it. Each is pinned by tests in
web/lib/editing/engine/__tests__/.
1. apply returns inverse: EditOp[], not a single op
apply(doc, op) returns { doc, inverse: EditOp[] } — a program. Forced: blade must drop the
right half and restore the left half's span, and deleteGap/closeGaps translate an unbounded
number of items, while compound forward ops are deferred and there is no merge/shift primitive in
the catalogue. Only the same thirteen named ops may appear in a program, in either direction —
length 1 for ten ops, 2 for blade, one move per affected item for the two gap ops.
2. insert refuses a partial overlap instead of rippling it
insert throws EditEngineError when the inserted item would partially overlap the following
item (0 < delta < duration). The ripple is provably non-invertible in that band: it destroys
nextRecordIn, and the post-op document is byte-identical to one where the item merely filled a
gap of that size — the two need different undo shifts, so an inverse that guesses silently
corrupts the document. Only two positions are accepted:
- fits in the gap (
recordOut <= nextRecordIn) — ripples nothing; inverse islift. - flush at the following item's start (
nextRecordIn === recordIn) — the classic ripple insert, shifting everything at/after that point right by the item's own duration; inverse isextract.
Placing over an existing item remains overwrite; pushing content aside from an arbitrary point
means inserting flush at the next item's start. The engine uses the ticket's prose rule for
extract ("adjacent → full ripple; already gapped → no shift"), which is what keeps insert,
lift and extract mutually exact inverses. E-501's generator must treat a refused op as a
rejection, not a failure.
3. trim's slide TRANSLATES the neighbour's edge; it never snaps it
In slide mode the neighbour's facing edge is translated by the same delta as the moved item. It is
never snapped onto the moved item's edge. Snapping looks equivalent — the two coincide when the
items are adjacent — but it silently swallows a pre-existing gap, and that gap's width is not
recoverable from the post-op document, so the op would not round-trip. A translation leaves every
gap exactly as wide as it was.
(Related, and from the same rule — an op may not destroy information its own inverse needs —
trim clamps deltaFrames to the invariants and its inverse carries minus the effective,
post-clamp delta, so a clamped trim still restores the document exactly.)
4. addMarker takes an optional, validated index
The contract orders markers only by frame, so markers sharing a frame have no canonical order.
Restoring such a marker by frame alone would land it at the end of its tie group rather than in its
old slot, and removeMarker's inverse would no longer deep-equal the document it undid. So
addMarker accepts an optional index, and the engine's own removeMarker inverse carries the
slot it removed the marker from.
Omitting index — the normal case for a hand-authored op — inserts after every marker whose frame
is <= the new one. When present, the index must be an integer within 0..markers.length and
must itself keep the list frame-ordered, so it cannot be used to break the invariant.
Two smaller notes that travel with the same reasoning: setMarker refuses a frame move that would
pass a neighbour (a re-sort cannot be undone by one op — removeMarker + addMarker is the path
for that), and TypeScript optionality follows the JSON Schema, not C# serialization: the schema
is the wire contract.
Storage, ownership and revision history
Four tables, all Inference-API-owned (migrated under __EFMigrationsHistory_Api, added by
AddEditTimelineTables) and mapped read-only in WorkflowEngineDbContext with the same
soft-delete filters, so engine-side code can query them without owning their schema.
| Table | Entity | Role |
|---|---|---|
edit_timelines | EditTimelineEntity | The document row: Id, ProjectId, Name, DocumentJson (jsonb), Revision, SeedStepResultId, timestamps, DeletedAt. |
edit_timeline_ops | EditTimelineOp | One revision's batch of client-authored ops, verbatim (jsonb). Append-only; (TimelineId, Revision) is unique. |
edit_timeline_snapshots | EditTimelineSnapshot | A full document snapshot at one revision (jsonb). (TimelineId, Revision) is unique. |
media_derivatives | MediaDerivative | Index of derived media artifacts (proxy / waveform / thumbnail) in the object store. Has no writer yet. |
EditTimelineEntity is deliberately named ...Entity rather than EditTimeline: the document
type lives in ReelBolt.Shared/Editing and is what DocumentJson serializes, while the row
carries the relational columns (project, revision, seed step result) the API queries without
deserializing the document. Revision is an EF Core concurrency token on the Inference API's
own mapping (the engine's read-only mapping does not mark it) — that is what makes the race path in
PATCH fail loudly instead of clobbering. DeletedAt is a soft-delete marker hidden by the query
filter, and rows cascade-delete with their project and timeline. The ops and snapshots tables each
carry a unique (TimelineId, Revision) index and cascade-delete with their timeline; only
edit_timelines and media_derivatives carry the soft-delete filter.
The op log and snapshots
Every PATCH that advances a timeline's revision writes exactly one op row, so the log is
append-only and (TimelineId, Revision) is unique. Alongside it, a full document snapshot is
retained every 20th revision (revision % 20 == 0 on the revision the PATCH produced). Ops and
snapshots share the same revision namespace. The op log is what makes history possible; P1 records
it and does not yet serve it.
Reading a document back
Both GET and a successful PATCH return the single-item response shape: the document plus id,
projectId, name, revision, seedStepResultId and timestamps. Reading uses one JSON serializer
configuration in both directions — a JsonSerializerOptions built on JsonSerializerDefaults.Web
(camelCase, case-insensitive) with deliberately no converter list
(EditTimelinesController.cs:143) — so a stored document always round-trips. The string form for
AspectPolicy/TrackKind and the other three contract enums is not configured there: it is the
type-level attribute on the enums themselves (EditTimelineEnumJsonConverter.cs:44), which is what
keeps the stored form aligned with EditTimeline.schema.json for every other reader and writer too —
and what makes a bare integer a binding failure rather than a form this configuration could ever
produce (see the document model).
An unparseable stored document_json is data corruption, not a client error: it is reported as
a failed parse and answered with a 500 that names the row, so an operator can find it — never a
400 blaming the caller.