quranic-universal-audio / docs /reference /state-machine.md
hetchyy's picture
deploy: inspector prod @ 7167546
28f135a verified
|
Raw
History Blame Contribute Delete
21.1 kB

State Machine

Storage: all reciter lifecycle state lives in SQLite (inspector.db), NOT a reciter_state.json. Tables: delivery_states (lifecycle), claims (assignee + marked_ready, normalized out), transitions (append-only event log). Each state.transition() is ONE durable SQLite transaction; on commit the DB is uploaded back to the bucket (CAS on db/inspector.seq) before the call returns (services/db/sync.py::durable_transaction). No per-slug lock β€” BEGIN IMMEDIATE + single-writer serializes same-slug writes.

Where it lives:

Concern Location
State machine / handlers / _HANDLERS inspector/services/state/state.py
delivery_states read/write + ReciterRow assembly inspector/services/db/repo_state.py
transitions append + read helpers inspector/services/db/repo_transitions.py
claims (assignee/marked_ready) read/write inspector/services/db/repo_claims.py
Audit facade (delegates to repo_transitions.append) inspector/services/state/audit.py
Canonical row + enums (Pydantic) qua_shared/schemas/config/state.py (ReciterRow, ReciterState, Visibility, RevisionContext)
Server-side predicates (can_edit, can_claim, …) inspector/services/auth/predicates.py
Role/reason gates inspector/services/auth/permissions.py

ReciterRow is the read DTO. assignee_hf_id / assignee_login / assignee_since / marked_ready are NOT stored on delivery_states β€” they are LEFT-JOINed from the open claims row (released_at IS NULL) by repo_state._assemble, and only populated when state == under_review.

Lifecycle states (column: delivery_states.state)

Enum ReciterState (qua_shared/schemas/config/state.py):

catalogued β†’ awaiting_alignment β†’ awaiting_review β†’ under_review β†’ released

No awaiting_timestamps. Publishing is a single edge under_review β†’ released, fired by the system only when the timestamps job succeeds. The reciter stays under_review (marked_ready) while the job runs, so a failed job is recoverable (re-run). See services/admin/timestamps_jobs.py::complete_timestamps_job + the /api/webhooks/ts-job-complete route.

TS regen on a released reciter is NOT a transition. Re-running the timestamps job on an already-released reciter (the Releases-tab "Regenerate TS" action) must not re-fire reciter.published β€” there's no released β†’ released edge. complete_timestamps_job branches on state: a released row takes _regenerate_timestamps_on_released, which records the new ts release, supersedes the prior, stamps the slug's HF/GH releases stale, and writes a reciter.ts_regenerated audit row (below) β€” all with no state change. Idempotent on (track='ts', slug, version=job_id).

State Editable? Required fields Notes
catalogued No state_since In catalog; no alignment. Entry point for catalog.added and re-request after soft-reject.
awaiting_alignment No state_since Alignment pipeline pending/running. Has a pending request entry (see services/state/pending_requests.py).
awaiting_review No (claimable) state_since Alignment done, files under reciters/<slug>/. No open claim. May carry revision_in_progress (set by admin.unlocked_for_revision).
under_review Yes (assignee, when marked_ready=0, visibility=public) state_since, open claim β†’ assignee_hf_id + assignee_since Claimed. marked_ready legal ONLY here. The timestamps job also runs against a marked_ready row (it stays here until the job succeeds).
released No (maintainer direct-edit only) state_since Files + timestamps on bucket; publicly visible via Inspector. Terminal lifecycle state. Reached directly from under_review on timestamps-job success.

Invariants (ReciterRow._check_state_invariants): assignee fields legal ONLY on under_review; marked_ready requires under_review + assignee; revision_in_progress legal ONLY on awaiting_review.

Orthogonal columns

delivery_states columns (writable set in repo_state._WRITABLE) plus claim-derived fields:

Field Storage Type Meaning
visibility delivery_states enum public (default) / discarded (hidden, any state). archived is deferred β€” not in the enum.
visibility_reason delivery_states str | null Reason set alongside discarded (or hard-reject).
timestamps_job_ids delivery_states list[str] (JSON) Append-only; every MFA timestamps job dispatched. Appended at launch (record_timestamps_job, no transition) and re-appended idempotently by reciter.published on success.
revision_in_progress delivery_states RevisionContext | null (JSON) Set by admin.unlocked_for_revision, cleared by reciter.published / reciter.unpublished. Carries unlocked_from_state (released), unlocked_at, unlocked_by_hf_id, original_assignee_hf_id (currently always None). Only valid on awaiting_review.
last_save_at delivery_states datetime | null Column exists on delivery_states/ReciterRow, but no state-machine transition currently writes it (carried through unchanged by _persist_state); maintainer direct-edit timestamping on published reciters is not implemented.
last_job_finished_at delivery_states datetime | null (ISO) Stamped on a timestamps job's terminal state — success (alongside publish→released) and failure (no state change) — by state.mark_timestamps_job_finished (no transition; job bookkeeping). Not on ReciterRow (repo_state._assemble reads named columns only — no schema/codegen change). Audit-only since the Reviews-tab notification dot was retired (no current reader).
created_by_transition_id delivery_states str FK to the transitions row that inserted the state row.
state_since delivery_states datetime Set on every state change.
assignee_hf_id / assignee_login / assignee_since claims (open row) str / str / datetime Claim holder. NOT on delivery_states.
marked_ready claims.marked_ready_at (open row) bool (derived: IS NOT NULL) Edits frozen, awaiting maintainer publish. Reviewer can flip back.

Force-claim columns (force_assignee_*, leases) do not exist β€” force-claim is deferred.

Transition matrix (matches _HANDLERS exactly)

actor role column: system = server/pipeline/callback (no role gate beyond plumbing); contributor+ = is_contributor_or_higher; maintainer+ = is_maintainer (owner inherits); owner = is_owner (strict β€” maintainer is NOT enough). "reason β‰₯10" = _require_reason (β‰₯permissions.MIN_REASON_CHARS); "reason optional" = no _require_reason call (route normalizes empty/short to "").

Event From To Other column changes Actor / gate
catalog.added (no row) catalogued inserts new row maintainer+
catalog.edited (any) (same) none β€” audit-only maintainer+
reciter.requested (no row) or catalogued (public) awaiting_alignment submits pending request (proposed_edits, comments, auto_claim) contributor+
reciter.request_rejected_soft awaiting_alignment catalogued archives pending β†’ requests/returned.json owner, reason β‰₯10
reciter.request_rejected_hard awaiting_alignment catalogued visibility=discarded, visibility_reason; archives pending β†’ discarded owner, reason β‰₯10
reciter.alignment_completed awaiting_alignment awaiting_review applies + clears pending catalog edits; if pending had auto_claim, folds a reciter.claimed into the same txn system
reciter.claimed awaiting_review (public) under_review opens claim: assignee_*, marked_ready=0 contributor+
reciter.released under_review (not marked_ready when actor is claim holder) awaiting_review closes claim claim-holder OR maintainer+
reciter.marked_ready under_review (+ all 5 checklist attestations True + 5 blocking validation counts == 0) (same) marked_ready=1; persists mark_ready_checklist + mark_ready_comment_checks + mark_ready_comment_issues onto the open claim claim-holder
reciter.unmarked_ready under_review (same) marked_ready=0; clears the three submission columns on the open claim claim-holder
reciter.merge_rejected under_review (marked_ready=1) (same) marked_ready=0 maintainer+, reason β‰₯10
reciter.published under_review (marked_ready=1) released closes claim; marked_ready=0; revision_in_progress=None; appends job_id→timestamps_job_ids maintainer+ (gate reciter.publish); fired by the system (SYSTEM_ACTOR, role OWNER) on timestamps-job success
reciter.unpublished released awaiting_review revision_in_progress=None maintainer+, reason β‰₯10
reciter.discarded (any) (same) visibility=discarded, visibility_reason maintainer+, reason β‰₯10
reciter.undiscarded (any discarded) (same) visibility=public, visibility_reason=None maintainer+
claim.force_released under_review awaiting_review closes claim; marked_ready=0 owner, reason optional
claim.reassigned under_review (same) reassigns claim: new assignee_*, marked_ready=0 (requires payload new_assignee_hf_id+new_assignee_login) owner, reason optional
admin.unlocked_for_revision released awaiting_review revision_in_progress={unlocked_from_state, unlocked_at, unlocked_by_hf_id, original_assignee_hf_id=None} maintainer+

Automated launches. The release-automation reconciler (automation.md) is an alternate launcher of these same job paths (generate-timestamps, cut, publish, refresh) on the owner's schedule/rules. The job-completion transition still fires with SYSTEM_ACTOR as above; the automation records SYSTEM_AUTOMATION as the job's launched_by + on its config-change audit, so the rail distinguishes an owner-configured automation from a manual action.

Folded / audit-only events (not in _HANDLERS, emitted directly via repo_transitions.append):

  • reciter.ts_regenerated β€” written by complete_timestamps_job when a timestamps job succeeds on an already-released reciter (regen). No state change (from_state=to_state=released); payload {job_id}. Classified hidden in activity_classification. Visibly distinct from the first publish's reciter.published.
  • reciter.ts_refreshed β€” written by repo_releases.mark_ts_refreshed when an out-of-band TS shard refresh (manual backfill / schema-bump re-stamp / local regen uploaded straight to the bucket) is reported via the internal endpoint POST /api/admin/internal/ts-refreshed (secret-gated, same X-Inspector-Job-Secret as the job-completion webhook). No state change (from_state=to_state=released); payload {chapters, reason}. Advances the current ts release produced_at (so ts_stale_info recomputes clean) and re-stamps HF/GH stale (TS_REGEN) β€” making a bucket-direct write no longer silent. Idempotent. Classified hidden. The shared qua_shared.inspector_notify.notify_ts_refreshed helper (best-effort POST) is what the backfill scripts call after a successful upload.
  • reciter.auto_claim_skipped β€” written inside the alignment txn when a non-owner auto-claim requester already holds another claim (_maybe_auto_claim).
  • request.intake_{submitted,accepted,returned,discarded} β€” slugless new-combo / new-reciter intake lifecycle (services/admin/intake.py). Slugless β‡’ silently dropped from both activity rails. None of these fire a state-machine transition β€” an unaccepted intake has no delivery/state row, and accept doesn't create one either: it just records the owner-confirmed reciter_id (new_reciter) and flips the request to accepted. The catalog reciter/delivery + state row + slug are minted later by the offline ingest, which alone knows the real source/channel/bitrate: the owner-authenticated POST /api/admin/intake/<rid>/ingest (services/admin/intake.py::ingest, audited request.intake_ingested) mints reciter+delivery+slug, writes the audio_manifest sidecar, and fires reciter.requested to seed awaiting_alignment β€” making ingest the second caller of that event, alongside the dashboard edit-request route. The seed ordering is load-bearing: reciter.alignment_completed is the sole awaiting_alignment β†’ awaiting_review edge and fires only when auto_detect sees reciters/<slug>/ for a slug already in awaiting_alignment, so ingest must seed the state row before the offline pipeline uploads the content folder β€” a folder that lands without the seeded row is silently ignored.

Mark-ready submission

reciter.marked_ready is not a bare flag-flip β€” it carries a submission payload that the reviewer fills via a modal form before the POST. The payload + the live validation counts both gate the transition; once accepted, the submission is persisted on the open claim row so admins can audit it from the Reviews drawer.

Wire shape (qua_shared/schemas/wire/mark_ready.py::MarkReadyRequest):

{
  "checklist": {
    "failed_alignments": true,
    "missing_words": true,
    "low_confidence": true,
    "splits_wasl_waqf": true,
    "basmala_amin_intros": true
  },
  "comment_checks": "...",   // optional, ≀4000 chars
  "comment_issues": "..."    // optional, ≀4000 chars
}

The five checklist keys mirror to the FE copy module at inspector/frontend/src/tabs/segments/copy/mark-ready/index.ts. The labels themselves live in sibling .md files (form.md, checklist.md, comments.md) so wording edits don't require touching components. Parity is asserted by inspector/frontend/src/tabs/segments/__tests__/parity/mark-ready-copy.test.ts and by the schema codegen guard.

Two gates run inside _h_marked_ready before the transition is accepted:

  1. Checklist completeness. All five values MUST be True. Any False β†’ InvalidTransition("checklist incomplete", details={unchecked: [...]}).

  2. Blocking validation counts. The handler calls services.validation.validate_reciter_segments(slug) and checks the six keys in MarkReadyRequest::BLOCKING_COUNT_KEYS against the live category_counts:

    Key Accordion
    low_confidence Low confidence
    low_confidence_v2 Low confidence v2
    boundary_adj May require boundary adjustment
    cross_verse Cross-verse
    basmala_amin Basmala + amin
    repetitions Repetitions

    Any non-zero count β†’ InvalidTransition("blocking validation counts must be zero before mark-ready", details={blocking_counts: {...}}). The client mirrors the same gate via $segValidation?.category_counts as a UX guard, but the server is authoritative.

Persisted columns on claims (the four mark_ready_* columns added in migrations 0007 + 0009):

Column Type Cleared on
mark_ready_checklist TEXT (JSON) unmarked_ready, released, force_released, reassigned (clears the open claim only; closed history rows keep their submission)
mark_ready_comment_checks TEXT same
mark_ready_comment_issues TEXT same
mark_ready_bypass_used INTEGER (0/1) same. Set to 1 when an owner (or a tier granted claim.mark_ready_skip_gates) submitted without the form.

These ride alongside marked_ready_at and are surfaced on AdminReviewOpenClaim.mark_ready_submission / AdminReviewClaimHistoryEntry.mark_ready_submission for the Reviews drawer. A send-back-and-re-mark cycle writes fresh values onto the new open claim; the prior cycle's submission lives on the closed history row.

InvalidTransition carries an optional details dict (added when the mark-ready gate fails); the app-level error handler at inspector/app.py includes it in the JSON envelope so the FE can render structured guidance instead of a flat error string.

Owner-bypass submission

Holders of the claim.mark_ready_skip_gates capability (owners by default, plus any tier the owner grants it to via the Permissions tab) skip both gates entirely. The route accepts an empty body; the handler short-circuits the Pydantic validation, the unchecked-keys gate, and the live validate_reciter_segments count check, then stamps bypass_used=True on the persisted submission. mark_ready_checklist is NULL for these rows β€” the admin Reviews drawer renders an "owner bypass" pill plus a single explanatory line in place of the checklist tick list, on both current claim and closed history entries. The route's capability check is a UX shortcut; the handler always re-checks the capability, so the bypass can't be exploited by a tier that lost the cap mid-flight.

What's NOT a state

  • ready_for_merge β€” superseded by marked_ready (a claims flag on under_review).
  • awaiting_timestamps β€” removed. Publish is now the single under_review β†’ released edge fired on timestamps-job success; the reciter sits in under_review (marked_ready) while the job runs.
  • discarded β€” superseded by visibility='discarded'; round-trip preserves lifecycle state.
  • archived β€” deferred; not in the Visibility enum.
  • completed / "published to HF dataset" β€” not a lifecycle state. ReciterState ends at released; dataset publication is out-of-band and not modeled in the state machine.

What's NOT a transition

  • under_review β†’ released directly by a human β€” there's no manual publish button; the only thing that fires reciter.published is the timestamps-job completion path (webhook + poll fallback), with SYSTEM_ACTOR. The maintainer's deliberate action is launching Generate-TS on a marked-ready row (gated reviews.generate_timestamps + reciter.publish).
  • released β†’ released on TS regen β€” re-running Generate-TS on an already-released reciter records a new ts release + reciter.ts_regenerated audit row but fires no transition (see the note box up top). The same route (POST /api/admin/generate-timestamps/<slug>) handles both first publish (marked-ready row) and regen (released row); any other state 409s.
  • Wildcard state.manual_override β€” replaced by discrete admin.* / claim.* events; each new recovery scenario gets a new named handler, not a free-form override.
  • Direct contributor re-edit of released β€” re-edits are maintainer-only, via admin.unlocked_for_revision to reopen back to awaiting_review.
  • Force-claim / force-acquire β€” entirely deferred (no lease, no force_assignee_*).

Server-side predicates (services/auth/predicates.py)

Pure (row, user) β†’ bool; anonymous user β†’ False. Backend re-checks on every mutating route β€” predicates only drive FE visibility. build_predicates(row, user, *, has_other_active_claim) returns the response map.

Predicate Condition
can_claim state=awaiting_review ∧ visibility=public ∧ signed in ∧ (owner OR not has_other_active_claim)
can_edit state=under_review ∧ not marked_ready ∧ visibility=public ∧ is_claim_holder
can_edit_as_admin maintainer+ ∧ state=under_review ∧ not marked_ready ∧ visibility=public
can_edit_as_owner owner ∧ not marked_ready ∧ visibility=public (any state)
can_mark_ready can_edit ∧ not marked_ready
can_unmark_ready state=under_review ∧ marked_ready ∧ is_claim_holder
can_release state=under_review ∧ not marked_ready ∧ is_claim_holder

All claim-ownership checks use hf_user_id, never login. One-claim-per-user is enforced via claims index (state.has_other_active_claim / repo_claims.open_claim_for_user); owners are exempt.

Transition audit

Every transition() writes exactly one transitions row (FK-first, before the delivery_states/claims diffs) via repo_transitions.append, enrolled in the same transaction (SAVEPOINT when nested). Columns: id, seq, ts, slug, event, from_state, to_state, actor_id, actor_login_snapshot, actor_role_snapshot, reason, result, payload (JSON), content_hash. Actor snapshot = {hf_user_id, login_at_time (cookie snapshot, never refetched), role}. repo_transitions read helpers (for_slug, since, feed) back the activity feeds.

After the transition row + state/claim diffs, _apply_event calls services.notifications.emit_for_event (best-effort, inside the same txn) to fan the event out to the affected user's "My Notifications" rail β€” e.g. the requester on a soft/hard reject, the prior assignee on claim.force_released, the requester on the auto-claim fold. The requester for reject/alignment events is captured BEFORE the handler runs (the pending row is archived mid-handler). See notifications.md.