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 andgetTranscript), 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 flagvoice.enabled), M2-31 (conformance suite,testdata/deferred.golden; it implies M2-09, M2-13 with the run context and M2-20 with the heartbeat reportcapabilities.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 (tablesfiles,file_upload_sessions,getFile, thefile.updatedbuilder,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 writesfile.updated); 3.4 "Completion and verification pass"; 7.3 last paragraph (box reader formvhd); 8 "Voice messages" completely (8.1 "Format and duration", 8.2 "Transcript", 8.3 "Transcoding"); 9.3 "Response" (inline list withaudio/mp4andaudio/mpeg); 10 "Attaching and agent invocation" ("forkind = 'voice'in modeservice, the transcript is alsodoneorfailed"); 12 row "Message deleted"; 13 "Endpoints and actions" (rowstranscribeandtranscript); 14 "Observability"; 16 R-FI-09, R-FI-10; 18voice.*,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" (rowvoice_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.6messages(kindwithvoice,has_attachments); 4.7files(mime_type,width,height,duration_ms,transcript_status,transcript_text,transcript_language,transcript_sourcewith R-DM-48: the derived ETag of a transcript, noversioncolumn,deleted_at,search_vector),message_attachments; 4.8runs(invocation_held_until, R-DM-44: "acceptance plusfile.invocation_hold_maxorvoice.transcript_hold_max", and R-DM-47: also set while the server transcript of a triggering voice message ispending); 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" (rowsfile); 2.4 (column Audit); 4.7 rowmessage.create; 4.8 rowsfile.read,file.transcribe,file.transcript.submit; 6 rowfile.updated.docs/spec/02-architecture.md: 2 table "Context" (row "Transcription service": "receives audio only when the switchvoice.enabledis on"); 4.3 rowfiles(Transcripts,FileFinalized"scan and, if applicable, transcript final"); 4.4 "Allowed dependencies" (rowsfiles,messaging,runs, "Tiers"); 5 "Layers within a module"; 8.3; 8.5 "What never happens in a transaction"; 12.3 "Job catalogue" (rowsfiles.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" (rowsgetTranscript,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.md1 (row "Processor": transcription service).docs/spec/14-search.md2 (transcript infiles.search_vector, weight B).docs/spec/15-client-architecture.md13 "Voice recording".docs/spec/16-operations.md6.2 (option flags).docs/api/openapi.yaml: operationscreateMessage,createThread,getFile,requestTranscription,submitTranscript,getTranscript(M3-05),downloadFile(M3-05),createUpload,uploadPart,completeUpload,getRunContext(M2-13),sendHeartbeat(M2-20),pollAgentEvents,listEvents,updateAgent,putFeatureFlag; schemasMessageCreate(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; parametersFileIdPath,IdempotencyKey,IfMatchRequired; headerETag; responsesConflict,PreconditionFailed,PreconditionRequired.docs/api/asyncapi.yaml: messagesevent.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 rowsD-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 ofdocs/RISKS.mdordocs/OPEN-QUESTIONS.mdnames this ticket.docs/operations/configuration.md: section 4 rowsvoice.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 rowvoice.enabled.- Briefs (merged names win, D-W-100):
M3-07-attachments-invocation.md(portfiles.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 readeradapters/isobmff, T3),M3-04-clamd-scan.md(outcome write,domain.Final,domain.Deliverable,clamdtest),M3-03-upload-sessions.md(portFiles,Filemapping,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:OnMessagePostedsteps 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 testTestCreateMessage_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 methodTranscriptwith itsTranscriptView;downloadFile),M3-07-attachments-invocation.mdOut of scope (the bullet on the attachment part of AG-20, which this ticket closes). - Code (all under
services/server/):internal/files(port,domain,appwithattach.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(portDirectory),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):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- 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).
- 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.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - 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. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/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:
- A voice message is a message with
kind = 'voice'and exactly one attachment (12 8.1).createMessageandcreateThread(first_message) acceptMessageCreate.kind = voiceand no longer answer400 validation_failedwitherrors[].codenot_enabledfor it (06 5 point 3, M1-56 D2);cardkeeps that answer. - 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 gives400 validation_failedwitherrors[].codevoice_format_unsupported; the same file can still be sent as a normal attachment. - 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); abovevoice.max_durationthe answer is400 validation_failedwitherrors[].codevoice_too_long. - Transcription mode
voice.transcription_modewithoff,agent(default) andservice(12 8.2, R-FI-10, D-FI-09). Modeagent: the operator agent of the thread stores the transcript withsubmitTranscript(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). - Mode
service: after the virus scan the worker calls the portTranscriberwith the adapteropenai_compatibleatvoice.transcription_url, the key from the secret provider (voice.transcription_api_key_ref), the time limitvoice.transcription_timeoutand a language hint fromusers.locale;transcript_source = 'service'(12 8.2, 09 13). Audio leaves the server only whilevoice.enabledis 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). requestTranscription(file.transcribe,write,rl.upload,Idempotency-Key) starts the job in modeservice; withvoice.enabledoff the answer is403 feature_disabled; in the other modes409 conflictwithreasontranscription_unavailable(12 8.2, 06 5 point 5).- Result:
transcript_statuspendingtodoneorfailedwithtranscript_textandtranscript_language; every change of the transcript writes the durable eventfile.updated(receive actionfile.read) without the text (12 2, 12 8.2, 05 6, 04 2.2). The transcript is searchable throughfiles.search_vector(03 5, 14 2) and visible to everybody withfile.read(09 13). - Routing (09 13, R-AG-16): mode
agentwith the effective capabilityvoice_inputof the candidate (owner permissionsupports_voiceand adapter report, 09 2.2), or modeservicewithvoice.enabledon for the author, is a transcription path; otherwise a triggering voice message is rejected at acceptance withtrigger_state = failedandvoice_unsupported(09 3.5, AG-25). - In mode
servicetheagent.invocationof a voice message is written only when the transcript isdoneorfailed, at the latest aftervoice.transcript_hold_max, exactly once, with the markerruns.invocation_held_until(04 15.3 point 1, 03 R-DM-44 and R-DM-47, 12 10, D-K-G6). - A service failure is retried by the job, then the transcript is
failed(02 14.4 row "transcription service"). - An agent that may not see the file gets the same
404as for an unknown file id (PLAN row; 12 1 point 2, AZ-P4, ADR 0017). - 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). - 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).
- The deferred conformance lines
AG-20 attachment M3-07,AG-20 transcript M3-09andAG-25 voice M3-09of M2-31 are closed: their parts run in the conformance suite and the lines leavetestdata/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,ModewithModeOff,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 andkernel-coreonly.internal/files/app/transcripts.go(the port methodsMode,Request,Submit),internal/files/app/transcribe.go(the use case of the job, Design note 9), the outgoing portsTranscriberandVoiceSwitchininternal/files/app/ports.go(or the file where M3-03 declared the outgoing ports offiles).internal/files/adapters/transcription/openai_compatible.go: the adapteropenai_compatibleofTranscriberover the egress client (Design note 11);internal/files/adapters/transcription/transcriptiontest/: the fake transcription service for tests (anhttptestserver on127.0.0.1port 0 that records requests and answers per test).- The job
files.transcribe: argsTranscribeArgs{FileID ids.ID}, handler and scheduler, in the adapter package where M3-04 putfiles.scan(for exampleinternal/files/adapters/jobs/transcribe.go). internal/files/adapters/httpapi/transcripts.go: the strict-server methodsRequestTranscriptionandSubmitTranscript.internal/files/adapters/postgres/queries/transcripts.sqland the regenerated sqlc code.internal/runs/domain/hold.goadditions:HoldWindow(transcriptOnly bool, fileMax, voiceMax time.Duration) time.Duration(Design note 13;appmaps thefiles.PendingReasonof the port to the boolean, so the domain stays free of other modules).
Change:
internal/files: the portAttachments(AttachCmd.Voice,AttachCmd.AuthorKind,PendingreturnsPendingReason, Design notes 6 and 13),domain(StateFactsgetsTranscriptStatus;BecameFinalusesInvocationFinal, Design note 10),app/attach.go(voice checks and the service start, Design note 6),app/filechange.go(recordFileChangecovers the transcript columns),app/get.go(ETagofgetFile, Design note 8),app/transcript.goandadapters/httpapi/download.goof M3-05 (TranscriptView.ETagand theETagheader ofgetTranscript, Design notes 3 and 8), the scan outcome of M3-04 and the container phase of M3-06 (Design note 5),adapters/isobmffonly ifReadMovieInfodoes not yet return the duration of an audio-only file.internal/messaging:domainbody rules forkind = voice(Design note 7), the create command (Kindpassed toAttachasVoice, the author kind), the M1-56 test that assertsnot_enabledforkindvoice.internal/runs: the acceptance subscriberOnMessagePosted(voice messages, the factsVoice,VoiceMode,VoiceInput, Design note 12),invokeOrHoldandReleaseInvocationof M3-07 (the window and the verdict usePendingReason, Design note 13), the follow-on run of M2-11 where it callsinvokeOrHold; the run context of M2-13 (app/runcontext.go:RunContextAttachment.transcriptwith the trust of its message, Design note 12 d).internal/agents:CapabilitiesofEffectiveCapabilities(M2-11 Design note 15) getsVoiceInput bool(permissionsupports_voiceand reportvoice_input);agentstestsets the report.internal/authz: the requirement offile.transcript.submitreplaces its deferred entry (Design note 14); remove it from the deferred golden list of M1-28.internal/wire: theTranscriptsport forrunsand the HTTP adapter; theTranscriberadapter with its egress client; theVoiceSwitchbinding (Design note 15); the jobfiles.transcribe(queuefiles, rolesworkerandall); the configuration keys of Design note 16 inconfigkeys_files.go(or the file of thevoice.*keys) and the cross-checkvoice_service_urlinconfigchecks.go; regeneratearchitecture/config-schema.json(go generate ./tools/configschema),testdata/txn_registrations.goldenand the jobs golden file if one exists.api/operations.yaml: moverequestTranscriptionandsubmitTranscriptfromlatertoenabled; rerun the route generator.test/leak:TestAZ_P4_RouteCoveragecovers both new routes with thefile_idfixture 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 filesscenarios/ag-20.yaml(cases attachment and transcript) andscenarios/ag-25.yaml(the case of 09 16.2), theirdeferredentries removed; the linesAG-20 attachment M3-07,AG-20 transcript M3-09andAG-25 voice M3-09removed fromtestdata/deferred.golden; the conductor additions of Design note 20 (therun_jobskinds 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.pyandcontrol.py(the Fake-Agent ops of Design note 20),sdk/python/tests/testing/test_fake_agent.pyandtest_control.py,sdk/python/README.md(section "Test doubles"),sdk/python/CHANGELOG.mdunder "Unreleased".docs/spec/12-files.md2, 8.1, 8.2, 10 and 18;docs/spec/09-agents.md13 and the closing paragraph of 16.2;docs/spec/02-architecture.md4.3 rowfilesand 12.3 rowfiles.transcribe;docs/spec/06-api.md10 point 5;docs/api/openapi.yaml(Design note 18);docs/operations/configuration.mdregenerated (make config-reference).- READMEs of
files,messaging,runs,agents,authz;services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-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 theRetry-Afterheader of409 file_not_ready(M3-05); this ticket only adds theETagof Design note 8 togetTranscript. - Transcoding and the variant
playback(12 8.3, the portMediaTranscoder): "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 (
esdsobject 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_inputitself (M2-20): this ticket only reads it throughagents.EffectiveCapabilities. - The other parts of the conformance suite and the line
AG-01 origin=import M8-03oftestdata/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.followupof 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 ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(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 -dort.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
transcriptionteston loopback, reached through the egress client with anegress.allowed_hostsentry127.0.0.1:<port>;allow_privatein 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 offileswith 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
clamdteston a Unix socket in amktemp -ddirectory, or withfile.scan.mode = offwhere the test does not concern the scan; stop only containers you started, by id. - The conformance suite runs the Fake-Agent through
uvas M2-31 does; a missing Docker-compatible runtime or a missinguvfails it, never skips it (P-09). - Test audio is generated by the tests (a minimal MP4 box tree with
ftypbrandM4A,moov,mvhdwith chosen timescale and duration, onetrakwithhdlrsounand anmp4asample 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¶
-
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 rowfiles: portTranscripts, eventFileFinalized"scan and, if applicable, transcript final");messagingpasses the kind of the message tofiles.Attachments.Attach(edgemessaging -> filesexists);runsasksfilesfor the effective mode and the pending reason (edgeruns -> filesexists since M3-07) andagentsforvoice_input. No new module edge:filesreadsvoice.enabledthrough its outgoing portVoiceSwitch(note 15) and the locale throughidentity.Directory(edgefiles -> identityexists). The transcription adapter is an adapter offilesand usesplatform/egress(02 4.4 "platformonly inadapters"). Ruling for docs/DECISIONS.md: the switch is an outgoing port bound ininternal/wire, not an edgefiles -> instance, because only the composition root needs to know where flags live (note 15). -
Effective mode (12 8.2, 02 2, 06 5 point 5).
Transcripts.Mode(ctx, subjectID, subjectKind)returnsvoice.transcription_mode, except thatserviceis returned asoffwhileVoiceSwitch.Enabledis 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 (forrequestTranscription) 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"). Modeagentandoffdo not depend on the switch. -
Ports (doc comments say every method runs inside the caller's unit of work where it writes, and that
RequestandSubmitauthorize 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, PendingTranscriptTranscriptViewis the type of M3-05's port methodTranscript(getTranscript), extended withETag; do not add a second one.AttachCmdgetsVoice bool(the message haskind = voice) andAuthorKind string(user,agent,app).Attachments.Pending(ctx, messageID)returns(PendingReason, error)instead of(bool, error):PendingFileswhen an attachment is not final in the sense of M3-04'sdomain.Final, elsePendingTranscriptwhen an attachment hastranscript_status = 'pending', elsePendingNone; adapt the callers of M3-07 (invokeOrHold,ReleaseInvocation, the follow-on run). -
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'sdomain.HasContainerMetadatatreats as MP4 audio (the pinned type detector of M3-04 reports an.m4awith brandM4Aand an MP4 whose only track is audio; pin the exact names in a table test with generated fixtures), and additionally foraudio/mpegwhenauthorKindisagent. 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_stateorscan_statusstillpending) givesNotReady; final but not deliverable givesBlocked; not a voice format givesFormatUnsupported; an MP4 with width or height (it has a video track) or without a duration (the box reader found nomvhdduration) givesFormatUnsupported; a duration abovevoice.max_durationgivesTooLong(equal passes); otherwiseOK. 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
filesstores 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 untilfile.updatedreports the file ready (15 13). -
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 offiles.scan(verdictscleanandskipped) leavesupload_state = 'pending'and insertsfiles.thumbnail, as M3-06 does for images; the container phase offiles.thumbnailthen runs on the pending row and its transaction T3 setsduration_ms(when the box reader found one) andupload_state = 'complete'together throughrecordFileChange, which makes the file final and publishesfiles.FileFinalized(M3-07 Design note 10 b). A box reader error leavesduration_msabsent and still setscomplete, 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. -
Attaching a voice message (
AttachwithVoice = true, inside the unit of work ofcreateMessageorcreateThread, 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 conflictwithreasonfile_not_attachableas before.b.
domain.VoiceCheckon the locked row:NotReadygives409 file_not_ready;Blockedgives409 file_blocked;FormatUnsupportedgives400 validation_failedwitherrors[].codevoice_format_unsupported;TooLonggives400 validation_failedwitherrors[].codevoice_too_long; the pointer is<Pointer>/0(/attachment_file_ids/0, increateThread/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): forModeServicesettranscript_status = 'pending'throughrecordFileChange(onefile.updatedin the scope of the message) and insertTranscribeArgs{FileID}withjobs.Inserter.InsertTxin the same transaction. ForModeAgentandModeOffthe file keepstranscript_status = 'none'.d. The message keeps
kind = 'voice'(M1-56 writes the column);MessagePosted.Kindisvoice, which is whatrunsreacts to (note 12). -
Body rules (
messaging/domain, M1-56 D2; Ruling for docs/DECISIONS.md for the codes).kind = voiceis no longernot_enabled(cardkeeps it). A voice message needs exactly one attachment: none gives400 validation_failedwitherrors[].coderequiredand pointer/attachment_file_ids, more than one giveserrors[].codelimit_reachedwith the same pointer (prefix/first_messageincreateThread).content_mdmay 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.componentsstay card-only. -
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 NULLand its attachment row;authorize(actor, file.transcript.submit, fileResource(f, ref))(M3-07 Design note 9). Unknown, foreign and invisible files give the same404(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_modeother thanagentgives409 conflictwithreasontranscription_unavailable(the same reason as 12 8.2 forfile.transcribe; a transcript of the agent would overwrite the service or contradict modeoff).c. Lock the file row
FOR UPDATE. Not deliverable:409 file_not_readyor409 file_blockedby M3-04'sdomain.Final; not a voice format (IsVoiceFormatwith the uploader's kind) gives409 conflictwithreasontranscription_unavailable.d. Precondition:
fileshas noversioncolumn, and the transcript's ETag is derived (03 R-DM-48, 06 8 point 1):domain.TranscriptETagis the strong ETag"<first 22 characters of the base64url SHA-256>"overtranscript_status,transcript_source,transcript_languageandtranscript_text, each followed by one zero byte (the same form as the ETag ofgetInstance, M1-46). The operation usesIfMatchRequired: a missing or*If-Matchgives428 precondition_requiredinStagePreconditions(M1-18); a value that is not exactly the current ETag gives412 version_conflictwithcurrent(theTranscriptview) and the currentETagheader. Compare the raw header value, becauseParseIfMatchaccepts only decimal versions.getFileandgetTranscript(M3-05) send the same value in the headerETag.e. Body:
textmust not contain U+0000 (errors[].codeinvalid, pointer/text) and must not exceedmessage.max_content_bytesin UTF-8 bytes (too_long); an emptytextis allowed (a silent recording).languageis optional and must passdomain.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_mismatchthrough the schema pattern of note 18).f. Write
transcript_status = 'done',transcript_text,transcript_language(absent when not sent),transcript_source = 'agent'throughrecordFileChange(onefile.updatedwithout text); answer200with theTranscriptview (text,language,sourceonly whendone, no JSONnull) and the newETag. A later submit with the new ETag replaces the transcript (a correction). No audit entry (05 4.8 column Audit "no"). -
requestTranscriptionand the jobfiles.transcribe(12 8.2, 02 12.3 rowfiles.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 andauthorize(actor, file.transcribe, fileResource(...))(unknown or invisible:404);VoiceSwitchoff for the caller gives403 feature_disabled(the operation's description);voice.transcription_modeother thanservicegives409 conflictwithreasontranscription_unavailable; lock the row; not deliverable gives409 file_not_readyor409 file_blocked; not a voice format gives409 conflictwithreasontranscription_unavailable. Statusnoneorfailed: setpendingthroughrecordFileChangeand insert the job (InsertTx); statuspendingordone: change nothing. Answer202with theTranscriptview. The idempotency stage of M1-20 replays a repeatedIdempotency-Key.b. Job args
TranscribeArgs{FileID ids.ID}, kindfiles.transcribe, queuefiles,HandlerOptions.MaxAttempts=voice.transcription_max_attempts, River's default backoff. One run: read the row outside a transaction; returnnilwhen it is gone, deleted or notpending(02 12.4 rule 1). WhenMode(uploader)is no longerserviceor the file is not deliverable, writefailed(step e) and returnnil. Otherwise read the locale withidentity.Directory.User(uploaded_by)(a principal that is not a person has no locale) and computedomain.LanguageHint(locale)(the primary subtag in lower case, empty when invalid); open the object withObjectStore.Getand callTranscriber.Transcribeoutside any transaction (02 8.5).c. Success: one unit of work locks the row and requires
pending,deleted_at IS NULLand the sameobject_keyas read; otherwise it writes nothing and returnsnil. It setsdone,transcript_text,transcript_language(the response's language whenValidLanguageTag, else the hint, else absent) andtranscript_source = 'service'throughrecordFileChange, which publishesfiles.FileFinalized(note 10), so the held invocation is released in the same transaction (M3-07 Design note 13 a).d. Errors:
ErrTranscriptionPermanentfrom the adapter writesfailedat once; any other error returns the error so River retries, and on the last attempt (job.Attempt >= voice.transcription_max_attempts) writesfailedinstead. A database error never writesfailed.e.
failed: lock the row as in c, settranscript_status = 'failed'withtranscript_text,transcript_languageandtranscript_sourceabsent, throughrecordFileChange(it publishesFileFinalizedas well, so the held invocation goes out with the transcriptfailed). -
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";StateFactsgetsTranscriptStatus.domain.BecameFinal(before, after)(M3-07) comparesInvocationFinal, sorecordFileChangepublishesFileFinalizedexactly 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.Finalanddomain.Deliverablekeep their meaning for delivery, so a deliverable file with a pending transcript is still served (M3-05 maps onlyFinalandDeliverable). Ruling for docs/DECISIONS.md: one function decides "final for the invocation", and settingpendingon a final file publishes nothing. -
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.Clientbuilt byinternal/wirewithOptions{Purpose: "transcription", Hosts: <exactly scheme, host and port of voice.transcription_url>, Redirects: RedirectNever, Timeout: voice.transcription_timeout}andMaxResponseBytesfromegress.max_response_bytes. A self-hosted service on a private address needs an operator entryhost[:port];allow_privateinegress.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>withmultipart/form-data, streamed from the object through anio.Pipe(no spool file): partfilewith the file nameaudio.m4aoraudio.mp3(neveroriginal_name) and the storedmime_type;modelonly whenvoice.transcription_modelis set;languageonly when the hint is not empty;response_format=json.Authorization: Bearer <key>only whenvoice.transcription_api_key_refis set, the key resolved per call throughsecrets.Provider.Resolve(cached bysecrets.cache_ttl, M1-09) and held in the redacting secret type.c. Response
200with a JSON object:text(string, required),language(string, optional); other members are ignored.textmust be valid UTF-8 without U+0000 and at mostmessage.max_content_bytesbytes.d. Error classes: transient are timeouts, connection errors,
408,429and5xx; permanent (ErrTranscriptionPermanent) areegress.BlockedError,egress.ErrResponseTooLarge, every3xx(never followed), every other4xx, a malformed or invalid body. Log one Warn line per failed call withbothy.file.id, the class and the status code; never the URL path or query, the key, a response body or text. -
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.OnMessagePostedacceptsOrigin = humanwithKindtextorvoice(M2-05 step 1 and M2-08 note 10 a said "voice comes with M3").b. For a voice message only:
MessageTrigger.Voice = true,VoiceModefromfiles.Transcripts.Mode(author), andVoiceInputof everyAgentFactsbuilt for the decision (thread operator, channel operator, mentioned agents) fromagents.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 pathrejectwithvoice_unsupported, which the merged mapping of M2-08 note 10 d turns intotrigger_state = failedwith that reason and no audit entry (a decision-function rejection); with a path,Decision.Transcriptisagentorservice.agent: the invocation is written at once (the audio is final since note 6 b), the adapter reads the file withfile.readand callssubmitTranscript.service: the invocation is held (note 13).d. In the run context of M2-13,
RunContextAttachment.transcriptistranscript_textwhentranscript_status = 'done'(absent otherwise) and the element keeps thetrustof its message (09 4.6, ADR 0054 point 4);ForMessagescarries the text internally, the APIFilenever does. The AG-25 case and the AG-20 attachment and transcript cases of the conformance suite follow note 20. -
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)whenHasAttachments(and unconditionally on the follow-on run, as M3-07 note 11 d); hold whenreason != PendingNone; the window isdomain.HoldWindow(reason == PendingTranscript, file.invocation_hold_max, voice.transcript_hold_max), so a voice message waiting for its transcript waits at mostvoice.transcript_hold_maxand one waiting for a scanfile.invocation_hold_max. The release time stays fixed at acceptance.ReleaseInvocationwith causeReleaseFinalreleases only whenPendingisPendingNone;ReleaseTimeoutreleases with the transcript stillpending. Follow-ups (agent.followup) are not held. Ruling for docs/DECISIONS.md: the transcript is a reason to hold only throughtranscript_status = 'pending', which only modeservicesets. -
Authorization (05 4.8; M1-28 requirement mapping; 07 4.1). Replace the deferred entry of
file.transcript.submitwithAll(ThreadOperator, Level(read)), evaluated on the file resource of M3-07 Design note 9: an attached file carriesThreadIDandChannelIDof its message, soThreadOperatoris 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 gets404. A visible caller who is not the operator gets the reason of the mergedThreadOperatoratom (subject_kind_not_allowed, 07 4.1) as403 forbidden. Remove the action from the golden deferred list;TestDeferredActionsGoldenand AZ-P3 stay green.file.transcribekeeps the requirementLevel(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 same404as an unknown id. -
VoiceSwitchbinding (M1-47).internal/wirebindsapp.VoiceSwitchtoinstance.Flags.Enabled(ctx, instance.FlagSubject{PrincipalID, Kind}, instance.FlagVoiceEnabled). The tests switch it withputFeatureFlagby the Owner. -
Configuration (12 18;
config.Keyof M1-07,Chapter=12-files.md,Defaultexactly the chapter cell,Docone English sentence; register each key only if absent):voice.max_duration(duration, positive; M1-46 registered it),voice.transcription_mode(enumoff,agent,service),voice.transcription_url(string, empty allowed),voice.transcription_api_key_ref(secret reference, empty means "no key", likepush.apns.private_key_ref),voice.transcription_timeoutandvoice.transcript_hold_max(duration, positive), and the two new keys of this ticket:voice.transcription_model(string, default empty: the request has nomodelfield; an OpenAI-compatible endpoint usually needs it, 12 8.2 names no key for it) andvoice.transcription_max_attempts(integer, at least 1, default5; 02 14.4 says "job retries, thentranscript_status = failed" without a number, and rule 9 needs a key). Cross-checkvoice_service_url: modeservicerequiresvoice.transcription_urlto be an absolute URL with schemehttpsorhttp, a host, no user info and no fragment; the error names the keys, never the value.TestConfigSchemaBuildslists the keys; regeneratedocs/operations/configuration.md; touchtools/configdoc/planned.yamlonly ifmake config-doc-checkreports a rule no key matches. -
Observability, privacy and gates (12 14, SEC-LOG-4, SEC-T10, 20 "No contents"; M1-69, M1-70).
- No new metric (
docs/observability/signals.mdnames none for transcription); the spanjob files.transcribecomes from the M1-14 handler wrapper, blocked egress attempts are counted bybothy.egress.blocked(M1-22). Logs carrybothy.file.id,bothy.message.id,bothy.run.idonly; never transcript text, language with text, file name, URL path or key. - Events:
file.updatedcarries theFileschema, which hastranscript_status,transcript_languageandtranscript_sourcebut no text (04 2.2 rule 4); problem bodies never echotext(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 ofRequestandSubmitafterauthorize(after_authorize); the load by id reuses theauthorize_resolutionentry of M3-03 and M3-07; the extendedPendingquery keeps the entry of M3-07. Writes are not linted.make sql-visibilitystays clean. test/leak:TestAZ_P4_RouteCoveragecoversrequestTranscriptionandsubmitTranscriptwith thefile_idfixture (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 classcontentand lets the fake service return a second canary; neither appears in logs, traces, metrics, audit rows,event_logpayloads or problem bodies (M1-69 allow matrix: database and authorized response bodies only).
- No new metric (
-
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.md2: for the invocation a file withtranscript_status = 'pending'is not final; MP4 audio becomescompletewith 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 modeservice, the states offile.transcribe(note 9 a), the rules offile.transcript.submit(note 8), the job's failure rule andvoice.transcription_max_attempts, the language hint and result, the request form andallow_private(note 11). 10: the hold windowvoice.transcript_hold_maxfor a pending transcript (note 13). 18: rowsvoice.transcription_modelandvoice.transcription_max_attempts.docs/spec/09-agents.md13:VoiceInputfrom permission and report, the switch for modeservice, 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.md4.3: one English sentence below the table naming the methods ofTranscripts(mode, request, submit) and the outgoing portsTranscriberandVoiceSwitchoffiles; 12.3: leave the rowfiles.transcribeas it is and add one English sentence below the table: the job is also inserted when a voice message is sent in modeservice, with at mostvoice.transcription_max_attemptsattempts.docs/spec/06-api.md10 point 5: one English sentence on the derived transcript ETag andIf-Match.docs/api/openapi.yaml:MessageCreate.kinddescription (voice: exactly one MP4 audio attachment, MP3 for agents, ready file,voice_format_unsupported,voice_too_long);createMessageandcreateThreaddescriptions (409 file_not_readyand409 file_blockedfor a voice file not ready);getFile200 headerETag(the transcript version forsubmitTranscript);getTranscript200 headerETag(the same value);submitTranscript: parameterIfMatchRequired, headerETagon200, responses409(Conflict),412(PreconditionFailed),428(PreconditionRequired), description (operator agent of the thread, modeagent,404for invisible files);requestTranscription: description of note 9 a;TranscriptSubmit.languagewith the pattern of note 8 e. Thenmake codegen-check,make api-lint,make api-checks, andmake api-tableswhen a generated block of 06 changes.
-
Rulings for
docs/DECISIONS.md(section 10, one rowD-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 ofsubmitTranscriptandrequestTranscriptionwithtranscription_unavailable(notes 8 b, 8 c, 9 a); the states ofrequestTranscription(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). -
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; theirdeferredentries and the three lines oftestdata/deferred.goldengo in the same change, andTestScenarioRegistry_DeferredGoldenthen holds onlyAG-01 origin=import M8-03.a. Conductor additions, each with a unit test next to the existing ones:
run_jobsaccepts the kindsfiles.scan,files.thumbnailandfiles.transcribe(M2-31 Design note 7 lists the kinds used); a humancallofuploadParttakes its body from a generated fixture named inwith(the MP4 audio of Boundaries, or a small text file), never from a file in the repository; acheckof kindfilereadsupload_state,scan_status,transcript_statusandtranscript_sourcethroughAdminDB, never the text. The scenarios setfile.scan.mode = offandvoice.transcription_modeinconfig.b. Fake-Agent ops (
fake_agent.py,control.py, through the SDK hand layer or, where it has no method, the generated corebothy_sdk.generated):read_file(downloadFilewith the agent's token; resultstatus,codeand the byte length, never the bytes in a reply);submit_transcript(getFilefor theETag, thensubmitTranscriptwith thatIf-Match,textandlanguagefromwith; resultstatus,code,reason). The opcontextreports in addition per element its attachments (file_id, whethertranscriptis present) and, for every attachment whose content the Fake-Agent read withread_fileand 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,uploadPartandcompleteUpload,run_jobs files.scanmakes it ready, and Alex's triggering message on Support/Eskalation carries it; Lotse receives, reads the context, reads the file withread_file: the attachment is listed on the trigger element, its content occurs only inside a marked data block although the trigger hastrust = trigger.d. AG-20 transcript (09 16.2 "the transcript carries the trust marker of its message (
trigger)"): modeagent, Lotse's runtime reportsvoice_inputthrough its heartbeat (M2-20) and the Owner setssupports_voice; Alex uploads an.m4a,run_jobs files.scanandrun_jobs files.thumbnailmake it ready (note 5), and Alex sends it askind = voicementioning Lotse; Lotse receives, starts, callssubmit_transcript, reads the context again: the trigger element hastrust = triggerand its attachment carriestranscript, 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
agentwith a Lotse whose runtime reports novoice_input: Alex's voice message (file made ready as in d) mentioning Lotse is stored withtrigger_state = failedandvoice_unsupported, and Lotse's nextreceivereturnsnone.
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.m4aand audio-only MP4 foruser,agentandapp;audio/mpegonly foragent;audio/wav,audio/ogg,video/mp4,application/pdfnever);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 abovevoice.max_duration, MP3 from an agent without duration passes);TestDeferContainerComplete(MP4 audio names true, video and QuickTime false);TestInvocationFinaland the extendedTestBecameFinal(every pair ofFinaltimes transcriptnone,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);TestLanguageHintandTestValidLanguageTag(en-GBgivesen,destays, empty,x,english,en_GBand over-long subtags are rejected).internal/messaging/domain:TestVoiceBodyRules(no attachment givesrequired, two givelimit_reached, pointers/attachment_file_idsand/first_message/attachment_file_ids; empty content with one attachment passes;cardstillnot_enabled).internal/runs/domain:TestHoldWindow(transcript only givesvoice.transcript_hold_max, otherwisefile.invocation_hold_max).internal/files/adapters/transcription:TestOpenAICompatible_RequestForm(one POST to the configured path; partsfilewith the nameaudio.m4aand the stored type,response_format=json;modelonly withvoice.transcription_model;languageonly with a hint;Authorizationonly with a key reference, read from at.TempDir()secret file; the original name never in the request);TestOpenAICompatible_ErrorClasses(table:200with text,200withouttext, invalid JSON, text with U+0000, text overmessage.max_content_bytes,307to another loopback server that records no request,400,401,408,429,500,503, a body overegress.max_response_bytes, a hang beyondvoice.transcription_timeout: transient or permanent as note 11 d);TestOpenAICompatible_OnlyConfiguredHost(a URL host without anegress.allowed_hostsentry 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 givesnot_found; an agent with read that is not the operator givesforbiddenwith the merged atom reason; Alex givesforbidden; an unattached file of Alex givesnot_foundfor Lotse);TestDeferredActionsGoldenwithoutfile.transcript.submit.internal/wire:TestVoiceConfigKeys(every key of note 16 with its chapter default;voice_service_urlrejects modeservicewith an empty URL, a relative URL,ftp://, user info and a fragment, and the error names keys only); the golden registrations show the jobfiles.transcribeon queuefiles.
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 ofTestCreateMessage_NotEnabledMembers400, whosecardrow stays): Alex sendskind = voicewith a ready.m4aof a duration belowvoice.max_durationin Support/Allgemein:201,kind: voice, one attachment withduration_msfrommvhd; the same throughcreateThreadwithfirst_message.TestVoice_FormatUnsupported400(plan): Alex sends a ready WAV, an MP3 and a PDF each askind = voice:400 validation_failedwitherrors[].codevoice_format_unsupportedand pointer/attachment_file_ids/0, nothing stored, the thread sequence without a gap; the same files askind = textattachments give201; an MP4 with a video track and an MP4 whosemvhdis missing givevoice_format_unsupported; Lotse sends the MP3 askind = voicein its thread:201withoutduration_ms.TestVoice_TooLong400: an.m4awhosemvhdsays one second more thanvoice.max_durationgives400 validation_failedwitherrors[].codevoice_too_long.TestVoice_NotReadyAndBlocked: with the fake clamd stopped the file is pending and the send answers409 file_not_ready; an EICAR reply makes itinfectedand the send answers409 file_blocked; a foreign file still answers409 conflictwithreasonfile_not_attachable.TestVoice_BodyRules:kind = voicewithout attachment and with two attachments gives the codes of note 7.TestAudioMP4CompleteAfterContainerPhase(note 5): after the scan the.m4arow ispendingwith a queuedfiles.thumbnail; after the container phase it iscompletewithduration_msin one transaction, with onefile.updatedand onefiles.FileFinalized; a broken box tree endscompletewithoutduration_ms.TestVoice_AG25_NoTranscriptionPath(AG-25, 09 3.5): in modeoff; in modeagentwith Lotse without effectivevoice_input(permission off, or report missing); in modeservicewithvoice.enabledoff for Alex: Alex's voice message mentioning Lotse is stored withtrigger_state = failedand reasonvoice_unsupported, no run, noagent.invocation, no auto thread, no audit entry, and no request reaches the fake service. A voice message that triggers nobody stays withouttrigger_state.TestVoice_ModeAgent_SubmitTranscript(09 13, 09 4.7): modeagent, Lotse withvoice_input: the voice message is invoked at once (noinvocation_held_until); Lotse polls, starts the run, reads the file (getFilewithETag), callssubmitTranscriptwith thatIf-Match:200withstatus: done,source: agent, the text and a newETag; exactly onefile.updatedin the scope of the message whose payload holdstranscript_status,transcript_language,transcript_sourceand no text; Sam receives it throughlistEvents;files.search_vectormatches a word of the transcript (@@ plainto_tsquery('simple', ...)through thepgtestconnection); 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 status404, body and headers after removingCorrelation-IdandDate; Alex (a person) gets403 forbidden; Lotse withoutIf-Matchgets428 precondition_required, with a stale ETag412 version_conflictwithcurrentandETag; text overmessage.max_content_bytesgives400 validation_failedwitherrors[].codetoo_long;languageenglishgivespattern_mismatch; in modeservice409 conflictwithreasontranscription_unavailable; for an image attachment of the same thread409 conflictwithreasontranscription_unavailable.TestVoice_ModeService_FakeService(plan "modeservicewith a fake service reports throughfile.updated"): modeservice,voice.enabledon, auto thread on Allgemein: Alex's voice message mentioning Lotse gives201with the filetranscript_status: pending, onefile.updatedwithpending, one queuedfiles.transcribe, the runqueuedwithinvocation_held_until= the fake clock at the send plusvoice.transcript_hold_maxand noagent.invocation. The job runs: the fake service got exactly one request withlanguageenand the.m4abytes; in one transactiontranscript_status = done,transcript_source = service, onefile.updatedwithdone(no text), and exactly oneagent.invocation, the columnNULL; Lotse's next poll delivers it; the hold job after the window writes nothing.TestVoice_ServiceFailures(02 14.4): the fake answers503untilvoice.transcription_max_attemptsattempts are used:failedafter the last attempt with onefile.updated, the held invocation released then; a400answer givesfailedat once; a307givesfailedwithout 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 beyondvoice.transcription_timeout; the clock passesvoice.transcript_hold_max; the hold job writes exactly oneagent.invocationwhile the transcript ispending; a laterdonewrites no second one.TestRequestTranscription: Alex in modeservice: afailedtranscript goes back topendingwith a new job,202;pendinganddonegive202without a job; the sameIdempotency-Keyreplays;voice.enabledoff gives403 feature_disabled; modeagentgives409 conflictwithreasontranscription_unavailable; Robin gets404identical to an unknown id; Sam (read only) gets403 forbidden; a pending file gives409 file_not_ready.TestVoice_HeldFollowOnRunAndFollowUp(M2-11 follow-on run and follow-ups, throughinvokeOrHoldof M3-07): a voice message deferred into a follow-on run in modeserviceis held withvoice.transcript_hold_max; a voice follow-up to a running run gets itsagent.followupat once.TestVoice_NoTranscriptInOutputs(SEC-LOG-4, SEC-T10): captured logs, spans, metrics, audit rows,event_logpayloads 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 withtranscriptand the element hastrust = trigger; with the transcriptpendingthe member is absent.TestGetTranscript_ETag(M3-05, note 8 d):getTranscriptandgetFilesend the sameETag; aftersubmitTranscriptboth send the new one, and that value is accepted asIf-Match.- Conformance (
test/agents, note 20):TestConformanceruns the AG-20 cases attachment and transcript and the AG-25 case green;TestScenarioRegistry_DeferredGoldenpasses withtestdata/deferred.goldenholding onlyAG-01 origin=import M8-03;TestScenarioFiles_Loadaccepts the newrun_jobskinds and thefilecheck and rejects an unknown kind; a conductor unit test feeds the generated fixture as the body ofuploadPart. Each new case was red before its implementation (red output in the report). sdk/python/tests/testing:test_fake_agent.pycoversread_file(byte length only, never the bytes in the reply),submit_transcript(If-MatchfromgetFile,412reported withcode) and the attachment and transcript fields ofcontextagainst M2-29's FakeServer;test_control.pycovers the new requests and replies.test/leakandtest/canaryas 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 = voiceis enabled (no longer400 validation_failedwitherrors[].codenot_enabled, M1-56); a voice message has exactly one ready MP4 audio attachment (MP3 also for agents) of at mostvoice.max_duration. - Another format answers
400 validation_failedwitherrors[].codevoice_format_unsupported, a longer recordingvoice_too_long, a file not ready409 file_not_readyor409 file_blocked; nothing is stored in these cases. - AG-25: a voice message without a transcription path gets
trigger_state = failedwithvoice_unsupportedin modesoff,agentwithoutvoice_input, andservicewithvoice.enabledoff. - Mode
agent: the operator agent stores the transcript withsubmitTranscriptandIf-Match; another agent that cannot read the file gets the same404as for an unknown file (plan "transcript of a foreign agent404"). - Mode
servicewith the fake service: the transcript endsdoneorfailedand every change is reported throughfile.updatedwithout text; theagent.invocationwaits for it, at mostvoice.transcript_hold_max, and is written exactly once. -
requestTranscriptionandsubmitTranscriptare enabled, registered and pass AZ-P1 and AZ-P4;file.transcript.submitis no longer deferred. - The run context lists the transcript of a voice message with the trust of its message (AG-20 transcript part);
getTranscriptandgetFilesend the transcriptETagthatsubmitTranscriptaccepts asIf-Match. - The conformance cases AG-20 attachment, AG-20 transcript and AG-25 are green, and
testdata/deferred.goldenno longer holdsAG-20 attachment M3-07,AG-20 transcript M3-09andAG-25 voice M3-09. - Audio reaches only
voice.transcription_urlthrough the egress client, without redirects and only whilevoice.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 rowsD-M3-09-<n>are updated;make sql-visibilityandmake config-doc-checkare green. -
make checkis 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(portTranscripts, the outgoing portsTranscriberandVoiceSwitch, the adapteropenai_compatiblewith its request form and error classes, the jobfiles.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) andauthz(requirement offile.transcript.submit) updated. - Rulings of Design note 19 recorded in
docs/DECISIONS.md(one row each with reason). services/server/CHANGELOG.mdentry 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 checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN) with the M3 focus T-NW-01 and T-AI-03 (PLAN 9 "Red team"); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - 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. - Review and red team verdicts and the findings fixed.
- What is not proven.
- 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>.