Operator annotations: the anchor contract¶
Audience: contributors and integrators. Operator-facing usage lives in the console how-to once the feature ships. This document freezes the contracts behind the annotation layer (issue #86): how a selected transcript span is addressed, validated, stored, rendered, and detected as stale. Code must match this document; a deliberate change here is a schema-version event, not a drift.
What an annotation is¶
An annotation is an operator-owned marker over a span of the reviewed
transcript: a highlight color, zero or more flat tags, and an optional margin
note. Annotations are a mutable workspace, not an adjudication record: they can
be edited and soft-deleted, and they never modify pipeline evidence. The
transcript columns raw_text, enhanced_text, and the review-state
corrected_text are never written by any annotation code path. A contract test
enforces this.
Coordinate system¶
The console renders the transcript as the ordered line list produced by
attributed_transcript (src/voxint/adjudication/transcript.py). Split
parents expand into derived child lines at read time; children carry the
immutable parent segment_id plus a half-open [word_start, word_end) window
into the parent's word tokens. Annotations always address the immutable parent
segment, in one of three anchor kinds. Rendered-line indices are never
persisted; they are a per-render projection.
Character offsets are Unicode code points, everywhere. The browser's DOM
exposes UTF-16 code units; the client converts to code points exactly once, in
frontend/src/lib/selection.ts, before anything touches the wire. The server
never sees or stores UTF-16 units.
Anchor kinds (anchor_schema_version = 1)¶
word_range: parent segment endpoints plus half-open parent-word indices. Granted only when every covered segment is word-faithful (see Classification) and both selection endpoints fall exactly on token boundaries. The only kind with precise, word-timing-derived seconds.text_range: parent segment endpoints plus half-open code-point offsets into each endpoint segment's effective text as it existed at capture. Required whenever the displayed text is not word-faithful raw text (enhanced or corrected).segment_range: whole immutable parent segments, no offsets. The fallback for whole-segment selections and for partial selections on segments with no word timings (words IS NULL): the exact selected text is preserved as the quote, but display and seek degrade honestly to whole-segment granularity. Timing is never fabricated.
All kinds capture, server-side, at write time: the derived quote_text, a
source_text_hash over the covered effective texts, and (word_range only)
precise start_seconds and end_seconds.
Wire schema for capture¶
Create, re-anchor, and the live pull-quote request share one capture payload. Per selection endpoint (start and end):
segment_id: the immutable parent segment UUID.child_word_start,child_word_end: a nullable pair. When present it must exactly name a currently rendered split child of that parent (derived from the current split boundaries); anything else is a 422. Null for unsplit lines.offset: a code-point index into that rendered line's text. Half-open across the pair: the start offset is inclusive, the end offset exclusive.
Plus one client_quote field: the client's own slice of its props text (never
Range.toString(), whose whitespace behavior at block boundaries is not a
stable protocol). The server derives the quote independently from stored text
and compares; a mismatch is a 409 stale conflict. client_quote is a
consistency assertion only. It never becomes stored text, and classification
is exclusively server-side; the client never picks the anchor kind.
Reverse selections are legal; the server normalizes direction by transcript position before validating.
Coordinate mapping (child-local to parent)¶
Rendered child text is the child's tokens joined and outer-stripped
(splits.py, derive_children). Parent coordinates are code-point offsets
into the parent's effective text. The mapping from a child-local offset to a
parent offset must account for two deltas:
- the parent outer-trim delta: joined tokens may differ from
raw_textby outer whitespace only (splittable_wordstoleratesjoined.strip() == raw_text.strip()), so token positions are located insideraw_textafter skipping its leading whitespace; - the child left-trim delta: each child's rendered text strips the leading whitespace of its own first token span.
One pure function pair in src/voxint/adjudication/annotations.py owns this
mapping and its inverse. An executable fixture table pins it: leading and
trailing spaces, space-prefixed tokens, embedded newlines, emoji, combining
marks, re-split, and un-split cases. Unsplit lines map one-to-one because the
rendered text equals the effective text.
Classification (server-side truth table)¶
Given validated, direction-normalized endpoints:
- If both offsets cover whole parent segments, the anchor is
segment_range.segment_rangealways means whole immutable parents; a whole split-child selection is not a segment_range. - Otherwise, if every covered segment is word-eligible and both offsets land
exactly on token boundaries, the anchor is
word_range. Word-eligibility issplittable_words(segment) is not None(tokens validate and reconcatenate to raw, no fired correction-trace entry,enhanced_textnull or raw-equal) and additionally no operator review correction (SegmentReviewState.corrected_textis null); the review-correction check is route-owned for splits and must be applied here explicitly. A whole split-child selection classifies as word_range. - Otherwise the anchor is
text_range, with offsets stored against the parent effective text at capture.
Segments with words IS NULL can never produce word_range. A partial
selection on such a segment stores segment_range with the verbatim quote;
the interface presents its display and seek as whole-segment, and never
implies sub-segment precision.
text_range offsets live in immutable parent coordinates. Split children are
render projections; re-splitting or un-splitting a parent changes projections
only and never rewrites stored anchors or hashes.
Source text hash¶
source_text_hash is the full sha256 hex digest of a versioned,
length-framed serialization of the covered segments in segment_index order:
"annv1" + for each segment: "{segment_id}:{code_point_length}:{text}"
encoded UTF-8, where text is the segment's effective text (corrected, else
enhanced, else raw) at capture and code_point_length is len(text) in code
points. Length framing plus segment ids make the serialization injective; a
segment containing a delimiter byte cannot collide with a different segment
partition. There is no revision-kind salt: byte-identical visible text stays
non-stale even if its source tier changed. A golden-hex unit test pins this
definition; changing it would mark every existing annotation stale at once,
so any change requires bumping anchor_schema_version.
The hash is stored for every anchor kind. At read time the server recomputes the hash over current effective texts; a mismatch marks the annotation stale.
Timing honesty¶
start_seconds and end_seconds are nullable. Only word_range stores them,
derived from word timings. text_range and segment_range store null; the
read path derives coarse segment-interval bounds for display and seek and
labels every API and export shape with timing_precision: "word" or
"segment". Downstream consumers treat "segment" bounds as jump targets, never
as clip-accurate edges: audio-clip extraction (#88, below) refuses a highlight
that is not word-timed for exactly this reason.
Staleness, refresh, re-anchor¶
At read time each annotation reports stale (hash mismatch) and its speaker
attribution is resolved from the current adjudication state, not from
anything captured.
Stale annotations render without inline highlight marks. Painting old offsets onto changed text would highlight words the operator never selected, so the transcript shows only a distinctly styled locator at the annotation's start line, labeled as approximate, and the panel shows the original captured quote with a "text changed" badge.
Refresh (an explicit operator action) re-derives quote, hash, and seconds only when the anchor still identifies the same span deterministically:
segment_range: always refreshable (whole segments are stable identity).word_range: refreshable only while every covered segment still grants word-eligibility; otherwise refused.text_range: a matching hash makes refresh a no-op; a stale hash means the offsets are dead and refresh is refused.
A refused refresh returns a 409 stale-anchor conflict. The recovery path is re-anchor: a PATCH carrying a complete fresh capture payload, validated exactly like a create, which atomically replaces the anchor and its snapshot. Refresh, re-anchor, and metadata edits are mutually exclusive PATCH operations.
Data model (migration 0031)¶
Three tables; annotations are mutable with soft delete.
annotation_tags(global, flat): verbatimnameplus a writer-computedname_normalized(trimmed, casefolded) carrying the UNIQUE constraint, a palettecolor,created_at, and nullablearchived_at. Archived tags disappear from pickers but remain visible on existing annotations. There is no tag delete in v1.transcript_annotations: endpoint parent-segment FKs (ON DELETE CASCADE), capturedstart_segment_index/end_segment_indexcopies for ordering, the anchor fields gated by per-kind CHECK constraints (word pair present only for word_range, char pair only for text_range, neither for segment_range; hash always present, lowercase hex, 64 chars), nullable paired seconds,quote_text,color_index,note,operator,idempotency_key(UNIQUE) with arequest_fingerprintof the canonical create payload, timestamps, anddeleted_at.pipeline_run_idis denormalized by the sole writer from the endpoint segments and cascade deletes with the run.annotation_tag_links: composite-key many-to-many, capped per annotation.
Caps (server-enforced): 6 highlight colors, 100 segments per span, 50,000 quote code points, 4,000 note code points, 8 tags per annotation.
API surface and error taxonomy¶
Reads (list, exports, stored quotes) require operator auth and onboarding
only, with no claim, so Copy works in a read-only review tab. The read routes
are GET /review/{run_id}/annotations (the panel list), GET
/review/{run_id}/annotations/{annotation_id}/export.md (one pull-quote), GET
/review/{run_id}/annotations/export.md (all filtered pull-quotes, honoring
the same ?tag= OR-union), GET
/review/{run_id}/annotations/{annotation_id}/export.json (one provenance
manifest), and GET /review/{run_id}/annotations/export.json (all filtered
manifests as a bundle). The JSON manifest (#122) carries per-line speaker
attribution, timecodes, clip integrity digest, per-stage model identity from
the pipeline run, and input-media SHA-256; the bulk route places run-level
provenance at the envelope level. The bundled ZIP routes (#281), GET
/review/{run_id}/annotations/{annotation_id}/export.zip and GET
/review/{run_id}/annotations/export.zip, package the pull-quote Markdown, the
provenance manifest (per-quote single, the run bundle in bulk), and every
extracted clip into one archive; the .md members are byte-identical to the
standalone endpoints and the .json members identical apart from their
per-request exported_at stamp (contract-tested in
tests/integration/test_export_zip.py; member names carry the full annotation
uuid so a bulk archive cannot birthday-collide truncated ids into duplicate
entries). The 404/409-stale semantics (atomic in bulk) match the standalone
exports, and a manifest-referenced clip that cannot be served fails the whole
export with the clip service's honest status rather than shipping a bundle
that silently lacks its audio. A highlight that never had a clip simply omits
that member. Clip generation never happens from the ZIP path; only the
CSRF-gated clip POST cuts audio. Run-scoped writes require the active review claim
token; a lost claim is a 409 marked X-Voxint-Conflict: claim. Creates carry a
client nonce;
replaying the same nonce with the same fingerprint returns the original row
(including a soft-deleted one; replay never resurrects or duplicates), and
the same nonce with a different payload is a 409 idempotency conflict.
Metadata edits and deletes are naturally idempotent and carry no nonce. Tag
writes have no run context and are CSRF-gated like run notes. The live
pull-quote request (POST /review/{run_id}/annotations/export/live.md) persists
nothing and therefore carries neither claim, nonce, nor CSRF; it classifies and
validates an unsaved selection with the same caps and code path as create (422 on
a bad anchor or cap, 409 stale on a drifted client quote) and returns the
markdown. Its optional note/tags decorate the returned trailer only.
- 422: malformed anchors, out-of-bounds offsets, unknown child ranges, violated caps, bad colors.
- 404: unknown run, segment, annotation, or tag, including any cross-run or forged id. Fail closed; do not distinguish forged from missing.
- 409: claim loss (
X-Voxint-Conflict: claim), archived run, stale quote or stale anchor (X-Voxint-Conflict: stale), duplicate tag name, idempotency payload mismatch (X-Voxint-Conflict: idempotency).
Repeated ?tag= filters are a union (OR), identically in the panel and in
exports.
Audio-clip extraction (#88) adds two more routes, covered in full in the Audio
clips section below: POST /review/{run_id}/annotations/{annotation_id}/clips
(CSRF-gated, no claim) generates or adopts the clip, and GET|HEAD
/runs/{run_id}/clips/{clip_id} serves it.
Console surface (Landing 1)¶
The review console (the review-stepper island) is the sole create path.
Selecting transcript text opens a toolbar with six color swatches, a tag picker
plus an inline new-tag field, and a note field; the h shortcut opens it for
the current selection, and an empty selection reports that rather than doing
nothing. Saving POSTs the capture to /review/{run_id}/annotations. The server
owns the anchor, so a client quote that no longer matches comes back as a 409 the
operator retries; the code-unit to code-point conversion happens once, in
frontend/src/lib/selection.ts, before anything reaches the wire.
Highlights paint as <mark class="hl-N"> pieces byte-identical to the line text,
so the marks never alter a character and the DOM selection offsets stay aligned.
The Highlights panel lists every annotation in transcript order with an OR-union
tag filter and per-row Jump, Edit, Delete, and, when a highlight is stale,
Refresh and Re-anchor. Re-anchor reads the current selection as the new anchor.
Speaker attribution and timing come from the read-time resolution, never the
captured copy, and timing is labeled approximate (a leading ≈) when
timing_precision is segment rather than word.
A stale annotation drops its inline marks and shows an approximate locator chip
at its old start line; the panel row still shows the original quote verbatim.
Creating and editing highlights are island-only. With JavaScript off,
review_transcript.html renders a read-only Highlights list from the same props
the island hydrates from, so the fallback and the live panel cannot disagree;
hydration replaces the fallback wholesale.
Landing 2 adds Copy to the panel: a per-row Copy button and a Copy-all action that honors the active tag filter. Both fetch the server-rendered markdown (the client never reassembles a quote) and write it to the clipboard, falling back to a selectable read-only field when the browser clipboard is unavailable (a plain-http LAN context). Copy is a read, so it stays available without the claim; a stale highlight's Copy is disabled until the operator refreshes or re-anchors it.
The highlight palette size (HIGHLIGHT_PALETTE_SIZE) is pinned across the
backend caps, the --hl-N design tokens, and the mark.hl-N rules by
tests/contracts/test_highlight_palette_parity.py.
Evidence pack (#331 Phase 7)¶
GET /review/{run_id}/annotations/evidence-pack renders a print-optimized
page of the run's highlights with their provenance: quote lines with live
speaker attribution and timing, tags, notes, the source text hash, clip
references, and the run's per-stage model identity, under a document header
carrying the source title, run id, media SHA-256, app version, and generation
time. The browser's Print / Save as PDF is the PDF engine; there is
deliberately no server-side PDF dependency. ?tag= is the panel's OR-union
filter, and a filtered page says so in its header.
One deliberate divergence from the exports, pinned in
tests/integration/test_evidence_pack.py: a STALE highlight renders with a
visible "re-anchor before citing" warning and its captured quote, instead of
failing the whole document with a 409. A human-readable evidence pack degrades
honestly; the machine-readable exports stay atomic.
Tag rollup on Explore (#331 Phase 7)¶
The Explore page shows a "Highlight tags" panel: annotation counts per tag,
corpus-wide or narrowed by the page's project filter. tag_stats in
src/voxint/api/explore_query.py is one synchronous GROUP BY join, not a
cached corpus_analysis_artifacts computation: at 8 tags per annotation the
query is far below the cost that justified caching TF-IDF term stats. Archived
tags, soft-deleted annotations, and archived runs are excluded (the run filter
keeps the rollup consistent with every other Explore scope query); annotations
on runs still under review count from the moment they exist, so there is
deliberately no run status filter. Ordering is count descending, then
name ascending. The panel has no drill-down in v1 and renders a one-line empty
state. tests/integration/test_explore_tags.py pins the exclusion, scoping,
and ordering invariants.
Pull-quote formatting¶
Stored and live pull-quotes (issue #86 Landing 2) build clipped TranscriptLine
values (line text sliced to the annotation span, speaker preserved) and pass them
through the existing to_markdown in src/voxint/export/__init__.py, so the quote
bytes match the file export by construction. The projection lives beside the
resolver (clip_lines_for_export); the markdown wrapper (annotation_pull_quote)
lives in the pure export module and never imports the resolver.
The body renders as the reading copy (to_markdown(..., timestamps=False)): timing
lives once, in the citation, so the body never shows a whole-segment bracket that
would misstate a sub-segment highlight's span. A thin trailer follows the body:
**Source:**the escaped source title (sidecar title, else acquisition-metadata title, else a cleaned filename) then aformat_timespanrange. The range carries a leading≈marker whentiming_precisionissegmentrather thanword, mirroring the on-screen panel.**Tags:**the annotation's tag names in the panel's alphabetical order, when it has any.**Note:**the note, line breaks folded to spaces, when it has one.
Title, tag names, and the note are untrusted, so each is inline-escaped with the
same _md_escape the transcript body uses; folding the note to a single line means
no physical line can sit at a block-leading position, so a leading # stays literal
prose. The body and every trailer field are separated by blank lines, and the whole
quote ends in exactly one newline. A single highlight (content_start at world in
a word_range) renders:
## Alice
> world
**Source:** Interview [00:00:01.000–00:00:02.000]
**Tags:** Key Point
**Note:** worth a look
A bulk export concatenates each highlight's block in the canonical transcript order
(rendered line index, then code-point offset, then annotation id, so a split-child
highlight orders by the line the operator sees, not by segment index) joined by the
thematic-break separator \n---\n\n. An empty filtered result is an empty body.
A stale highlight has no live spans, and the stored quote_text alone cannot
reconstruct the live speaker attribution and per-line geometry a faithful quote
needs. Exporting one is refused (409 X-Voxint-Conflict: stale) rather than
fabricated; a bulk export fails atomically if any matched highlight is stale. The
operator refreshes or re-anchors it first.
Annotation exports use their own one-entry media-type table (ANNOTATION_MEDIA_TYPES,
text/markdown); the transcript TranscriptFormat/MEDIA_TYPES tables are not
extended.
Audio clips (#88)¶
A word-timed highlight can be extracted as a standalone WAV clip: the exact
span of processed audio the highlight covers, cut from the run's normalized 16 kHz
mono intermediate. It is extraction, not editing. The clip is a faithful
copy of those samples; nothing is re-encoded, trimmed for silence, or altered.
Preconditions (the route enforces all three). The highlight must be live
(a foreign, forged, or soft-deleted id is a 404, fail closed), non-stale (a
drifted highlight is a 409 X-Voxint-Conflict: stale, refresh or re-anchor it
first), and word-timed (a segment_range/text_range highlight has no
clip-accurate edges and is a 422). These mirror the pull-quote gates, resolved
against the same corrected render.
Sample bounds. The pure core (src/voxint/media/clips.py) maps the
highlight's start_seconds/end_seconds to integer sample bounds with Decimal
arithmetic: floor the start, ceil the end, half-open [start, end), clamped to
the source frame count read from the WAV header. Rounding outward by design, so
a clip never drops an edge sample of the selected words. A safety bound
(clip_max_duration_seconds, default 300) refuses an implausibly long span; it
is a guard, not a tunable feature flag.
Content-addressed, idempotent cache. A clip's identity is the sha256 of
clip:v1:{normalized_artifact_id}:{annotation_id}:{start_sample}:{end_sample},
so identical requests adopt the one canonical row and a re-anchor (new sample
bounds) makes a new clip. Generation serializes per clip under a
transaction-scoped advisory lock keyed on that digest, and a partial-unique index
over live (non-reclaimed) clip rows lets a regeneration insert a fresh row
rather than revive a tombstone. The stored meta snapshot records the requested
seconds, the resolved sample bounds, the source artifact id, and the annotation's
source_text_hash at extraction time: an immutable attribution record, unaffected
by any later edit to the highlight or the transcript.
Reclaimable cache. A clip is a derived convenience, not a durable asset. It
serves from its own file independently of the normalized audio, so a clip stays
downloadable after the source WAV is reclaimed; but a cache miss can only be
regenerated while that source is still live (a reclaimed source is a 409, the
run must be re-processed first). The media GC sweep (issue #15) reclaims clip
files too, aged by the clip's own created_at so a clip freshly extracted on
an old terminal run survives the next sweep. A reclaimed clip's row survives; the
serve route answers 410 Gone.
Routes.
| Method | Route | Auth | Success | Errors |
|---|---|---|---|---|
POST |
/review/{run_id}/annotations/{annotation_id}/clips |
operator + CSRF (clip-extract), no claim |
201 {clipId, downloadUrl, filename} |
403 bad CSRF, 404 unknown run/annotation, 409 stale or source reclaimed, 422 not word-timed |
GET HEAD |
/runs/{run_id}/clips/{clip_id} |
operator | 200/206 audio/wav |
404 unknown/unservable, 410 reclaimed |
The POST is CSRF-gated but claim-less, like the tag writes and the pull-quote
export: generation is idempotent, so a same-span replay is harmless. The serve
route supports byte ranges (so a browser can scrub the clip) and always sets
Content-Disposition: attachment with a server-derived ASCII filename
(voxint-{run8}-clip-{clip8}.wav), never any client or quote text. The console's
Highlights panel offers an Extract clip action beside Copy, shown only for
word-timed highlights; it posts the route and then navigates a temporary
same-origin anchor to downloadUrl, letting Content-Disposition drive the save
rather than buffering bytes in the page.