quranic-universal-audio / docs /reference /segments-editor.md
hetchyy's picture
deploy: inspector prod @ 349d570
4cc98c7 verified
|
Raw
History Blame Contribute Delete
26.9 kB
# Segments editor
The Segments tab is the full WIP editor: trim / split / merge / re-reference / delete / ignore, with auto-validation on save. Edits are `SegmentCommand`s applied by a pure reducer to a normalized client store, recorded as an op log, POSTed as a save payload, and replayed server-side against the same domain layer onto `detailed.json`. Save and undo round-trip through the Pydantic schemas in `qua_shared/schemas/` β€” writers never construct dict literals.
## Module map
| Concern | Path |
|---|---|
| FE command union | `inspector/frontend/src/tabs/segments/domain/command.ts` |
| FE reducer | `inspector/frontend/src/tabs/segments/domain/apply-command.ts` |
| FE identity | `inspector/frontend/src/tabs/segments/domain/identity.ts` |
| FE registry (validation twin) | `inspector/frontend/src/tabs/segments/domain/registry.ts` |
| FE inverse-patch (client undo) | `inspector/frontend/src/tabs/segments/domain/inverse-patch.ts` |
| FE normalized store | `inspector/frontend/src/tabs/segments/stores/segments.ts` |
| FE dirty / op log | `inspector/frontend/src/tabs/segments/stores/dirty.ts` |
| FE history store | `inspector/frontend/src/tabs/segments/stores/history.ts` |
| FE edit utils | `inspector/frontend/src/tabs/segments/utils/edit/` |
| FE save utils | `inspector/frontend/src/tabs/segments/utils/save/` |
| FE history utils | `inspector/frontend/src/tabs/segments/utils/history/` |
| BE save | `inspector/services/segments/save.py` |
| BE undo | `inspector/services/segments/undo.py` |
| BE read query | `inspector/services/segments/segments_query.py` |
| BE auto-split lookup | `inspector/services/segments/auto_split.py` |
| BE history query | `inspector/services/activity/history_query.py` |
| BE patch domain | `inspector/domain/command.py` (`SegmentPatch`, `apply_inverse_patch`) |
| BE identity | `inspector/domain/identity.py` |
| Adapters | `inspector/adapters/{save_payload,segments_json,detailed_json}.py` |
| Routes (mutations) | `inspector/routes/segments/edit.py` |
| Routes (read history) | `inspector/routes/segments/validation.py` |
| On-disk schemas | `qua_shared/schemas/bucket/{segment,edit_history}.py` |
### Service-module import contract
Service modules live in domain subpackages (`services/segments/`, `services/activity/`, …). `inspector/services/__init__.py` re-aliases every submodule into `sys.modules` under its legacy flat name. Both spellings resolve to the same module object:
| Canonical | Legacy alias (still used by routes/tests) |
|---|---|
| `services.segments.save` | `services.save` |
| `services.segments.undo` | `services.undo` |
| `services.segments.auto_split` | `services.auto_split` |
| `services.activity.history_query` | `services.history_query` |
When adding a service module, drop it in the subpackage and add one re-export line in `services/__init__.py`.
## Edit operations
Each user action dispatches a `SegmentCommand` through `applyCommand`. `confidence` is set to `1.0` by every command that resolves an alignment (split children inherit parent confidence unchanged until ref-edited). FE edit modules build the command + dispatch; the reducer owns mutation.
| Op | Command type | Confidence after | FE module | BE handling |
|---|---|---|---|---|
| Trim | `trim` | `1.0` | `utils/edit/trim.ts`, `trim-zoom.ts`, `enter.ts` | full_replace path; `_make_seg` + `_stamp_persisted_classifier_fields` |
| Split | `split` | children inherit parent (β†’ `1.0` on later ref-edit) | `utils/edit/split.ts`, `split-zoom.ts`, `enter.ts` | full_replace; new child UIDs persisted |
| Merge | `merge` | `1.0` | `utils/edit/merge.ts` | full_replace; consumed UID dropped, kept UID = earlier seg |
| Edit reference | `editReference` (`op_type` `edit_reference` \| `confirm_reference`) | `1.0` | `utils/edit/reference.ts` (`beginRefEdit`/`commitRefEdit`) | patch path (field-level) or full_replace |
| Delete | `delete` | n/a | `utils/edit/delete.ts` | full_replace; seg removed |
| Auto-fill / auto-fix | `autoFixMissingWord` | `1.0` | `utils/edit/auto-fix.ts`, `reference.ts` | full_replace; `op_context_category` defaults `missing_words`, `fix_kind=auto_fix` |
| Ignore | `ignoreIssue` | `1.0` | `utils/edit/ignore.ts` | patch path; appends `category` to `ignored_categories` |
| Set wasl | `setIsWasl` | unchanged | `utils/edit/setIsWasl.ts` | full_replace; `is_wasl` toggled (omitted when `False`) |
Edit-flow support modules in `utils/edit/`:
| File | Role |
|---|---|
| `common.ts` | `exitEditMode`, `finalizeEdit` (attaches forward `patch` to the op) |
| `enter.ts` | `enterEditWithBuffer` β€” shared entry for trim / split |
| `reference.ts` | `beginRefEdit` / `commitRefEdit` |
The reducer never writes `seg.ignored_categories` except for the explicit `ignoreIssue` command. Editing from a validation accordion card records `op_context_category` (history pill provenance) but does NOT mutate the persisted ignore list β€” soft-card dismissal is FE session state.
## Command grammar
`SegmentCommand` is a discriminated union on `type`. TS twin: `domain/command.ts`. Python has no command-object twin β€” the on-wire op carries a `command` envelope validated by name in `save.py::_validate_command_envelopes`; the actual mutation is replayed from the save payload + the op `patch`, not from re-running a Python reducer.
Command variants (TS):
| `type` | Key fields |
|---|---|
| `trim` | `segmentUid`, `delta:{time_start?,time_end?}` |
| `split` | `segmentUid`, `splitMs:number\|number[]`, `newUids?`, `refs?`, `texts?`, `wasls?` (+ legacy `firstRef`/`secondRef`/`secondHalfUid`) |
| `merge` | `fromUid`, `toUid`, `mergedRef?`, `mergedText?`, `direction?` |
| `editReference` | `segmentUid`, `matched_ref`, `matched_text?`, `opType?` |
| `delete` | `segmentUid` |
| `ignoreIssue` | `segmentUid`, `category` |
| `autoFixMissingWord` | `segmentUid`, `matched_ref`, `matched_text?` |
| `setIsWasl` | `segmentUid`, `is_wasl` |
`CommandBase` carries `sourceCategory`/`contextCategory` (history-pill provenance), `fixKind` (`manual`\|`auto_fix`\|`audit`\|`ignore`), and `_mountId` (UI binding; reducer ignores it).
### Reducer β€” `apply-command.ts`
`applyCommand(state, command, ctx)` is pure: no store writes, no I/O, no input mutation. Returns `CommandResult`:
- `nextState: CommandNextState` β€” `{byId, affectedChapter, removedSegmentUids?, insertedSegmentUids?, idsByChapter?}`.
- `operation: CommandOperation` β€” extends wire `EditOp` with `type`, `kind` (`structural`\|`single-index`), `snapshots:{before,after}`, `targetSegmentIndex:{chapter,index}`, `command` envelope.
- `affectedChapters: number[]`.
- `validationDelta?: {resolved, introduced}` β€” `resolved` populated from `sourceCategory`/`contextCategory` for per-segment registry categories.
- `patch: SegmentPatch` β€” `{before, after, removedIds, insertedIds, affectedChapterIds}`, the forward diff that drives both client and server undo.
Wire `op_type` mapping (`OP_TYPE_BY_COMMAND`): `trim→trim_segment`, `split→split_segment`, `merge→merge_segments`, `editReference→edit_reference`, `delete→delete_segment`, `ignoreIssue→ignore_issue`, `autoFixMissingWord→auto_fix_missing_word`, `setIsWasl→set_is_wasl`. Structural commands (`trim`/`split`/`merge`/`delete`) force the chapter's save into full_replace mode.
Op recording: dispatcher calls reducer β†’ gets `operation` β†’ `finalizeOp(chapter, op)` pushes it onto `_opLog[chapter]` (`stores/dirty.ts`) and `markDirty(chapter, index, structural)`. `finalizeEdit` (`utils/edit/common.ts`) attaches the forward `patch` to the op so client-side discard can invert it.
### Snapshots β€” `snapshotSeg` (`stores/dirty.ts`)
Each op carries `targets_before`/`targets_after` (and mirror `snapshots.before/after`) of `SegSnapshot` dicts. Snapshot fields: `segment_uid`, `index_at_save`, `audio_url`, `time_start`, `time_end`, `matched_ref`, `confidence`, optional `wrap_word_ranges`/`entry_ref`/`chapter`/`ignored_categories`/`is_wasl`. Migration #5: `matched_text` and `phonemes_asr` are NOT snapshotted (derivable / retired). Backend `save.py::_attach_classified_issues` enriches each persisted snapshot with `classified_issues: string[]` at write time so the History delta reads it directly.
## Normalized state β€” `stores/segments.ts`
`SegmentState = {byId: Record<uid, Segment>, idsByChapter: Record<chapter, uid[]>, selectedChapter}`. `byId` is keyed by `segment_uid`; `idsByChapter` preserves render order.
- `segmentsStore` is a `derived` store over `segAllData` + `selectedChapter` (`stores/chapter.ts`) β€” it rebuilds via `buildSegmentState` whenever upstream fires (load, save republish). No imperative population.
- Pure selectors operate on a `SegmentState` value (not the store): `getChapterSegments`, `getSegByChapterIndex`, `getAdjacentSegments`, `findByUid`. ~50 legacy call sites depend on these.
- `applyNextState(current, slice)` is a pure reducer that folds a `CommandNextState` into a `SegmentState`. Used by client-side patch flows; production write goes through `segAllData.update` which re-derives `segmentsStore`.
- Segments without `segment_uid` are skipped (kept out of `byId`).
Dependent stores: `dirty.ts` (op log + dirty map), `validation.ts`, `filters.ts`, `history.ts`, `playback.ts`.
### Dirty op log β€” `stores/dirty.ts`
Module-level `Map`s keyed by `number` chapter: `_dirtyMap` (`{indices:Set<number>, structural:boolean}`), `_opLog` (`EditOp[]`). `dirtyTick` writable bumps on every mutation; `isDirtyStore` derives from it. Save reads `getDirtyMap()` + `getChapterOps(ch)`; on success calls `clearSavedOps(ch, ops)` (drops only saved op_ids, recomputes dirty entry) so concurrent edits queued during the fetch survive. `amendSegInOp` overlays fields onto an op's `targets_after`/`snapshots.after`/`patch.after` in sync (post-CV-split wasl picks amend the split op in place rather than emit a `set_is_wasl` row).
## Segment identity
UUID5, deterministic. TS and Py twins MUST produce identical strings for the same `(chapter, original_index, start_ms)` triple.
```
NAMESPACE_INSPECTOR = uuid.UUID("00000000-0000-0000-0000-000000000001")
uid = uuid5(NAMESPACE_INSPECTOR, f"{chapter}:{original_index}:{start_ms}")
```
- Py: `domain/identity.py::derive_uid(chapter, original_index, start_ms)`, `backfill_entry_uids`, `backfill_entries_uids` (chapter parsed from `entry["ref"]`).
- TS: `domain/identity.ts::deriveUid({chapter, originalIndex, startMs})`, `backfillSegmentUids(segs, chapter)`. TS ships a hand-rolled synchronous SHA-1 (`_sha1`) so derivation is callable in store initializers / vitest (no async `crypto.subtle`).
Invariant: changing the namespace, the format string, or the input triple breaks identity continuity across saves. Existing UIDs are never re-derived (`backfill` skips any seg that already has one β€” MUST-4).
Identity lifecycle:
| Event | UID source |
|---|---|
| First server load, no UID | Backfilled deterministically (`backfill_entries_uids` on read in `adapters/detailed_json.py::load_entries_from_bytes`; FE `backfillSegmentUids`) |
| Subsequent loads | Read from `detailed.json` |
| Split | First child reuses parent UID; later children take `cmd.newUids[]` else `crypto.randomUUID()` |
| Merge | Merged seg keeps the **earlier** seg's UID; later seg's UID dropped (in `removedIds`) |
| Trim / edit-reference / ignore / set-wasl | UID preserved |
| Delete | UID removed (in `patch.removedIds`) |
## Save flow
Client β†’ server, ordered. Mutating routes require `@require_same_origin` β†’ `@require_edit_lock(admin_bypass=True)` (`routes/segments/edit.py`).
1. **Build payload (client)** β€” `utils/save/execute.ts::executeSave(isAutoSave?)`. Iterates `getDirtyMap()`. Per chapter: `structural` entry β†’ `SavePayloadFull` (`{full_replace:true, segments:[...], operations:chOps}`) from `getChapterSegments(ch)`; else `SavePayloadPatch` (`{segments:[{index, segment_uid, matched_ref, confidence, ignored_categories?, is_wasl?}], operations:chOps}`). Payload omits `matched_text`/`phonemes_asr` (server derives / retired). `op_peaks` attached when in-memory peaks exist (`collectOpPeaks`). Snapshots payload before any network IO so concurrent edits don't mix in. `utils/save/payload.ts::buildPayloadFromCommandResult` builds the same shape from a `CommandResult`. Preview UI built by `utils/save/preview.ts` + `actions.ts`.
2. **POST (client)** β€” `POST /api/seg/save/<reciter>/<chapter>` (raw `fetch`, `credentials: same-origin`). 401 β†’ sign-in modal; other non-2xx β†’ error toast.
3. **Route (server)** β€” `edit.py::seg_save` validates body has `segments`, builds `Actor` from `g.current_user` (`_actor_from_g`), calls `save_seg_data(reciter, chapter, updates, actor=)`.
4. **Save (server)** β€” `services/segments/save.py::save_seg_data`:
1. `_validate_command_envelopes` β€” every op with a `type` must carry a `command` whose `type` is in `_ALLOWED_COMMAND_TYPES` and equals `op.type`. (Patch-style ops without `type` pass through.)
2. `load_detailed(reciter)`; filter `matching` entries for `chapter`; build `(existing_by_time, existing_by_uid)` lookups (`adapters/save_payload.build_seg_lookups`).
3. Apply: `_apply_full_replace` (rebuilds `segments` via `adapters/save_payload.make_seg`; by_ayah routes each payload seg to its entry by `audio_url`) or `_apply_patch` (field-level by `index`). Both call `_stamp_persisted_classifier_fields` (sets `qalqala_letter` via `compute_qalqala_letter`, `is_boundary_adj` structural-only) so the validate fast-path reads persisted fields instead of recomputing.
4. `_persist_and_record`: `_validate_op_patches` β†’ `persist_detailed` (atomic `detailed.json` write through `data_dir.write_detailed_doc`, then `rebuild_segments_json`) β†’ build batch (`schema_version`, `batch_id=uuid7()`, `chapter`, `saved_at_utc`, `save_mode`, `operations`, `actor`) with `_ensure_patch_on_ops` + `_attach_classified_issues` β†’ `data_dir.append_edit_history` β†’ `append_peaks_records` (op_peaks) β†’ cache invalidation.
5. Returns `{"ok": True}` or `({"error": ...}, status)`.
5. **Refresh (client)** β€” on success: `refreshValidation()` FIRST (ships new `split_group_index`), then deferred `clearSavedOps` per chapter, then `resetHistoryLoader()` so the next History open re-fetches.
`segments.json` rebuild (`adapters/segments_json.build_segments_doc`): verse-aggregated `{verse_ref: [[start_word, end_word, t_from, t_to], ...]}`, cross-verse spans keyed by the compound `matched_ref`. `_meta` preserved from the existing on-bucket doc.
Cache invalidation (`services/storage/cache.py`): `pop_seg_caches_affected_by_segment_edit`, `append_history_batch` (extends cached parsed list in place β€” no re-parse), `_refresh_split_group_index_on_save` (pops split-group index only when batch has split ops), `pop_seg_auto_split` only when `batch_changes_segment_set`.
### detailed.json segment shape β€” `qua_shared/schemas/bucket/segment.py`
`DetailedSegment` (`extra="forbid"`): required `time_start`/`time_end`/`matched_ref`; `qalqala_letter`, `is_boundary_adj`, `confidence`, `wrap_word_ranges`, `segment_uid`, `ignored_categories`, `ignored` (legacy wildcard), `is_wasl`, `flag` (`SegmentFlag | None`, see [Flagged issues](#flagged-issues)). Writers emit via `model_dump(exclude_none=True)`. Migration #5 dead fields stripped on read with INFO log: `matched_text`, `phonemes_asr`, `has_repeated_words`, plus snapshot-only `audio_url`/`chapter`/`entry_ref`/`index_at_save`/`display_text`. Unknown keys β†’ WARNING + strip (writer-drift signal). `DetailedEntry` strips legacy `audio`. Both extraction (`.local/extraction/segments/outputs.py`) and Inspector save MUST round-trip through these models.
## edit_history.jsonl
Append-only JSONL at the reciter's storage root (`<storage>/<slug>/edit_history.jsonl`, routed by `services/storage/data_dir.py`). Schema: `qua_shared/schemas/bucket/edit_history.py`.
| Record kind | Written by | Distinguishing field |
|---|---|---|
| Batch | `save.py::_persist_and_record` (one per save) | `operations[]`, no `reverts_*` |
| Revert | `undo.py::_append_revert_record` (one per undo) | `reverts_batch_id` (+ `reverts_op_ids` for per-op undo); empty `operations` |
`EditHistoryBatch` fields: `schema_version` (default `1`), `batch_id`, `saved_at_utc`, `actor:{hf_user_id, login_at_time, role}`, `operations`, `chapter` (Inspector save) **or** `chapters` (pipeline multi-chapter), `batch_type` (pipeline only), `reverts_batch_id`/`reverts_op_ids`. `EditOperation`: `op_id`, `kind` (user) **or** `op_type` (pipeline), `fix_kind`, `op_context_category`, `patch` (typed `EditOpPatch`), `targets_before`/`targets_after`. `op_context_category` (the validation category the edit was launched from β€” read by `build_resolved_by_edit_index`) and `patch` (the forward-change envelope the undo path reverses) are **declared live fields**, not extras. `save_mode` is **no longer persisted** on a batch β€” it's derived wire-side from the batch's op shape in `history_query.py::_derive_save_mode` (the pure-`forbid` batch model rejects any on-disk `save_mode`; the prod migration stripped it).
Read endpoint: `GET /api/seg/edit-history/<reciter>` (`routes/segments/validation.py::seg_edit_history` β†’ `services/activity/history_query.py::load_edit_history`). The query filters fully-reverted batches, strips per-op-reverted ops, merges batches sharing a `batch_id` (multi-chapter saves), enriches snapshot `audio_url` from the audio manifest, and aggregates summary stats. Clients see only the effective history.
**Integrity.** There is no standalone `validators/validate_edit_history.py` and no CI integrity job β€” the v1 file-hash chain and genesis record were dropped in v2. Integrity is enforced structurally by the schema: every model is pure `extra="forbid"`, so any unknown/legacy key (`file_hash_after`, `record_type`, `save_mode`, `reciter`, `applied_at_utc`, `snapshots`, `command`, …) raises `ValidationError` rather than being silently stripped. The prod-data migration already rewrote on-disk records to the canonical shape, so live data parses clean. `parse_edit_history_line` still returns `None` for genesis-style / `batch_id`-less lines.
Schema slim-down (Migration #5): snapshots no longer carry `matched_text` (derived via `services/reference/quran_refs.py::dk_text_for_ref` when needed β€” see `apply_inverse_patch::_hydrate`); per-op timestamps banned; `phonemes_asr`/`has_repeated_words` retired. New records are the slim shape; the prod migration rewrote legacy records to it (the pure-`forbid` model would otherwise reject the old keys).
## Undo
Patch-based, forward-only. Undo never rewinds history β€” it computes the reverse transformation, applies it to live entries, persists, and appends a **revert** record. `services/segments/undo.py`.
- `apply_reverse_op(entries, op, chapter_set)`: if the op carries a `patch`, route through `_reverse_via_patch` β†’ `domain/command.py::apply_inverse_patch` (drop `insertedIds`, restore `before` snapshots in place, re-insert `removedIds`, re-sort affected chapters by `time_start`). Patches claiming chapters outside the batch scope raise `ValueError`. Ops without a `patch` (legacy) fall back to per-`op_type` branch helpers (`_reverse_trim`/`_reverse_split`/`_reverse_merge`/`_reverse_delete`/`_reverse_ref_edit`/`_reverse_ignore`), each verifying the current seg still matches `targets_after` (raises `ValueError` 409 on conflict).
- `undo_batch(reciter, batch_id, actor=)` β€” reverses all ops in a batch (skipping already-undone ops), in reverse order. Rejects: not found (404), is-a-revert (400), already fully reverted (400), all ops individually undone (400), conflict (409).
- `undo_ops(reciter, batch_id, op_ids, actor=)` β€” reverses a subset; rejects already-undone / not-in-batch ops.
Both persist via `persist_detailed`, append a revert record, then evict caches (`pop_seg_caches_affected_by_segment_edit`, `pop_seg_split_group_index`, conditional `pop_seg_auto_split`). Redo is not exposed (re-applying the forward batch would be the mechanism).
Endpoints (`routes/segments/edit.py`):
| Endpoint | Body | Service |
|---|---|---|
| `POST /api/seg/undo-batch/<reciter>` | `{batch_id}` | `undo_batch` |
| `POST /api/seg/undo-ops/<reciter>` | `{batch_id, op_ids}` | `undo_ops` |
Client: `utils/save/undo.ts` β€” `onBatchUndoClick`, `onOpUndoClick`, `onChainUndoClick` (reverses a split chain batch-by-batch in order), then `_afterUndoSuccess` marks `historyDataStale`, `refreshValidation()` + `reloadSegAll()`. **Client-only discard** of *unsaved* ops: `onPendingOpsDiscard` inverts each op's forward `patch` against `segAllData.segments` via `domain/inverse-patch.ts::applyInversePatchToSegments` (reverse op-log order), drops them from `_opLog`, and recomputes the dirty entry β€” no server round-trip, history untouched.
## History view
Server: `services/activity/history_query.py`. `load_edit_history` (cached, per-reciter lock) β†’ `{batches, summary}`. `parse_history_for_reciter` reads raw records (cache-aware; save/undo append in place). Derived indices over the same parsed list: `build_split_group_index` (`{root_uid: [descendants]}`, transitive closure of `split_segment` ops, fixpoint capped at `_SPLIT_GROUP_MAX_PASSES=32`) and `build_resolved_by_edit_index` (`{uid: {category}}` for ops whose `op_context_category ∈ RESOLVES_BY_EDIT_CATEGORIES`; lets a card disappear after an edit without writing `ignored_categories`). `HISTORY_NEUTRAL_CATEGORIES = {basmala_amin, muqattaat}` are excluded from per-op classified-issue deltas in `save.py`.
Client store: `stores/history.ts` (`historyData`, `historyDataStale`, edit chains). Utils `utils/history/`: `loader.ts` (lazy per-reciter fetch + `resetHistoryLoader`), `render.ts` (response β†’ store), `chains.ts` (`buildEditChains` β€” split-chain construction), `items.ts` (`groupRelatedOps`, flatten batches into display items), `actions.ts` (panel lifecycle). Components `components/history/`: `HistoryPanel`, `HistoryBatch`, `HistoryOp`, `HistoryArrows`, `HistoryFilters`, `EditChainRow`.
### Generation tiers (timestamps-staleness)
The edit-history response also carries `generations` β€” the ascending TS-generation boundary list (`services/segments/history_tiers.py::generation_timeline` over `repo_releases.all_releases_for_track_slug`, merged onto the cached blob in the route so the heavy parse stays cached). The FE partitions batches into **tiers** split by these boundaries (`items.ts::tierOf`): tier 0 = before the first generation ("Initial"), tier N = after generation #N, and the highest tier = the current edits not yet folded into the generated timestamps. `HistoryFilters` renders a **tier filter** (one pill per tier) that surfaces **only when the current tier holds timestamp-affecting edits** (`entryAffectsTimestamps` against `TS_AFFECTING_OP_TYPES` β€” the lockstep mirror of `qua_shared/segment_edit_ops.py`); with nothing pending the section is hidden. The "Pending regen" pill is the same drift that flags the reciter's `ts` track stale on the Releases tab (`segments_edited` β€” see [`dataset-and-releases.md`](dataset-and-releases.md)).
## Flagged issues
A manual "needs a second look" annotation any editor can attach to a segment β€” a required root comment plus an append-only reply thread. **Not a validation category**: it never appears in the filter dropdown or issue types, and produces no history category pill.
- **Persistence.** `DetailedSegment.flag: SegmentFlag | None` (`qua_shared/schemas/bucket/segment.py`). `SegmentFlag = {comment, actor:{hf_user_id, login_at_time, role}, flagged_at_utc, follow_ups:[{comment, actor, at_utc}]}`. Absent on unflagged segs (`exclude_none`). The flag is **backend-owned** β€” mutated only by `save.py::_apply_flag_ops`; `make_seg` preserves it across a full_replace, and `apply_inverse_patch` preserves the live value across an unrelated undo (it is never carried in `snapshotSeg`, so an FE snapshot can't corrupt it).
- **Command + save.** `flagSegment` command (`domain/command.ts`, op_type `flag_segment`, `op_context_category=null`, single-index patch). `_apply_flag_ops` resolves the target by `segment_uid` and rebuilds the flag through the `SegmentFlag` model with a **server-authoritative** actor + timestamp (the client never supplies identity). Three intents: `set` (create, comment required), `edit` (replace root comment, flagger-only β€” an **empty comment removes the flag**, the unflag mechanism), `followup` (append a reply, any editor). The FE dispatch is `utils/edit/flag.ts::flagSegment`; it rides the normal op-log + autosave path.
- **Read / redaction.** `GET /api/seg/all` (`routes/segments/data.py`) reduces the persisted flag to the FE shape via `services/segments/flags.py::flag_view`: author identity is shown as **role only** unless the caller holds `segments.see_flagger_identity` (owner + maintainer), and each comment carries a non-identifying `mine` so the flagger can find their own to edit. `count_flagged` powers the admin Review-drawer `flagged_issues_count`.
- **UI.** A non-registry **Flagged Issues** accordion renders at the top of `ValidationPanel.svelte` only when `segAllData.segments` has flagged segs; each row is a `FlaggedCard.svelte` (live `SegmentRow` + comment thread). Every card gets a flag button + inline comment editor + hover tooltip on `SegmentRow.svelte`. History shows the op as **Flag comment** (`EDIT_OP_LABELS`). Guide: the `flagging` accordion guide (last in `REQUIRED_GUIDE_KEYS`).
- **Notification.** A `followup` on a flag whose root author isn't the replier notifies the original flagger via `services.notifications.notify_flag_reply` (collected by `_apply_flag_ops`, emitted by `save_seg_data` after a successful save, best-effort, own durable txn). Surfaces on the flagger's Dashboard "My Notifications" rail β€” see [notifications.md](notifications.md).
## Auto-split / auto-detect
**Auto-split** (`services/segments/auto_split.py`) is read-only lookup, not an edit op. MFA alignment that produces cursor positions runs **offline** (`qua_shared/auto_split_precompute.py`), persisting `<reciter>/auto_split_v1.json` keyed by `segment_uid`. At runtime `POST /api/seg/auto-split/<reciter>` (`edit.py::seg_auto_split`) β†’ `compute_auto_split` reads the sidecar via `load_auto_split` (O(1), in-memory) and returns `{cursors:int[]|None, refs:str[]|None, kind:"cross_verse"|"repetition"|null, source:"sidecar"|"miss"}`. On a miss the FE flips the button from *Auto Split* back to plain *Split* and falls back to manual single-cursor placement. The returned `cursors`/`refs` feed a `split` command (`cmd.splitMs` array + `cmd.refs`).
**Auto-detect** (`services/segments/auto_detect.py`) is unrelated to segment editing β€” it is the request-lifecycle reconciler that fires `reciter.alignment_completed` when new `reciters/<slug>/` folders appear (polling loop + `POST /api/admin/reconcile`). It does not touch `detailed.json` segments.