feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) #39

Merged
mathias merged 299 commits from feat/33-audio-upload-transcript into main 2026-10-03 14:13:37 +00:00
Owner

Closes nothing yet — see "What is not done". Squash-merge; human review gate. Label: risk/high (private-content store; guardrail-reversing ADR).

Read first

  1. ADR-033 is Proposed, decider pending you. An agent cannot accept it; merging this PR is the acceptance. It supersedes ADR-007's STT deferral for user-supplied audio only; the YouTube audio-download rejection is untouched.
  2. Two decisions are yours and block production, not review: #34 (ffmpeg/ffprobe in the distroless image — the Dockerfile is deliberately unchanged, uploads are off by default) and #35 (berget fallback sends private audio to a cloud endpoint; ships enabled as #33 specifies, endpoint shown per transcript).
  3. Repo CLAUDE.md says TBD / no PRs; this PR follows your explicit instruction for this task.

Order, as asked

ADR → RLS-scoped store + isolation test → upload → transcription adapter → chat. Ceiling measured before chunking, iguana holding ≤ 370 MB of ollama models throughout. Red-first: each slice has a (red) commit whose failure is the missing feature; going green was mutation-checked (the commit messages say what).

What was measured (and corrected #33)

  • docs/research/audio-length-ceiling-2026-10-01.md — no transcription ceiling up to 90 min (174 MB, one request, 440 s, ~12× realtime). The first failure is the public llm-api.d-ma.be ingress returning 413 above ~8–10 MB, not a Metal OOM; Tapir calls the NodePort. Chunking is therefore not built.
  • docs/research/chat-context-over-long-transcripts-2026-10-01.md — chat does not retrieve, it stuffs and truncates. phi4-mini 400s at ~13k tokens; gemma4-26b is right to 90,000 chars and silently drops the head at 120,000. Budget 90,000; uploads open on gemma4-26b.
  • #33 also said transcripts is "the only non-RLS table" (false: also invitations, user_identities, schema_migrations); the isolation test now pins the exact set. Comment on #33 lists all four corrections.

Evidence

  • task check → exit 0, 0 FAIL (run 4×; last at the final commit).
  • Live end-to-end (opt-in TestE2E_UploadTranscribeAndChat): real AAC .m4a → real ffprobe/ffmpeg → iguana/kb-whisper → stored → real chat model answered a Swedish question from the transcript; spool dir empty afterwards. With the primary forced to fail, berget/kb-whisper served it and the row recorded endpoint=berget/kb-whisper.
  • The gateway client was also run against the real gateway at 21 MB (above the ingress cap) on both backends.
  • An adversarial reviewer found no vulnerability, but its report is thinner than it reads: several "mutation would fail" claims were not executed, and it dismissed ffmpeg abuse by inspecting argv rather than hostile file content. I probed that myself: ffmpeg 7.0.2 refuses crafted HLS/concat by default, and I added -protocol_whitelist file,pipe + -format_whitelist so it does not depend on the version. Treat the review as weak evidence; the mutation checks and e2e are the strong ones.

No real transcript committed

All audio is macOS-TTS synthetic (say -v Alva) generated in a scratchpad; nothing audio-shaped is in the tree. The private /bygge recording was not touched. #31's custody decision is still open and unaffected.

What is not done (each filed)

#34 ffmpeg in image · #35 berget default · #36 per-model chat budgets / retrieval / silent-truncation guard · #37 per-recording delete, nav link, ingress body limit, spool volume · #38 unmeasured limits (>90 min, concurrency, real speech, Metal OOM).
Known hazard, unsolved: the default upload chat model (gemma4-26b) is a large ollama model on the same host as whisper — exactly the infra#273 trigger.

Deviations from "one file per commit"

Where files cannot compile apart: a migration's up+down pair, .templ + generated _templ.go, handlers.go + the handler that needs its fields, gateway.go + chain.go, config.go + uploads.go. Everything else is one file per commit.

Suggested after merge

Tag v0.31.0 (minor: new ADR + feature; last tag is v0.30.0 — CLAUDE.md's "v0.15.0" was already stale, now corrected only for the migrations line).


Created via git-mcp on behalf of @mathias

Closes nothing yet — see "What is not done". Squash-merge; human review gate. Label: **risk/high** (private-content store; guardrail-reversing ADR). ## Read first 1. **ADR-033 is `Proposed`, decider pending you.** An agent cannot accept it; merging this PR is the acceptance. It supersedes ADR-007's STT deferral **for user-supplied audio only**; the YouTube audio-download rejection is untouched. 2. **Two decisions are yours and block production, not review:** #34 (ffmpeg/ffprobe in the distroless image — the Dockerfile is deliberately unchanged, uploads are **off by default**) and #35 (berget fallback sends private audio to a cloud endpoint; ships enabled as #33 specifies, endpoint shown per transcript). 3. Repo CLAUDE.md says TBD / no PRs; this PR follows your explicit instruction for this task. ## Order, as asked ADR → RLS-scoped store + isolation test → upload → transcription adapter → chat. Ceiling measured **before** chunking, iguana holding ≤ 370 MB of ollama models throughout. Red-first: each slice has a `(red)` commit whose failure is the missing feature; going green was mutation-checked (the commit messages say what). ## What was measured (and corrected #33) - `docs/research/audio-length-ceiling-2026-10-01.md` — **no transcription ceiling up to 90 min** (174 MB, one request, 440 s, ~12× realtime). The first failure is the public `llm-api.d-ma.be` ingress returning **413 above ~8–10 MB**, not a Metal OOM; Tapir calls the NodePort. Chunking is therefore **not built**. - `docs/research/chat-context-over-long-transcripts-2026-10-01.md` — chat does not retrieve, it stuffs and truncates. `phi4-mini` 400s at ~13k tokens; `gemma4-26b` is right to 90,000 chars and **silently drops the head at 120,000**. Budget 90,000; uploads open on gemma4-26b. - #33 also said `transcripts` is "the only non-RLS table" (false: also `invitations`, `user_identities`, `schema_migrations`); the isolation test now pins the **exact set**. Comment on #33 lists all four corrections. ## Evidence - `task check` → exit 0, 0 FAIL (run 4×; last at the final commit). - Live end-to-end (opt-in `TestE2E_UploadTranscribeAndChat`): real AAC `.m4a` → real ffprobe/ffmpeg → `iguana/kb-whisper` → stored → real chat model answered a Swedish question from the transcript; spool dir empty afterwards. With the primary forced to fail, `berget/kb-whisper` served it and the row recorded `endpoint=berget/kb-whisper`. - The gateway client was also run against the real gateway at 21 MB (above the ingress cap) on both backends. - An adversarial reviewer found no vulnerability, **but its report is thinner than it reads**: several "mutation would fail" claims were not executed, and it dismissed ffmpeg abuse by inspecting argv rather than hostile file content. I probed that myself: ffmpeg 7.0.2 refuses crafted HLS/concat by default, and I added `-protocol_whitelist file,pipe` + `-format_whitelist` so it does not depend on the version. Treat the review as weak evidence; the mutation checks and e2e are the strong ones. ## No real transcript committed All audio is macOS-TTS synthetic (`say -v Alva`) generated in a scratchpad; nothing audio-shaped is in the tree. The private `/bygge` recording was not touched. #31's custody decision is still open and unaffected. ## What is not done (each filed) #34 ffmpeg in image · #35 berget default · #36 per-model chat budgets / retrieval / silent-truncation guard · #37 per-recording delete, nav link, ingress body limit, spool volume · #38 unmeasured limits (>90 min, concurrency, real speech, Metal OOM). **Known hazard, unsolved:** the default upload chat model (`gemma4-26b`) is a large ollama model on the same host as whisper — exactly the infra#273 trigger. ## Deviations from "one file per commit" Where files cannot compile apart: a migration's up+down pair, `.templ` + generated `_templ.go`, `handlers.go` + the handler that needs its fields, `gateway.go` + `chain.go`, `config.go` + `uploads.go`. Everything else is one file per commit. ## Suggested after merge Tag **v0.31.0** (minor: new ADR + feature; last tag is v0.30.0 — CLAUDE.md's "v0.15.0" was already stale, now corrected only for the migrations line). --- _Created via git-mcp on behalf of @mathias_
mathias added 56 commits 2026-10-01 15:14:50 +00:00
No transcription ceiling found up to 90 min: one 174 MB request through the
LiteLLM NodePort transcribed in 440 s. The first real failure is the public
gateway ingress rejecting bodies over ~8-10 MB with 413, not whisper or a
Metal OOM. Also records that srt arrives wrapped in a JSON envelope, that the
two backends differ in timestamp granularity, and that the existing chat path
truncates at 18k chars (~22 min of speech).

Synthetic Swedish TTS fixture only; no real transcript.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Proposed, decider pending. Supersedes ADR-007's STT deferral for
user-supplied audio only; the YouTube audio-download rejection stands.
Settles #28 decision 1 as a separate RLS-scoped uploaded_transcripts table
and records the measured length ceiling, the ingress 413, and the 18k-char
chat truncation that #33 assumed did not exist. Two DECISION NEEDED items
(ffmpeg in the runtime image; berget fallback for private audio).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Drives RLS directly as the non-superuser app role: deny-all with no scope,
own-rows-only scoped reads, zero-row cross-user UPDATE/DELETE, rejected
cross-user INSERT, FORCE + NOT NULL user_id, cascade on DeleteUser. Also pins
the EXACT set of public tables with row security off, so a new table added
without RLS fails CI rather than passing it (ADR-033). The existing test only
named transcripts; #33's "the only non-RLS table" is false -- invitations,
user_identities and schema_migrations are off too.

Red: relation "uploaded_transcripts" does not exist. Set test passes today
and guards the migration that follows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Separate table, not a user_id on the shared transcripts (ADR-021). ENABLE +
FORCE ROW LEVEL SECURITY with the migration-003 policy, user_id NOT NULL
ON DELETE CASCADE, typed error_kind, per-row serving endpoint, JSONB segments.
CHECK constraints: done => content, failed => error_kind.

Up and down are one commit: golang-migrate pairs them.

Mutation-checked: dropping FORCE, the policy, or the cascade each turn a test
red. FORCE is only observable structurally -- the app role is not the table
owner, so no behavioural test can see it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Lifecycle queued -> transcribing -> done|failed, per-row serving endpoint,
segments JSONB round trip, owner-only get/write (not-found, not forbidden),
list without transcript text, and the start-up sweep that fails unfinished
rows as `interrupted` for every user (audio is not retained, so no resume).

Red: CreateUpload and friends are undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Segment, Transcription, UploadStatus, UploadErrorKind and UploadedTranscript.
Error kinds are typed, never a provider's string. UploadedTranscript is a
distinct type from the shared public-caption Transcript on purpose.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Create/Start/Complete/Fail/Get/List plus the start-up sweep that fails
unfinished rows as interrupted (audio is not retained, so no resume). Every
call goes through withUser AND names user_id in its predicate, so isolation
does not rest on RLS alone and stays testable under the superuser test
connection; a non-owner gets ErrUploadNotFound, never a distinct forbidden.
List drops transcript text.

Mutation-checked: removing the user predicate from Get or from the UPDATEs
turns the cross-user tests red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Probe-based, not extension-based: a Teams voice-only export is typically an
.mp4 with no video stream, indistinguishable from video by name (ADR-033).
Red: package uploads does not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Video is checked first so it is always refused as video; limits and reasons
are typed so callers and tests branch on Reason, not message text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Red: uploads.Job, Intake and ErrUnreadable are undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Bounded read (cap+1 byte tells 'at the limit' from 'over'); a file that fails
validation creates no row; a prober fault is our error, not the user's bad
file; a row whose job cannot be submitted is failed, never left queued.

Mutation-checked: cap off-by-one, missing cleanup and deleting the handed-over
file each turn a test red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Red: package audio does not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Cover art is not video; non-zero exit is the file's fault (ErrUnreadable),
a missing binary / cancelled ctx / bad output is ours. WaitDelay bounds a
cancelled probe whose child holds the pipe open.

Checked against real ffprobe on iguana: JSON shape matches the fixtures and
/etc/hosts exits 1 with 'Invalid data found when processing input'. The unit
tests drive a stub process; ffprobe is not installed on koala.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Real Router + real Intake + real Postgres; only ffprobe and the worker are
faked. Covers: the form states the limits up front; accepted audio queues a
row and redirects; video / too-long / too-large are rejected synchronously
(422/422/413) with a message and NO row; a non-owner gets 404 without the
title leaking; unfinished states keep polling and terminal states stop (no
stuck spinner); a done transcript names the endpoint that served it.

Red: App.Uploads / App.UploadLimits are undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Two defects in my red tests, found when going green: body() drains the
recorder so a second call read empty, and two assertions expected a different
case than the user-facing message. The assertions were not weakened.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
GET/POST /uploads, GET /uploads/{id}, GET /uploads/{id}/status, mounted only
when an Uploader is wired. The multipart body is streamed to Intake, never
buffered. Rejections are synchronous: 422 (video, too long, unreadable) or 413
with the user-facing message and no row. Another user's id is a 404 that does
not leak the title. Unfinished states poll; done/failed render without
hx-trigger so nothing polls forever (ADR-025). A done transcript names the
endpoint that served it.

handlers.go, uploads.go and the templ pair are one commit: the App fields and
Store methods are what make the handlers compile.

Mutation-checked: dropping the 413 mapping, the not-found mapping, or letting
a done card poll each turn a test red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Gateway: field is `file`, no Expect header, a computed Content-Length (not
chunked, not buffered -- a 90 min WAV is ~170 MB), verbose_json normalised
with the last segment clamped to the audio duration, errors independent of
the body, an empty transcript is a failure. Chain: ANY primary failure falls
back and the row records the fallback endpoint; a cancelled job never falls
back to the cloud.

Red: package transcribe does not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Streams the WAV from disk with a computed Content-Length (no buffering, no
chunking), posts verbose_json with field `file` and language=sv, clamps the
final segment to the audio duration, and treats an empty transcript as a
failure. The chain falls back on ANY primary error, records the serving
endpoint, and never falls back once the context is cancelled.

gateway.go and chain.go are one commit: the chain's tests use the gateway's
StatusError and ErrEmptyTranscript, so neither compiles alone.

Mutation-checked: field name, clamp, cancel-fallback, content length and the
recorded endpoint each turn a test red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The httptest fake proves only what we THINK the gateway accepts. Skipped
unless TAPIR_LIVE_WAV and TAPIR_LIVE_GATEWAY_URL are set. Run once on
2026-10-01 with 11 min of synthetic Swedish (21 MB, above the 8-10 MB ingress
cap, via the NodePort): both iguana/kb-whisper (52.8 s) and berget/kb-whisper
(35.4 s) accepted the streamed, computed-Content-Length body and returned
usable verbose_json. Synthetic audio only; this repo is public.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Red: audio.FFmpeg is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Replaces whisper-server --convert. A zero exit that wrote nothing is an error.
Also fixes the red test's stub, which wrote an empty file and so (correctly)
tripped the no-output check.

Checked against real ffmpeg on iguana with the same argv: AAC .m4a in,
pcm_s16le 16000 Hz mono WAV out, duration preserved.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Success path deletes both the upload and the WAV; failures are typed and still
delete the audio; a shutdown mid-job leaves the row for the start-up sweep
(it is not a transcription failure); a hung endpoint is failed by the job
timeout, never an eternal spinner; strictly one job at a time (two long jobs on
iguana were never measured); stopping deletes queued audio and refuses new jobs.

Red: uploads.Worker is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Mutation-testing the worker showed deleting stop()'s cleanup went unnoticed:
Process was incidentally deleting the audio of jobs it should never have
started. The stop test now asserts only the running job was converted (red on
the old code). The timeout test no longer hangs for the 10-minute go test
default when the timeout is removed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Queue -> StartTranscribing -> ffmpeg to 16 kHz WAV -> gateway chain ->
CompleteUpload, with a per-job timeout so a hung endpoint ends in failed, not
a spinner. The upload and the WAV are deleted on every path (audio is never
retained). A shutdown mid-job is not a failure: the row is left for the
start-up sweep. Cancellation wins over a non-empty queue, so queued jobs are
never started on shutdown; stop() deletes their audio and refuses new jobs.

Concurrency is 1 on purpose: two simultaneous long jobs on iguana were never
measured, and a Metal OOM there latches whisper (infra#273). The queue is
process-local, so ADR-018's single-replica assumption now has a second
dependent.

Mutation-checked, -race clean: cleanup, shutdown-vs-failure, timeout,
concurrency and stop-cleanup each turn a test red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Off by default until the image has ffmpeg; 200 MB / 90 min (the longest length
measured); iguana primary, berget fallback that an explicit empty value
disables; zero or negative limits are refused rather than silently rejecting
every upload.

Red: the Config fields do not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Embedded UploadConfig. Off by default until the image carries ffmpeg; 200 MB /
90 min; iguana primary with a berget fallback that an explicit empty
TAPIR_TRANSCRIBE_FALLBACK_MODEL disables (private audio then never leaves the
homelab); zero or negative limits fail at load instead of rejecting every
upload at runtime.

uploads.go and the two-line hook in config.go are one commit: the tests need both.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Red: buildUploads is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Returns nils when disabled (never a typed-nil Intake). When enabled: private
0700 spool dir, delete audio left by a crash, fail rows a restart left queued
or transcribing (the audio is not retained, so they cannot resume), then build
the iguana->berget chain, worker and intake. An empty
TAPIR_TRANSCRIBE_FALLBACK_MODEL keeps private audio on the homelab. Enabling
with no gateway fails at start-up, not at the first upload.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Wires buildUploads into cmdServe: Intake and limits into the web App, the
worker on the server's ctx. Logs the two facts an operator must not miss: the
queue is process-local (single replica), and a configured fallback is a cloud
endpoint that private audio can leave the homelab through.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The 18,000-char default is ~22 min of speech and keeps only the head; an
uploaded hour is ~50,000 chars. Red: Request.MaxChars is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
chat.Request.MaxChars overrides the Service cap when positive. The 18,000-char
default (~22 min of speech) suits captions; an uploaded hour is ~50,000 chars
and truncation keeps only the head. This is reuse of the existing chat path,
not a fork.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Owner sees the panel posting to /uploads/{id}/chat; an unfinished upload
redirects to its status; ANOTHER USER'S upload is 404 on GET and POST and no
model is ever called with it; the owner's transcript is sent with the upload
budget (not the 18,000-char caption default); an unoffered model falls back; a
model failure is inline and keeps the conversation; routes are absent without a
chat backend; the done card links to chat only when chat is wired.

Red: App.UploadChatMaxChars is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
templ escapes the apostrophe in "couldn't", so the assertion could never match
the rendered page. A test defect, not a loosening: the failure must still be
shown inline with the conversation kept.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
GET/POST /uploads/{id}/chat, mounted only when both an Uploader and a Chatter
are wired. The row comes from the user-scoped private store, so another user's
upload is a 404 before any model is involved. The owner's transcript goes to
the SAME Chatter with App.UploadChatMaxChars instead of the 18,000-char caption
default. The chat panel is shared, not forked: chatView gains PostURL/Subject
(empty = a YouTube video, so existing callers are unchanged). The done card
links to chat only when chat is wired.

Mutation-checked: dropping the upload budget, ignoring the done-status guard,
or scoping the lookup to the wrong user each turn tests red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Real ffprobe/ffmpeg, the live gateway and a real chat model, a private
embedded Postgres. Skipped unless TAPIR_E2E_AUDIO is set. Run 2026-10-01 with
82 s of synthetic Swedish as AAC .m4a:
- primary: iguana/kb-whisper, 82.6 s audio, 1,186 chars; asked who owns the
  timber order, the answer named Anders and the 15 November deadline from the
  transcript; the spool directory was empty afterwards.
- primary forced to fail (TAPIR_TRANSCRIBE_MODEL=iguana/does-not-exist): the
  fallback served it and the row recorded endpoint=berget/kb-whisper.
Synthetic audio only; this repo is public.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Adds the private uploaded_transcripts entity and the exact-set isolation rule
(ADR-033). Also corrects two lists that still named `transcripts` as an RLS'd
table: it has been shared non-RLS since ADR-021 / migration 015, and these
lists sit next to the very boundary that matters.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The 'no audio-download + STT' bullet is narrowed to YouTube (ADR-007 stands);
adds the upload packages, the exact-set isolation test and the pending ffmpeg
decision to the build state.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
iguana/gemma4-26b answered correctly at 90,000 chars but SILENTLY dropped the
head of a 120,000-char transcript (prompt tokens fell to 16k; 'not in the
text'); koala/phi4-mini has an 8,192-token context and answers HTTP 400 at
~13k tokens. So the budget is 90,000 and the upload chat model defaults to
gemma4-26b. Red: UploadChatModel is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Red: App.UploadChatModel is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The Chatter's own default is the summarizer's (koala/phi4-mini, 8,192-token
context), which answers HTTP 400 on an hour of speech. Empty keeps the old
behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
I wired Uploads and UploadLimits in cmdServe but not UploadChatMaxChars: chat
over a long recording would have silently used the 18,000-char caption
default. A helper that main and the e2e test both call makes that a tested fact.

Red: wireUploads is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
cmdServe assigned Uploads and UploadLimits but not UploadChatMaxChars, so chat
over a long recording would have used the 18,000-char caption default. It now
calls wireUploads, which is tested.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
phi4-mini 400s at ~13k tokens; gemma4-26b is correct to 90,000 chars and
silently loses the HEAD at 120,000 (no error, no truncation flag). The
end-needle test alone passed at 120k -- only a head-needle exposed it.
Records the default-model trap and the iguana contention hazard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The proposed 120,000-char budget was a guess; measured, gemma4-26b silently
loses the head at that size and phi4-mini (first in the offered list) 400s on
an hour of speech. Budget is now 90,000, uploads open on gemma4-26b, the
retrieval trigger is concrete, and the iguana contention hazard is recorded.
ADR-033 is still Proposed, so this is an in-place correction, not a supersession.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A file's CONTENT can tell ffmpeg to open other paths or URLs (HLS, concat
playlists). ffmpeg 7.0.2 refuses the ones tried, but that is version luck and
the image's ffmpeg is still to be chosen (#34). Pin protocol_whitelist to
file,pipe and format_whitelist to the demuxers real audio uses, before the input.

Red: audio.AllowedDemuxers is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The upload is attacker-supplied and its CONTENT can steer ffmpeg to open other
files or URLs (HLS/concat playlists). Pins -protocol_whitelist file,pipe and
-format_whitelist to the containers real recordings use, before the input
(after -i it would apply to the output).

Verified with the real static ffmpeg 7.0.2 on koala: real AAC .m4a and WAV
still probe/convert; a crafted concat file is rejected with 'Format not on
whitelist'. Note ffmpeg 7.0.2 ALREADY refused the playlists tried without the
whitelist, so the live check cannot prove the whitelist -- the argv unit tests
do; this protects against a different ffmpeg version being put in the image.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
test(audio): opt-in live ffprobe check on hostile playlists and real audio
CI / Lint / Test / Vet (pull_request) Successful in 40s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
6ebb4e49e9
Skipped unless TAPIR_LIVE_FFPROBE is set. Synthetic/crafted files only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
mathias added the risk/hightype/feat labels 2026-10-01 15:14:58 +00:00
mathias added 17 commits 2026-10-01 16:44:15 +00:00
The first draft rejected video and named #28. The maintainer's first real
recording -- a Teams meeting with the camera off -- is still an .mp4 with a
video track, so that rejected the primary use case. Decision 1 and 4 now:
accept, extract audio only, discard the video, record had_video, and tell the
user. A file with no audio stream is still rejected. Video features stay #28.
In-place amendment: the ADR is still Proposed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A Teams meeting with the camera off is still an .mp4 with a video track.
Video with no audio is now rejected as having no audio.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Red: Store.CreateUpload does not take hadVideo yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Red: CreateUpload takes no hadVideo and the row has no HadVideo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Video with no audio is rejected for having none. A file with a video track is
accepted and marked had_video; every state of its page (queued, transcribing,
done, failed) says only its audio was used and the video was discarded, and a
plain audio file shows no such notice.

Red: the had_video column does not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Up and down are one commit: golang-migrate pairs them. RLS is unchanged: the
column is on the already-FORCE'd table, so the exact-set isolation test still
holds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Only no-audio, unreadable length and too-long are refused. ReasonVideo is gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
My assertion expected a lowercase 'only'; the sentence begins 'Only'. The user
facing wording is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A Teams meeting with the camera off is still a video file. It is accepted, only
its audio is transcribed, and the page says so in queued, transcribing, done and
failed -- not just at the end.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Run 2026-10-01 with 82 s of synthetic Swedish muxed with black video (H.264 +
AAC .mp4, built with the real ffmpeg): accepted, had_video=true, transcribed by
iguana/kb-whisper, the page said only its audio was used, and chat answered.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
docs(data-model): had_video
CI / Lint / Test / Vet (pull_request) Successful in 26s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
365dff5c85
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Owner

Scope change since the description above was written (details on #33): a file with a video track is now accepted, its audio used and the video discarded, with a notice on the page and a had_video column (migration 018). Reason: the first real recording, a camera-off Teams meeting, is still an .mp4 with a video track, so the original "reject video, name #28" rule rejected the primary use case. ADR-033 decisions 1 and 4 were amended in place (still Proposed). Where the description says video is rejected, read it as superseded. task check exit 0 at the latest commit.


Created via git-mcp on behalf of @mathias

**Scope change since the description above was written** (details on #33): a file with a video track is now **accepted**, its audio used and the video discarded, with a notice on the page and a `had_video` column (migration 018). Reason: the first real recording, a camera-off Teams meeting, is still an `.mp4` with a video track, so the original "reject video, name #28" rule rejected the primary use case. ADR-033 decisions 1 and 4 were amended in place (still Proposed). Where the description says video is rejected, read it as superseded. `task check` exit 0 at the latest commit. --- _Created via git-mcp on behalf of @mathias_
mathias added 4 commits 2026-10-01 17:35:34 +00:00
Rejections are synchronous and leave no row, so an operator could not tell 'the
user never retried' from 'the page refused it' (it happened during the first
real-recording trial). The reason is operational; the filename and title are
the user's private content.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Found by comparing the first real one-hour transcript against pianissimo:
whisper.cpp wraps at ~60 chars and can wrap INSIDE a word. The continuation
segment has no leading space, the text field joins segments with newlines, and
a segment can be a lone '.'. Measured 2026-10-01: 7% of segments (96/1332) in a
synthetic hour, 42/1302 lines in the real one. My earlier tests used short
sentences and could not see it. berget's segments are whole sentences with no
leading space, so the two backends need different joins.

Red: Text still joins by newline.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
fix(transcribe): rebuild the transcript from segments so split words are whole
CI / Lint / Test / Vet (pull_request) Successful in 22s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
d11afb5ff1
whisper.cpp wraps at ~60 chars and can wrap inside a word; the response's text
field joins segments with newlines, so chat saw 'nyt' / 't godkännande'. Text
is now rebuilt from the raw segments: concatenated when any segment carries a
leading space (whisper.cpp), joined by a space when none do (berget's sentence
level), whitespace collapsed; the text field is only a fallback.

Checked against the first real one-hour transcript with pianissimo's vocabulary
as an independent yardstick: 36 spans changed, 31 now consist entirely of words
pianissimo also produced, words unknown to pianissimo fell 224 -> 177 and word
agreement rose 92.4% -> 93.1%. Five rejoined spans still contain an unknown
word (names/loanwords or an unlucky glue) -- a residual risk, small.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
mathias added 1 commit 2026-10-01 17:45:57 +00:00
docs(research): kb-whisper vs pianissimo on one real hour of Swedish speech
CI / Lint / Test / Vet (pull_request) Successful in 22s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
ebc6cc3ff8
A near tie on accuracy (blind A/B by the speaker: 17 vs 13 on the 30 spans that
change meaning) and a 6x difference in speed (40 s vs 4m08s for 63.7 min).
Different error profiles: pianissimo better on terms and numbers, kb-whisper on
everyday words. Aggregate numbers only -- no transcript text or domain terms,
this repo is public. Also records the mid-word wrapping bug the comparison found
and its fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
mathias added 2 commits 2026-10-01 21:15:14 +00:00
Eleven local models, 120 calls each, scored against the speaker's blind answers
on 30 disagreements, with pass criteria fixed before the run. gemma4-31b (97%)
and gemma4-26b (92%) pass; nothing else clears accuracy and order-consistency.
Records why 31b is slow (MLX gives the same speed; Low Power Mode is the
unproven suspect) and the caveats: 30 items, one recording, thinking models
under-budgeted. Aggregate numbers only; this repo is public.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
docs(adr): ADR-034 transcribe with two engines, reconcile with a local model
CI / Lint / Test / Vet (pull_request) Successful in 29s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
de9871c9c3
Proposed, decider pending. Two local engines on one WAV; word-level diff with
pianissimo as the base (readability and timing, NOT accuracy -- that was a tie);
trivial disputes resolved without a model; the rest adjudicated by a LOCAL model
(alias must start koala/ or iguana/) whose only freedom is choosing between the
two readings. Reconciliation never fails a job; the page says what was done and
lists the uncertain spots. Off by default. Validation: re-upload the real
recording and keep the feature only if the reconciled reading is right >= 85% of
the time and no worse than the better single engine.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
mathias added 14 commits 2026-10-01 21:20:41 +00:00
Keeps the word as written, keys it without case/punctuation/hyphens/plus, joins a
lone dash or full stop to its neighbour so it is not a word the engines can
disagree about. Red: package reconcile does not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A random-input property test compares the equal runs against a dynamic-programming
LCS: a diff that is valid but not minimal would turn real agreement into disputes
the model is then asked about. A 6,500-word, 380-edit case mirrors a real hour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Prefix/suffix trimmed first; adjacent deletes and inserts read as one Replace;
beyond 3,000 edits (two texts sharing almost nothing) the middle is one Replace
instead of gigabytes of trace. Mutation-checked: a wrong tie-break, missing
replace coalescing, wrong suffix accounting and a punctuation-bearing key each
turn tests red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A dispute carries both readings with their own spelling; differences separated by
a single shared word are one decision, two shared words keep them apart; compound
splits/joins and a short run of filler words are resolved without a model, while
numbers, content words, and 'nej' (an answer) are not.

Red: FindDisputes, AutoResolve and ContextAround are undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Disputes carry both readings with their own spelling; differences separated by
one shared word merge into one decision. Compound splits/joins and one or two
filler particles are resolved without a model; 'nej', numbers and content words
never are. Mutation-checked (merge distance, filler limit, 'nej', compound rule).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Readings in the requested order; 1/2 mapped to base/alt under both orders;
UNSURE, an unparseable or out-of-range reply is unsure rather than a guess; an
empty reading is described, not blank; a model error is an error, distinguishable
from 'unsure'. The optional order-checked wrapper asks twice with the order swapped
and keeps only agreement.

Red: Request, Verdict, NewLLMAdjudicator and NewOrderChecked are undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Mutation-testing the adjudicator showed dropping the word boundary went unnoticed:
'ANSWER: 12' would be read as 1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sends the exact wording that scored 92% (gemma4-26b) and 97% (gemma4-31b); maps
1/2 back to base/alt under both orders; an unreadable reply is unsure, a transport
error is an error. OrderChecked asks twice with the order swapped and keeps only
agreement. Mutation-checked: answer mapping, unparseable-as-guess, the word
boundary and the order check each turn a test red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Identical texts and trivial disputes cost no model call; an alt verdict takes the
alternative's own words, base/unsure keep the base and only unsure is flagged;
insertions and deletions land in the right place; the reading order alternates; the
model gets the base's context. Three consecutive model errors stop the whole
reconciliation (base text, none of the partial changes, reason recorded) while a
success in between resets the breaker; a cancelled job is an error, not 'skipped'.
Spots carry the second they start at; the stored list is capped, the counts are not.

Red: Reconcile, Options, MaxContested and domain.Reconcile* are undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Disputes separated by a single shared word are merged into one decision by design,
so several of my fixtures were secretly one dispute. Corrected before any
implementation existed; the rule is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Status (reconciled / single_engine / skipped), exact counts, and a bounded list of
contested spots (changed or unsure) with a position and both readings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Mutation-testing showed the forced-unsure line was untested because my fake
always returned unsure with its errors.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
feat(reconcile): merge two transcripts with a model choosing between disagreements
CI / Lint / Test / Vet (pull_request) Successful in 20s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
f25acebe4c
Base text stands except where the model picks the alternative's reading; trivial
disputes skip the model; the reading order alternates; the base's context goes to
the model. Fails soft: three consecutive model errors return the base text
unreconciled (none of the partial changes) with the reason; a verdict returned with
an error is ignored; a cancelled job is an error, not 'skipped'. Counts are exact,
the stored list of contested spots is capped at 200 and carries the second each
starts at. Mutation-checked on nine behaviours.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
mathias added 8 commits 2026-10-01 21:23:40 +00:00
Both run in order (the fast one first) and are reconciled; identical texts cost no
model call; one engine down uses the other and says so, with the cloud fallback
untouched; only when BOTH local engines fail does the fallback run, and the row says
berget served it; both failing with no fallback reports both errors; a cancelled
job never reaches the cloud; a model that is down yields the base text unreconciled,
not a failed job.

Red: transcribe.Ensemble is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Mutation-testing showed each of the two cancellation checks was redundant with the
other in my first test. Now a cancel during the first engine must not start the second
or the cloud, and a cancel during the second must not be recorded as a single-engine
success ('kb-whisper failed').

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Runs the fast engine then the other from one WAV and reconciles; one engine down
uses the other and says so; the model down gives the base text unreconciled; the
cloud fallback runs only when BOTH local engines fail and is recorded as such; a
cancelled job stops everything and never reaches the cloud. The endpoint lists every
contributor. Mutation-checked, including both cancellation points.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Counts and contested spots round-trip; a single-engine transcript has NO
reconciliation (nil, not empty -- the page must not claim a second opinion); a
skipped reconciliation keeps its reason.

Red: Transcription.Reconciliation is stored nowhere and UploadedTranscript has none.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
JSONB, NULL for a single engine so the page cannot claim a second opinion. On the
already FORCE'd, RLS-scoped table, so the exact-set isolation test is unaffected.
Up and down are one commit: golang-migrate pairs them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
CompleteUpload writes it (SQL NULL when there was none, never the JSON text 'null':
the page claims a second opinion only if the column is non-NULL); GetUpload reads it,
List does not. Mutation-checked.

domain.go and the store are one commit: the store tests need the new field.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Reconciled: who the two engines and the deciding model were, and the counts
(differences, settled automatically, decided by the model, uncertain). The contested
spots are listed with a minute mark, both readings and which was used, uncertain ones
marked, readings escaped, and a very long list shortened with its true count. A
single-engine transcript makes no claim of a second opinion; one-engine and
model-unavailable cases say why.

Red: the page renders none of this yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
feat(web): say what reconciliation did, and never claim a second opinion that was not there
CI / Lint / Test / Vet (pull_request) Successful in 21s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
6fb7a52980
Reconciled: the two engines, the deciding model and the exact counts; the contested
spots (changed or uncertain) listed with a minute mark and both readings, the first
50 shown and the rest counted from the exact totals. Single-engine and
model-unavailable results say why. A nil reconciliation renders nothing. Readings are
escaped. Mutation-checked: the cap, the minute rounding and the 'more' count.

uploads.go and the templ pair are one commit: the helpers and the template need each other.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
mathias added 18 commits 2026-10-01 21:28:07 +00:00
TAPIR_RECONCILE_BASE_MODEL, not 'second model': the engine added to turn the
feature on is the one whose TEXT is the base, and the old name hid that. All three
models must be local aliases; the base must differ from the other engine. ADR is
still Proposed, so this is an in-place correction.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Off by default; a base engine needs an adjudicator; every model must be a local alias
(koala/ or iguana/): a cloud adjudicator is a start-up error because private meeting
content must not reach it; the base engine must differ from the other engine; default
job timeout 60m (two engines plus adjudication).

Red: the Config fields do not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Mutation-testing showed a substring match instead of a prefix match went unnoticed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
TAPIR_RECONCILE_BASE_MODEL turns it on and needs TAPIR_RECONCILE_MODEL; every model must
be a local alias (koala/ or iguana/, matched as a PREFIX): a cloud adjudicator is a
start-up error because private meeting content must not reach it; the two engines must
differ; default job timeout 60m. Off by default. Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The reconciler was measured at temperature 0 but the client hardcodes 0.2. Zero must
be sent explicitly, and the default must not change for existing callers.

Red: WithTemperature is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Zero is sent as zero (the field has no omitempty). The reconciler's measured accuracy
was at temperature 0, so it runs there; every other caller keeps 0.2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Red: buildTranscriber is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Found because the project's lint step cannot fail: the Taskfile runs
'golangci-lint run ./... || echo "not installed -- skipping"', so a failing lint
prints that message and exits 0. CI's log showed these findings and still passed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Default is the single-engine chain exactly as before. With TAPIR_RECONCILE_BASE_MODEL set,
the base engine and the primary run on the same WAV and a LOCAL model (config has already
refused any non-local alias) reconciles them, at temperature 0 with a short reply -- the
conditions its accuracy was measured under. The cloud fallback is unchanged and runs
only when both local engines fail.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
TAPIR_E2E_RECONCILE=1 runs pianissimo + kb-whisper + gemma4-26b on the audio and
asserts the reconciliation is recorded, every contributor is named in the endpoint, and
the page says what was done. Run 2026-10-01 on 82 s of synthetic Swedish: 3 disputes, all
3 changed; against the known script 2 were right (virket, a missing 'procent') and 1 was
WRONG (Maria -> Vi replaced a correct base reading). A smoke test, not a measurement.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
docs(claude): two-engine reconciliation, and that task check does not gate lint (#41)
CI / Lint / Test / Vet (pull_request) Successful in 21s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
7ae888a468
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Owner

Scope added since the description above: two-engine transcription with a local reconciler (ADR-034, migration 019, internal/reconcile, transcribe.Ensemble). Off by default; the existing single-engine path is unchanged. ADR-034 is Proposed like ADR-033 — merging accepts both.

Also fixed on this branch: the transcribe gateway client now rebuilds text from segments (kb-whisper wraps mid-word), the llm client gained WithTemperature (the reconciler was measured at temperature 0), and rejected uploads are logged by reason only.

A correction to my earlier "gate green" claims. task check does not fail on lint: the Taskfile runs golangci-lint run ./... || echo "not installed — skipping", so findings print that message and exit 0 (#41; CI run 8730 shows it). My earlier green reports covered format, vet and tests but not lint. Three real errcheck findings in this PR's code were hiding behind that; they are fixed, and at the latest commit golangci-lint run ./... reports 0 issues and task check exits 0 with 0 failing tests (checked by exit code, not by grepping).

Needs you: ADR-033 and ADR-034 (Proposed); #34 ffmpeg in the image; #35 berget fallback default; and the blind re-check on a real recording that ADR-034 names as its deciding test.


Created via git-mcp on behalf of @mathias

**Scope added since the description above:** two-engine transcription with a local reconciler (ADR-034, migration 019, `internal/reconcile`, `transcribe.Ensemble`). Off by default; the existing single-engine path is unchanged. ADR-034 is `Proposed` like ADR-033 — merging accepts both. Also fixed on this branch: the `transcribe` gateway client now rebuilds text from segments (kb-whisper wraps mid-word), the `llm` client gained `WithTemperature` (the reconciler was measured at temperature 0), and rejected uploads are logged by reason only. **A correction to my earlier "gate green" claims.** `task check` does not fail on lint: the Taskfile runs `golangci-lint run ./... || echo "not installed — skipping"`, so findings print that message and exit 0 (#41; CI run 8730 shows it). My earlier green reports covered format, vet and tests but not lint. Three real `errcheck` findings in this PR's code were hiding behind that; they are fixed, and at the latest commit `golangci-lint run ./...` reports **0 issues** and `task check` exits 0 with 0 failing tests (checked by exit code, not by grepping). **Needs you:** ADR-033 and ADR-034 (Proposed); #34 ffmpeg in the image; #35 berget fallback default; and the blind re-check on a real recording that ADR-034 names as its deciding test. --- _Created via git-mcp on behalf of @mathias_
mathias added 2 commits 2026-10-02 05:13:10 +00:00
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
docs(adr-034): gemma4-31b slowness explained; reopen the 26B-vs-31B reconciler choice
CI / Lint / Test / Vet (pull_request) Successful in 22s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
7f8e3677b3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
mathias added 2 commits 2026-10-02 06:01:33 +00:00
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
docs(adr-034): record the first blind check; bar ambiguity and the unanswered second condition
CI / Lint / Test / Vet (pull_request) Successful in 23s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
1a89eb3868
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
mathias added 43 commits 2026-10-02 07:06:07 +00:00
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
test(e2e): a finished job's phases are all closed and it was reporting while it ran (ADR-035)
CI / Lint / Test / Vet (pull_request) Successful in 23s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
7029dd1d7f
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Owner

Added to this PR (2026-10-02): progress, logging and judged spots. ADR-035 is Proposed; merge = acceptance. Closes #42, #43, #44.

Prompted by the first real run (63.6 min of audio, 21 min 49 s): the page showed only queued/transcribing/done for 22 minutes, the log said nothing, and the evaluation could not be finished because kept spots were not stored.

What changed

  • Progress (#42). The page shows the job's real phases as a log: finished ones collapse to a line with their duration, the running one has a spinner and elapsed time, and only the deciding phase gets a bar (real counts, 142 of 282). It tells running from quiet (the deciding phase has not advanced for 5 min) from lost (no heartbeat for 45 s) using two timestamps, alive_at and progress_at (migration 020). No percentage is shown that does not come from real counts. Same tokens, mono font and components as the rest of tapir; no new JS.
  • Logging (#43). One internal/progress.Tracker is the only writer, so the page and the log cannot disagree. One INFO line per phase, adjudicated counts at most once a minute, WARN on a soft-failed engine or a tripped breaker, "still running" every 5 min. Ids, counts, durations and error text only; a test asserts no transcript word and no spool path in the log.
  • Judged spots (#44). Every spot the model decided is stored with its verdict and the 8 words before it (cap 2,000). The page lists only changed and uncertain ones. Saved segments now carry the reconciled words with the base's timing (left as the base's when they cannot be aligned). This makes ADR-034's second condition measurable next time.

Verified, by exit code: golangci-lint run ./... 0 (0 issues), go vet ./... 0, go test -race -count=1 ./... 0 (20 packages, no failures), task check 0, gofumpt clean. The live e2e (TestE2E_UploadTranscribeAndChat, synthetic video, two engines, local models) passes in 28.8 s and records 8 phases with the heartbeat stamped. Mutation checks were run on each new unit; three survived and each was fixed by a stronger test (heartbeat wait, cross-segment span) or by removing unreachable code.

Screenshot review (headless Chrome on iguana, light and dark, seven states) found three defects, all fixed: the spinner sat 4 px off the glyph column, the bar overshot the time column, and the lost mark was the accent colour instead of the error colour.

Not done / limits

  • CI for head 7029dd1 was still running when this was written. I have not seen it green; check the run before merging.
  • A single blocking engine call has no sub-progress, so inside it the page shows elapsed time only. A hung call with a live service is bounded only by the 60-minute job timeout.
  • Not re-measured on a real long recording: the ADR-034 check has not been repeated with the new stored spots. The next real run will answer its second condition.
  • The progress display was reviewed by me from screenshots; it has not been reviewed by you.

Created via git-mcp on behalf of @mathias

**Added to this PR (2026-10-02): progress, logging and judged spots. ADR-035 is Proposed; merge = acceptance. Closes #42, #43, #44.** Prompted by the first real run (63.6 min of audio, 21 min 49 s): the page showed only queued/transcribing/done for 22 minutes, the log said nothing, and the evaluation could not be finished because kept spots were not stored. **What changed** - **Progress (#42).** The page shows the job's real phases as a log: finished ones collapse to a line with their duration, the running one has a spinner and elapsed time, and only the deciding phase gets a bar (real counts, `142 of 282`). It tells **running** from **quiet** (the deciding phase has not advanced for 5 min) from **lost** (no heartbeat for 45 s) using two timestamps, `alive_at` and `progress_at` (migration 020). No percentage is shown that does not come from real counts. Same tokens, mono font and components as the rest of tapir; no new JS. - **Logging (#43).** One `internal/progress.Tracker` is the only writer, so the page and the log cannot disagree. One INFO line per phase, `adjudicated` counts at most once a minute, WARN on a soft-failed engine or a tripped breaker, "still running" every 5 min. Ids, counts, durations and error text only; a test asserts no transcript word and no spool path in the log. - **Judged spots (#44).** Every spot the model decided is stored with its verdict and the 8 words before it (cap 2,000). The page lists only changed and uncertain ones. Saved `segments` now carry the reconciled words with the base's timing (left as the base's when they cannot be aligned). This makes ADR-034's second condition measurable next time. **Verified, by exit code:** `golangci-lint run ./...` 0 (0 issues), `go vet ./...` 0, `go test -race -count=1 ./...` 0 (20 packages, no failures), `task check` 0, gofumpt clean. The live e2e (`TestE2E_UploadTranscribeAndChat`, synthetic video, two engines, local models) passes in 28.8 s and records 8 phases with the heartbeat stamped. Mutation checks were run on each new unit; three survived and each was fixed by a stronger test (heartbeat wait, cross-segment span) or by removing unreachable code. **Screenshot review** (headless Chrome on iguana, light and dark, seven states) found three defects, all fixed: the spinner sat 4 px off the glyph column, the bar overshot the time column, and the lost mark was the accent colour instead of the error colour. **Not done / limits** - **CI for head `7029dd1` was still running when this was written.** I have not seen it green; check the run before merging. - A single blocking engine call has no sub-progress, so inside it the page shows elapsed time only. A hung call with a live service is bounded only by the 60-minute job timeout. - Not re-measured on a real long recording: the ADR-034 check has not been repeated with the new stored spots. The next real run will answer its second condition. - The progress display was reviewed by me from screenshots; it has not been reviewed by you. --- _Created via git-mcp on behalf of @mathias_
Author
Owner

Update to my note above: CI run 8801 for head 7029dd1 has completed with conclusion success (checked live, https://git.d-ma.be/mathias/tapir/actions/runs/8801). Note that CI is not a lint gate on its own terms either until #41 is fixed; the lint result in my note is from running golangci-lint run ./... directly (exit 0, 0 issues).


Created via git-mcp on behalf of @mathias

Update to my note above: CI run 8801 for head `7029dd1` has completed with conclusion **success** (checked live, https://git.d-ma.be/mathias/tapir/actions/runs/8801). Note that CI is not a lint gate on its own terms either until #41 is fixed; the lint result in my note is from running `golangci-lint run ./...` directly (exit 0, 0 issues). --- _Created via git-mcp on behalf of @mathias_
Author
Owner

Adversarial review of head 7029dd1 (skills:pr-gatekeeper): BLOCK

Run at the owner's request, review only. The reviewer reproduced findings by execution in a scratch copy and did not touch the worktree (git status identical before and after). This is the reviewer's report; I have not yet re-run its reproductions. Each finding below gets a failing test first, then a fix, in follow-up commits on this branch.

Held under execution: tenant isolation (non-owner gets 404 on page, status and chat, with no title or step label leaked; foreign and nonexistent ids look identical), hostile ffmpeg inputs (HLS, ffconcat, SDP, ffmetadata, DASH, m3u, bare file lists, all refused), output escaping (titles, filenames, spot words, labels, reasons), cancel-then-cloud ordering in Chain and Ensemble, the exact-set RLS test, reconcile robustness (100,000 randomised runs, no panic), no secrets or media in the diff or history. About 50 mutations went red.

Findings

  1. HIGH, owner call. A private uploaded transcript can be sent to a cloud model through the chat switcher. The switcher lists berget/mistral-small by default and /uploads/{id}/chat posts the transcript (up to 90k chars) to whichever listed model the form names; TAPIR_UPLOAD_CHAT_MODEL is not checked as local. The approved berget decision (#35) covers audio transcription only. Reproduced.
  2. MEDIUM. The duration limit is a header claim. A 2 h m4a with patched headers probed as 10 s, passed the 90-minute limit, and converted to a 120-minute WAV (23x amplification). A file truncated at 44% succeeds with a partial transcript and no warning.
  3. MEDIUM. Provider response text reaches the log, the database and the page (Reconciliation.Reason), contradicting ADR-033's "typed, never a provider's string". Mechanism reproduced with a fake gateway; whether a real gateway echoes prompts is not known.
  4. MEDIUM. A hung primary never reaches the cloud fallback: no per-engine deadline, and the chain refuses the fallback once the job context has expired. Reproduced.
  5. LOW-MEDIUM. The spool path (and a control-character filename) is logged on the convert and multipart error paths.
  6. LOW-MEDIUM. The worker queue is unbounded (100,000 submits returned nil; each holds up to 200 MB on disk).
  7. LOW. 500 instead of 4xx for a non-UUID status path and for NUL or invalid UTF-8 in a title; a swallowed read error.
  8. LOW. isLocalModel is a bare prefix check: koala/../berget/kb, koala//x, koala/x?host=berget, iguana/ are accepted. Whether LiteLLM would route them elsewhere is not known.
  9. LOW. Test honesty. Surviving mutants: log removals (Begin, Finish, FailRunning, the worker's convert/transcribe/transcribed lines), Tracker.Fail not saving, a CompleteUpload or temp-file failure not calling abort(), the StatusError snippet cap, the snapshot copy. Overclaiming names: TheLogNamesEveryPhaseInOrderAndNeverTheTranscriptOrThePath (happy path only), EveryTransitionIsLoggedByTheSameCall, TheFullPageRendersTheSameDisplayAsTheFragment, FailuresAreTypedAndStillDeleteTheAudio.
  10. INFO. Some test fixtures use payment jargon the reviewer could not tell from the private recording; a research note names a private recording file. No transcript text found. To be scrubbed.

Not verified by the reviewer: mov alis sibling references, ffprobe CPU/stream-count abuse, CSRF cookie flags, real LiteLLM routing of odd aliases, an unbounded gateway response body, alive_at DB clock vs app clock, a hung DB blocking Tracker.save, non-amd64.

This PR should not be merged until the above are addressed or explicitly accepted by the owner.


Created via git-mcp on behalf of @mathias

## Adversarial review of head `7029dd1` (skills:pr-gatekeeper): **BLOCK** Run at the owner's request, review only. The reviewer reproduced findings by execution in a scratch copy and did not touch the worktree (git status identical before and after). **This is the reviewer's report; I have not yet re-run its reproductions.** Each finding below gets a failing test first, then a fix, in follow-up commits on this branch. **Held under execution:** tenant isolation (non-owner gets 404 on page, status and chat, with no title or step label leaked; foreign and nonexistent ids look identical), hostile ffmpeg inputs (HLS, ffconcat, SDP, ffmetadata, DASH, m3u, bare file lists, all refused), output escaping (titles, filenames, spot words, labels, reasons), cancel-then-cloud ordering in Chain and Ensemble, the exact-set RLS test, reconcile robustness (100,000 randomised runs, no panic), no secrets or media in the diff or history. About 50 mutations went red. **Findings** 1. **HIGH, owner call. A private uploaded transcript can be sent to a cloud model through the chat switcher.** The switcher lists `berget/mistral-small` by default and `/uploads/{id}/chat` posts the transcript (up to 90k chars) to whichever listed model the form names; `TAPIR_UPLOAD_CHAT_MODEL` is not checked as local. The approved berget decision (#35) covers audio transcription only. Reproduced. 2. **MEDIUM. The duration limit is a header claim.** A 2 h m4a with patched headers probed as 10 s, passed the 90-minute limit, and converted to a 120-minute WAV (23x amplification). A file truncated at 44% succeeds with a partial transcript and no warning. 3. **MEDIUM. Provider response text reaches the log, the database and the page** (`Reconciliation.Reason`), contradicting ADR-033's "typed, never a provider's string". Mechanism reproduced with a fake gateway; whether a real gateway echoes prompts is not known. 4. **MEDIUM. A hung primary never reaches the cloud fallback**: no per-engine deadline, and the chain refuses the fallback once the job context has expired. Reproduced. 5. **LOW-MEDIUM. The spool path (and a control-character filename) is logged** on the convert and multipart error paths. 6. **LOW-MEDIUM. The worker queue is unbounded** (100,000 submits returned nil; each holds up to 200 MB on disk). 7. **LOW. 500 instead of 4xx** for a non-UUID status path and for NUL or invalid UTF-8 in a title; a swallowed read error. 8. **LOW. `isLocalModel` is a bare prefix check**: `koala/../berget/kb`, `koala//x`, `koala/x?host=berget`, `iguana/` are accepted. Whether LiteLLM would route them elsewhere is not known. 9. **LOW. Test honesty.** Surviving mutants: log removals (`Begin`, `Finish`, `FailRunning`, the worker's convert/transcribe/transcribed lines), `Tracker.Fail` not saving, a `CompleteUpload` or temp-file failure not calling `abort()`, the `StatusError` snippet cap, the snapshot copy. Overclaiming names: `TheLogNamesEveryPhaseInOrderAndNeverTheTranscriptOrThePath` (happy path only), `EveryTransitionIsLoggedByTheSameCall`, `TheFullPageRendersTheSameDisplayAsTheFragment`, `FailuresAreTypedAndStillDeleteTheAudio`. 10. **INFO.** Some test fixtures use payment jargon the reviewer could not tell from the private recording; a research note names a private recording file. No transcript text found. To be scrubbed. **Not verified by the reviewer:** mov `alis` sibling references, ffprobe CPU/stream-count abuse, CSRF cookie flags, real LiteLLM routing of odd aliases, an unbounded gateway response body, `alive_at` DB clock vs app clock, a hung DB blocking `Tracker.save`, non-amd64. This PR should not be merged until the above are addressed or explicitly accepted by the owner. --- _Created via git-mcp on behalf of @mathias_
mathias added 78 commits 2026-10-02 14:52:30 +00:00
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
docs(claude): errors are logged and shown as kinds, and what carries transcript text must be local (ADR-035)
CI / Lint / Test / Vet (pull_request) Successful in 25s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
aa54089230
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
mathias added 33 commits 2026-10-02 15:24:39 +00:00
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
test(web): Go escapes for the bidirectional characters, which staticcheck ST1018 requires -- task check does not gate lint (#41)
CI / Lint / Test / Vet (pull_request) Successful in 25s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
08ffc2ff62
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Owner

Second adversarial pass (head aa54089): BLOCK (narrow) -- and what was done about it

Reviewer's report, reproduced by execution in a scratch copy; worktree untouched (git status identical before and after). F1-F4 held under attack: no escape of the private-content boundary, no provider text in logs, DB or page, every hang shape reaches the fallback, forged chat models always land on a local one.

Findings and status at head 08ffc2f

  1. MEDIUM, reproduced -- my regression. The new audio_incomplete check refused a complete recording whose container header over-estimated its length (a VBR MP3 claimed 79 min for 20 real ones). Fixed: a short file is transcribed and the audio step carries a note ("the audio is 20 min long; the file claimed 1 h 19 min..."); it is no longer refused. The 80%, two-minute and 30-second tolerances are now pinned by boundary tests.
  2. MEDIUM, reproduced. A valid local TAPIR_UPLOAD_CHAT_MODEL outside the video chat's chain was silently swapped for koala/phi4-mini. Fixed: it is added to the shared chat service's list when uploads are on and it is local.
  3. Dockerfile never built. Correction to what I told you: I said CI builds the image on this PR. It does not (if: github.event_name != 'pull_request'); run 8810 was green without building it. I built it myself with buildah on koala: the first build FAILED (tar: xz: Cannot exec; the golang image has no xz), so merging would have broken the build on main. Fixed (71cd50a), rebuilt: 180 MB, ffmpeg and ffprobe execute as nonroot in distroless, env correct. The first CI build of this Dockerfile is still the merge to main.
  4. A body cut off mid-file was a 500: now a 400. 5. Queue room is now checked before spooling, probing or creating a row. 6. The engine timeout default is 20 minutes (two wedged engines no longer eat 50 of 60). 8. Bidirectional controls are stripped from titles; Swedish letters, emoji and joiners are kept.
  5. Start-up log of the spool path (low, operator-only): not changed. 9. The "decided by Mathias" lines in the ADRs record the owner's own words in this conversation; the reviewer could not confirm that independently.

Mutants that survived in the second pass (boundary tolerances, the ensemble's deadline wiring, an unpinned ratio) now have tests and die. Two test names were overclaiming and were renamed.

Verified at 08ffc2f, by exit code: golangci-lint run ./... 0 (it failed once on two staticcheck findings in my own new test; task check still said 0, which is #41), go vet 0, go test -race -count=1 ./... 0 (21 packages), task check 0; live e2e on the 31B passes. A step note was screenshot-reviewed.

CI for 08ffc2f has not been checked yet. A third, narrow review of the delta since aa54089 is running; this PR should not be merged before it reports.


Created via git-mcp on behalf of @mathias

## Second adversarial pass (head `aa54089`): **BLOCK (narrow)** -- and what was done about it Reviewer's report, reproduced by execution in a scratch copy; worktree untouched (git status identical before and after). **F1-F4 held under attack**: no escape of the private-content boundary, no provider text in logs, DB or page, every hang shape reaches the fallback, forged chat models always land on a local one. **Findings and status at head `08ffc2f`** 1. **MEDIUM, reproduced -- my regression.** The new `audio_incomplete` check refused a *complete* recording whose container header over-estimated its length (a VBR MP3 claimed 79 min for 20 real ones). **Fixed:** a short file is transcribed and the audio step carries a note ("the audio is 20 min long; the file claimed 1 h 19 min..."); it is no longer refused. The 80%, two-minute and 30-second tolerances are now pinned by boundary tests. 2. **MEDIUM, reproduced.** A valid local `TAPIR_UPLOAD_CHAT_MODEL` outside the video chat's chain was silently swapped for `koala/phi4-mini`. **Fixed:** it is added to the shared chat service's list when uploads are on and it is local. 3. **Dockerfile never built.** **Correction to what I told you: I said CI builds the image on this PR. It does not** (`if: github.event_name != 'pull_request'`); run 8810 was green without building it. I built it myself with buildah on koala: **the first build FAILED** (`tar: xz: Cannot exec`; the golang image has no xz), so merging would have broken the build on main. Fixed (`71cd50a`), rebuilt: 180 MB, `ffmpeg` and `ffprobe` execute as `nonroot` in distroless, env correct. The first CI build of this Dockerfile is still the merge to main. 4. A body cut off mid-file was a 500: now a 400. 5. Queue room is now checked before spooling, probing or creating a row. 6. The engine timeout default is 20 minutes (two wedged engines no longer eat 50 of 60). 8. Bidirectional controls are stripped from titles; Swedish letters, emoji and joiners are kept. 7. Start-up log of the spool path (low, operator-only): not changed. 9. The "decided by Mathias" lines in the ADRs record the owner's own words in this conversation; the reviewer could not confirm that independently. **Mutants that survived in the second pass** (boundary tolerances, the ensemble's deadline wiring, an unpinned ratio) now have tests and die. Two test names were overclaiming and were renamed. **Verified at `08ffc2f`, by exit code:** `golangci-lint run ./...` 0 (it failed once on two staticcheck findings in my own new test; `task check` still said 0, which is #41), `go vet` 0, `go test -race -count=1 ./...` 0 (21 packages), `task check` 0; live e2e on the 31B passes. A step note was screenshot-reviewed. **CI for `08ffc2f` has not been checked yet. A third, narrow review of the delta since `aa54089` is running; this PR should not be merged before it reports.** --- _Created via git-mcp on behalf of @mathias_
mathias added 12 commits 2026-10-02 15:37:15 +00:00
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
docs(adr): third review -- the hang-up path, the client-read marker, the note kept visible
CI / Lint / Test / Vet (pull_request) Successful in 26s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
6e50a97b6d
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Owner

Third adversarial pass (delta aa54089..08ffc2f): BLOCK (narrow) -- fixed at head 6e50a97

Reviewer's report; reproduced by execution in a scratch copy, worktree untouched. HELD: the note replaces the false refusal (the 4739-s-claim MP3 is now transcribed with a note; a header that lies low is still refused), the configured chat model is really used and cannot reach the cloud, queue room is checked atomically (30 concurrent accepts: 3 ok, 27 busy, no leak), titles, the 20-minute engine timeout, and the Dockerfile gating (dry-run: tampered tarball, both-404 and partial download all fail the build).

Findings and status

  1. MEDIUM, reproduced over a real socket. A client that hangs up mid-file logged an ERROR (and half-close returned a 500): the error page ran its database read on a cancelled context. My in-process test could not see it. Fixed: a request whose context is cancelled is logged at INFO and not answered; the new tests drive a real listener with half-close, full close and RST.
  2. LOW-MEDIUM. Mapping io.ErrUnexpectedEOF to a 400 would also turn a database connection drop into "your upload was cut off". Fixed: the 400 follows a marker, uploads.ErrClientRead, set only on errors from reading the client's body; a test shows a wrapped DB EOF stays a 500 with an ERROR log.
  3. LOW-MEDIUM. The note that replaced the refusal sat in a collapsed list on the finished page. Fixed: the record opens when any step has a note; a clean record stays collapsed.
  4. The video chat's switcher also lists the upload model while uploads are on (local only): recorded in ADR-035. 5. A stopped worker answers a misleading "busy": filed (#46).

Survivors addressed: all twelve bidirectional characters and the exact 80% boundary are now pinned; HasRoom is compared with Submit at every step and for a stopped worker; the note's placement is asserted, not just its presence. One mutant still survives (marking every receive error as the client's) because a disk-full write cannot be injected without refactoring the spool; filed as #46 item 12.

Verified at 6e50a97, by exit code: golangci-lint run ./... 0, go vet 0, go test -race -count=1 ./... 0 (21 packages), task check 0.

CI for 6e50a97 is not yet checked. A fourth, very narrow review of this last delta is running. The PR is not mergeable by my account until it reports and until you have decided.


Created via git-mcp on behalf of @mathias

## Third adversarial pass (delta `aa54089..08ffc2f`): **BLOCK (narrow)** -- fixed at head `6e50a97` Reviewer's report; reproduced by execution in a scratch copy, worktree untouched. **HELD:** the note replaces the false refusal (the 4739-s-claim MP3 is now transcribed with a note; a header that lies low is still refused), the configured chat model is really used and cannot reach the cloud, queue room is checked atomically (30 concurrent accepts: 3 ok, 27 busy, no leak), titles, the 20-minute engine timeout, and the Dockerfile gating (dry-run: tampered tarball, both-404 and partial download all fail the build). **Findings and status** 1. **MEDIUM, reproduced over a real socket.** A client that hangs up mid-file logged an ERROR (and half-close returned a 500): the error page ran its database read on a cancelled context. My in-process test could not see it. **Fixed:** a request whose context is cancelled is logged at INFO and not answered; the new tests drive a real listener with half-close, full close and RST. 2. **LOW-MEDIUM.** Mapping `io.ErrUnexpectedEOF` to a 400 would also turn a *database* connection drop into "your upload was cut off". **Fixed:** the 400 follows a marker, `uploads.ErrClientRead`, set only on errors from reading the client's body; a test shows a wrapped DB EOF stays a 500 with an ERROR log. 3. **LOW-MEDIUM.** The note that replaced the refusal sat in a collapsed list on the finished page. **Fixed:** the record opens when any step has a note; a clean record stays collapsed. 4. The video chat's switcher also lists the upload model while uploads are on (local only): recorded in ADR-035. 5. A stopped worker answers a misleading "busy": filed (#46). **Survivors addressed:** all twelve bidirectional characters and the exact 80% boundary are now pinned; `HasRoom` is compared with `Submit` at every step and for a stopped worker; the note's placement is asserted, not just its presence. **One mutant still survives** (marking every receive error as the client's) because a disk-full write cannot be injected without refactoring the spool; filed as #46 item 12. **Verified at `6e50a97`, by exit code:** `golangci-lint run ./...` 0, `go vet` 0, `go test -race -count=1 ./...` 0 (21 packages), `task check` 0. CI for `6e50a97` is not yet checked. A fourth, very narrow review of this last delta is running. The PR is not mergeable by my account until it reports and until you have decided. --- _Created via git-mcp on behalf of @mathias_
mathias added 9 commits 2026-10-02 15:47:39 +00:00
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
test(web): the 499 test cancels the client mid-Accept, as a real hang-up does
CI / Lint / Test / Vet (pull_request) Successful in 27s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
12d1365607
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Owner

Fourth adversarial pass (delta 08ffc2f..6e50a97): BLOCK -- fixed at head 12d1365

HELD: the client-read marker (a spool write fault induced with RLIMIT_FSIZE is not marked; an ffprobe failure, a store failure and a submit failure wrapping io.ErrUnexpectedEOF are not marked; an oversize body is still a 413), the open record when a step has a note (no injection through the note), all twelve bidirectional controls and legitimate Arabic, Hebrew, Persian and emoji titles, and the regression sweep.

Findings and status

  1. MEDIUM, reproduced. A hang-up inside the title part still logged a false ERROR: that branch had no guard. My real-socket tests only covered mid-file. Fixed, and the real-socket tests now cover every position (in the title, in the file part's headers, mid-file) times every hang-up (half-close, full close, RST): nine cases.
  2. LOW-MEDIUM. An abandoned request left an implicit empty 200. Fixed: it answers 499.
  3. LOW (inferred). A Postgres error logged raw can quote a fragment of the offending value, which for a transcript row is transcript text. Fixed: a database error is logged as its SQLSTATE (kind=sql_22P02), never its message, and every worker log now carries a kind.

The survivors the reviewer found (the title guard, the header-part guard) are now killed, plus the new guards, the 499 and the SQLSTATE mapping. One test name was overclaiming and was renamed. The reviewer's note that a hang-up with a genuine server fault at the same moment is logged only at INFO is a design trade-off, not changed.

Gate at 12d1365, by exit code: lint 0, vet 0, go test -race -count=1 ./... 0 (21 packages), task check 0.

A slip of mine, disclosed: my first gate run for this head exited 1 on the race tests and my command pushed anyway because I chained with ; instead of && (your standing rule). The failure was not the code: an orphaned embedded Postgres from an earlier session's test run had held port 55170 for a day (embedded-postgres start: process already listening). I killed that exact process, re-ran the whole gate cleanly (all exit 0 as above), and confirmed the pushed commit is the one that was verified.

CI for 12d1365 is not yet checked, and the first CI build of the Dockerfile is the merge to main. Four reviews, each narrower, have each found something real in the previous round's fixes. The decision to merge is the owner's.


Created via git-mcp on behalf of @mathias

## Fourth adversarial pass (delta `08ffc2f..6e50a97`): **BLOCK** -- fixed at head `12d1365` **HELD:** the client-read marker (a spool write fault induced with RLIMIT_FSIZE is not marked; an ffprobe failure, a store failure and a submit failure wrapping `io.ErrUnexpectedEOF` are not marked; an oversize body is still a 413), the open record when a step has a note (no injection through the note), all twelve bidirectional controls and legitimate Arabic, Hebrew, Persian and emoji titles, and the regression sweep. **Findings and status** 1. **MEDIUM, reproduced.** A hang-up inside the *title* part still logged a false ERROR: that branch had no guard. My real-socket tests only covered mid-file. **Fixed**, and the real-socket tests now cover every position (in the title, in the file part's headers, mid-file) times every hang-up (half-close, full close, RST): nine cases. 2. **LOW-MEDIUM.** An abandoned request left an implicit empty `200`. **Fixed:** it answers 499. 3. **LOW (inferred).** A Postgres error logged raw can quote a fragment of the offending value, which for a transcript row is transcript text. **Fixed:** a database error is logged as its SQLSTATE (`kind=sql_22P02`), never its message, and every worker log now carries a kind. The survivors the reviewer found (the title guard, the header-part guard) are now killed, plus the new guards, the 499 and the SQLSTATE mapping. One test name was overclaiming and was renamed. The reviewer's note that a hang-up with a genuine server fault at the same moment is logged only at INFO is a design trade-off, not changed. **Gate at `12d1365`, by exit code: lint 0, vet 0, `go test -race -count=1 ./...` 0 (21 packages), `task check` 0.** **A slip of mine, disclosed:** my first gate run for this head exited 1 on the race tests and my command pushed anyway because I chained with `;` instead of `&&` (your standing rule). The failure was not the code: an orphaned embedded Postgres from an earlier session's test run had held port 55170 for a day (`embedded-postgres start: process already listening`). I killed that exact process, re-ran the whole gate cleanly (all exit 0 as above), and confirmed the pushed commit is the one that was verified. CI for `12d1365` is not yet checked, and the first CI build of the Dockerfile is the merge to main. Four reviews, each narrower, have each found something real in the previous round's fixes. The decision to merge is the owner's. --- _Created via git-mcp on behalf of @mathias_
mathias merged commit 4a2067063d into main 2026-10-03 14:13:37 +00:00
Sign in to join this conversation.