# stapel-recordings 0.19.0

Recording lifecycle: audio/video upload (presigned single-PUT and multipart), storage, a configurable processing pipeline (convert, transcribe, diarize, merge) producing a unified speaker-attributed transcript, optional automatic summaries, and a watchdog that recovers stuck or abandoned recordings.

Contract: axes 1 · surface 40 · extension points 7 · operations 10 · error codes 59.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- SUMMARIZE_ENABLED [bool, default true] — Automatic meeting summaries
  When on, each completed transcript is additionally summarized (via the agent module's language model) and the summary is stored on the recording; the transcript itself is always produced.

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- abort_multipart_upload_session — stapel_recordings.services.abort_multipart_upload_session
  instead of: stapel_recordings.storage.RecordingStorage.abort_multipart_upload
  Cancel an unfinished multipart upload: aborts it at the object store AND deletes the session row. Aborting at the storage layer alone leaves a live UploadSession pointing at a key that no longer exists, which a retrying client will happily finalize.
- erase — stapel_recordings.erasure.erase
  instead of: stapel_recordings.models.Recording.delete
  The ONLY code that destroys a recording, for any subject: account, workspace, meeting or recording. Removes the rows, everything cascading from them (speakers, segments, upload sessions, shares, jobs, embeddings) and every storage object they reference through the STORAGE seam, then returns Django's own per-model tally so a gdpr receipt can carry counts instead of a claim. Idempotent by construction — a redelivered erasure removes nothing and still answers. Hand-rolled deletion is how an object survives its row: the keys live on the row you are about to delete.
- fail_resummarize — stapel_recordings.stages.fail_resummarize
  instead of: stapel_recordings.pipeline.fail_stage
  Close a standalone re-summary whose task failed for good (True when it owned the task). No retry — the Task primitive already spent its attempts — and no recording.resummarized event, so a host metering re-summaries is never billed for a summary that was not produced. Wire it before pipeline.fail_stage in a task.failed subscriber.
- fail_stage — stapel_recordings.pipeline.fail_stage
  Move a recording to the DLQ once the task it was awaiting has failed for good. DLQ, not retry: the Task primitive has already exhausted its own max_attempts.
- purge_soft_deleted_recordings — stapel_recordings.tasks.purge_soft_deleted_recordings
  The retention job: opens a gdpr erasure for every recording soft-deleted longer than PURGE_AFTER_DAYS ago that has none in flight. It deletes nothing itself — that is the point, since routing the purge through an erasure is what gets a single-recording delete receipted by every owner that claims it. Schedule it (see get_recordings_beat_schedule) or a soft delete is a UI state forever.
- reprocess_recording — stapel_recordings.pipeline.reprocess_recording
  instead of: stapel_recordings.events.emit_stage
  completed -> queued, running the WHOLE pipeline again from stage 0 — for a finished recording a host wants re-processed after changing the stage list, the transcription provider or a stage's config. It clears the progress cursor (completed / completed_index / carried ctx) that retry_recording deliberately keeps, mints a NEW run_id and increments attempt so the re-run's events are distinguishable from the first run's (a metered consumer keyed on the recording alone bills the re-run as free), and refuses every other source status without side effects. Clearing those metadata keys by hand is the same operation with none of the guards.
- resume_resummarize — stapel_recordings.stages.resume_resummarize
  instead of: stapel_recordings.pipeline.resume_stage
  Apply an arriving llm.summarize task result to a standalone re-summary, answering True only when the task belonged to one. Wire it BEFORE pipeline.resume_stage in a task.completed subscriber: a re-summary runs on a finished recording, whose status the pipeline driver treats as terminal, so the driver would silently drop the result. Redelivery-safe — a second delivery finds the job closed and answers False.
- resume_stage — stapel_recordings.pipeline.resume_stage
  Complete a stage that was awaiting a task, using its result — mirrors the successful tail of run_stage: same lock, same completion marker, same events. Checks task_id: delivery is at-least-once, and a response to a stale attempt must not be applied.
- retry_recording — stapel_recordings.pipeline.retry_recording
  instead of: stapel_recordings.events.emit_stage
  The ONLY way back from error: an explicit error -> queued transition that resumes at the first stage whose name has not completed. Expose it from a retry endpoint or admin action instead of setting status and re-emitting recording.stage by hand — a redelivered event deliberately never resurrects a DLQ'd recording, and a hand-reset status without the completed-set cursor re-runs stages whose results were already published.
- run_stage — stapel_recordings.pipeline.run_stage
  One step of the generic driver, normally reached only through the recording.stage consumer. Call it directly when you are driving the pipeline synchronously on purpose (a management command, an integration test) — and note the index is a duplicate-delivery hint, not a stage selector: which stage runs is decided by NAME against the currently resolved list, so this cannot be used to skip ahead.
- start_pipeline — stapel_recordings.pipeline.start_pipeline
  instead of: stapel_recordings.events.emit_stage
  Kick processing for a recording whose file has landed. Idempotent under duplicate deliveries: it locks the row and writes the metadata.pipeline start marker in the same transaction as the stage-0 emit, so two deliveries produce one pipeline. Emitting recording.stage yourself gets you neither the lock nor the marker.
- validated_upload_ext — stapel_recordings.services.validated_upload_ext
  instead of: django.core.validators.FileExtensionValidator
  The one place an upload filename is judged: returns the object-key suffix (".mp3") when the extension is in UPLOAD_EXTENSION_ALLOWLIST, raises UnsupportedUploadExtension otherwise. Reach for this instead of a hand-rolled whitelist or a FileExtensionValidator with a literal list — a second allowlist is a second thing to extend when the host's NORMALIZER learns a new container, and the one that gets forgotten is the one that lets an unhandled file into the pipeline.
### predicate
- check_workspace_membership — stapel_recordings.services.check_workspace_membership
  instead of: stapel_core.comm.call, stapel_workspaces.services.get_membership
  The fail-closed "may this user see this workspace's recordings" check — asks workspaces.check_membership by comm name, so it reads the same in a monolith and across services and never imports stapel-workspaces. Any wiring failure DENIES; hand-write the comm call and the natural except-branch is the permissive one, which is how an account that belongs to no workspace ends up listing someone else's recordings.
- is_valid_source_type — stapel_recordings.sources.is_valid_source_type
  instead of: stapel_recordings.models.SourceType.values
  The membership test for a source kind — ask this rather than comparing against the model enum's values, which rejects exactly the kinds a host went to the trouble of registering.
- recordings_for — stapel_recordings.erasure.recordings_for
  Turn a gdpr (subject_type, subject_key) pair into the recordings this module owns about that subject — the one place the four subject vocabularies are resolved (owner, workspace, meeting-or-metadata-link, id). Returns an empty queryset for a key that addresses nothing rather than raising, because an owner that cannot find a subject owns nothing about it and a zero-count receipt is the honest answer.
- summary_from_result — stapel_recordings.stages.summary_from_result
  Read an llm.summarize result and answer with the summary text or None. One truthiness rule for 'a summary was produced', shared by the merge stage and the standalone re-summary — two hand-rolled checks are how one caller starts storing an empty string as a summary while the other reports a failure.
### factory
- create_upload_session — stapel_recordings.services.create_upload_session
  instead of: stapel_recordings.storage.RecordingStorage.presigned_put_url
  Mint a single-PUT presigned upload for a recording — the sanctioned way to hand a client an upload URL, because the UploadSession row it writes carries the TTL, the size ceiling and the storage key that finalize_upload later validates against. A bare presigned_put_url() gives you the URL and none of that.
- default_pipeline_resolver — stapel_recordings.pipeline.default_pipeline_resolver
  The stock PIPELINE_RESOLVER — returns the PIPELINE setting verbatim. It is already the seam's default value, so a host never wires it; call it only from inside a custom resolver that wants "my per-workspace list, else the configured default" as its fallback.
- finalize_upload — stapel_recordings.services.finalize_upload
  instead of: stapel_recordings.events.emit_uploaded
  Close an upload session and enter the pipeline: completes the multipart if there was one, stamps the real object size off a HEAD, moves the recording to queued and emits recording.uploaded through the outbox — idempotent under a concurrent second finalize. This is also the entry point for a programmatic import (a batch of historical recordings, a recorder that uploaded out of band); emitting recording.uploaded yourself skips every one of those steps.
- get_erasure_client — stapel_recordings.erasure.get_erasure_client
  instead of: stapel_gdpr.orchestrator.gdpr_orchestrator
  The configured ERASURE_CLIENT — how this module ASKS for an erasure rather than performing one. Use it (never an import of stapel_gdpr) when host code wants a recording erased through the receipts path; the seam is what lets a monolith call an orchestrator in-process and a service call one over HTTP.
- get_recordings_beat_schedule — stapel_recordings.tasks.get_recordings_beat_schedule
  The beat entries this module needs, on the cadence PURGE_SCHEDULE states: merge into CELERY_BEAT_SCHEDULE. Fleet canon (stapel_gdpr.tasks.get_gdpr_beat_schedule) — a host that spells the entry by hand pins a task path that refactors out from under it, and checks.W010 only knows to stay quiet about the name this factory emits.
- get_stage — stapel_recordings.stages.get_stage
  Resolve one stage name to a ready-to-run Stage instance — the lookup a custom driver, or a stage that delegates to another stage, needs. Lazy on purpose: only the requested handler is imported, so a broken dotted path elsewhere in the STAGES overlay breaks only the pipelines that include that stage instead of every recording at once.
- get_storage — stapel_recordings.storage.get_storage
  instead of: boto3.client, django.core.files.storage.default_storage
  THE object-store handle for anything that touches recording bytes — the configured RecordingStorage (Django default_storage, the bundled S3/MinIO backend, or the host's own), memoized per resolved class. An export script, a custom stage or an admin action goes through this rather than building its own boto3 client or reaching for default_storage: the STORAGE seam is what makes the bucket, the credentials and the presign policy one decision instead of one per call site.
- identity_fields — stapel_recordings.stages.identity_fields
  The same attribution block built from raw ids, for callers that have them without a Recording — a live question, a search, a host view that knows its request.user. Absent ids are omitted rather than sent as null, which matters because the llm.* schemas type both as strings and reject unknown or ill-typed properties outright.
- identity_payload — stapel_recordings.stages.identity_payload
  The {user_id, workspace_id} block to merge into any llm.* payload a stage delegates, read off the recording being processed. Reach for it in every custom stage that calls the agent: a stage runs on a queue long after the request that created the recording, so there is no current user to read, and the agent writes one billable ledger row per provider call. Omit it and that row is unattributable — the spend is recorded and belongs to nobody. Hand-rolling the dict instead is how the two spellings drift and half the ledger goes dark.
- register_stage — stapel_recordings.stages.register_stage
  instead of: stapel_recordings.stages.BUILTIN_STAGES
  Add, replace or (with None) remove a pipeline stage at runtime — a Stage subclass, an instance, or a plain callable(recording, ctx) -> ctx, adapted automatically. The fork-free way for a plugin, an AppConfig.ready() or a test to contribute a stage when a dotted path in the STAGES setting is not an option; mutating BUILTIN_STAGES reaches the same map from the wrong side and loses to both the setting overlay and this registry.
- registered_source_types — stapel_recordings.sources.registered_source_types
  instead of: stapel_recordings.models.SourceType.values
  Every registered source-kind key, sorted — for a choices list, a filter parameter's enum, or an error message that has to name the valid options. Recording.source_type is a free CharField precisely so that this list, and not a migration, is what grows.
- reset_runtime_stages — stapel_recordings.stages.reset_runtime_stages
  Drop every runtime registration at once — the teardown hook that stops one test's stage from leaking into the next. Not a production call: a host that registers its stages in AppConfig.ready() would be unregistering its own module.
- reset_storage_cache — stapel_recordings.storage.reset_storage_cache
  Drop the memoized backend so the next get_storage() re-resolves the STORAGE setting — the supported way to make a runtime settings change (an override_settings block, a fixture) take effect. Overwhelmingly a test-harness concern: production code that finds itself calling this is configuring storage in the wrong place.
- resolve_pipeline — stapel_recordings.pipeline.resolve_pipeline
  The stage list that will actually run for THIS recording, resolver included — read it whenever you need to show or reason about a pipeline (an admin page, a progress bar, a stage-count estimate). The PIPELINE setting is only the default resolver's answer, and a host that swapped PIPELINE_RESOLVER has made reading it a lie.
- resolve_resource_key — stapel_recordings.resources.resolve_resource_key
  instead of: django.core.signing.loads
  Turn a resource_key a client sent back into a recording id, or None when it is missing, forged or corrupt. Any host endpoint that accepts one of these tokens resolves it here — the signature is salted per purpose, so unsigning it yourself either fails or, with the wrong salt in place, accepts a token minted for something else.
- resolve_source_types — stapel_recordings.sources.resolve_source_types
  instead of: stapel_recordings.models.SourceType
  The effective {key: label} source-kind map: the built-in four with the SOURCE_TYPES overlay merged over them. Render pickers and admin labels from this — the SourceType model enum is only the default set, so a host that added "zoom" from settings gets a blank label everywhere the enum was read directly.
- resolve_stages — stapel_recordings.stages.resolve_stages
  instead of: stapel_recordings.stages.BUILTIN_STAGES
  The effective {name: handler} map — built-ins, then the STAGES overlay, then runtime registrations. Read this to answer "which stages exist in this deployment" (a pipeline editor's picker, a config check, an error message listing the options); BUILTIN_STAGES answers only "which stages shipped", which stops being the same question the moment a host configures anything.
- resource_key — stapel_recordings.resources.resource_key
  The opaque, signed handle a recording is referenced by in API payloads. Anything rendering a recording for a client publishes this instead of the row's UUID: the list surface is workspace-scoped, so it returns recordings owned by other members, and their raw ids would cross the wire in a form a client can store, guess at and replay.
- run_identity — stapel_recordings.pipeline.run_identity
  instead of: stapel_recordings.models.Recording.workflow_state
  The recording's current pipeline run as {run_id, attempt} — the same pair recording.stage_completed / recording.completed / recording.failed carry. Read it to tie a run to what it cost: an invoice line, a credit hold, an audit trail, or the idempotency key a metering consumer builds (recording_id + run_id — keyed on the recording alone, every reprocess after the first is free). Read-only, and cheaper than re-deriving the workflow_state key names, which are the driver's private cursor layout.
- start_multipart_upload — stapel_recordings.services.start_multipart_upload
  instead of: stapel_recordings.storage.RecordingStorage.create_multipart_upload
  The multipart counterpart of create_upload_session: one call returns the session, the per-part presigned URLs and the part size the client must cut at. Use it for anything large enough to need parts — the part size is configuration, and a client that picks its own produces parts the completion step rejects.
- start_resummarize — stapel_recordings.stages.start_resummarize
  instead of: stapel_core.comm.call, stapel_recordings.pipeline.reprocess_recording
  Regenerate ONE recording's summary — the same llm.summarize call the merge stage makes, with no re-transcription and no pipeline run. This is the cheap counterpart of reprocess_recording, which re-runs every stage (a second STT bill) for a recording whose transcript is usually already correct. Returns (job, started); a request that arrives while one is in flight joins it instead of paying for a second summary. Raises NoTranscriptToSummarize / SummarizationUnavailable rather than degrading quietly, so a caller can answer 409 vs 503 honestly.
- store_summary — stapel_recordings.stages.store_summary
  instead of: stapel_recordings.models.Recording.save
  The one sanctioned way to persist a produced summary: writes the text AND pins it to the version key of the transcript it was built from, so 'is this summary still current' is computed instead of guessed. Assigning recording.summary yourself leaves the previous transcript's key in place, and a freshly regenerated summary then reads as stale forever — the key outlives the flag.
- submit_task — stapel_recordings.stages.submit_task
  instead of: stapel_core.comm.call
  Submit long-running work (transcription, summary) as a task and return control right away: a result comes back only under synchronous dispatch, otherwise StageAwaiting is raised and the stage resumes in Stage.resume on task.completed. Unlike comm.call, a synchronous call would hold the worker for the model's entire runtime and requires guessing a timeout that a queue makes unguessable.
- unregister_stage — stapel_recordings.stages.unregister_stage
  Undo one register_stage call, leaving whatever it shadowed — a built-in or a STAGES overlay entry — back in place. Distinct from register_stage(name, None), which actively REMOVES the stage from the resolved map.

## Extension points — what a product replaces, fork-free
- NORMALIZER [dotted_path]
  Swap the audio-normalization step: ffmpeg by default, a passthrough for environments without it, or a custom converter.
- PIPELINE [ordered_list]
  The flagship seam: the processing pipeline is an ordered list of stage names run by a generic driver — reorder, subset (e.g. skip diarization) or insert stages (e.g. PII redaction) purely from settings, no fork.
- PIPELINE_RESOLVER [dotted_path]
  Source the stage list at runtime per recording — point it at a DB or per-workspace definition so operators can edit pipelines in a UI without a redeploy.
- SOURCE_TYPES [merge_registry]
  Extend the recording source-kind registry ({key: label}, merged over the built-in meet/dictaphone/upload/other) — add zoom, teams, phone and so on from settings without editing a code enum.
- STAGES [merge_registry]
  Replace, remove or add stage handlers ({name: dotted-path | None}, merged over the built-ins) or register them at runtime via register_stage().
- STORAGE [dotted_path]
  Swap the object-storage backend (RecordingStorage implementation): any Django storage by default, bundled S3/MinIO backend, or your own.
- serializer_seams [class_override]
  Every view declares request/response serializer seams (SerializerSeamMixin) — subclass the view, override the attribute, remount the URL.

## Fits with — fleet dependencies
- stapel-agent (optional) — provides the llm.transcribe / llm.summarize / llm.embed / llm.complete comm Functions the pipeline stages and the question-answering path call by name, no import. One exception, the [qa] extra: stapel_recordings.vector.qa imports sanitize_for_rag from stapel_agent.safety.markers, because transcript text entering an LLM prompt must be stripped of injection markers and a second copy of that stripper is a second thing to update when a marker is added
- stapel-auth (optional) — every endpoint requires an authenticated user (IsAuthenticated); stapel-auth is the shelf's session issuer — any stapel-core-compatible JWT issuer satisfies the check
- stapel-core (required) — comm bus (recording.stage pipeline driving, llm.transcribe / llm.summarize calls), JWT authentication, GDPR provider registry, AppSettings config layer

## HTTP operations (10) — call by operationId, never by a typed path
Paths are relative to `/recordings/api/v1/`.
### Recordings
- POST /recordings — recordings_api_v1_recordings_create
- POST /recordings/{recording_id}/finalize — recordings_api_v1_recordings_finalize_create
- GET /recordings — recordings_api_v1_recordings_list
- GET /recordings/{recording_id}/media — recordings_api_v1_recordings_media_retrieve
- POST /recordings/{recording_id}/reprocess — recordings_api_v1_recordings_reprocess_create
- POST /recordings/{recording_id}/resummarize — recordings_api_v1_recordings_resummarize_create
- GET /recordings/{recording_id} — recordings_api_v1_recordings_retrieve
- GET /shares/{link_token}/media — recordings_api_v1_shares_media_retrieve
- GET /shares/{link_token} — recordings_api_v1_shares_retrieve
- POST /shares/{link_token}/unlock — recordings_api_v1_shares_unlock_create

## Error codes (59) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.invalid_ad_id [400] fix_input
- error.400.recording_invalid_state [400] fix_input
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.share_passcode_required [401] reauthenticate
- error.401.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.402.recording_payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.network_blocked [403] contact_support
- error.403.recording_action_denied [403] retry
- error.403.recording_workspace_forbidden [403] retry
- error.403.share_permission_denied [403] retry
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.not_found [404] retry
- error.404.recording_not_found [404] retry
- error.404.share_not_found [404] retry
- error.404.verification_challenge_not_found [404] verify
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.409.recording_invalid_state [409] fix_input
- error.409.recording_media_not_stored [409] fix_input
- error.409.recording_no_transcript [409] fix_input
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.413.recording_too_large [413] retry
- error.415.recording_unsupported_media [415] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.share_unlock_throttled [429] wait_and_retry
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.503.mandate_unavailable [503] retry
- error.503.recording_media_unavailable [503] retry
- error.503.recording_summarize_unavailable [503] retry
- error.503.recording_upload_unverifiable [503] retry
