Skip to main content

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​

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.

PieceLocation
Authoritative contractinference/src/ReelBolt.Shared/Editing/EditTimeline.schema.json
C# document recordsReelBolt.Shared/Editing/ (EditTimeline.cs, Item.cs, Keyframed.cs, Provenance.cs)
Validator (server trust boundary)ReelBolt.Shared/Editing/EditTimelineValidator.cs
v1 manifest → v2 document seederReelBolt.Shared/Editing/EditTimelineSeeder.cs
REST surfaceReelBolt.Inference.Api/Controllers/EditTimelinesController.cs
TypeScript mirror + edit engineweb/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​

FieldTypeNotes
schemaVersion2Fixed.
iduuidThe timeline's own identity.
projectIduuidThe owning project. Set from the route on create, never from a seed manifest.
namestringUser-facing; required and non-blank on create.
settingsEditTimelineSettingsSee below.
tracksTrack[]Compositing order.
markersMarker[]Frame-ordered.
inOutEditRange?Optional { inFrame, outFrame } playback range. Default null.
seedEditTimelineSeed?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.

typeKindOwn fields
mediaClipMediaClipassetRef (required), sourceIn (default 0), playbackRate (default 1.0, must be > 0), enabled, gainDb
titleClipTitleCliptext (required), fontSize (48), fontFamily (Arial), color (#FFFFFF), enabled
generatorClipGeneratorClipgeneratorType (required), generatorConfig, enabled
insertClipInsertClipassetRef (optional), quad (a keyframed QuadCorners), enabled
captionCaptiontext (required), language (en), enabled
transitionTransitiontransitionType (required), easing (linear), enabled
compoundClipCompoundClipstub — TODO(P8), no own fields
multicamClipMulticamClipstub — TODO(P7), no own fields
adjustmentClipAdjustmentClipstub — 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: value is the clip's static gain, keys a 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 null gainDb rather than a defaulted one. The single forced exception: Keyframed<double>.Value cannot express "absent", so a ducking envelope present with no static bed level is emitted with the identity 0 dB, which the envelope then overrides from its first key.
  • InsertClip.quad — a keyframed QuadCorners (four QuadPoints in source-frame coordinates), corner-pinning an insert onto a tracked region. v1 records corner positions only, so every seeded keyframe is linear.

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 convention lib/utils/timecode.ts already uses for its fps argument — so 30000/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. timecodeToFrames returns 0 for 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. framesToTimecode treats a non-finite or negative frame count as 0 and 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.

OpPayloadWhat it does
inserttrackId, index, itemSplices item at its canonical array index, then ripples the following items when it lands flush at the next item's start.
overwriteitemId, itemReplaces the content of the addressed item. item.id must equal itemId. Any span is allowed as long as it overlaps nothing.
liftitemIdRemoves the item and leaves the gap.
extractitemIdRipple-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.
trimitemId, edge (in|out), mode (ripple|roll|slip|slide), deltaFramesMoves one edge, clamped to the invariants.
bladeitemId, atFrame, newItemIdSplits an item strictly inside its span. Left half keeps the id; right half takes newItemId and advances sourceIn when the kind carries one.
deleteGaptrackId, atFrameDeletes the gap containing atFrame by shifting every later item left by its length.
closeGapstrackIdRemoves 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.
moveitemId, toTrackId, toRecordInMoves an item (keeping its duration) to another position and/or track, leaving a gap behind.
setPropertyitemId, key, valueSets one allowlisted property.
addMarkermarker, index?Adds a marker, spliced so the marker list stays frame-ordered.
setMarkermarkerId, patchPatches a marker's fields. frame may only move within the slot its neighbours allow.
removeMarkermarkerIdRemoves 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 → lift the right half, then overwrite the left half back to its original span (2 ops).
  • deleteGap / closeGaps → one move per 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 kindSettable keys
mediaClipname, enabled, playbackRate, gainDb
titleClipname, enabled, text, fontSize, fontFamily, color
generatorClipname, enabled
insertClipname, enabled
captionname, enabled, text, language
transitionname, enabled
compoundClip, multicamClip, adjustmentClipname 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.

MethodPathAuthDescription
GET/api/v1/projects/{projectId}/edit-timelinesOwnerLists 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-timelinesOwnerCreates a timeline: blank, or seeded from a step result's timeline manifest.
GET/api/v1/projects/{projectId}/edit-timelines/{id}OwnerReturns one timeline's current document and revision.
PATCH/api/v1/projects/{projectId}/edit-timelines/{id}OwnerApplies 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:

  1. project lookup (404) and timeline lookup (404)
  2. whole-body size ceiling → 413 Payload Too Large, before the body is bound or parsed
  3. serialized document or ops past its own ceiling → 413, no write
  4. stale baseRevision → 409 with currentRevision, no write
  5. a null document → 400; then EditTimelineValidator → 400 with every violation, no write
  6. one transaction: document + revision + op row (+ snapshot), commit
  7. DbUpdateConcurrencyException from 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:

ConditionResponse
step result missing, or in another project404 (indistinguishable from nonexistent)
step result carries no timeline manifest (timelineStorageKey null)400
manifest cannot be fetched or read502 Bad Gateway — an upstream/storage failure, not a client error
the server-built document fails validation500, 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​

RuleBound
Tracks per documentat most 64
Items per trackat 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 is lift.
  • 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 is extract.

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.

TableEntityRole
edit_timelinesEditTimelineEntityThe document row: Id, ProjectId, Name, DocumentJson (jsonb), Revision, SeedStepResultId, timestamps, DeletedAt.
edit_timeline_opsEditTimelineOpOne revision's batch of client-authored ops, verbatim (jsonb). Append-only; (TimelineId, Revision) is unique.
edit_timeline_snapshotsEditTimelineSnapshotA full document snapshot at one revision (jsonb). (TimelineId, Revision) is unique.
media_derivativesMediaDerivativeIndex 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.