Bug-hunt round 10: timeline evidence integrity, capture diagnostics, pipeline gates, docs accuracy - #27
Merged
Conversation
A hand-edited or exchanged timeline.jsonl could carry two utterances sharing one id: analyze's id-keyed uttText index silently kept only the later one, so an honest verbatim quote of the first was rejected while a quote of the second validated for a finding anchored at the first's time, durably pairing a quote with a moment it was never spoken at. Likewise an entry whose src was neither speech nor event fell out of both of report's render buckets and vanished from the rendered timeline while end() still counted its time into the Duration header and analyze kept its id citable. analyze now refuses duplicate non-empty ids (in SafeText form, the form the index and emitted request use) at timeline load, naming both lines; report and analyze refuse an unknown src via a shared timeline.CheckSrc, naming the line and value. report deliberately keeps rendering duplicate ids (its join is positional); merge-produced timelines are unaffected. Assisted-by: Claude:claude-fable-5
ffmpeg prints its avfoundation input banner on every successful open (no -hide_banner is passed), so the bare module name in avSignatures made looksLikeAVFailure true for essentially every start-up failure: a full disk, a missing encoder, or an unwritable session directory was headlined 'most likely a permissions issue' with a System Settings pointer, and the non-permissions branch of classifyRecorderExit was unreachable for any failure past the input dump. The bare 'failed to' and 'abort' substrings matched benign warnings the same way. The list now carries device-open indicators only; the real TCC lines (Failed to create AVCaptureDeviceInput, Failed to open device, not authorized) keep matching. Assisted-by: Claude:claude-fable-5
WriteFileNoFollow opens with O_TRUNC, so the prior sidecar's bytes were destroyed before the new ones were written; a write failure in between left a truncated audio.offset.json behind — and because the conversion rollback triggers only on a sidecar write that succeeded, the truncated file was never restored. Every later bare transcribe then refused on the unreadable sidecar, with the prior offset unrecoverable from the session. writeOffsetSidecar (and the rollback restore) now go through a new session.WriteFileAtomicNoFollow: same-directory temp file plus rename, refusing a planted symlink up front like WriteFileNoFollow, surfacing the close error before the rename so a deferred write-back failure cannot rename a corrupt temp file into place. Assisted-by: Claude:claude-fable-5
demo -addr :0 printed the unopenable http://localhost:0 and exited 0: the displayed URL came from the requested address, while the real port was knowable only outside the tool. Serve now records the bound address on the returned http.Server (keeping the requested host, taking the port from the listener), and demo.Run and record's status line print that. For any explicit port the two agree and nothing changes. Assisted-by: Claude:claude-fable-5
The over-long-record refusal printed 'record %d' with the 0-based index into the caller's merged, time-sorted slice — 'record 0' is a line of no file, and no line of the source transcript either. The message now names the 1-based line of the file being written, the only position an operator can count to. Assisted-by: Claude:claude-fable-5
record.Run defaults Options.Log to os.Stderr; transcribe.Run wrote its offset status line straight to opts.Log, so a caller leaving Log nil panicked at the first Fprintf. Default it the same way. Assisted-by: Claude:claude-fable-5
The three bounded stderr tails (record's outputTail and lockedBuffer.tail, transcribe's tail) sliced at a byte offset, so a non-ASCII device or file name straddling the cut opened the operator-facing message with an invalid UTF-8 fragment. Advance the cut to the next rune start. Assisted-by: Claude:claude-fable-5
navigator.sendBeacon returns false (it does not throw) when the UA refuses to queue the body — most commonly the in-flight beacon quota, which a large rrweb batch can hit — and both call sites had already drained the buffer with splice, so the batch was lost with no trace: nothing reached the server, so the stderr refusal log could not see the drop either. The demo page (and the how-to's copyable snippet) now check the return and fall back to fetch. Also guard labelFor against a non-Element target, matching selectorFor. Assisted-by: Claude:claude-fable-5
The bundled demo app seeded its display-name field with a name outside the repository's Alice/Bob/Carol persona set, and the tutorial narrating this exact interaction has Alice changing the name to 'Alice'. Seed it as Alice. Assisted-by: Claude:claude-fable-5
The trust-model comments said only a verification gh performs and rejects refuses the install, but the implementation also refuses when gh cannot complete the verification (network or API failure) — and rightly so: an inconclusive verification cannot be told apart from one an attacker is blocking, and that attacker could also serve a substituted tarball with a matching SHA256SUMS. Align the two comment blocks with the fail-closed behavior instead of loosening it. Assisted-by: Claude:claude-fable-5
A failing command substitution inside say's argument does not trip set -e, so an unrunnable installed binary (a wrong-platform asset, a noexec mount) printed 'Installed: ... ()' and exited 0. Capture the binary's own version output, refuse the install when it fails to run or reports anything other than 'testimony $VERSION', and print the captured value in the success line. Assisted-by: Claude:claude-fable-5
The smoke assertions were all satisfiable from the transcript and findings alone — deleting interactions.jsonl left every one green — so a regression that dropped all events, or broke the utterance-event join, passed both ci and the release verify gate. Assert the exact header counts and an event-only selector string in both workflows. Also close two adjacent gaps: parse install.sh (sh -n, bash -n) in ci, since it is served live from main as the documented install path with no syntax gate; and, post-release, download a published tarball and require the binary's own version output to name the tag — the Go linker silently ignores a -X flag whose symbol does not exist, so a renamed internal/cli.Version would ship every tarball as 'testimony dev' with the release green. Assisted-by: Claude:claude-fable-5
- session-directory.md and the report entry: the header duration is the latest moment on the timeline (t1 of the last utterance when speech closes the session), not 'from the last entry'; report's entry names its inputs (manifest.json required, findings.jsonl when present) as the sibling entries do. - record's entry states the ffmpeg prerequisite README and the installer already state. - instrument-your-own-app.md and the allowWrite doc comment describe the Origin guard as loopback (any loopback host, whatever its port), which is what the code enforces; only cli.md had it right. - analyse-a-session.md names the non-interactive review form as the way to change a recorded verdict; the interactive walk offers only findings still unverified. - how-alignment-works.md scopes the word-timestamp claim to what the join consumes: whole-utterance boundaries, sharpened by alignment — no word-level anchoring knob exists. - README states that voice and screen capture need macOS, and its pipeline diagram corners now join up. - CHANGELOG: the preamble no longer promises a Breaking section no release has (breaks are called out under Changed); the Unreleased groups drop internal bug-hunt round labels and merge the duplicated invocation-contract group; round-10 entries added. - DECISIONS.md records the round. Assisted-by: Claude:claude-fable-5
BuildEntries copies each utterance's id verbatim, so a transcript reusing one merged at exit 0 and produced a duplicate-id timeline.jsonl — which analyze then refused while naming the generated file the next merge would recreate, not the transcript the operator can repair. Both adversarial PR reviews flagged the gap (and the false claim in the earlier commit that merge never emits duplicates). checkedUtterances now refuses the reuse, naming both utterances; ids are compared in SafeText form, the collision they later become in analyze's index. Assisted-by: Claude:claude-fable-5
ReadJSONL skips blank lines, so the new duplicate-id and unknown-src refusals' 'line N' labels miscounted on exactly the hand-edited files they target — the same a-line-of-no-file defect this branch fixed in WriteJSONL. The messages now say 'entry N', and the comments state that entry ordinals match file lines only for blank-line-free files. Assisted-by: Claude:claude-fable-5
…write WriteFileAtomicNoFollow used os.CreateTemp plus Chmod, which applies perm exactly: the offset sidecar became the one artefact whose mode ignored the process umask, and an operator-tightened 0600 sidecar silently widened to 0644 on rewrite. The temp file now opens O_CREATE|O_EXCL with the effective mode (umask applies, as in a plain open), and an existing regular file's own mode is preserved across the replacement. Assisted-by: Claude:claude-fable-5
The version assertion ran after install(1) had already replaced any previously good binary, so a refusal died with the bad binary in place — unlike the checksum and attestation refusals, which fail before touching the destination. The check now runs on the extracted binary in the download's temp directory; only a binary that runs and names the pinned release is installed. Releases predating the version stamp (v0.1.0 only) report 'testimony dev' and are refused, which the comment records. Assisted-by: Claude:claude-fable-5
The round's earlier comment rewrite claimed an unreachable attestation API refuses the install, but the auth pre-check itself needs the API: an authenticated gh on a network that blocks api.github.com fails that check and takes the checksum-fallback branch with a not-authenticated note. Both comment blocks now state the real boundary — cannot-attempt (absent, too old, or authentication not establishable) falls back with a note; a verification attempted and then rejected or failed mid-way refuses, fail closed. Assisted-by: Claude:claude-fable-5
The fetch fallback added for a refused sendBeacon had no rejection handler, and the enclosing try/catch cannot catch an async rejection — so a refused or oversized keepalive POST surfaced as an unhandled promise rejection, in a snippet whose comment promises capture never breaks the app under test. Both the demo page and the how-to's copyable snippet gain the catch; the how-to's interaction() also gains the non-Element target guard the demo page already has. Assisted-by: Claude:claude-fable-5
Seeding the field as Alice made the tutorial's step a no-op: it has Alice changing the display name to 'Alice', so a reader following it literally typed nothing and fired no input event, losing the very interaction the tutorial and the bundled sample session record. Bob keeps the seed inside the persona set while leaving Alice's change real. Assisted-by: Claude:claude-fable-5
Adversarial-review corrections: the new duplicate-id and unknown-src refusals are documented in cli.md and session-directory.md (the pages enumerate every other refusal); both session-end/duration definitions state the maximum-over-entries rule instead of the 'last utterance' paraphrase, which overlapping utterances falsify; the verdict-change command carries its required -session flag; README says 'elsewhere' rather than 'on Linux' (the capture gate is on every non-macOS platform); the CHANGELOG drops the sh/bash one-liner overclaim, marks the two new refusals as Behaviour changes per its own preamble (now pointing at that convention instead of a Breaking section), removes a stray blank line, and describes the installer and recorder-diagnosis fixes precisely; the avSignatures comment states the heuristic's limits on both sides instead of overclaiming device-open precision; offsetSidecarExists names the atomic write it now meets; the allowWrite comment is rewrapped; DECISIONS.md's ADR link no longer points at a directory that does not exist yet. Assisted-by: Claude:claude-fable-5
…t ids checkedUtterances compares utterances only with each other, so a transcript utterance named into the ev-NNN namespace collided with the event ids BuildEntries synthesises: merge exited 0 with a duplicate-id timeline that analyze then refused, blaming the generated file — the misdirection the transcript-boundary check was added to remove, and a counterexample to the reference's merge-never-emits-duplicates claim. Merge now scans the built entries and refuses the collision, naming the transcript. The reuse message also notes ids are compared with invisible characters stripped. Assisted-by: Claude:claude-fable-5
…ng it Verifying the extracted binary in the download's temp directory broke the documented curl-pipe-sh path on hosts with a noexec /tmp or TMPDIR: a perfectly good binary failed the probe with exit 126 after checksum and attestation had passed, with a misleading wrong-platform diagnosis. The probe now runs from a staged copy inside the install directory — where the binary must be executable to be of any use — and only the verified copy is renamed onto the final name, so a refusal still leaves any previously installed binary untouched. The staged name is swept by both cleanup traps. Assisted-by: Claude:claude-fable-5
…t mode The predictable pid-counter temp names let anyone who can write the session directory pre-plant all 101 candidates and deterministically block the sidecar write, where os.CreateTemp's random names could not be exhausted; names now carry a crypto/rand suffix. Mode preservation is applied with fchmod on the temp file, which the umask does not filter, so an existing file's mode round-trips exactly instead of tighten-only; the comment states the two deliberate rename-semantics differences (read-only target replaced, possible temp residue on crash). Assisted-by: Claude:claude-fable-5
The CLI reference's merge-never-carries-either-defect claim is restated over the now-true guarantee (utterance ids repeating or colliding with synthesized event ids both refuse); the ingest comment names both merge gates; the CHANGELOG preamble drops the Behaviour-marker convention claim its own history does not keep uniformly, the merge bullet covers the namespace collision, and the ragged bullet is refilled; the README paragraph is rewrapped; the how-to snippet gets the demo page's own labelFor (guarded, posting with empty selector and label rather than dropping the record), matching the same-conventions claim. Assisted-by: Claude:claude-fable-5
Both final reviews noted the transcript id row documented only same-file uniqueness while merge now also refuses a collision with the synthesized ev-NNN event ids, and the refilled CHANGELOG bullet kept one over-long line; the row states the full rule and the bullet wraps with its siblings. Assisted-by: Claude:claude-fable-5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Round 10 of the bug-hunt loop (state: #24). Four hunt dimensions, every candidate finding faced two independent adversarial refuters; a finding survived only when both failed to kill it. 3 substantive and 22 nitpick findings confirmed and fixed; 3 refuted. Every code fix carries a test watched to fail before the change; full gate set green (gofmt, vet, test, -race, pipeline smoke).
Substantive findings (fixed)
analyzevalidated quotes against the wrong utterance when timeline ids collide.internal/analyze/validate.go:59keyeduttTextby id, so of two utterances sharing an id only the later survived the index; nothing upstream enforces uniqueness (internal/timeline/timeline.go:294-337copies transcript ids verbatim). Reproduced both directions: an honest verbatim quote of the first duplicate rejected at exit 1, and a quote of the second accepted for a finding anchored at the first's time — durably pairing a quote with a moment it was never spoken at, in the artefact the verbatim gate exists to protect. Fixed where both refuters located the root: analyze's timeline load now refuses duplicate non-empty ids (inSafeTextform, the form the index and the emitted request use), naming both lines.reportdeliberately keeps rendering such files — its join is positional (internal/report/report.go:60-72).Every post-banner recorder failure was diagnosed as a permissions denial.
internal/record/tcc.go:12-18listed the bare"avfoundation"(and"failed to") as failure signatures, but ffmpeg printsInput #0, avfoundation, …on every successful input open, solooksLikeAVFailurewas effectively always true and the non-permissions branch ofclassifyRecorderExit(tcc.go:69-72) unreachable for any failure past the input dump: a full disk or missing encoder was headlined "most likely a permissions issue" with a System Settings pointer. The list now carries device-open indicators only; the genuine TCC lines (Failed to create AVCaptureDeviceInput,Failed to open device,not authorized) keep matching, and the existing tests (includingrecord_test.go's"Failed to open the device"assertion) stay green.Unknown-
srctimeline entries vanished silently while remaining citable.internal/report/report.go:51-58bucketed bysrcwith no default, so a hand-edited entry with e.g."src":"Event"was absent from the rendered timeline whileend()(report.go:201-218) still counted its time into the Duration header andinternal/analyze/validate.go:53kept its id citable as evidence — reproduced: a report whose Findings section cites an id at a moment its own Timeline section does not contain. Both readers now refuse via a sharedtimeline.CheckSrc, naming the line; merge-produced timelines only ever carry the two documented values.Nitpick findings (fixed)
Code. The offset sidecar writes atomically via a new
session.WriteFileAtomicNoFollow— the previousO_TRUNCwrite destroyed the prior bytes before writing, so a mid-write failure left a truncated sidecar the rollback (gated on a successful write,internal/transcribe/transcribe.go:153-172) never restored.demo -addr :0prints the actually-bound address instead ofhttp://localhost:0(internal/demo/demo.go:424-433echoed the request;Servenow recordsln.Addr()'s port on the returned server). The demo page checkssendBeacon's return and falls back tofetch— a refused beacon lost the already-drained batch with no trace, the one capture drop the server's stderr refusal log cannot see (internal/demo/assets/index.html:152-158,186-190; the how-to's copyable snippet matched).labelForgains theinstanceof ElementguardselectorForalready had. The three diagnostic stderr tails cut on rune boundaries (internal/record/recorders.go,internal/record/proc.go,internal/transcribe/transcribe.go).session.WriteJSONL's over-long refusal names a 1-based output line instead of the 0-basedrecord 0.transcribe.Rundefaults a nilLoglikerecord.Runinstead of panicking. The demo app's seeded display name is nowAlice(persona rule; the tutorial narrating this exact interaction says Alice types "Alice").Checks and installer. The pipeline smoke in
ci.ymlandrelease.ymlwas fully satisfiable with the event half of the pipeline dead — verified by deletinginteractions.jsonl: all five assertions stayed green. Both now assert the exact header counts and an event-only selector.release.ymlgains a post-release step asserting a published tarball's binary reports the tag (the Go linker silently ignores a-Xflag whose symbol does not exist, so a renamedinternal/cli.Versionwould shiptestimony devwith the release green — mechanism verified by building with a typo'd symbol). CI parsesinstall.shwithsh -nandbash -n(it is served live from main with no syntax gate).install.shverifies the installed binary runs and reports the pinned release before announcing success — a failing command substitution inside an argument does not tripset -eu, so an unrunnable binary printedInstalled: … ()at exit 0 (install.sh:197). Its trust-model comments now state the fail-closed attestation behaviour the code has always had (both refuters independently rejected loosening it: an inconclusive verification is indistinguishable from one an attacker is blocking).Docs. The report header duration is defined as the session's latest moment, matching
report.go:201-218and the same page's ownsessionEnddefinition, not "from the last entry" (docs/reference/session-directory.md:157vs:123).report's CLI entry names its inputs (manifest.jsonrequired — reproduced exit 1 without it;findings.jsonlwhen present).record's entry states the ffmpeg prerequisite. TheOriginguard is documented as loopback in the how-to and theallowWritecomment (internal/demo/demo.go:369-375ignores the port; onlycli.md:35had it right — reproduced:Origin: http://localhost:3000accepted, cross-origin 403).analyse-a-session.mdnamesreview -finding … -verdict …as the way to change a verdict (the interactive walk queues only unverified findings,internal/review/review.go:118-130— reproduced "No unverified findings to review.").how-alignment-works.mdscopes the word-timestamp claim to the whole-utterance boundaries the join actually consumes (payload["words"]has no reader anywhere;-windowoperates at utterance granularity). README states voice and screen capture need macOS (internal/record/platform.go:14-21skips both streams off darwin; README carried no platform caveat) and its pipeline diagram corners join up. CHANGELOG: the preamble no longer promises a Breaking section no release has; the[Unreleased]groups drop internal round labels and merge the duplicated invocation-contract group; round-10 entries added.Considered and rejected (refuter verdicts)
cli.md's-audiooffset/sidecar wording as self-contradictory — killed: line 63 defines the page's shorthand ("or when-audiopoints at the session's ownaudio.wav") and the later lines use that established sense;session-directory.md:81is the long form of the same rule.session-directory.md'smode"defaultA" — killed: the field is write-only with no consumer; the table note is an interpretation rule consistent with "only Mode A is produced today".transcribe-a-recording.md:39word-timestamp sentence is accurate (WhisperX's forced alignment does set the segment times the join uses) and stands unchanged; loosening the installer's attestation failure handling was rejected as a downgrade vector; deleting the"failed to"signature outright would have broken an existing test and un-matched real TCC lines, so the fix narrows rather than deletes.Recorded, not fixed
VERSIONpin on main necessarily precedes its tag; during that window the documented one-liner fails with "Release not found" (install.sh:134-138). Structural to the pin-then-tag ordering; process note only..abcd/docs-lint.jsondeclares blocker-severity gates no CI workflow runs (the abcd tooling is not available in CI).ci.yml:9-13push'**'+pull_request, ref-keyed concurrency). The trigger set is tied to the merge queue's required contexts (ci.yml:5-8), so left as a deliberate judgement call.timeline.EventsNear(test-only, documented as the join's specification) andanalyze.Validate(no callers at all) are dead exported API withininternal/; noted for a deliberate decision rather than removed./dev/nullTTY-gate nitpick stands as recorded in rounds 8-9.