Spaces:
Running
State Machine
Storage: all reciter lifecycle state lives in SQLite (
inspector.db), NOT areciter_state.json. Tables:delivery_states(lifecycle),claims(assignee + marked_ready, normalized out),transitions(append-only event log). Eachstate.transition()is ONE durable SQLite transaction; on commit the DB is uploaded back to the bucket (CAS ondb/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 edgeunder_review β released, fired by the system only when the timestamps job succeeds. The reciter staysunder_review(marked_ready) while the job runs, so a failed job is recoverable (re-run). Seeservices/admin/timestamps_jobs.py::complete_timestamps_job+ the/api/webhooks/ts-job-completeroute.
TS regen on a
releasedreciter is NOT a transition. Re-running the timestamps job on an already-released reciter (the Releases-tab "Regenerate TS" action) must not re-firereciter.publishedβ there's noreleased β releasededge.complete_timestamps_jobbranches on state: areleasedrow takes_regenerate_timestamps_on_released, which records the newtsrelease, supersedes the prior, stamps the slug's HF/GH releases stale, and writes areciter.ts_regeneratedaudit 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_ACTORas above; the automation recordsSYSTEM_AUTOMATIONas the job'slaunched_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 bycomplete_timestamps_jobwhen a timestamps job succeeds on an already-releasedreciter (regen). No state change (from_state=to_state=released); payload{job_id}. Classifiedhiddeninactivity_classification. Visibly distinct from the first publish'sreciter.published.reciter.ts_refreshedβ written byrepo_releases.mark_ts_refreshedwhen 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 endpointPOST /api/admin/internal/ts-refreshed(secret-gated, sameX-Inspector-Job-Secretas the job-completion webhook). No state change (from_state=to_state=released); payload{chapters, reason}. Advances the currenttsreleaseproduced_at(sots_stale_inforecomputes clean) and re-stamps HF/GH stale (TS_REGEN) β making a bucket-direct write no longer silent. Idempotent. Classifiedhidden. The sharedqua_shared.inspector_notify.notify_ts_refreshedhelper (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-confirmedreciter_id(new_reciter) and flips the request toaccepted. The catalog reciter/delivery + state row + slug are minted later by the offline ingest, which alone knows the real source/channel/bitrate: the owner-authenticatedPOST /api/admin/intake/<rid>/ingest(services/admin/intake.py::ingest, auditedrequest.intake_ingested) mints reciter+delivery+slug, writes theaudio_manifestsidecar, and firesreciter.requestedto seedawaiting_alignmentβ making ingest the second caller of that event, alongside the dashboard edit-request route. The seed ordering is load-bearing:reciter.alignment_completedis the soleawaiting_alignment β awaiting_reviewedge and fires only whenauto_detectseesreciters/<slug>/for a slug already inawaiting_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:
Checklist completeness. All five values MUST be
True. AnyFalseβInvalidTransition("checklist incomplete", details={unchecked: [...]}).Blocking validation counts. The handler calls
services.validation.validate_reciter_segments(slug)and checks the six keys inMarkReadyRequest::BLOCKING_COUNT_KEYSagainst the livecategory_counts:Key Accordion low_confidenceLow confidence low_confidence_v2Low confidence v2 boundary_adjMay require boundary adjustment cross_verseCross-verse basmala_aminBasmala + amin repetitionsRepetitions 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_countsas 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 bymarked_ready(aclaimsflag onunder_review).awaiting_timestampsβ removed. Publish is now the singleunder_review β releasededge fired on timestamps-job success; the reciter sits inunder_review(marked_ready) while the job runs.discardedβ superseded byvisibility='discarded'; round-trip preserves lifecyclestate.archivedβ deferred; not in theVisibilityenum.completed/ "published to HF dataset" β not a lifecycle state.ReciterStateends atreleased; dataset publication is out-of-band and not modeled in the state machine.
What's NOT a transition
under_review β releaseddirectly by a human β there's no manual publish button; the only thing that firesreciter.publishedis the timestamps-job completion path (webhook + poll fallback), withSYSTEM_ACTOR. The maintainer's deliberate action is launching Generate-TS on a marked-ready row (gatedreviews.generate_timestamps+reciter.publish).released β releasedon TS regen β re-running Generate-TS on an already-released reciter records a newtsrelease +reciter.ts_regeneratedaudit 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 discreteadmin.*/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, viaadmin.unlocked_for_revisionto reopen back toawaiting_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.