# stapel-recordings 0.13.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 27 · extension points 7 · operations 5 · error codes 47.
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.
- fail_stage — stapel_recordings.pipeline.fail_stage
  Увести запись в DLQ, когда ждавшаяся задача провалилась окончательно. Именно DLQ: Task-примитив уже отработал свои max_attempts.
- 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 ASR tier or a stage's config. It clears the progress cursor (completed / completed_index / carried ctx) that retry_recording deliberately keeps, 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_stage — stapel_recordings.pipeline.resume_stage
  Досчитать стадию, ждавшую задачу, по её результату — симметрия успешного хвоста run_stage: тот же замок, отметка о завершении и события. Сверяет task_id: доставка at-least-once, и ответ устаревшей попытки применять нельзя.
- 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.
### 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_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.
- 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.
- 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.
- submit_task — stapel_recordings.stages.submit_task
  instead of: stapel_core.comm.call
  Поставить долгую работу (расшифровка, сводка) задачей и вернуть управление сразу: результат приходит только при синхронной диспетчеризации, иначе поднимается StageAwaiting и стадия продолжится в Stage.resume по task.completed. Вместо comm.call: синхронный вызов держит воркер всё время работы модели и требует угадать срок ожидания, а он неугадываем при очереди.
- 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 (5) — 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
- POST /recordings/{recording_id}/reprocess — recordings_api_v1_recordings_reprocess_create
- GET /recordings/{recording_id} — recordings_api_v1_recordings_retrieve

## Error codes (47) — 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.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.network_blocked [403] contact_support
- error.403.recording_workspace_forbidden [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.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.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.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
