Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b7657fe
fix: refuse duplicate ids and unknown src in exchanged timelines
REPPL Jul 29, 2026
e40371b
fix: match only device-open failures as permissions signatures
REPPL Jul 29, 2026
1d78c8c
fix: write the offset sidecar atomically
REPPL Jul 29, 2026
f73f241
fix: print the bound address for an ephemeral capture port
REPPL Jul 29, 2026
203ac34
fix: name the offending WriteJSONL record as an output line
REPPL Jul 29, 2026
fb13e11
fix: default transcribe's log sink instead of panicking on nil
REPPL Jul 29, 2026
e7c3ec1
fix: cut diagnostic tails on rune boundaries
REPPL Jul 29, 2026
2253aef
fix: fall back to fetch when sendBeacon refuses a capture batch
REPPL Jul 29, 2026
1902ffe
fix: seed the demo display name with the Alice persona
REPPL Jul 29, 2026
ca39e13
docs: state the installer's fail-closed attestation contract
REPPL Jul 29, 2026
a39fbd4
fix: verify the installed binary runs and names the pinned release
REPPL Jul 29, 2026
92b1f00
chore: gate the event half of the pipeline smoke and the release stamp
REPPL Jul 29, 2026
620fe25
docs: correct reference and how-to claims against the code
REPPL Jul 29, 2026
5780d1f
fix: refuse a reused utterance id at the transcript boundary
REPPL Jul 29, 2026
ff7d2cc
fix: name timeline refusal positions as entry ordinals
REPPL Jul 29, 2026
3a4c856
fix: match the truncating write's permission semantics in the atomic …
REPPL Jul 29, 2026
71cf7eb
fix: verify the release binary before installing it
REPPL Jul 29, 2026
006b44f
docs: draw the installer's attestation attempt boundary precisely
REPPL Jul 29, 2026
c2a25ae
fix: keep capture snippets from surfacing async refusals
REPPL Jul 29, 2026
faa0593
fix: seed the demo display name as Bob
REPPL Jul 29, 2026
6c46364
docs: align reference pages, CHANGELOG, and code comments with review
REPPL Jul 29, 2026
160d454
fix: refuse a transcript utterance id colliding with synthesized even…
REPPL Jul 29, 2026
5cdea61
fix: stage the release binary in the install directory before verifyi…
REPPL Jul 29, 2026
c7220ea
fix: randomize atomic temp names and preserve an existing file's exac…
REPPL Jul 29, 2026
7bc86a5
docs: close the second review round's wording findings
REPPL Jul 29, 2026
cffa216
docs: state the event-id namespace rule in the transcript table
REPPL Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .abcd/work/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Append-only, one line per decision, newest last. Date-prefixed.
Architecture-shaping decisions graduate to an ADR under
[`../development/decisions/adrs/`](../development/decisions/adrs/).
`.abcd/development/decisions/adrs/` (created with the first ADR).

- 2026-07-17 — Adopt the three-tier working-state layout (`.abcd/development/`
durable, `.abcd/work/` shared, `.abcd/.work.local/` local-only) and the
Expand Down Expand Up @@ -268,3 +268,18 @@ Architecture-shaping decisions graduate to an ADR under
(endpoint preconditions, evidence cap, sessionEnd bound, ffmpeg's real
consumers, the demo page's CDN fetch disclosed in privacy). Vendoring
rrweb (offline capture) needs a dependency decision — reported, not done.
- 2026-07-29 — Bug-hunt round 10: four hunters, two adversarial refuters.
3 substantive and 22 nitpick findings confirmed and fixed; 3 candidates
refuted (the -audio sidecar wording, the mode default claim, the
line-wrap premise). Headlines: analyze refuses duplicate timeline ids
(the id-keyed quote validator paired quotes with the wrong moment) and,
with report, refuses unknown-src entries instead of silently omitting
them; the recorder permissions headline fires only on a device-open
failure, not on ffmpeg's ordinary banner; the offset sidecar writes
atomically; the CI smoke gains event-half assertions and the release
workflow asserts the shipped binary names the tag; install.sh verifies
the installed binary before announcing success. Recorded, not fixed:
the VERSION-pin window before a tag exists (process), docs-lint gates
not running in CI (tooling), doubled CI runs on PR branches (trigger
set is tied to the merge queue), and the /dev/null TTY gate (per
round 8).
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ jobs:
grep -q "## Findings" examples/sample-session/report.md
grep -q "### Confirmed (1)" examples/sample-session/report.md
grep -q "\*\*F-001\*\* bug" examples/sample-session/report.md
# The event half of the pipeline: every assertion above is satisfiable
# from the transcript and findings alone (verified: deleting
# interactions.jsonl left them all green), so the header counts and an
# event-only selector string gate the interactions → timeline → report
# path too. The exact counts pin the bundled fixture; extending the
# sample session means updating them here and in release.yml.
grep -q "\*\*Utterances:\*\* 10 · \*\*Events:\*\* 10" examples/sample-session/report.md
grep -q "data-testid=save-btn" examples/sample-session/report.md

# install.sh is served live from main as the documented install path, so
# a merged syntax error breaks `curl | sh` for every new user with no
# gate having run. Parse it with both shells an operator realistically
# pipes it into.
- name: Installer syntax
run: |
set -euo pipefail
sh -n install.sh
bash -n install.sh

# Secrets never land in history: full-history scan on every push/PR. Run as a
# pinned, checksum-verified CLI (no PR-comment / SARIF GitHub-API integration
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ jobs:
grep -q "## Findings" examples/sample-session/report.md
grep -q "### Confirmed (1)" examples/sample-session/report.md
grep -q "\*\*F-001\*\* bug" examples/sample-session/report.md
# The event half of the pipeline — see ci.yml's identical assertions.
grep -q "\*\*Utterances:\*\* 10 · \*\*Events:\*\* 10" examples/sample-session/report.md
grep -q "data-testid=save-btn" examples/sample-session/report.md

# Build and publish ONLY after verify is green. Tarballs are cross-compiled from
# the same pushed commit (github.sha) that verify just gated, so the shipped
Expand Down Expand Up @@ -249,6 +252,32 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Assert the released binary reports the tag
# The version stamp rides on the -X flag naming internal/cli.Version,
# and the Go linker silently ignores -X for a symbol that does not
# exist — rename or move the variable and every tarball ships
# reporting "testimony dev" while the release stays green, the first
# observer being an end user. Download one tarball fresh from the
# published Release and require the binary's own version output to
# name the tag. Unlike the attestation step above this runs on private
# repos too, so it is a separate step with its own download.
run: |
set -euo pipefail
DL="$RUNNER_TEMP/version-check"
mkdir -p "$DL"
gh release download "$TAG" \
--repo "${GITHUB_REPOSITORY}" \
--pattern "testimony_${TAG}_linux_amd64.tar.gz" \
--dir "$DL"
tar -xzf "$DL/testimony_${TAG}_linux_amd64.tar.gz" -C "$DL" testimony
got="$("$DL/testimony" version)"
if [ "$got" != "testimony $TAG" ]; then
echo "released binary reports \"$got\", expected \"testimony $TAG\"" >&2
exit 1
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Assert the release job pushed nothing to the default branch
# Close the no-branch-commit tripwire opened at job start. Runs last so it
# covers every preceding step. !cancelled(): the tripwire must still run when
Expand Down
73 changes: 65 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,74 @@ All notable changes to Testimony are recorded here. The format follows
uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a
leading `v`.

Before v1.0.0, minor releases may make breaking changes; each one is
called out in a **Breaking** section.
Before v1.0.0, minor releases may make breaking changes; a change that can
break an existing invocation is called out in the entry that records it.

## [Unreleased]

### Fixed

Invocation contract (from the round-8 bug-hunt):
Evidence integrity:

- **Behaviour:** `merge` refuses a transcript whose utterance ids repeat or
collide with the event ids it synthesises, and `analyze` refuses a timeline
carrying duplicate entry ids — inputs earlier versions accepted. Of two
utterances sharing an id, only the later one reached the quote validator,
so an honest verbatim quote of the first was rejected while a quote of the
second validated for a finding anchored at the first one's time. `report`,
whose join is positional, still renders such a timeline.
- **Behaviour:** `report` and `analyze` refuse a timeline entry whose `src`
is neither `speech` nor `event` — previously accepted at exit 0 — instead
of dropping it from the rendered timeline while counting its time into the
header duration and keeping its id citable as evidence.
- The audio offset sidecar is written atomically (temp file and rename): a
write failure part-way through used to leave a truncated sidecar behind,
blocking every later bare `transcribe` with the prior offset
unrecoverable from the session.

Capture and diagnostics:

- A recorder start-up failure is headlined as a likely permissions issue
only when the ffmpeg output carries a device-failure signature; the module
banner alone — printed on every successful open — used to route a full
disk or a missing encoder to the System Settings pane.
- `demo -addr :0` prints the actually-bound address instead of the
unopenable `http://localhost:0`; the same applies to `record -demo`.
- The demo page falls back to `fetch` when `sendBeacon` refuses to queue a
capture batch, which used to drop the batch silently; its seeded display
name now uses the Alice persona.
- Diagnostic stderr tails cut on rune boundaries rather than mid-character;
a record refused by the JSONL writer is named as a 1-based line of the
output file; `transcribe` defaults its log sink instead of panicking when
a caller leaves it unset.

Checks and installer:

- The pipeline smoke test asserts the event half of the pipeline (the
header counts and an event-only selector); every prior assertion was
satisfiable from the transcript and findings alone, so a regression that
dropped all events kept the gate green.
- The release workflow downloads a published tarball and requires the
binary's own version output to name the tag; the checks parse `install.sh` with
`sh -n` and `bash -n`.
- `install.sh` verifies the release binary runs and reports the pinned
release before installing it; an unrunnable binary (a wrong-platform
asset) used to replace any previously installed one and print a success
line at exit 0. Its trust-model comments now state precisely which
attestation failures refuse the install (a verification attempted and
rejected, or failed mid-way) and which fall back to the checksum with a
note (a gh that cannot attempt it).

Documentation:

- Reference and how-to corrections against the code: the report header
duration definition, `report`'s inputs, `record`'s ffmpeg prerequisite,
the loopback `Origin` rule, the command that changes a recorded verdict,
and the word-timestamp claim scoped to the utterance boundaries the join
operates on. The README states that voice and screen capture need macOS,
and its pipeline diagram joins up.

Invocation contract:

- Every usage error now exits 2, as the CLI reference documents: a missing
required `-session`, a disallowed flag combination (`analyze -out` with
Expand All @@ -32,9 +92,6 @@ Invocation contract (from the round-8 bug-hunt):
workflow now gates the pin against the tag so it cannot go stale again. The
installer also renders single-option prompts correctly and names the actual
cause when a release download fails.

Invocation contract (round 9):

- A stray positional argument is refused as a usage error; it used to be
silently swallowed together with every flag after it, so the command ran
with defaults at exit 0.
Expand All @@ -43,7 +100,7 @@ Invocation contract (round 9):
`transcribe -engine`, and a malformed capture `-addr` (which also no longer
creates a session directory before refusing).

Capture integrity (round 9):
Capture integrity:

- `POST /api/interactions` refuses with 400 any record `merge` would refuse —
a non-object body, or a missing/implausible `t` or missing `kind` — instead
Expand All @@ -57,7 +114,7 @@ Capture integrity (round 9):
- A recorder that exits on its own mid-session still validates the artefacts
the other recorders left and prints the next-command block.

Installer (round 9):
Installer:

- An unauthenticated (or attestation-incapable) `gh` no longer refuses the
install as a false provenance failure: it falls back to the verified
Expand Down
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ timestamped interaction stream, rendered as a report that shows what was said ne
to what was done.

```
voice ──► local Whisper ──► transcript.jsonl ─┐
├─► timeline.jsonl ─► report.md
clicks ──► rrweb / hooks ──► interactions.jsonl ┘
voice ──► local Whisper ──► transcript.jsonl ─────
├─► timeline.jsonl ─► report.md
clicks ──► rrweb / hooks ──► interactions.jsonl ──
```

Raw audio and video never leave your machine; only derived text is analysed. See
Expand Down Expand Up @@ -56,9 +56,12 @@ open examples/sample-session/report.md
```

Then capture a real one: `testimony record -demo` starts a capture session —
recording your voice and clicks in one command — and prints every step. The
[getting-started tutorial](docs/tutorials/getting-started.md) walks the whole
path — record, think aloud, transcribe, merge, report — in about five minutes. The result interleaves speech with interface events:
recording your voice and clicks in one command — and prints every step. Voice
and screen capture need macOS; elsewhere, `record` skips those streams and
says so, and an external recording joins the session via `transcribe -audio`.
The [getting-started tutorial](docs/tutorials/getting-started.md) walks the
whole path — record, think aloud, transcribe, merge, report — in about five
minutes. The result interleaves speech with interface events:

```
**[00:22] P1:** “Hm. I clicked save and nothing happened. No message, no
Expand Down
2 changes: 1 addition & 1 deletion docs/explanation/how-alignment-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@ A window — rather than exact matching — is deliberate. Transcription timesta

Plain speech recognition emits segment-level times, which can be off by several seconds — as coarse as the join window itself, which makes joining speech to an individual click unreliable. WhisperX adds forced alignment: it pins each recognised word to its own moment in the audio.

That precision is what the pipeline is built around. When a participant says "I expected *this* button to save immediately", the word "this" carries its own timestamp, close to the click on the thing it refers to. Word times make the utterance-to-event join trustworthy at its default width, allow it to be tightened when a single deictic word needs anchoring, and are the reason WhisperX is the preferred engine — whisper.cpp works, but its segment-level times lean harder on the window.
That precision is what the pipeline is built around. When a participant says "I expected *this* button to save immediately", the word "this" carries its own timestamp, close to the click on the thing it refers to. The join itself operates on whole utterances — each utterance's span, widened by the window — so what the alignment buys is trustworthy utterance boundaries: word-accurate segment times keep the join honest at its default width, and they are the reason WhisperX is the preferred engine — whisper.cpp works, but its segment-level times lean harder on the window.
6 changes: 4 additions & 2 deletions docs/how-to/analyse-a-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,10 @@ open sessions/<dir>/report.md

The Findings section lists findings under **Confirmed**, **Unverified**,
**Duplicate**, and **Rejected**, each with its quote, anchor, and — where you
recorded one — the verdict and its date. Re-run `review` any time to change a
verdict; the latest one wins, and the history is kept.
recorded one — the verdict and its date. Change a verdict at any time with
`testimony review -session sessions/<dir> -finding F-NNN -verdict <verdict>`
(the interactive walk offers only findings that are still unverified); the
latest one wins, and the history is kept.

For the exact field rules, see the
[session directory reference](../reference/session-directory.md#findingsjsonl);
Expand Down
23 changes: 18 additions & 5 deletions docs/how-to/instrument-your-own-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ This creates a fresh session directory (with `manifest.json` anchoring the sessi

Both endpoints accept POST only (anything else returns 405) and return `204 No Content` on success. `/api/interactions` caps the body at 4 MiB — the readable JSONL line limit, since one request becomes one line — and rejects with 400 anything the merge step could not read back: invalid JSON, a body that is not a JSON object, or an object missing the required `t` or `kind` from the table below; `/api/events` caps the batch body at 8 MiB and rejects anything that is not a JSON array with 400. A body over its cap gets 413, as does a batch element that would itself exceed the 4 MiB line limit.

To defend the evidence against cross-origin forgery (CSRF) and DNS-rebinding, the write endpoints require `Content-Type: application/json`, a loopback `Host`, and — when present — a same-origin `Origin`. Post from your app's own origin (see the proxy in step 5) and always set the JSON content type, as the snippet below does. Each accepted body is re-encoded to a single line, so one request is always exactly one JSONL record.
To defend the evidence against cross-origin forgery (CSRF) and DNS-rebinding, the write endpoints require `Content-Type: application/json`, a loopback `Host`, and — when present — a loopback `Origin` (any loopback host, whatever its port; anything else gets 403). Post from your app's own origin (see the proxy in step 5) and always set the JSON content type, as the snippet below does. Each accepted body is re-encoded to a single line, so one request is always exactly one JSONL record.

## 2. Add stable `data-testid` attributes

Expand Down Expand Up @@ -54,19 +54,32 @@ A minimal capture script, following the same conventions as the demo app — pre
if (el.id) return el.tagName.toLowerCase() + "#" + el.id;
return el.tagName.toLowerCase();
}
function labelFor(el) {
if (!(el instanceof Element)) return "";
var t = (el.closest("[data-testid]") || el).textContent || "";
return t.trim().replace(/\s+/g, " ").slice(0, 40);
}
function post(url, body) {
try {
navigator.sendBeacon
? navigator.sendBeacon(url, new Blob([JSON.stringify(body)], { type: "application/json" }))
: fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), keepalive: true });
var json = JSON.stringify(body);
// sendBeacon returns false when the UA refuses to queue the body
// (e.g. the in-flight beacon quota): fall back to fetch so the
// records are not lost silently.
var queued = navigator.sendBeacon
? navigator.sendBeacon(url, new Blob([json], { type: "application/json" }))
: false;
if (!queued) {
fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: json, keepalive: true })
.catch(function () { /* an async refusal must not surface as an unhandled rejection */ });
}
} catch (e) { /* capture must never break the app */ }
}
function interaction(kind, el, extra) {
var payload = {
t: Date.now(),
kind: kind,
selector: selectorFor(el),
text: ((el.closest("[data-testid]") || el).textContent || "").trim().replace(/\s+/g, " ").slice(0, 40),
text: labelFor(el),
route: location.hash || location.pathname
};
if (extra) for (var k in extra) payload[k] = extra[k];
Expand Down
Loading