feat(api): let a recording carry its own words, name and measurements - #142
Merged
Conversation
Migration 0013 added the columns and nothing read or wrote them. Four
things the recording page needs now exist behind the API:
- `GET /api/sessions/{id}/transcript`, assembled by the very function the
worker builds the published protocol with, under the same guild's
`merge_gap_seconds` and `document_provider`. Authorised by calling
`SessionReads.session_for` -- the same scoped statement the session's
own metadata endpoint is served from, not a second copy of the rule.
- `GET`/`PUT /api/sessions/{id}/name`, participant-authorised. Titles and
descriptions are shared, one per session, unlike a tag; the asymmetry
is deliberate and `sturnus.console.naming` says why.
- Search reaches titles and descriptions, and still does not reach a
transcript. No trigram index: it would need `CREATE EXTENSION pg_trgm`
inside the migration the worker runs at startup.
- The worker writes `sample_rate`, `channels` and `stored_bytes` while it
still holds both copies of a track on disk, and the session's tracks
serve them -- so a metadata tab costs no S3 round trip. Nullable with
no backfill, and null is not zero.
A session whose audio retention has expired still has its transcript.
The response says `audio_available: false` rather than looking broken.
TheMeinerLP
force-pushed
the
feat/api-recording-detail
branch
from
August 23, 2026 13:34
d9a23bd to
6531bb6
Compare
This was referenced Aug 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Migration 0013 created every column this round needs and left them empty on purpose. Four of them are the recording page's:
session.title,session.description, andtranscription_job.sample_rate/channels/stored_bytes. Nothing wrote them and nothing read them. This is the API side of all four, plus the transcript endpoint §4 of the phase-2 spec asks for.No console change. The shapes are at the bottom, because the UI is built against them next.
The transcript needed no storage, only a door
transcription_job.transcripthas always been kept indefinitely — the retention sweep deletes the S3 object and nothing but an enqueue or a requeue clears the column. SoGET /api/sessions/{id}/transcriptadds no storage; what it adds is a way to read what is already there.It calls
sturnus.application.assembly.assemble. The same function, from the same rows, under the same guild'smerge_gap_secondsand the samedocument_provider, localised to UTC exactly as_create_session_documentlocalises it. A second merge implementation would have drifted from the published protocol, and the drift would have shown up as a console that disagrees with the document about where one speaker stopped and the next began.Two things
assembleneeded around it — binding a provider onto the link repository, and turningmerge_gap_secondsinto atimedelta— moved out ofworker.pyand intoassembly.pyasBoundLinksandmerge_gap_from.assemblehas two callers now; anything both have to do to reach it belongs next to it rather than in one of them.merge_gap_fromis also total where the worker's inline expression was not:docs/operations.md§4.1 tells operators they may editguild_configwith SQL, and amerge_gap_secondsof"half a minute"should merge blocks by the default rule, not answer 500 on a tab.Authorisation is the same call, not the same rule written twice
Every other read port in
sturnus.console.portscarriesrequested_by, because an authorisation a handler applies afterwards is one a handler can forget.TranscriptReaderdoes not, and that is the requirement rather than a gap: the handler asksSessionReads.session_forfirst — literally the statement/api/sessions/{id}is served from, scoped bysession_participantinside the SQL — and answers 404 when it comes backNone. Expressing the participant rule a second time, as anotherWHERE, would have been a second place for the two answers to diverge.This grants nothing new. The transcript is already inside the protocol document that gets posted to the channel and linked from the session's own row. It is the same words, to the same people, through a different door. What it is still not is searchable — see below.
A session whose audio is gone still has its transcript
That is intended and it is the point of the retention window being about the recording rather than the minutes. So this endpoint answers 200 for a session whose audio endpoints answer 404, and carries
audio_available: falseso the console can say which of the two happened instead of rendering an empty tab that reads as a bug.There is a second way for the tab to be empty, and it means the opposite thing, so it gets its own field:
pending_tracksis how many of the session's speakers have not been transcribed yet. Zero blocks with a pending track is a meeting still being decoded; zero blocks with none is a meeting nobody spoke in. Both are an empty list and only that number tells them apart.A session still being recorded is answered rather than refused: its jobs are not enqueued until it closes and
assemblecannot place words between a start and an end that does not exist, soended_atis null,blocksis empty, andpending_trackssays how many are coming.A title is not a tag, and the difference is who it belongs to
session_tagis keyed by its owner: two people label the same meeting differently and neither reads the other's words.session.titleandsession.descriptionare one per session and shared by everybody who was in it.A tag is how one person finds a thing again; a title is what the meeting was. "kunde" and "nochmal ansehen" are notes to self and would be noise — or an opinion published to colleagues — if everyone saw them. "Sprint 34 planning" is not a remark about the meeting, it is its name, and a name four attendees each had to type separately is four names for one thing.
It follows that a participant may overwrite what another participant wrote, and there is no history and no author recorded. That is the trade every shared document makes; the alternative is per-person titles, which is tags again spelled longer.
GET/PUT /api/sessions/{id}/name, participant-authorised, one endpoint for both fields because one form writes both and two endpoints would let a form save half of itself.The bounds, and why these numbers
MAX_TITLE_CHARS = 200. A title is rendered in a heading, a list row and a browser tab, all of which truncate long before this. Two hundred is comfortably more than anybody types into a name field and short enough that no layout has to plan for it; a title needing more than a line is a description.MAX_DESCRIPTION_CHARS = 4000. A few paragraphs — an agenda, the decisions, who is doing what. Deliberately far short of the transcript it sits next to: this field is context for the minutes, not a second copy of them, and an unbounded text column reachable by every participant of every session is a storage decision nobody made.Otherwise it is stored as it was typed.
sturnus.console.namingnormalises almost nothing, and that is the contrast withsturnus.console.tags: a tag is lowercased and folded hard because two spellings filter differently and people report that as a tag disappearing, while a title is compared to nothing. Three things do happen and each survives the "store what you are given" rule:\r\nfrom a<textarea>is stored as\nso two identical descriptions are not two different strings.tags.normalisemakes: a stripped one produces stored text that differs from what was typed in a way nothing on the screen can show.Search reaches titles, and still not transcripts
?q=now matchessession.titleandsession.descriptionalongside the channel name, participant display names and the reader's own tags. Everything it matches is already in the response this same person gets from/api/sessions, so it narrows what somebody can see rather than widening it — a title a colleague typed is on a meeting the searcher was already in and could already open.It still does not reach a transcript, and the console says so on screen. A search index over spoken words makes "did anybody ever mention X" a question the system answers, which is a use of a colleague's voice nobody agreed to when they consented to being recorded.
sturnus.console.filtersargues this at length and the argument is unchanged.No index, deliberately.
ILIKE '%…%'over free text is answered by a GIN trigram index and by no btree, and a trigram index needsCREATE EXTENSION pg_trgm— a privileged statement, in a migration the worker runs in-process at startup, on a deployment whose database role may not be permitted to create extensions. A deployment that cannot come up is worse than a scan, and the scan here is small: the statement has already narrowed to one person's sessions before any pattern is evaluated.Follow-up, if it is ever wanted: create the extension and the GIN index in a migration that is not on the worker's startup path, where failing to create it is a feature that will not switch on rather than a system that will not start. Nothing in this PR needs it and nothing here should be read as deferring a problem — it is a scan over rows already restricted to one participant.
The file describes itself, once, where both copies exist
sturnus.console.spectrogram.parse_track_formatwalks the RIFF header live on every request — a ranged GET and a chunk decrypt to answer "how many channels" — andS3AudioStore.sizeis a second round trip for the object size.The worker holds the encrypted object and the plaintext WAV it decrypted out of it, and deletes both a few lines later.
_recorded_audioreads them there, withwave(the standard library wrote the file; the streaming parser exists because a console request has no file, not because two parsers were wanted), and the value travels toJobQueue.completealongside the transcript so one lease fences both — a worker that has lost its job must not stamp the row with a size it measured for a copy nobody is waiting for.A header nothing can parse produces
Noneand leaves the columns null rather than failing the job. Failing — and eventually killing aftermax_attempts— over a header would trade the words for the file size.Nullable, no backfill, exactly as
audio_secondswas in 0007, and null is not zero. A row predating the columns has audio that may already be deleted, so there is nothing to read them from, and nought hertz would be a claim about a recording nobody looked at.sturnus.console.statisticsalready insists on that distinction and this follows it.stored_bytesis the stored object — the encrypted one, the numberS3AudioStore.sizeis asked for — because the question it answers is what keeping this recording costs.The shapes, since the frontend will code against them
GET /api/sessions/{id}/transcript—Cache-Control: private, no-store{ "session_id": "4711", "started_at": "2026-08-21T12:00:00+00:00", "ended_at": "2026-08-21T13:00:00+00:00", "audio_available": false, "pending_tracks": 0, "participants": [ { "discord_user_id": "100", "display_name": "anna", "external_user_id": "c9a1b2e3-…", "external_display_name": "Anna A." } ], "blocks": [ { "discord_user_id": "100", "display_name": "anna", "started_at": "2026-08-21T12:03:11+00:00", "text": "wir sind uns einig" } ] }audio_available: falsewith a non-emptyblocksis the retention case and is not an error state — render the words and say the recording is gone.ended_at: nullis a session still being recorded;blocksis then empty andpending_trackssays how many speakers are still to come. 404 for a session that does not exist and for one you were not in, indistinguishably.GET/PUT /api/sessions/{id}/name{ "title": "Sprint 34 planning", "description": "what we decided" }Both members are optional on
PUTand absent means null: it is a replace, so a client sending only a title clears the description. 400 with a fixed reason for text over the bounds, for a control character, and for a member that is not text or null; the reason never repeats what was sent. 404 for a session you were not in. The response is the stored pair, which may differ from what was submitted by trimming.GET /api/sessionsandGET /api/sessions/{id}— additive only:{ "title": "Sprint 34 planning", "description": "what we decided", "tracks": [ { "discord_user_id": "100", "display_name": "anna", "audio_seconds": 3600.0, "speech_seconds": 240.5, "segment_count": 42, "sample_rate": 16000, "channels": 1, "stored_bytes": 1048576 } ] }The three new track members are numbers, not strings — unlike a snowflake,
stored_byteswould have to reach nine petabytes before a JSON number lost a digit. All three arenullfor a job finished before migration 0013, and null must not be rendered as0.Checks
2259 passedon this branch, of which 112 are new; mypy, ruff check and ruff format all clean. Rebased ontomainata6e09fb.