Skip to content

M3-09: Voice messages and transcript

Status: ready | Depends on: M1-47, M2-31, M3-05, M3-06, M3-07 | Milestone: M3

Goal

After this ticket a person sends a voice message: a message with kind = voice and exactly one attachment, an AAC-LC recording in an MP4 container (audio/mp4, .m4a), and an agent may also send MP3 (audio/mpeg). createMessage and createThread (first_message) accept MessageCreate.kind = voice (until now 400 validation_failed with errors[].code not_enabled, M1-56) and check the audio before they store anything: the file must be checked and deliverable, its detected type must be a voice format (otherwise 400 validation_failed with errors[].code voice_format_unsupported), and its duration read by the server from mvhd must not exceed voice.max_duration (otherwise 400 validation_failed with errors[].code voice_too_long). The transcript comes from one of two places, chosen by voice.transcription_mode: in mode agent (the default) the operator agent of the thread transcribes the audio itself and stores the text with submitTranscript (action file.transcript.submit; any other agent gets the same 404 as an unknown file when it cannot see the file); in mode service, and only while the switch voice.enabled is on, the worker sends the audio through the egress client to the one configured, OpenAI-compatible transcription service (port Transcriber, adapter openai_compatible) and reports the result through file.updated, and a person can start it again with requestTranscription (action file.transcribe). Agents are routed by 09 "Voice messages": a voice message that triggers an agent without a transcription path is rejected at acceptance with voice_unsupported (AG-25), and in mode service the agent invocation waits until the transcript is done or failed, at the latest voice.transcript_hold_max, using the hold of M3-07 (runs.invocation_held_until, R-DM-44). No transcript text ever appears in an event, a log line, a span, a metric, an audit entry or a problem body. This is the voice part of the M3 goal that files, images and voice messages reach people and agents only after they are checked, R-FI-10, R-AG-16 and the server side of plan ticket M5-07.

Read first

  • docs/PLAN.md: 3 "Way of working per ticket"; 9 "M3 Platform services" (Goal, Scope, Out of scope, Exit criteria 1 and 2, Test strategy, Red team, Dependencies "from M2 for M3-07, M3-09", Risks); the rows M3-03, M3-04, M3-06 and M3-07 (what exists), M3-09 (this ticket, the contract, with the three deferred conformance lines of M2-31 it closes), M3-05 (download and getTranscript), M3-08, M3-14, M3-15 (who reads files and transcripts later); 8 rows M2-04, M2-05, M2-08, M2-09, M2-11, M2-13, M2-20 and M2-31; 7 rows M1-47, M1-56, M1-69, M1-70; 11 row M5-07 and 10 row M4-22 (the clients that record, send and play voice messages).
  • Dependencies: the header names M1-47 (instance.Flags, the registered flag voice.enabled), M2-31 (conformance suite, testdata/deferred.golden; it implies M2-09, M2-13 with the run context and M2-20 with the heartbeat report capabilities.voice_input), M3-05 (getTranscript, download), M3-06 and M3-07 (D-W-108). Implied through them and therefore not in the header: M3-04 (scan pass, files.FileFinalized, domain.Final, clamdtest), M3-03 (tables files, file_upload_sessions, getFile, the file.updated builder, authorize.FileFacts), M3-01 (object store), M2-11 (follow-ups, the follow-on run, agents.EffectiveCapabilities), M2-10, M2-08 (delivery, acceptance), M2-07 (pollAgentEvents), M2-06, M2-05 (OnMessagePosted, Open), M2-04 (the trigger decision with its voice rule), M2-02, M1-71, M1-70 (test/leak, the SQL visibility lint), M1-69 (test/canary), M1-58, M1-56, M1-55, M1-46, M1-44, M1-28, M1-26, M1-22 (egress client), M1-20, M1-18, M1-14, M1-13, M1-12, M1-09 (secret provider) and the rest of their closure.
  • docs/spec/12-files.md: 1 "Goals and limits" point 1; 2 "States of a file" (definitions "servable" and "final", the rule that every change of the transcript writes file.updated); 3.4 "Completion and verification pass"; 7.3 last paragraph (box reader for mvhd); 8 "Voice messages" completely (8.1 "Format and duration", 8.2 "Transcript", 8.3 "Transcoding"); 9.3 "Response" (inline list with audio/mp4 and audio/mpeg); 10 "Attaching and agent invocation" ("for kind = 'voice' in mode service, the transcript is also done or failed"); 12 row "Message deleted"; 13 "Endpoints and actions" (rows transcribe and transcript); 14 "Observability"; 16 R-FI-09, R-FI-10; 18 voice.*, file.invocation_hold_max.
  • docs/spec/09-agents.md: 2.2 "Features" (row "Voice input", supports_voice, voice_input); 3.4 "Decision per message"; 3.5 "Reasons for rejection at acceptance" (row voice_unsupported); 4.6 "Run context" (attachments with transcript; "The transcript of a voice message, in contrast, carries the trust mark of its message"); 4.7 row "Deliver transcript"; 13 "Voice messages" completely; 16.2 AG-20 and AG-25 and the closing paragraph (deferred parts, M2-31); 19 R-AG-16; 21.
  • docs/spec/03-data-model.md (core contract, read only): 4.6 messages (kind with voice, has_attachments); 4.7 files (mime_type, width, height, duration_ms, transcript_status, transcript_text, transcript_language, transcript_source with R-DM-48: the derived ETag of a transcript, no version column, deleted_at, search_vector), message_attachments; 4.8 runs (invocation_held_until, R-DM-44: "acceptance plus file.invocation_hold_max or voice.transcript_hold_max", and R-DM-47: also set while the server transcript of a triggering voice message is pending); 6.3 "Lock order"; the rows R-DM-47 and R-DM-48.
  • docs/spec/04-sync-protocol.md (core contract, read only): 2.2 "Envelope" (rules 3 and 4: no content beyond the schema); 15.3 "Invocation and run start" point 1 ("or if it is a voice message whose transcript the server creates").
  • docs/spec/05-actions.md (core contract, read only): 2.2 "Target of a resource" (rows file); 2.4 (column Audit); 4.7 row message.create; 4.8 rows file.read, file.transcribe, file.transcript.submit; 6 row file.updated.
  • docs/spec/02-architecture.md: 2 table "Context" (row "Transcription service": "receives audio only when the switch voice.enabled is on"); 4.3 row files (Transcripts, FileFinalized "scan and, if applicable, transcript final"); 4.4 "Allowed dependencies" (rows files, messaging, runs, "Tiers"); 5 "Layers within a module"; 8.3; 8.5 "What never happens in a transaction"; 12.3 "Job catalogue" (rows files.transcribe, files.thumbnail, runs.invocation_hold_timeout); 12.4 "Rules for jobs" (rules 1 to 5); 14.4 "Failure of a dependency" (row "transcription service").
  • docs/spec/06-api.md: 5 "Errors" (points 3, 5 and 7); 7 "Idempotency"; 8 "Concurrency" (points 1 to 3); 10 point 5 (voice messages); 16 "Endpoints" (rows getTranscript, requestTranscription, submitTranscript).
  • docs/spec/07-permissions.md: 4.1 "Decisions and reasons"; 4.3 "Visibility"; 7.4 "Query pattern" rules 1 and 3; 11.2 AZ-P1, AZ-P4, AZ-P12.
  • docs/spec/19-security.md: SEC-UP-5, SEC-NET-1, SEC-NET-2, SEC-NET-3, SEC-LOG-4, SEC-AI-1, 14 "Tests" (SEC-T09, SEC-T10). docs/spec/20-privacy.md 1 (row "Processor": transcription service). docs/spec/14-search.md 2 (transcript in files.search_vector, weight B). docs/spec/15-client-architecture.md 13 "Voice recording". docs/spec/16-operations.md 6.2 (option flags).
  • docs/api/openapi.yaml: operations createMessage, createThread, getFile, requestTranscription, submitTranscript, getTranscript (M3-05), downloadFile (M3-05), createUpload, uploadPart, completeUpload, getRunContext (M2-13), sendHeartbeat (M2-20), pollAgentEvents, listEvents, updateAgent, putFeatureFlag; schemas MessageCreate (kind), MessageKind, Message, Attachment, File, FilePayload, FileUpdatedEvent, TranscriptSubmit, Transcript, TranscriptStatus, TranscriptSource, RunContextAttachment, AgentInvocationEvent, Problem (x-problem-codes: validation_failed, forbidden, feature_disabled, not_found, conflict, file_not_ready, file_blocked, version_conflict), FieldError; parameters FileIdPath, IdempotencyKey, IfMatchRequired; header ETag; responses Conflict, PreconditionFailed, PreconditionRequired. docs/api/asyncapi.yaml: messages event.file.updated, event.agent.invocation.
  • ADRs 0008, 0010, 0011, 0017, 0023, 0032, 0033, 0048, 0050, 0053, 0054, 0064.
  • docs/DECISIONS.md: D-AG-15, D-FI-09, D-K-D12, D-K-G6, D-W-30, D-W-100, D-W-104 (R-DM-44 and the other amendments), D-W-106, D-W-107, D-W-108 (R-DM-47, R-DM-48 and the dependencies of this ticket), section 10 (format of the rows D-M3-09-<n>).
  • docs/security/threat-model.md: asset A2 "Files, transcripts", T-AI-03 (a transcript as injection source), T-NW-01 (SSRF), T-UP-01. No item of docs/RISKS.md or docs/OPEN-QUESTIONS.md names this ticket.
  • docs/operations/configuration.md: section 4 rows voice.max_duration, voice.transcription_mode, voice.transcription_url, voice.transcription_api_key_ref, voice.transcription_timeout, voice.transcript_hold_max, file.invocation_hold_max, egress.allowed_hosts, egress.timeout, egress.max_response_bytes, database.statement_timeout; section 7 row voice.enabled.
  • Briefs (merged names win, D-W-100): M3-07-attachments-invocation.md (port files.Attachments, AttachCmd, fileResource, recordFileChange, domain.BecameFinal, invokeOrHold, domain.HoldUntil, ReleaseInvocation, runs.invocation_hold_timeout), M3-06-image-metadata-thumbnails.md (Design notes 1, 3 e, 10 d and 11: files.thumbnail, domain.HasContainerMetadata, the box reader adapters/isobmff, T3), M3-04-clamd-scan.md (outcome write, domain.Final, domain.Deliverable, clamdtest), M3-03-upload-sessions.md (port Files, File mapping, app/events.go, filestest), M2-04-trigger-decision.md (Design notes 2 to 6: AgentFacts.VoiceInput, MessageTrigger.Voice, VoiceMode, Decision.Transcript, D-M2-04-2), M2-05-runs-state-machine.md (Design note 6: OnMessagePosted steps 1 to 8), M2-08-invocation-delivery.md (Design note 10 f), M2-11-run-followups.md (Design note 8 c, Design note 15: agents.EffectiveCapabilities, agentstest), M2-09-run-lifecycle.md, M1-56-messaging-send.md (D2, the test TestCreateMessage_NotEnabledMembers400), M1-47-feature-flags.md (instance.Flags, FlagSubject, FlagVoiceEnabled), M1-28-authz-core.md (requirement atoms, the "Permission" text mapping, deferred entries, TestDeferredActionsGolden), M1-26-identity-principals.md (identity.Directory.User, UserView.Locale, kernel/textcheck), M1-22-egress-client.md (egress.New, Options, HostPolicy, BlockedError, ErrResponseTooLarge), M1-18-http-chain.md (FormatETag, ParseIfMatch, validation codes), M1-14-background-jobs.md (jobs.Job, HandlerOptions, Inserter.InsertTx), M1-09-secret-provider.md, M1-70-leak-tests.md, M1-69-canary-test.md, M2-13-run-context.md (requirement 18, RunContextAttachment, ReadRunContext), M2-20-heartbeat-presence.md (sendHeartbeat, capabilities.voice_input), M2-31-agent-conformance-suite.md (Design notes 3, 5, 7 and 13: the scenario format, the Fake-Agent ops, run_jobs, testdata/deferred.golden; Tests rows AG-20 and AG-25), M3-05-file-download.md (Design note 4: getTranscript, the port method Transcript with its TranscriptView; downloadFile), M3-07-attachments-invocation.md Out of scope (the bullet on the attachment part of AG-20, which this ticket closes).
  • Code (all under services/server/): internal/files (port, domain, app with attach.go, filechange.go, events.go, get.go, the image and container job of M3-06, adapters/postgres, adapters/jobs, adapters/isobmff, adapters/httpapi, adapters/filestest), internal/messaging (create, body rules, MessagePosted), internal/runs (app/accept.go, app/hold.go, app/runcontext.go, domain/trigger.go, domain/hold.go), the transcript read of M3-05 (internal/files/app/transcript.go, adapters/httpapi/download.go), internal/agents (port, EffectiveCapabilities, agentstest), internal/authz, internal/instance (flags), internal/identity (port Directory), internal/kernel/authorize, internal/platform/egress, internal/platform/jobs, internal/platform/httpserver, internal/wire, architecture/modules.yaml, api/operations.yaml, test/leak, test/canary, test/agents (scenarios, conductor, testdata/deferred.golden). At the repository root: tools/lint/sql_visibility.yaml (M1-70). Outside the server: sdk/python/src/bothy_sdk/testing (the Fake-Agent of M2-31).

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
  4. Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. Limits, windows and thresholds only through named configuration keys listed in docs/operations/configuration.md; code comments and docs name the key, never the number.
  5. Specification first: a change to behaviour, API, event, right or configuration updates docs/spec/ or docs/api/ in the same change. The core contract (docs/spec/00-glossary.md, 03-data-model.md, 04-sync-protocol.md, 05-actions.md) is not edited by this ticket.

Product requirements implemented here:

  1. A voice message is a message with kind = 'voice' and exactly one attachment (12 8.1). createMessage and createThread (first_message) accept MessageCreate.kind = voice and no longer answer 400 validation_failed with errors[].code not_enabled for it (06 5 point 3, M1-56 D2); card keeps that answer.
  2. Formats (12 8.1): AAC-LC in an MP4 container (audio/mp4, extension .m4a) for every author; audio/mpeg (MP3) in addition for agents. Any other format gives 400 validation_failed with errors[].code voice_format_unsupported; the same file can still be sent as a normal attachment.
  3. Duration (12 8.1): the server reads it for MP4 from mvhd (timescale and duration) with its own box reader in the worker, never in the API process (SEC-UP-5, 12 7.3); above voice.max_duration the answer is 400 validation_failed with errors[].code voice_too_long.
  4. Transcription mode voice.transcription_mode with off, agent (default) and service (12 8.2, R-FI-10, D-FI-09). Mode agent: the operator agent of the thread stores the transcript with submitTranscript (file.transcript.submit, "agent-self: operator of the thread whose message the file is attached to, and read", rl.agent_runtime, If-Match), transcript_source = 'agent' (05 4.8, 09 4.7, 09 13).
  5. Mode service: after the virus scan the worker calls the port Transcriber with the adapter openai_compatible at voice.transcription_url, the key from the secret provider (voice.transcription_api_key_ref), the time limit voice.transcription_timeout and a language hint from users.locale; transcript_source = 'service' (12 8.2, 09 13). Audio leaves the server only while voice.enabled is on (02 2) and only to the configured address, never to an address from user data, and only through the egress client (SEC-NET-1, SEC-NET-2, T-NW-01).
  6. requestTranscription (file.transcribe, write, rl.upload, Idempotency-Key) starts the job in mode service; with voice.enabled off the answer is 403 feature_disabled; in the other modes 409 conflict with reason transcription_unavailable (12 8.2, 06 5 point 5).
  7. Result: transcript_status pending to done or failed with transcript_text and transcript_language; every change of the transcript writes the durable event file.updated (receive action file.read) without the text (12 2, 12 8.2, 05 6, 04 2.2). The transcript is searchable through files.search_vector (03 5, 14 2) and visible to everybody with file.read (09 13).
  8. Routing (09 13, R-AG-16): mode agent with the effective capability voice_input of the candidate (owner permission supports_voice and adapter report, 09 2.2), or mode service with voice.enabled on for the author, is a transcription path; otherwise a triggering voice message is rejected at acceptance with trigger_state = failed and voice_unsupported (09 3.5, AG-25).
  9. In mode service the agent.invocation of a voice message is written only when the transcript is done or failed, at the latest after voice.transcript_hold_max, exactly once, with the marker runs.invocation_held_until (04 15.3 point 1, 03 R-DM-44 and R-DM-47, 12 10, D-K-G6).
  10. A service failure is retried by the job, then the transcript is failed (02 14.4 row "transcription service").
  11. An agent that may not see the file gets the same 404 as for an unknown file id (PLAN row; 12 1 point 2, AZ-P4, ADR 0017).
  12. Transcripts and file names never appear in logs, spans, metrics, audit details, problem bodies or events (SEC-LOG-4, 12 14, 20 "No contents", SEC-T10); no JSON null (ADR 0053).
  13. No call to the object store or the transcription service inside a database transaction (02 8.5); every job run repeats without double effect (02 12.4 rule 1).
  14. The deferred conformance lines AG-20 attachment M3-07, AG-20 transcript M3-09 and AG-25 voice M3-09 of M2-31 are closed: their parts run in the conformance suite and the lines leave testdata/deferred.golden (PLAN row M3-09, D-W-108, 09 16.2).

Scope

All paths under services/server/ unless they start with docs/, sdk/ or tools/. Where M3-03, M3-04, M3-05, M3-06, M3-07, M2-04, M2-05, M2-08, M2-11, M2-13, M2-31 or M1-56 already created a file named below, extend it. No migration: every column this ticket writes (files.duration_ms, transcript_status, transcript_text, transcript_language, transcript_source, runs.invocation_held_until) exists since M3-03 and M3-07.

Create:

  • internal/files/transcripts.go (port): Transcripts, SubmitCmd, TranscriptView, Mode with ModeOff, ModeAgent, ModeService (Design note 3).
  • internal/files/domain/voice.go: IsVoiceFormat(mimeType string, authorKind string) bool, DeferContainerComplete(mimeType string) bool, VoiceCheck(VoiceFacts) VoiceVerdict, InvocationFinal(StateFacts) bool, TranscriptETag(TranscriptFacts) string, LanguageHint(locale string) string, ValidLanguageTag(s string) bool (Design notes 4, 5, 8 and 10), standard library and kernel-core only.
  • internal/files/app/transcripts.go (the port methods Mode, Request, Submit), internal/files/app/transcribe.go (the use case of the job, Design note 9), the outgoing ports Transcriber and VoiceSwitch in internal/files/app/ports.go (or the file where M3-03 declared the outgoing ports of files).
  • internal/files/adapters/transcription/openai_compatible.go: the adapter openai_compatible of Transcriber over the egress client (Design note 11); internal/files/adapters/transcription/transcriptiontest/: the fake transcription service for tests (an httptest server on 127.0.0.1 port 0 that records requests and answers per test).
  • The job files.transcribe: args TranscribeArgs{FileID ids.ID}, handler and scheduler, in the adapter package where M3-04 put files.scan (for example internal/files/adapters/jobs/transcribe.go).
  • internal/files/adapters/httpapi/transcripts.go: the strict-server methods RequestTranscription and SubmitTranscript.
  • internal/files/adapters/postgres/queries/transcripts.sql and the regenerated sqlc code.
  • internal/runs/domain/hold.go additions: HoldWindow(transcriptOnly bool, fileMax, voiceMax time.Duration) time.Duration (Design note 13; app maps the files.PendingReason of the port to the boolean, so the domain stays free of other modules).

Change:

  • internal/files: the port Attachments (AttachCmd.Voice, AttachCmd.AuthorKind, Pending returns PendingReason, Design notes 6 and 13), domain (StateFacts gets TranscriptStatus; BecameFinal uses InvocationFinal, Design note 10), app/attach.go (voice checks and the service start, Design note 6), app/filechange.go (recordFileChange covers the transcript columns), app/get.go (ETag of getFile, Design note 8), app/transcript.go and adapters/httpapi/download.go of M3-05 (TranscriptView.ETag and the ETag header of getTranscript, Design notes 3 and 8), the scan outcome of M3-04 and the container phase of M3-06 (Design note 5), adapters/isobmff only if ReadMovieInfo does not yet return the duration of an audio-only file.
  • internal/messaging: domain body rules for kind = voice (Design note 7), the create command (Kind passed to Attach as Voice, the author kind), the M1-56 test that asserts not_enabled for kind voice.
  • internal/runs: the acceptance subscriber OnMessagePosted (voice messages, the facts Voice, VoiceMode, VoiceInput, Design note 12), invokeOrHold and ReleaseInvocation of M3-07 (the window and the verdict use PendingReason, Design note 13), the follow-on run of M2-11 where it calls invokeOrHold; the run context of M2-13 (app/runcontext.go: RunContextAttachment.transcript with the trust of its message, Design note 12 d).
  • internal/agents: Capabilities of EffectiveCapabilities (M2-11 Design note 15) gets VoiceInput bool (permission supports_voice and report voice_input); agentstest sets the report.
  • internal/authz: the requirement of file.transcript.submit replaces its deferred entry (Design note 14); remove it from the deferred golden list of M1-28.
  • internal/wire: the Transcripts port for runs and the HTTP adapter; the Transcriber adapter with its egress client; the VoiceSwitch binding (Design note 15); the job files.transcribe (queue files, roles worker and all); the configuration keys of Design note 16 in configkeys_files.go (or the file of the voice.* keys) and the cross-check voice_service_url in configchecks.go; regenerate architecture/config-schema.json (go generate ./tools/configschema), testdata/txn_registrations.golden and the jobs golden file if one exists.
  • api/operations.yaml: move requestTranscription and submitTranscript from later to enabled; rerun the route generator.
  • test/leak: TestAZ_P4_RouteCoverage covers both new routes with the file_id fixture of M3-03; test/canary: the scenario drives both new operations with a canary transcript (Design note 17).
  • tools/lint/sql_visibility.yaml: the exceptions of Design note 17 (M1-70).
  • test/agents (M2-31, Design note 20): the scenario files scenarios/ag-20.yaml (cases attachment and transcript) and scenarios/ag-25.yaml (the case of 09 16.2), their deferred entries removed; the lines AG-20 attachment M3-07, AG-20 transcript M3-09 and AG-25 voice M3-09 removed from testdata/deferred.golden; the conductor additions of Design note 20 (the run_jobs kinds of the file pipeline, the upload body, the checks of the file and its transcript); test/agents/README.md.
  • sdk/python/src/bothy_sdk/testing/fake_agent.py and control.py (the Fake-Agent ops of Design note 20), sdk/python/tests/testing/test_fake_agent.py and test_control.py, sdk/python/README.md (section "Test doubles"), sdk/python/CHANGELOG.md under "Unreleased".
  • docs/spec/12-files.md 2, 8.1, 8.2, 10 and 18; docs/spec/09-agents.md 13 and the closing paragraph of 16.2; docs/spec/02-architecture.md 4.3 row files and 12.3 row files.transcribe; docs/spec/06-api.md 10 point 5; docs/api/openapi.yaml (Design note 18); docs/operations/configuration.md regenerated (make config-reference).
  • READMEs of files, messaging, runs, agents, authz; services/server/CHANGELOG.md under "Unreleased"; docs/DECISIONS.md section 10 rows D-M3-09-<n> (Design note 19).

This ticket adds no action, no event type, no metric and no top-level Makefile target. It introduces exactly two configuration keys, voice.transcription_model and voice.transcription_max_attempts, with their rows in 12 18 in the same change (Design note 16).

Out of scope

  • Download, getTranscript, fetch tickets and the Retry-After header of 409 file_not_ready (M3-05); this ticket only adds the ETag of Design note 8 to getTranscript.
  • Transcoding and the variant playback (12 8.3, the port MediaTranscoder): "later"; other audio formats stay normal attachments.
  • Reading the duration of MP3 files: 12 8.1 lets the server take the client's figure for display, but no request field carries it (spec gap reported); MP3 voice messages have no duration_ms.
  • Checking the AAC profile inside the MP4 (esds object type): the server checks the container, the type and the duration only (Design note 4).
  • Recording, playback and the transcript view in the apps (M4-22, M5-07).
  • The heartbeat report capabilities.voice_input itself (M2-20): this ticket only reads it through agents.EffectiveCapabilities.
  • The other parts of the conformance suite and the line AG-01 origin=import M8-03 of testdata/deferred.golden (M2-31, M8-03).
  • Voice messages from imports (OGG or Opus, M8) and the Hermes adapter's send_voice (plan gap of M2-33).
  • Holding agent.followup of a voice follow-up until its transcript is final: not held, as M3-07 Design note 11 e rules for attachments.
  • Deleting transcripts with their file (M3-08, which removes the whole row, R-DM-46); search over transcripts (M3-14 reads files.search_vector, which already contains the transcript); text extraction (M3-15 never extracts transcripts).
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M3-09-voice-transcript; one writer per worktree.
  • Conventional Commits with the module as scope (for example feat(files): ..., feat(runs): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through pgtest (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d or t.TempDir(), removed at the end.
  • Never read or print secrets. Never log, print or persist an invocation token or a transcription key. No network calls except package downloads for pinned tools and Go modules; the transcription service in tests is only the fake of transcriptiontest on loopback, reached through the egress client with an egress.allowed_hosts entry 127.0.0.1:<port>;allow_private in the test configuration. No real transcription service, no model call, no agent runtime.
  • Never lower a threshold, disable a gate or delete a test.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11). The marker of a held invocation (runs.invocation_held_until, R-DM-44, also for a pending server transcript by R-DM-47) and the transcript columns of files with their derived ETag (R-DM-48) are core contract; build on them as written (Design notes 8 and 13).
  • This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 or M2 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.
  • The same holds for the merged code of M3-01, M3-03, M3-04, M3-05, M3-06 and M3-07 and of the conformance suite of M2-31 (scenario keys, step kinds, Fake-Agent ops): follow their names (Attachments, AttachCmd, recordFileChange, BecameFinal, invokeOrHold, ReleaseInvocation, the box reader, the scan outcome, fileResource) and list each difference.
  • clamd runs in tests only as the fake clamd server of clamdtest on a Unix socket in a mktemp -d directory, or with file.scan.mode = off where the test does not concern the scan; stop only containers you started, by id.
  • The conformance suite runs the Fake-Agent through uv as M2-31 does; a missing Docker-compatible runtime or a missing uv fails it, never skips it (P-09).
  • Test audio is generated by the tests (a minimal MP4 box tree with ftyp brand M4A, moov, mvhd with chosen timescale and duration, one trak with hdlr soun and an mp4a sample entry; a few MPEG audio frame headers for MP3); no recorded speech, no files from the internet.
  • Time only through kernel/clock; tests move a fake clock and call job handlers directly, they never sleep for a window.
  • The SQL visibility lint of M1-70 is on the base branch: every new reading query on a content table carries the visibility predicate or an exception entry with category and reason (Design note 17); never widen an existing exception.

Design notes

  1. Dependencies and placement (02 4.3, 4.4; ADR 0008, ADR 0050). Everything about the audio and the transcript lives in files (02 4.3 row files: port Transcripts, event FileFinalized "scan and, if applicable, transcript final"); messaging passes the kind of the message to files.Attachments.Attach (edge messaging -> files exists); runs asks files for the effective mode and the pending reason (edge runs -> files exists since M3-07) and agents for voice_input. No new module edge: files reads voice.enabled through its outgoing port VoiceSwitch (note 15) and the locale through identity.Directory (edge files -> identity exists). The transcription adapter is an adapter of files and uses platform/egress (02 4.4 "platform only in adapters"). Ruling for docs/DECISIONS.md: the switch is an outgoing port bound in internal/wire, not an edge files -> instance, because only the composition root needs to know where flags live (note 15).

  2. Effective mode (12 8.2, 02 2, 06 5 point 5). Transcripts.Mode(ctx, subjectID, subjectKind) returns voice.transcription_mode, except that service is returned as off while VoiceSwitch.Enabled is false for the subject. Ruling for docs/DECISIONS.md: the subject is the author of the voice message (for routing and the automatic start), the caller (for requestTranscription) and the uploader (in the job); reason: the flag supports a rollout to people and groups (M1-47), and audio may leave only while the switch is on for the person whose voice it is (02 2 row "Transcription service"). Mode agent and off do not depend on the switch.

  3. Ports (doc comments say every method runs inside the caller's unit of work where it writes, and that Request and Submit authorize themselves):

    // Mode is the effective transcription mode (12 section 8.2).
    type Mode string // ModeOff "off", ModeAgent "agent", ModeService "service"
    
    // Transcripts answers the transcription mode and stores transcripts (12 8.2, 09 13).
    type Transcripts interface {
        Mode(ctx context.Context, subjectID ids.ID, subjectKind string) (Mode, error)
        Request(ctx context.Context, a actor.Actor, fileID ids.ID) (TranscriptView, error)
        Submit(ctx context.Context, a actor.Actor, cmd SubmitCmd) (TranscriptView, error)
    }
    type SubmitCmd struct { FileID ids.ID; IfMatch, Text, Language string }
    type TranscriptView struct { FileID ids.ID; Status, Text, Language, Source, ETag string }
    
    // PendingReason tells why the attachments of a message are not final (12 2, 12 10).
    type PendingReason uint8 // PendingNone, PendingFiles, PendingTranscript
    

    TranscriptView is the type of M3-05's port method Transcript (getTranscript), extended with ETag; do not add a second one. AttachCmd gets Voice bool (the message has kind = voice) and AuthorKind string (user, agent, app). Attachments.Pending(ctx, messageID) returns (PendingReason, error) instead of (bool, error): PendingFiles when an attachment is not final in the sense of M3-04's domain.Final, else PendingTranscript when an attachment has transcript_status = 'pending', else PendingNone; adapt the callers of M3-07 (invokeOrHold, ReleaseInvocation, the follow-on run).

  4. Voice formats and the voice check (12 8.1, 12 7.3; Ruling for docs/DECISIONS.md for b to d).

    a. domain.IsVoiceFormat(mimeType, authorKind): true for the detected names that M3-06's domain.HasContainerMetadata treats as MP4 audio (the pinned type detector of M3-04 reports an .m4a with brand M4A and an MP4 whose only track is audio; pin the exact names in a table test with generated fixtures), and additionally for audio/mpeg when authorKind is agent. People and apps send only MP4 audio (12 8.1 "audio/mpeg (MP3) is also accepted for agents").

    b. domain.VoiceCheck(VoiceFacts{UploadState, ScanStatus, MimeType, AuthorKind, HasWidth, HasHeight, DurationMs, HasDuration, MaxDuration}) returns the first failing verdict in this order: not final (upload_state or scan_status still pending) gives NotReady; final but not deliverable gives Blocked; not a voice format gives FormatUnsupported; an MP4 with width or height (it has a video track) or without a duration (the box reader found no mvhd duration) gives FormatUnsupported; a duration above voice.max_duration gives TooLong (equal passes); otherwise OK. MP3 has no duration and passes the duration rule (Out of scope; spec gap).

    c. The codec inside the MP4 (AAC-LC) is not verified: the API process must not parse audio (SEC-UP-5) and files stores no codec fact; a wrong codec only fails playback on a device. The type, the absence of a video track and the duration are checked, which is what 12 8.1 requires the server to read.

    d. The file must be deliverable at send time. Reason: the detected type and the duration are written by the worker after the read pass (12 3.4, 12 7.3), and the plan row demands a synchronous 400 validation_failed; a client keeps the voice message in its outbox until file.updated reports the file ready (15 13).

  5. MP4 audio is complete only after the container phase (change to M3-06 Design note 1 a and 10 d; Ruling for docs/DECISIONS.md). domain.DeferContainerComplete(mimeType) is true exactly for the MP4 audio names of note 4 a. For these types the final transaction of files.scan (verdicts clean and skipped) leaves upload_state = 'pending' and inserts files.thumbnail, as M3-06 does for images; the container phase of files.thumbnail then runs on the pending row and its transaction T3 sets duration_ms (when the box reader found one) and upload_state = 'complete' together through recordFileChange, which makes the file final and publishes files.FileFinalized (M3-07 Design note 10 b). A box reader error leaves duration_ms absent and still sets complete, so the file stays usable as a normal attachment. Reason: without this a voice file would be deliverable for a moment without its duration, and a send in that moment would be refused as unsupported. Video and QuickTime keep the order of M3-06. Change the M3-06 test expectation for MP4 audio, never delete it.

  6. Attaching a voice message (Attach with Voice = true, inside the unit of work of createMessage or createThread, after the checks of M3-07 Design note 4 on the locked file row):

    a. M3-07's checks run first, so a foreign, deleted or already attached file answers 409 conflict with reason file_not_attachable as before.

    b. domain.VoiceCheck on the locked row: NotReady gives 409 file_not_ready; Blocked gives 409 file_blocked; FormatUnsupported gives 400 validation_failed with errors[].code voice_format_unsupported; TooLong gives 400 validation_failed with errors[].code voice_too_long; the pointer is <Pointer>/0 (/attachment_file_ids/0, in createThread /first_message/attachment_file_ids/0). The unit of work rolls back, so neither message nor attachment row stays and the thread sequence is given back.

    c. Transcripts.Mode(author): for ModeService set transcript_status = 'pending' through recordFileChange (one file.updated in the scope of the message) and insert TranscribeArgs{FileID} with jobs.Inserter.InsertTx in the same transaction. For ModeAgent and ModeOff the file keeps transcript_status = 'none'.

    d. The message keeps kind = 'voice' (M1-56 writes the column); MessagePosted.Kind is voice, which is what runs reacts to (note 12).

  7. Body rules (messaging/domain, M1-56 D2; Ruling for docs/DECISIONS.md for the codes). kind = voice is no longer not_enabled (card keeps it). A voice message needs exactly one attachment: none gives 400 validation_failed with errors[].code required and pointer /attachment_file_ids, more than one gives errors[].code limit_reached with the same pointer (prefix /first_message in createThread). content_md may be empty, as for every message with an attachment (M3-07 Design note 5 a); a caption is allowed and its mentions trigger as in a text message. components stay card-only.

  8. submitTranscript (Transcripts.Submit; 05 4.8, 09 4.7, 09 13; Ruling for docs/DECISIONS.md for the ETag and the checks).

    a. Load the file by id with deleted_at IS NULL and its attachment row; authorize(actor, file.transcript.submit, fileResource(f, ref)) (M3-07 Design note 9). Unknown, foreign and invisible files give the same 404 (AZ-P4); a visible caller who is not the operator agent of the thread gets the denial of the requirement (note 14).

    b. voice.transcription_mode other than agent gives 409 conflict with reason transcription_unavailable (the same reason as 12 8.2 for file.transcribe; a transcript of the agent would overwrite the service or contradict mode off).

    c. Lock the file row FOR UPDATE. Not deliverable: 409 file_not_ready or 409 file_blocked by M3-04's domain.Final; not a voice format (IsVoiceFormat with the uploader's kind) gives 409 conflict with reason transcription_unavailable.

    d. Precondition: files has no version column, and the transcript's ETag is derived (03 R-DM-48, 06 8 point 1): domain.TranscriptETag is the strong ETag "<first 22 characters of the base64url SHA-256>" over transcript_status, transcript_source, transcript_language and transcript_text, each followed by one zero byte (the same form as the ETag of getInstance, M1-46). The operation uses IfMatchRequired: a missing or * If-Match gives 428 precondition_required in StagePreconditions (M1-18); a value that is not exactly the current ETag gives 412 version_conflict with current (the Transcript view) and the current ETag header. Compare the raw header value, because ParseIfMatch accepts only decimal versions. getFile and getTranscript (M3-05) send the same value in the header ETag.

    e. Body: text must not contain U+0000 (errors[].code invalid, pointer /text) and must not exceed message.max_content_bytes in UTF-8 bytes (too_long); an empty text is allowed (a silent recording). language is optional and must pass domain.ValidLanguageTag (a BCP 47 tag of the form primary subtag of two or three letters, then subtags of two to eight letters or digits; pattern_mismatch through the schema pattern of note 18).

    f. Write transcript_status = 'done', transcript_text, transcript_language (absent when not sent), transcript_source = 'agent' through recordFileChange (one file.updated without text); answer 200 with the Transcript view (text, language, source only when done, no JSON null) and the new ETag. A later submit with the new ETag replaces the transcript (a correction). No audit entry (05 4.8 column Audit "no").

  9. requestTranscription and the job files.transcribe (12 8.2, 02 12.3 row files.transcribe, 02 12.4, 02 14.4 row "transcription service"; Ruling for docs/DECISIONS.md for the states, the failure rule and the language).

    a. Request: load and authorize(actor, file.transcribe, fileResource(...)) (unknown or invisible: 404); VoiceSwitch off for the caller gives 403 feature_disabled (the operation's description); voice.transcription_mode other than service gives 409 conflict with reason transcription_unavailable; lock the row; not deliverable gives 409 file_not_ready or 409 file_blocked; not a voice format gives 409 conflict with reason transcription_unavailable. Status none or failed: set pending through recordFileChange and insert the job (InsertTx); status pending or done: change nothing. Answer 202 with the Transcript view. The idempotency stage of M1-20 replays a repeated Idempotency-Key.

    b. Job args TranscribeArgs{FileID ids.ID}, kind files.transcribe, queue files, HandlerOptions.MaxAttempts = voice.transcription_max_attempts, River's default backoff. One run: read the row outside a transaction; return nil when it is gone, deleted or not pending (02 12.4 rule 1). When Mode(uploader) is no longer service or the file is not deliverable, write failed (step e) and return nil. Otherwise read the locale with identity.Directory.User(uploaded_by) (a principal that is not a person has no locale) and compute domain.LanguageHint(locale) (the primary subtag in lower case, empty when invalid); open the object with ObjectStore.Get and call Transcriber.Transcribe outside any transaction (02 8.5).

    c. Success: one unit of work locks the row and requires pending, deleted_at IS NULL and the same object_key as read; otherwise it writes nothing and returns nil. It sets done, transcript_text, transcript_language (the response's language when ValidLanguageTag, else the hint, else absent) and transcript_source = 'service' through recordFileChange, which publishes files.FileFinalized (note 10), so the held invocation is released in the same transaction (M3-07 Design note 13 a).

    d. Errors: ErrTranscriptionPermanent from the adapter writes failed at once; any other error returns the error so River retries, and on the last attempt (job.Attempt >= voice.transcription_max_attempts) writes failed instead. A database error never writes failed.

    e. failed: lock the row as in c, set transcript_status = 'failed' with transcript_text, transcript_language and transcript_source absent, through recordFileChange (it publishes FileFinalized as well, so the held invocation goes out with the transcript failed).

  10. Final for the invocation (12 2 "final", 12 10; M3-04 Design note 6, M3-07 Design note 10). domain.InvocationFinal(f) = domain.Final(f) && f.TranscriptStatus != "pending"; StateFacts gets TranscriptStatus. domain.BecameFinal(before, after) (M3-07) compares InvocationFinal, so recordFileChange publishes FileFinalized exactly when the last condition for an invocation is met: the scan result, the container phase of note 5, or the end of a pending transcript. domain.Final and domain.Deliverable keep their meaning for delivery, so a deliverable file with a pending transcript is still served (M3-05 maps only Final and Deliverable). Ruling for docs/DECISIONS.md: one function decides "final for the invocation", and setting pending on a final file publishes nothing.

  11. Adapter openai_compatible (12 8.2, 09 13, SEC-NET-1 to SEC-NET-3, T-NW-01; Ruling for docs/DECISIONS.md for the request form and the error classes).

    a. One egress.Client built by internal/wire with Options{Purpose: "transcription", Hosts: <exactly scheme, host and port of voice.transcription_url>, Redirects: RedirectNever, Timeout: voice.transcription_timeout} and MaxResponseBytes from egress.max_response_bytes. A self-hosted service on a private address needs an operator entry host[:port];allow_private in egress.allowed_hosts (M1-22 Design note 4); say so in 12 8.2. The URL is instance configuration only; no request field, file name or user value ever reaches the request line.

    b. Request: POST <voice.transcription_url> with multipart/form-data, streamed from the object through an io.Pipe (no spool file): part file with the file name audio.m4a or audio.mp3 (never original_name) and the stored mime_type; model only when voice.transcription_model is set; language only when the hint is not empty; response_format = json. Authorization: Bearer <key> only when voice.transcription_api_key_ref is set, the key resolved per call through secrets.Provider.Resolve (cached by secrets.cache_ttl, M1-09) and held in the redacting secret type.

    c. Response 200 with a JSON object: text (string, required), language (string, optional); other members are ignored. text must be valid UTF-8 without U+0000 and at most message.max_content_bytes bytes.

    d. Error classes: transient are timeouts, connection errors, 408, 429 and 5xx; permanent (ErrTranscriptionPermanent) are egress.BlockedError, egress.ErrResponseTooLarge, every 3xx (never followed), every other 4xx, a malformed or invalid body. Log one Warn line per failed call with bothy.file.id, the class and the status code; never the URL path or query, the key, a response body or text.

  12. Routing at acceptance (09 3.4, 3.5, 13; M2-04 Design notes 2 to 6; M2-05 Design note 6; M2-08 Design note 10).

    a. runs.OnMessagePosted accepts Origin = human with Kind text or voice (M2-05 step 1 and M2-08 note 10 a said "voice comes with M3").

    b. For a voice message only: MessageTrigger.Voice = true, VoiceMode from files.Transcripts.Mode(author), and VoiceInput of every AgentFacts built for the decision (thread operator, channel operator, mentioned agents) from agents.EffectiveCapabilities(agent).VoiceInput. A text message reads neither, so the text path costs no extra statement.

    c. The decision function decides (M2-04 note 6, D-M2-04-2): without a transcription path reject with voice_unsupported, which the merged mapping of M2-08 note 10 d turns into trigger_state = failed with that reason and no audit entry (a decision-function rejection); with a path, Decision.Transcript is agent or service. agent: the invocation is written at once (the audio is final since note 6 b), the adapter reads the file with file.read and calls submitTranscript. service: the invocation is held (note 13).

    d. In the run context of M2-13, RunContextAttachment.transcript is transcript_text when transcript_status = 'done' (absent otherwise) and the element keeps the trust of its message (09 4.6, ADR 0054 point 4); ForMessages carries the text internally, the API File never does. The AG-25 case and the AG-20 attachment and transcript cases of the conformance suite follow note 20.

  13. Hold and release (04 15.3 point 1, 03 R-DM-44 and R-DM-47, 12 10; M3-07 Design notes 11 to 13). In invokeOrHold: reason := files.Attachments.Pending(messageID) when HasAttachments (and unconditionally on the follow-on run, as M3-07 note 11 d); hold when reason != PendingNone; the window is domain.HoldWindow(reason == PendingTranscript, file.invocation_hold_max, voice.transcript_hold_max), so a voice message waiting for its transcript waits at most voice.transcript_hold_max and one waiting for a scan file.invocation_hold_max. The release time stays fixed at acceptance. ReleaseInvocation with cause ReleaseFinal releases only when Pending is PendingNone; ReleaseTimeout releases with the transcript still pending. Follow-ups (agent.followup) are not held. Ruling for docs/DECISIONS.md: the transcript is a reason to hold only through transcript_status = 'pending', which only mode service sets.

  14. Authorization (05 4.8; M1-28 requirement mapping; 07 4.1). Replace the deferred entry of file.transcript.submit with All(ThreadOperator, Level(read)), evaluated on the file resource of M3-07 Design note 9: an attached file carries ThreadID and ChannelID of its message, so ThreadOperator is the fixed operator of that thread or the channel's effective operator for a main thread; an unattached file has no thread and is visible only to its uploader, so another agent gets 404. A visible caller who is not the operator gets the reason of the merged ThreadOperator atom (subject_kind_not_allowed, 07 4.1) as 403 forbidden. Remove the action from the golden deferred list; TestDeferredActionsGolden and AZ-P3 stay green. file.transcribe keeps the requirement Level(write) of the text mapping (if the merged code lists it as deferred, replace it the same way). Ruling for docs/DECISIONS.md: "foreign agent" of the plan row is an agent that cannot read the file; it gets the same 404 as an unknown id.

  15. VoiceSwitch binding (M1-47). internal/wire binds app.VoiceSwitch to instance.Flags.Enabled(ctx, instance.FlagSubject{PrincipalID, Kind}, instance.FlagVoiceEnabled). The tests switch it with putFeatureFlag by the Owner.

  16. Configuration (12 18; config.Key of M1-07, Chapter = 12-files.md, Default exactly the chapter cell, Doc one English sentence; register each key only if absent): voice.max_duration (duration, positive; M1-46 registered it), voice.transcription_mode (enum off, agent, service), voice.transcription_url (string, empty allowed), voice.transcription_api_key_ref (secret reference, empty means "no key", like push.apns.private_key_ref), voice.transcription_timeout and voice.transcript_hold_max (duration, positive), and the two new keys of this ticket: voice.transcription_model (string, default empty: the request has no model field; an OpenAI-compatible endpoint usually needs it, 12 8.2 names no key for it) and voice.transcription_max_attempts (integer, at least 1, default 5; 02 14.4 says "job retries, then transcript_status = failed" without a number, and rule 9 needs a key). Cross-check voice_service_url: mode service requires voice.transcription_url to be an absolute URL with scheme https or http, a host, no user info and no fragment; the error names the keys, never the value. TestConfigSchemaBuilds lists the keys; regenerate docs/operations/configuration.md; touch tools/configdoc/planned.yaml only if make config-doc-check reports a rule no key matches.

  17. Observability, privacy and gates (12 14, SEC-LOG-4, SEC-T10, 20 "No contents"; M1-69, M1-70).

    • No new metric (docs/observability/signals.md names none for transcription); the span job files.transcribe comes from the M1-14 handler wrapper, blocked egress attempts are counted by bothy.egress.blocked (M1-22). Logs carry bothy.file.id, bothy.message.id, bothy.run.id only; never transcript text, language with text, file name, URL path or key.
    • Events: file.updated carries the File schema, which has transcript_status, transcript_language and transcript_source but no text (04 2.2 rule 4); problem bodies never echo text (06 5 point 3).
    • SQL visibility lint: exceptions with category and reason for the job's read and lock of the file row (system_job), the lock read of Request and Submit after authorize (after_authorize); the load by id reuses the authorize_resolution entry of M3-03 and M3-07; the extended Pending query keeps the entry of M3-07. Writes are not linted. make sql-visibility stays clean.
    • test/leak: TestAZ_P4_RouteCoverage covers requestTranscription and submitTranscript with the file_id fixture (a file of Alex for a requester who is not its uploader); AZ-P1 counts the two routes.
    • test/canary: the scenario sends a voice message, submits a transcript whose text is a canary of class content and lets the fake service return a second canary; neither appears in logs, traces, metrics, audit rows, event_log payloads or problem bodies (M1-69 allow matrix: database and authorized response bodies only).
  18. Specification and API (rule 10; nothing in 00, 03, 04 or 05 changes; every new sentence in English, existing text stays as it is; YAML in English):

    • docs/spec/12-files.md 2: for the invocation a file with transcript_status = 'pending' is not final; MP4 audio becomes complete with its container values (note 5). 8.1: the checks and answers of notes 4 and 6 in their order, MP3 without duration, codec not verified, the file must be ready. 8.2: effective mode and the switch (note 2), the automatic start at send in mode service, the states of file.transcribe (note 9 a), the rules of file.transcript.submit (note 8), the job's failure rule and voice.transcription_max_attempts, the language hint and result, the request form and allow_private (note 11). 10: the hold window voice.transcript_hold_max for a pending transcript (note 13). 18: rows voice.transcription_model and voice.transcription_max_attempts.
    • docs/spec/09-agents.md 13: VoiceInput from permission and report, the switch for mode service, follow-ups not held, the transcript in the run context with the trust of its message. 16.2 closing paragraph: the parts attachment and transcript of AG-20 and AG-25 are no longer deferred (one English sentence where M2-31's sentence lists them).
    • docs/spec/02-architecture.md 4.3: one English sentence below the table naming the methods of Transcripts (mode, request, submit) and the outgoing ports Transcriber and VoiceSwitch of files; 12.3: leave the row files.transcribe as it is and add one English sentence below the table: the job is also inserted when a voice message is sent in mode service, with at most voice.transcription_max_attempts attempts.
    • docs/spec/06-api.md 10 point 5: one English sentence on the derived transcript ETag and If-Match.
    • docs/api/openapi.yaml: MessageCreate.kind description (voice: exactly one MP4 audio attachment, MP3 for agents, ready file, voice_format_unsupported, voice_too_long); createMessage and createThread descriptions (409 file_not_ready and 409 file_blocked for a voice file not ready); getFile 200 header ETag (the transcript version for submitTranscript); getTranscript 200 header ETag (the same value); submitTranscript: parameter IfMatchRequired, header ETag on 200, responses 409 (Conflict), 412 (PreconditionFailed), 428 (PreconditionRequired), description (operator agent of the thread, mode agent, 404 for invisible files); requestTranscription: description of note 9 a; TranscriptSubmit.language with the pattern of note 8 e. Then make codegen-check, make api-lint, make api-checks, and make api-tables when a generated block of 06 changes.
  19. Rulings for docs/DECISIONS.md (section 10, one row D-M3-09-<n> each, with reason and place): the switch as an outgoing port (note 1); the subject of the switch (note 2); MP3 for agents only and the format names (note 4 a); the order of the voice check and the ready file at send (notes 4 b and 4 d); codec not verified (note 4 c); MP4 audio complete after the container phase (note 5); the automatic start at send (note 6 c); exactly one attachment and its codes (note 7); the derived ETag (note 8 d); mode and format checks of submitTranscript and requestTranscription with transcription_unavailable (notes 8 b, 8 c, 9 a); the states of requestTranscription (note 9 a); the failure rule and the last attempt (note 9 d); language hint and result (note 9 c); InvocationFinal (note 10); request form and error classes (note 11); the hold window by pending reason (note 13); "foreign agent" (note 14); the two new keys (note 16); the conformance cases and the Fake-Agent ops for files (note 20).

  20. Conformance scenarios (09 16.2 AG-20 and AG-25; M2-31 Design notes 3, 5, 7 and 13; M3-07 Out of scope, the AG-20 attachment bullet; PLAN row M3-09, D-W-108). The three deferred parts become cases of the scenario files under services/server/test/agents/scenarios/, in the format of M2-31 Design note 3; their deferred entries and the three lines of testdata/deferred.golden go in the same change, and TestScenarioRegistry_DeferredGolden then holds only AG-01 origin=import M8-03.

    a. Conductor additions, each with a unit test next to the existing ones: run_jobs accepts the kinds files.scan, files.thumbnail and files.transcribe (M2-31 Design note 7 lists the kinds used); a human call of uploadPart takes its body from a generated fixture named in with (the MP4 audio of Boundaries, or a small text file), never from a file in the repository; a check of kind file reads upload_state, scan_status, transcript_status and transcript_source through AdminDB, never the text. The scenarios set file.scan.mode = off and voice.transcription_mode in config.

    b. Fake-Agent ops (fake_agent.py, control.py, through the SDK hand layer or, where it has no method, the generated core bothy_sdk.generated): read_file (downloadFile with the agent's token; result status, code and the byte length, never the bytes in a reply); submit_transcript (getFile for the ETag, then submitTranscript with that If-Match, text and language from with; result status, code, reason). The op context reports in addition per element its attachments (file_id, whether transcript is present) and, for every attachment whose content the Fake-Agent read with read_file and every transcript, whether that text occurs only inside a marked data block of the composed prompt (ADR 0054 point 4).

    c. AG-20 attachment (the part M3-07 left, 09 16.2 "file content of the attachment appears only in the marked data block, even when uploaded by the triggering person"): Alex uploads a text file through createUpload, uploadPart and completeUpload, run_jobs files.scan makes it ready, and Alex's triggering message on Support/Eskalation carries it; Lotse receives, reads the context, reads the file with read_file: the attachment is listed on the trigger element, its content occurs only inside a marked data block although the trigger has trust = trigger.

    d. AG-20 transcript (09 16.2 "the transcript carries the trust marker of its message (trigger)"): mode agent, Lotse's runtime reports voice_input through its heartbeat (M2-20) and the Owner sets supports_voice; Alex uploads an .m4a, run_jobs files.scan and run_jobs files.thumbnail make it ready (note 5), and Alex sends it as kind = voice mentioning Lotse; Lotse receives, starts, calls submit_transcript, reads the context again: the trigger element has trust = trigger and its attachment carries transcript, and the transcript occurs only inside a marked block.

    e. AG-25 (09 16.2 "Voice message without a transcription path", 09 3.5): mode agent with a Lotse whose runtime reports no voice_input: Alex's voice message (file made ready as in d) mentioning Lotse is stored with trigger_state = failed and voice_unsupported, and Lotse's next receive returns none.

Tests (write them first)

Test world: 07 10 and 11.1 (space Support with channels Allgemein and Eskalation, operator Lotse in mode organization; Texter operating Marketing without any grant on Support; G-1 Alex trigger with upload on Support, users.locale of Alex en-GB; group Leser with Sam, G-4 read on Support/Allgemein; G-5 Lotse write and upload on Support; Robin without a grant on Support; the Owner). Audio fixtures are generated by the tests (Boundaries). Files are uploaded through the real chain (createUpload, uploadPart, completeUpload, M3-03) and made ready by calling the handlers of files.scan and files.thumbnail directly with file.scan.mode = off, except where a test needs the fake clamd of clamdtest. Lotse's effective voice_input is set per test through the report capabilities.voice_input of sendHeartbeat with Lotse's agent token (M2-20) and the permission supports_voice through updateAgent. The adapter side is pollAgentEvents and plain HTTP with Lotse's agent token (M2-07, M2-09). The fake service of transcriptiontest runs on 127.0.0.1 port 0 with voice.transcription_url pointing to it and egress.allowed_hosts = 127.0.0.1:<port>;allow_private; voice.enabled is switched with putFeatureFlag by the Owner (note 15). The fake clock drives voice.transcript_hold_max and file.invocation_hold_max (small values in the test configuration). Payloads and responses validate against their schemas and contain no JSON null.

Unit:

  • internal/files/domain: TestIsVoiceFormat (the detected names of the generated .m4a and audio-only MP4 for user, agent and app; audio/mpeg only for agent; audio/wav, audio/ogg, video/mp4, application/pdf never); TestVoiceCheck (one row per verdict and the order of note 4 b: pending, infected, error, wrong type, MP4 with width, MP4 without duration, duration equal to and one millisecond above voice.max_duration, MP3 from an agent without duration passes); TestDeferContainerComplete (MP4 audio names true, video and QuickTime false); TestInvocationFinal and the extended TestBecameFinal (every pair of Final times transcript none, pending, done, failed; pending to done on a final file is true, setting pending publishes nothing); TestTranscriptETag (stable, changes with each of the four fields, the zero separator keeps ("a","bc") and ("ab","c") apart, 22 base64url characters in quotes); TestLanguageHint and TestValidLanguageTag (en-GB gives en, de stays, empty, x, english, en_GB and over-long subtags are rejected).
  • internal/messaging/domain: TestVoiceBodyRules (no attachment gives required, two give limit_reached, pointers /attachment_file_ids and /first_message/attachment_file_ids; empty content with one attachment passes; card still not_enabled).
  • internal/runs/domain: TestHoldWindow (transcript only gives voice.transcript_hold_max, otherwise file.invocation_hold_max).
  • internal/files/adapters/transcription: TestOpenAICompatible_RequestForm (one POST to the configured path; parts file with the name audio.m4a and the stored type, response_format = json; model only with voice.transcription_model; language only with a hint; Authorization only with a key reference, read from a t.TempDir() secret file; the original name never in the request); TestOpenAICompatible_ErrorClasses (table: 200 with text, 200 without text, invalid JSON, text with U+0000, text over message.max_content_bytes, 307 to another loopback server that records no request, 400, 401, 408, 429, 500, 503, a body over egress.max_response_bytes, a hang beyond voice.transcription_timeout: transient or permanent as note 11 d); TestOpenAICompatible_OnlyConfiguredHost (a URL host without an egress.allowed_hosts entry for its loopback address is blocked before any connection, SEC-T09 part transcription); TestOpenAICompatible_LogsNoContent (captured logs of every row contain neither text nor key nor path).
  • internal/authz: TestFileTranscriptSubmitRequirement (fake sources: Lotse as operator of the thread of an attached file with read allows; Texter without read gives not_found; an agent with read that is not the operator gives forbidden with the merged atom reason; Alex gives forbidden; an unattached file of Alex gives not_found for Lotse); TestDeferredActionsGolden without file.transcript.submit.
  • internal/wire: TestVoiceConfigKeys (every key of note 16 with its chapter default; voice_service_url rejects mode service with an empty URL, a relative URL, ftp://, user info and a fragment, and the error names keys only); the golden registrations show the job files.transcribe on queue files.

Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain, M3-01's file system driver in a mktemp -d root):

  • TestVoice_KindVoiceEnabled (plan; replaces the voice row of TestCreateMessage_NotEnabledMembers400, whose card row stays): Alex sends kind = voice with a ready .m4a of a duration below voice.max_duration in Support/Allgemein: 201, kind: voice, one attachment with duration_ms from mvhd; the same through createThread with first_message.
  • TestVoice_FormatUnsupported400 (plan): Alex sends a ready WAV, an MP3 and a PDF each as kind = voice: 400 validation_failed with errors[].code voice_format_unsupported and pointer /attachment_file_ids/0, nothing stored, the thread sequence without a gap; the same files as kind = text attachments give 201; an MP4 with a video track and an MP4 whose mvhd is missing give voice_format_unsupported; Lotse sends the MP3 as kind = voice in its thread: 201 without duration_ms.
  • TestVoice_TooLong400: an .m4a whose mvhd says one second more than voice.max_duration gives 400 validation_failed with errors[].code voice_too_long.
  • TestVoice_NotReadyAndBlocked: with the fake clamd stopped the file is pending and the send answers 409 file_not_ready; an EICAR reply makes it infected and the send answers 409 file_blocked; a foreign file still answers 409 conflict with reason file_not_attachable.
  • TestVoice_BodyRules: kind = voice without attachment and with two attachments gives the codes of note 7.
  • TestAudioMP4CompleteAfterContainerPhase (note 5): after the scan the .m4a row is pending with a queued files.thumbnail; after the container phase it is complete with duration_ms in one transaction, with one file.updated and one files.FileFinalized; a broken box tree ends complete without duration_ms.
  • TestVoice_AG25_NoTranscriptionPath (AG-25, 09 3.5): in mode off; in mode agent with Lotse without effective voice_input (permission off, or report missing); in mode service with voice.enabled off for Alex: Alex's voice message mentioning Lotse is stored with trigger_state = failed and reason voice_unsupported, no run, no agent.invocation, no auto thread, no audit entry, and no request reaches the fake service. A voice message that triggers nobody stays without trigger_state.
  • TestVoice_ModeAgent_SubmitTranscript (09 13, 09 4.7): mode agent, Lotse with voice_input: the voice message is invoked at once (no invocation_held_until); Lotse polls, starts the run, reads the file (getFile with ETag), calls submitTranscript with that If-Match: 200 with status: done, source: agent, the text and a new ETag; exactly one file.updated in the scope of the message whose payload holds transcript_status, transcript_language, transcript_source and no text; Sam receives it through listEvents; files.search_vector matches a word of the transcript (@@ plainto_tsquery('simple', ...) through the pgtest connection); a correction with the new ETag replaces the text.
  • TestSubmitTranscript_ForeignAgent404 (plan; AZ-P4): Texter submits for Alex's voice file and for a random UUIDv7: identical status 404, body and headers after removing Correlation-Id and Date; Alex (a person) gets 403 forbidden; Lotse without If-Match gets 428 precondition_required, with a stale ETag 412 version_conflict with current and ETag; text over message.max_content_bytes gives 400 validation_failed with errors[].code too_long; language english gives pattern_mismatch; in mode service 409 conflict with reason transcription_unavailable; for an image attachment of the same thread 409 conflict with reason transcription_unavailable.
  • TestVoice_ModeService_FakeService (plan "mode service with a fake service reports through file.updated"): mode service, voice.enabled on, auto thread on Allgemein: Alex's voice message mentioning Lotse gives 201 with the file transcript_status: pending, one file.updated with pending, one queued files.transcribe, the run queued with invocation_held_until = the fake clock at the send plus voice.transcript_hold_max and no agent.invocation. The job runs: the fake service got exactly one request with language en and the .m4a bytes; in one transaction transcript_status = done, transcript_source = service, one file.updated with done (no text), and exactly one agent.invocation, the column NULL; Lotse's next poll delivers it; the hold job after the window writes nothing.
  • TestVoice_ServiceFailures (02 14.4): the fake answers 503 until voice.transcription_max_attempts attempts are used: failed after the last attempt with one file.updated, the held invocation released then; a 400 answer gives failed at once; a 307 gives failed without a second request anywhere; deleting the message while the job waits leaves the file deleted and the job without effect.
  • TestVoice_ServiceHoldTimeout: the fake hangs beyond voice.transcription_timeout; the clock passes voice.transcript_hold_max; the hold job writes exactly one agent.invocation while the transcript is pending; a later done writes no second one.
  • TestRequestTranscription: Alex in mode service: a failed transcript goes back to pending with a new job, 202; pending and done give 202 without a job; the same Idempotency-Key replays; voice.enabled off gives 403 feature_disabled; mode agent gives 409 conflict with reason transcription_unavailable; Robin gets 404 identical to an unknown id; Sam (read only) gets 403 forbidden; a pending file gives 409 file_not_ready.
  • TestVoice_HeldFollowOnRunAndFollowUp (M2-11 follow-on run and follow-ups, through invokeOrHold of M3-07): a voice message deferred into a follow-on run in mode service is held with voice.transcript_hold_max; a voice follow-up to a running run gets its agent.followup at once.
  • TestVoice_NoTranscriptInOutputs (SEC-LOG-4, SEC-T10): captured logs, spans, metrics, audit rows, event_log payloads and problem bodies of the service and agent flows contain neither the transcript texts nor the original name.
  • TestRunContext_TranscriptTrust (AG-20 transcript part, M2-13): the run context of the voice message lists the attachment with transcript and the element has trust = trigger; with the transcript pending the member is absent.
  • TestGetTranscript_ETag (M3-05, note 8 d): getTranscript and getFile send the same ETag; after submitTranscript both send the new one, and that value is accepted as If-Match.
  • Conformance (test/agents, note 20): TestConformance runs the AG-20 cases attachment and transcript and the AG-25 case green; TestScenarioRegistry_DeferredGolden passes with testdata/deferred.golden holding only AG-01 origin=import M8-03; TestScenarioFiles_Load accepts the new run_jobs kinds and the file check and rejects an unknown kind; a conductor unit test feeds the generated fixture as the body of uploadPart. Each new case was red before its implementation (red output in the report).
  • sdk/python/tests/testing: test_fake_agent.py covers read_file (byte length only, never the bytes in the reply), submit_transcript (If-Match from getFile, 412 reported with code) and the attachment and transcript fields of context against M2-29's FakeServer; test_control.py covers the new requests and replies.
  • test/leak and test/canary as note 17; AZ-P1 green.

Commands that must pass, from the repository root:

cd services/server && go test -race -count=1 ./internal/files/... ./internal/messaging/... ./internal/runs/... ./internal/agents/... ./internal/authz/... ./internal/wire/... ./test/...
cd sdk/python && uv run --locked pytest
make codegen-check
make arch
make register-check
make api-lint
make api-checks
make config-reference
make config-doc-check
make sql-visibility
make docs
make linkcheck
make check

make config-reference must leave no diff afterwards. Run make api-tables only when a generated block of 06 changed.

Acceptance criteria

  • MessageCreate.kind = voice is enabled (no longer 400 validation_failed with errors[].code not_enabled, M1-56); a voice message has exactly one ready MP4 audio attachment (MP3 also for agents) of at most voice.max_duration.
  • Another format answers 400 validation_failed with errors[].code voice_format_unsupported, a longer recording voice_too_long, a file not ready 409 file_not_ready or 409 file_blocked; nothing is stored in these cases.
  • AG-25: a voice message without a transcription path gets trigger_state = failed with voice_unsupported in modes off, agent without voice_input, and service with voice.enabled off.
  • Mode agent: the operator agent stores the transcript with submitTranscript and If-Match; another agent that cannot read the file gets the same 404 as for an unknown file (plan "transcript of a foreign agent 404").
  • Mode service with the fake service: the transcript ends done or failed and every change is reported through file.updated without text; the agent.invocation waits for it, at most voice.transcript_hold_max, and is written exactly once.
  • requestTranscription and submitTranscript are enabled, registered and pass AZ-P1 and AZ-P4; file.transcript.submit is no longer deferred.
  • The run context lists the transcript of a voice message with the trust of its message (AG-20 transcript part); getTranscript and getFile send the transcript ETag that submitTranscript accepts as If-Match.
  • The conformance cases AG-20 attachment, AG-20 transcript and AG-25 are green, and testdata/deferred.golden no longer holds AG-20 attachment M3-07, AG-20 transcript M3-09 and AG-25 voice M3-09.
  • Audio reaches only voice.transcription_url through the egress client, without redirects and only while voice.enabled (instance.Flags) is on; no transcript text appears in logs, spans, metrics, audit, events or problem bodies.
  • 12, 09, 02, 06, openapi.yaml, configuration.md, the READMEs, the CHANGELOG and the rows D-M3-09-<n> are updated; make sql-visibility and make config-doc-check are green.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
  • READMEs of files (port Transcripts, the outgoing ports Transcriber and VoiceSwitch, the adapter openai_compatible with its request form and error classes, the job files.transcribe, the voice check, InvocationFinal, MP4 audio after the container phase, the derived ETag, query exceptions), messaging (voice body rules), runs (voice facts at acceptance, the hold window), agents (VoiceInput) and authz (requirement of file.transcript.submit) updated.
  • Rulings of Design note 19 recorded in docs/DECISIONS.md (one row each with reason).
  • services/server/CHANGELOG.md entry under 'Unreleased'.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings English, next free number, make adr-index).
  • make check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN) with the M3 focus T-NW-01 and T-AI-03 (PLAN 9 "Red team"); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of make check.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven.
  5. Follow-ups that could not be done in this scope, each with its reason, and spec gaps found (at least: the client's MP3 duration without a request field).

Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.