diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md
index ed7365b..3e6f8ed 100644
--- a/.abcd/work/DECISIONS.md
+++ b/.abcd/work/DECISIONS.md
@@ -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
@@ -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).
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9d4a5cd..eae00d4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5ee7c7f..82df5b9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d3eb42e..86bfb91 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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.
@@ -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
@@ -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
diff --git a/README.md b/README.md
index 6937395..f48062b 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
diff --git a/docs/explanation/how-alignment-works.md b/docs/explanation/how-alignment-works.md
index bd5f2d9..9eee0ca 100644
--- a/docs/explanation/how-alignment-works.md
+++ b/docs/explanation/how-alignment-works.md
@@ -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.
diff --git a/docs/how-to/analyse-a-session.md b/docs/how-to/analyse-a-session.md
index e409120..301bf21 100644
--- a/docs/how-to/analyse-a-session.md
+++ b/docs/how-to/analyse-a-session.md
@@ -104,8 +104,10 @@ open sessions/
/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/ -finding F-NNN -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);
diff --git a/docs/how-to/instrument-your-own-app.md b/docs/how-to/instrument-your-own-app.md
index 31e1f85..094c5dd 100644
--- a/docs/how-to/instrument-your-own-app.md
+++ b/docs/how-to/instrument-your-own-app.md
@@ -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
@@ -54,11 +54,24 @@ 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) {
@@ -66,7 +79,7 @@ A minimal capture script, following the same conventions as the demo app — pre
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];
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
index 5b54925..221d2fd 100644
--- a/docs/reference/cli.md
+++ b/docs/reference/cli.md
@@ -97,7 +97,7 @@ testimony report -session DIR [-window 2.5]
| `-session` | *(required)* | session directory |
| `-window` | `2.5` | utterance-to-event join window, in seconds |
-Behaviour: attaches each event to the first utterance whose span, widened by the window on both sides, contains it; events matched by no utterance appear as standalone lines. Writes `report.md` into the session directory and prints `wrote `.
+Behaviour: reads `manifest.json` (required) and `timeline.jsonl`, plus `findings.jsonl` when present (the Findings section; without the file it is a short notice pointing at `analyze` and `review`). A timeline entry whose `src` is neither `speech` nor `event` is refused rather than silently omitted from the rendered record. Attaches each event to the first utterance whose span, widened by the window on both sides, contains it; events matched by no utterance appear as standalone lines. Writes `report.md` into the session directory and prints `wrote `.
## `testimony record`
@@ -120,7 +120,7 @@ testimony record [-out sessions] [-app NAME] [-participant P1] [-commit HASH]
| `-demo` | off | also serve the instrumented demo app into the same session directory |
| `-addr` | `:8737` | demo server listen address (with `-demo`) |
-Behaviour: creates a new session directory named after the current time (`YYYY-MM-DD_HHMMSS`) under the `-out` root and writes `manifest.json` (app, participant, tasks, commit, `t0_epoch_ms` set to now) through the same code path as `demo`. On macOS it captures the default microphone to `audio.wav` (16 kHz mono PCM, the canonical ASR input — no re-conversion needed downstream) and, with `-video`, the screen to `screen.mp4`. Audio-only is the default; `-video` opts in. With `-demo` it also serves the demo app so one command captures voice and clicks into the same directory.
+Behaviour: creates a new session directory named after the current time (`YYYY-MM-DD_HHMMSS`) under the `-out` root and writes `manifest.json` (app, participant, tasks, commit, `t0_epoch_ms` set to now) through the same code path as `demo`. On macOS it captures the default microphone to `audio.wav` (16 kHz mono PCM, the canonical ASR input — no re-conversion needed downstream) and, with `-video`, the screen to `screen.mp4`; both recorders need ffmpeg on `PATH`. Audio-only is the default; `-video` opts in. With `-demo` it also serves the demo app so one command captures voice and clicks into the same directory.
The command blocks until interrupted (`Ctrl+C`). On SIGINT/SIGTERM — and on SIGHUP, so closing the terminal window mid-session finalises exactly like Ctrl+C — it sends each recorder an interrupt so it finalises its container, waits up to five seconds, and hard-kills only on timeout. It then validates each recorder's artefact — `audio.wav`, and `screen.mp4` with `-video` — and prints the exact next commands with the real session directory: with a usable `audio.wav` in place it offers `transcribe` → `merge` → `report` without `-audio`, because the recording is already present.
@@ -141,7 +141,7 @@ testimony analyze -session DIR -ingest FILE # validate the answer → find
| `-out` | *(stdout)* | emit mode: write the request to `FILE` instead of stdout |
| `-ingest` | *(off)* | ingest mode: validate the answer JSON at `FILE` (or `-` for stdin) into `findings.jsonl` |
-`analyze` runs in exactly one mode: emit (no `-ingest`) or ingest (`-ingest`). Combining `-out` and `-ingest` is an error. Emit reads `manifest.json` and `timeline.jsonl`; ingest reads `timeline.jsonl` only. Both hint to run `merge` first when the timeline is missing.
+`analyze` runs in exactly one mode: emit (no `-ingest`) or ingest (`-ingest`). Combining `-out` and `-ingest` is an error. Emit reads `manifest.json` and `timeline.jsonl`; ingest reads `timeline.jsonl` only. Both hint to run `merge` first when the timeline is missing, and both refuse a timeline whose entries carry a `src` other than `speech` or `event`, or a duplicated entry id — findings cite evidence by id, so a reused one cannot be resolved unambiguously (a `merge`-produced timeline never carries either defect: `merge` refuses a transcript whose utterance ids repeat or collide with the `ev-NNN` event ids it synthesises).
Emit behaviour: writes a single self-contained prompt — the rubric version header (`testimony-analysis/v1`), the second-coder stance, two-pass instructions (segment coding, then session synthesis), the rubric body (five `type` definitions, the `1..4` severity scale, the evidence hard-constraints), the session context (app, participant, tasks), the timeline lines inline, and the required output shape with a worked example. Nothing in the session directory is mutated. The timeline is emitted whole (v1 does not chunk by task boundary; the manifest carries no task timestamps). With `-out FILE` the prompt goes to a file and the command prints `wrote `; otherwise it prints to stdout.
diff --git a/docs/reference/session-directory.md b/docs/reference/session-directory.md
index 879322e..df9d32d 100644
--- a/docs/reference/session-directory.md
+++ b/docs/reference/session-directory.md
@@ -65,7 +65,7 @@ One utterance per line. Times are session-relative seconds (audio time plus the
| Field | Type | Required | Meaning |
|---|---|---|---|
-| `id` | string | yes | sequential utterance ID: `utt-001`, `utt-002`, … |
+| `id` | string | yes | sequential utterance ID: `utt-001`, `utt-002`, …; unique within the file and outside the `ev-NNN` namespace `merge` synthesises for events (`merge` refuses a reused or colliding id, since findings cite evidence by id) |
| `t0` | number | yes | utterance start, session-relative seconds |
| `t1` | number | yes | utterance end, session-relative seconds |
| `speaker` | string | no | speaker label; `"P1"` when the engine supplies no diarisation |
@@ -96,7 +96,7 @@ The merged record — one entry per line, speech and interface events on the sha
| Field | Type | Meaning |
|---|---|---|
| `t` | number | entry time, session-relative seconds |
-| `src` | string | `"speech"` or `"event"` |
+| `src` | string | `"speech"` or `"event"` (a closed set: `report` and `analyze` refuse any other value); entry ids must be unique for `analyze`, which resolves cited evidence by id |
| `id` | string | `utt-NNN` (from the transcript) or `ev-NNN` (assigned at merge, in input order) |
| `payload` | object | source-dependent, see below |
@@ -120,7 +120,7 @@ Ingest validates every finding against the merged timeline and is the sole valid
| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string | yes | `F-NNN`, zero-padded (`^F-\d{3}$`); unique within the file |
-| `t` | number | yes | finding time, session-relative seconds; within `[sessionStart, sessionEnd]` (`sessionStart` is `0` unless the timeline holds negative-time utterances from a recording predating `t0`; `sessionEnd` is the latest moment on the timeline — the end (`t1`) of the last utterance when speech closes the session, else the latest entry time) |
+| `t` | number | yes | finding time, session-relative seconds; within `[sessionStart, sessionEnd]` (`sessionStart` is `0` unless the timeline holds negative-time utterances from a recording predating `t0`; `sessionEnd` is the latest moment on the timeline — the maximum over all entries, taking an utterance's end (`t1`) and an event's time) |
| `type` | string | yes | one of `bug`, `friction`, `inconsistency`, `preference`, `idea` |
| `severity` | integer | yes | usability-severity scale `1..4`: cosmetic, minor, major, blocker |
| `mode` | string | no | `A` or `B`, default `A`; only Mode A is produced today |
@@ -154,6 +154,6 @@ A finding's effective status starts `unverified`; verdict records apply in file
Human-readable Markdown rendered from the timeline and findings:
-- a header with session name, app, participant, duration (`MM:SS`, from the last entry), and utterance/event counts, plus the task list;
+- a header with session name, app, participant, duration (`MM:SS`, the latest moment on the timeline — the maximum over all entries, taking an utterance's end `t1` and an event's time), and utterance/event counts, plus the task list;
- a **Timeline** section: each utterance as `**[MM:SS] :** ""`, with the events joined to it (within the report's join window) as indented bullets `[MM:SS] "" value="…" ()`; events matched by no utterance appear as standalone bullets in time order;
- a **Findings** section rendering `findings.jsonl` grouped by effective status (Confirmed, Unverified, Duplicate, Rejected), each group headed with a count and each finding line carrying its id, type, severity, clock, quote, anchor, and any verdict and date. When there is no `findings.jsonl` the section is a short notice pointing at `analyze` and `review`.
diff --git a/install.sh b/install.sh
index 85e5c5d..176941d 100644
--- a/install.sh
+++ b/install.sh
@@ -25,9 +25,13 @@
# CLI) is available it ALSO runs `gh attestation verify` against the release
# workflow's SLSA build-provenance (authenticity — cryptographic proof the tarball
# was built by REPPL/Testimony's own release.yml, the strong anchor). A gh that
-# cannot attempt the verification — absent, unauthenticated, or too old to know
-# attestations — means the install proceeds on the checksum alone, with a note
-# saying so; only a verification gh performs and rejects refuses the install.
+# cannot attempt the verification — absent, too old to know attestations, or
+# unable to establish its authentication (which an unreachable API also causes
+# at the auth check) — means the install proceeds on the checksum alone, with
+# a note saying so. Once the verification is attempted, any failure refuses
+# the install, fail closed: a rejection, and equally a verification that
+# could not complete, because mid-verification failure cannot be told apart
+# from an attacker blocking it. Re-run when connectivity returns.
# No per-release hash is pinned in this script: the checksums are fetched from
# the release itself and the attestation binds them to the workflow.
# Everything installs into user-owned locations by default; sudo is never invoked.
@@ -124,8 +128,8 @@ install_binary() {
# the script: a trap that only cleans up returns control after the handler,
# so Ctrl+C at a dependency prompt read was swallowed into the safe-default
# answer and the run carried on.
- trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"}' EXIT
- trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"}; trap - EXIT; exit 130' INT TERM
+ trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"} ${staged:+"$staged"}' EXIT
+ trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"} ${staged:+"$staged"}; trap - EXIT; exit 130' INT TERM
say "Downloading $tarball ..."
# A bad --version (or a platform the release never published) surfaces from
@@ -163,8 +167,14 @@ Refusing to install."
# command or --signer-workflow fails the same way — treating those as
# "attestation FAILED" made a freshly `brew install`ed gh strictly worse
# than no gh at all, refusing a tarball whose checksum had just verified.
- # Only a verification gh actually performed and rejected refuses the
- # install, and gh's own output is shown instead of being swallowed.
+ # Once the verification is attempted, every failure refuses the install,
+ # fail closed — a rejected attestation, and equally a verification that
+ # fails mid-way (network, API outage): at that point an inconclusive
+ # answer cannot be told apart from one an attacker is blocking. The auth
+ # check above draws the attempt boundary: a gh whose authentication
+ # cannot be established — which an unreachable API also causes — is the
+ # checksum-fallback case, with a note naming it. gh's own output is shown
+ # instead of being swallowed.
if have gh && ! gh auth status --hostname github.com >/dev/null 2>&1; then
say "NOTE: 'gh' is installed but not authenticated — installed on the checksum alone."
say " 'gh attestation verify' needs an authenticated gh; run 'gh auth login'"
@@ -191,10 +201,26 @@ Refusing to install."
say " https://cli.github.com — then re-run this installer."
fi
- mkdir -p "$INSTALL_DIR"
tar -xzf "$tmp/$tarball" -C "$tmp" testimony
- install -m 0755 "$tmp/testimony" "$INSTALL_DIR/testimony"
- say "Installed: $INSTALL_DIR/testimony ($("$INSTALL_DIR/testimony" version))"
+ # Prove the binary runs and is the release it claims BEFORE it replaces
+ # anything: a failing command substitution inside say's argument does not
+ # trip `set -e`, so an unrunnable binary (a wrong-platform asset)
+ # previously replaced the installed one and printed "Installed: ... ()"
+ # at exit 0. The probe runs from a staged copy inside INSTALL_DIR — the
+ # download's temp directory may sit on a noexec mount (a hardened /tmp,
+ # a noexec TMPDIR), where executing "$tmp/testimony" fails for a
+ # perfectly good binary — and only the verified copy is renamed onto the
+ # final name, so a refusal leaves any previously installed binary
+ # untouched. Releases predating the version stamp (v0.1.0) report
+ # "testimony dev" and are refused here; every release since prints its
+ # own tag.
+ mkdir -p "$INSTALL_DIR"
+ staged="$INSTALL_DIR/.testimony.staged.$$"
+ install -m 0755 "$tmp/testimony" "$staged"
+ installed_version="$("$staged" version)" || { rm -f "$staged"; die "the release binary failed to run from $INSTALL_DIR (a wrong-platform asset, or a noexec mount?): $tarball"; }
+ [ "$installed_version" = "testimony $VERSION" ] || { rm -f "$staged"; die "the release binary reports \"$installed_version\", expected \"testimony $VERSION\"; refusing to install it"; }
+ mv -f "$staged" "$INSTALL_DIR/testimony"
+ say "Installed: $INSTALL_DIR/testimony ($installed_version)"
case ":$PATH:" in
*":$INSTALL_DIR:"*) : ;;
diff --git a/internal/analyze/analyze_test.go b/internal/analyze/analyze_test.go
index e69bd81..1ab599e 100644
--- a/internal/analyze/analyze_test.go
+++ b/internal/analyze/analyze_test.go
@@ -766,3 +766,49 @@ func TestEmitRequestSanitisesTimelineBidi(t *testing.T) {
t.Fatalf("sanitised timeline text missing from the emitted request:\n%s", got)
}
}
+
+// TestIngestRefusesDuplicateTimelineIDs pins the duplicate-id refusal in
+// loadTimeline. Before it, indexTimeline's id-keyed uttText map silently kept
+// only the LAST utterance sharing an id, so against this timeline a quote
+// spoken in the second utt-001 validated for a finding anchored at the first
+// one's time — the exact quote-to-moment fabrication the verbatim gate exists
+// to prevent — while an honest quote of the first utterance was rejected.
+func TestIngestRefusesDuplicateTimelineIDs(t *testing.T) {
+ const dupTimeline = `{"t":1,"src":"speech","id":"utt-001","payload":{"speaker":"P1","t1":2,"text":"first thing"}}
+{"t":19.2,"src":"event","id":"ev-003","payload":{"kind":"click","route":"#general","selector":"[data-testid=save-btn]","text":"Save"}}
+{"t":40,"src":"speech","id":"utt-001","payload":{"speaker":"P1","t1":45,"text":"second thing entirely"}}
+`
+ const answer = `{"findings":[
+ {"id":"F-001","t":1.0,"type":"bug","severity":3,"quote":"second thing entirely",
+ "evidence":["utt-001","ev-003"]}
+]}`
+ dir := writeSession(t, dupTimeline)
+ _, err := Ingest(dir, strings.NewReader(answer))
+ if err == nil {
+ t.Fatal("Ingest accepted a timeline with duplicate utterance ids")
+ }
+ if !strings.Contains(err.Error(), "utt-001") {
+ t.Fatalf("error must name the duplicated id: %v", err)
+ }
+ if _, statErr := os.Stat(filepath.Join(dir, session.FindingsFile)); statErr == nil {
+ t.Fatal("findings.jsonl written despite the refusal")
+ }
+}
+
+// TestIngestRefusesUnknownTimelineSrc pins the CheckSrc gate on analyze's
+// read path: an entry with an unrecognised src previously still contributed
+// its id to the citable evidence set and its time to the session end bound,
+// although report would never render it.
+func TestIngestRefusesUnknownTimelineSrc(t *testing.T) {
+ const badSrc = `{"t":22,"src":"speech","id":"utt-004","payload":{"speaker":"P1","t1":28,"text":"I clicked save and nothing happened"}}
+{"t":100,"src":"Event","id":"ev-009","payload":{"kind":"click","selector":"[data-testid=save-btn]"}}
+`
+ dir := writeSession(t, badSrc)
+ _, err := Ingest(dir, strings.NewReader(goodAnswer))
+ if err == nil {
+ t.Fatal("Ingest accepted a timeline entry with unknown src")
+ }
+ if !strings.Contains(err.Error(), "unknown src") || !strings.Contains(err.Error(), "entry 2") {
+ t.Fatalf("error must name the unknown src and its entry: %v", err)
+ }
+}
diff --git a/internal/analyze/ingest.go b/internal/analyze/ingest.go
index cbe55db..c2f8895 100644
--- a/internal/analyze/ingest.go
+++ b/internal/analyze/ingest.go
@@ -21,12 +21,44 @@ import (
const maxAnswerBytes = 16 << 20
// loadTimeline reads the merged timeline, hinting to run merge first when it is
-// missing (matching report).
+// missing (matching report). It refuses a timeline this package cannot validate
+// findings against: an unknown src (timeline.CheckSrc) or a duplicate entry id.
+// The duplicate check exists because analyze is the one id-keyed consumer left:
+// indexTimeline keys uttText by id, so of two utterances sharing an id only the
+// later survives the index — an honest verbatim quote of the first is rejected,
+// while a quote of the second validates for a finding anchored at the first's
+// time, durably pairing a quote with a moment it was never spoken at. Merge
+// refuses a duplicated utterance id at the transcript boundary and an
+// utterance id colliding with the event ids it synthesises (see
+// timeline.checkedUtterances and the id scan in timeline.Merge), so this
+// check bites on a hand-edited or exchanged timeline.jsonl, which reaches this
+// reader without passing through merge — report deliberately stays positional
+// and keeps rendering such a file. Ids are compared in their session.SafeText
+// form, the form the index and the emitted request use, so two ids distinct
+// only by stripped bytes count as duplicates too. Entries with an empty id are
+// skipped: they cannot be cited, and calling two id-less lines "duplicates of
+// \"\"" would misname the actual problem. Positions are 1-based entry
+// ordinals, which match file lines only when the file has no blank lines
+// (ReadJSONL skips those).
func loadTimeline(dir string) ([]timeline.Entry, error) {
entries, err := session.ReadJSONL[timeline.Entry](filepath.Join(dir, session.TimelineFile))
if err != nil {
return nil, fmt.Errorf("read timeline (run `testimony merge` first?): %w", err)
}
+ if err := timeline.CheckSrc(entries); err != nil {
+ return nil, err
+ }
+ seen := map[string]int{}
+ for i, e := range entries {
+ id := session.SafeText(e.ID)
+ if id == "" {
+ continue
+ }
+ if prev, dup := seen[id]; dup {
+ return nil, fmt.Errorf("timeline id %q appears at entries %d and %d: ids must be unique for evidence to cite them unambiguously", id, prev, i+1)
+ }
+ seen[id] = i + 1
+ }
return entries, nil
}
diff --git a/internal/demo/assets/index.html b/internal/demo/assets/index.html
index 46c2fb8..d32ba6d 100644
--- a/internal/demo/assets/index.html
+++ b/internal/demo/assets/index.html
@@ -81,7 +81,7 @@