# In-application bug reports

Document: SPEC-026. Revision: 0.1. Date: 2026-11-03. Lifecycle: Draft implementing D-209. The request to post bug reports to the human's GitHub repository is CONFIRMED; the mechanisms, limits and defaults below that are not quoted from the human are PROPOSED engineering choices made during the human's absence and remain open to confirmation. Supersedes: none.

## Request and scope

CONFIRMED D-209 (human request, 2026-11-03): “Please implement a bug submit function in the application such that it will post the bug report to my github repo. Currently the repo is not public but I will change that when I get home, so continue working under the assumption that it will be.” The repository is `megaschlong/sloppy-voice`. Debug builds are retained deliberately because bug reports are expected.

The feature lets a person describe a problem from inside the client and either have their Home server create a public GitHub issue, or open a prefilled GitHub “new issue” page in their own browser. No client contains or receives a GitHub credential. Reports are not end-to-end encrypted and are published; the client says so before sending. This does not add telemetry, crash collection, automatic uploads or any change to the SPEC-021 voice journal, which stays local unless the person explicitly attaches it to one report.

[SPEC-005](005-clients.md#in-application-bug-reports-d-209) owns client presentation; [SPEC-008](008-server.md#bug-report-posting-d-209) owns the server endpoint. This document owns the shared contract, limits and Markdown rules so neither component duplicates them.

## Report contract

PROPOSED wire contract `bug-reports/1`:

- A server that can post directly lists `bug-reports/1` in `/api/info` `capabilities`. Absence means direct posting is unavailable; clients then offer only the GitHub fallback.
- `POST /api/bug-reports`, authenticated with the ordinary Home session bearer token, JSON body `{id, title, description, client:{kind, version, platform}, diagnostics?, includeAccount?}`. Unknown fields are rejected.
- `id` is a client-generated UUID and the idempotency key, scoped to the authenticated account. A repeated identical request returns the original result (`replayed: true`) without creating another issue for 24 hours. Reusing the identifier with different content returns `409 bug_report_conflict`; a concurrent repeat while GitHub is still being called returns `409 bug_report_pending`. A failed attempt releases the identifier so the same report can be retried.
- Success returns `{number, url, repository, replayed}` where `url` is `https://github.com/{repository}/issues/{number}`. Clients open only URLs of that shape.
- `client.kind` is one of `web`, `electron`, `android`, `ios`, `qt`. `version` matches `[0-9A-Za-z.+_-]{1,64}`. `platform` is a one-line operating-system/browser description.
- `includeAccount: true` asks the server to add the authenticated account's qualified address (`username@domain`). The address is never taken from the request body. Default false.
- Visitors from other homes and conversation-only connections cannot submit; they use their own Home server.

Errors use the existing `{error, message}` shape: `400 bug_report_invalid`, `401` (authentication), `403 home_account`, `409` (above), `429 rate_limit` (account hourly or server daily limit, distinguished in the message), `503 bug_reports_unavailable` (not configured, or GitHub rejected the server credential/repository: 401, 403 without rate limiting, 404, 410), `503 bug_report_busy` (GitHub 429 or exhausted rate limit), `502 bug_report_rejected` (GitHub 422), `502 bug_report_upstream` (other GitHub failures or an unexpected success body) and `504 bug_report_timeout` (no GitHub response; the issue may or may not exist). Clients offer the GitHub fallback after any failure and switch to fallback-only after `bug_reports_unavailable`.

## Content and privacy rules

Before sending, clients show exactly what is attached: client kind, version and platform, plus the account address and diagnostics only when their checkboxes are selected. Both checkboxes default to off for every new report.

- Title: required, one line after normalization (NFC; control, format and line/paragraph separator characters become spaces; whitespace collapsed), at most 120 Unicode code points.
- Description: required, multiple lines after normalization (CR/CRLF become LF; control/format characters other than LF and tab removed; trailing spaces removed), at most 8000 code points. Steps to reproduce belong in the description.
- Diagnostics (optional, opt-in): a JSON text of at most 32 768 UTF-8 bytes. Clients build it from their existing allowlisted SPEC-021 voice journal (web/Electron/mobile web clients also add one projected current-call counter snapshot), remove opaque call/generation identifiers, apply the redaction rules below, and drop the oldest events until the text fits. Message text, account names, keys, tokens, SDP, ICE candidates, TURN credentials and network addresses are never added.
- Redaction (applied by clients before preview and again by the server): SDP lines and ICE candidates, bearer tokens, JWT-shaped values, GitHub tokens, `stun:`/`turn:` URLs, IPv4 addresses, IPv6 addresses (`::` or at least five groups; clock times are retained) and any run of 32 or more base64/URL-safe characters are replaced.

The issue body begins with a fixed marker comment and a server-authored table (client, version, platform, server domain, reporter or “Not shared”) before the reporter's description, so reporter text cannot appear above or replace that metadata. Reporter text is neutralised outside fenced code blocks: `@name` and `@org/team` mentions become a fullwidth at sign (`＠`) so nobody is notified (email-style `a@b` is unchanged); `owner/repo#N` cross-references become `＃`; `<` that could start HTML, a comment or an autolink becomes `&lt;`, and entity-shaped `&` becomes `&amp;`. A fenced code block left open by the reporter is closed before later sections. Diagnostics are placed in a backtick fence longer than any backtick run they contain, so they cannot break out. Titles are plain text on GitHub; only mentions and cross-references are neutralised there. GitHub's own sanitizer remains in effect.

The fallback link is `https://github.com/megaschlong/sloppy-voice/issues/new?title=…&body=…` with every character except RFC 3986 unreserved characters percent-encoded. It uses the same body format (marked as prepared for manual submission, without a server row) and is at most 6000 characters: diagnostics are dropped first with a visible note, then the description is shortened with `…(truncated)`. The person submits it with their own GitHub account; GitHub, not Sloppy Voice, then controls notification and attribution.

## Configuration

Deployment settings (server-only; scope: one control service; changes take effect on deployment/restart; no persisted or user override):

| Property | Type and validation | Default and status | Notes |
| --- | --- | --- | --- |
| `BUG_REPORT_GITHUB_TOKEN` | Secret string matching `github_pat_[A-Za-z0-9_]{20,255}` or `gh[pousr]_[A-Za-z0-9]{20,255}` | None — PROPOSED: absent or empty disables direct posting | Fine-grained personal access token restricted to the one repository with Issues: read and write. Stored only as a Worker secret or owner-readable local environment value; never logged, returned, embedded in clients or sent anywhere except `api.github.com`. An invalid value disables direct posting and logs `{"type":"bug-report","event":"disabled","reason":"invalid_token"}`; it does not stop the chat service. |
| `BUG_REPORT_REPOSITORY` | `owner/name`; owner 1–39 letters, digits or hyphens not starting with a hyphen; name 1–100 letters, digits, `.`, `_`, `-`, not starting with `.` | `megaschlong/sloppy-voice` — CONFIRMED repository, PROPOSED setting | Invalid values disable direct posting (`reason: invalid_repository`). |
| `BUG_REPORT_LABEL` | 1–50 characters, no commas, control/format characters or surrounding spaces | None — PROPOSED: no label is sent | Optional single label. GitHub may ignore labels the token cannot apply; leaving it unset avoids label-related rejection. Invalid values disable direct posting (`reason: invalid_label`). |

Fixed protocol rules (not configuration; changed only by amending this document): capability `bug-reports/1`; title 120 code points; description 8000 code points; version 64 and platform 200 code points; diagnostics 32 768 UTF-8 bytes; fallback URL 6000 characters; fallback repository `megaschlong/sloppy-voice`; idempotency replay 24 hours and pending reservation 60 seconds; GitHub request timeout 10 seconds; GitHub API version header `2022-11-28`. PROPOSED limits: 5 reports per account per clock hour and 20 per server per UTC day, counted when a validated report is forwarded to GitHub (failures still count; replays do not). Each request also counts against the existing 240-per-minute authenticated API budget. User preferences: none; both opt-ins reset to off for each report.

## Testing

Unit tests (`tests/bug-reports.test.ts`, node:test with a mocked GitHub transport; the real GitHub API is never called):

- Settings parsing: absent/empty token disables; malformed token, repository and label each fail closed with the stated reason; default repository; the chat configuration still loads with an invalid token.
- Validation: required title/description, exact 120/8000 code-point boundaries including astral characters, normalization of control/bidirectional characters, client kinds, version and platform bounds, diagnostics JSON and byte bound, rejection of unknown fields including a client-supplied account.
- Markdown: mention, team mention, cross-reference, HTML/comment/entity neutralisation; preservation of email addresses and fenced reporter code; closing of an unterminated reporter fence; diagnostics fence longer than embedded backticks; metadata precedes reporter text; titles are not entity-escaped.
- Redaction of SDP, candidates, bearer/JWT/GitHub tokens, TURN URLs, IPv4/IPv6 and long opaque strings while retaining timestamps and counters.
- Client logic: diagnostics projection removes call identifiers and non-allowlisted fields and keeps the newest events within 32 KB; form problem messages; request omits diagnostics unless opted in; environment detection for web, Electron and mobile with version fallback and platform truncation; fallback URL prefill, strict encoding, diagnostics-first dropping and truncation within 6000 characters; server-rendered workspace shows the public notice with both opt-ins off and offers only the fallback without the capability.
- Server endpoint: capability only when configured; unconfigured `503` without contacting GitHub; authentication required; validation `400`; exact GitHub URL, method, headers, label and neutralised body; account address only on opt-in and only from the authenticated identity; idempotent replay, conflict and concurrent-pending responses; per-account hourly and per-server daily limits and their renewal; mapping of GitHub 401/403/404/410/422/429/500, network failure and malformed success to the stated errors; retry after failure; no token or report text in responses or operator logs.
- Electron opener: only bounded `https://github.com/{owner}/{repo}/issues/new` or `/issues/{n}` pages are opened; other origins, schemes, credentials, fragments and paths are refused.

Qt unit tests (`native-client/tests/bug-report.cpp`, QtTest, offscreen) cover the C++ mirror of validation, environment, Markdown neutralisation, redaction, diagnostics bounding, request shape and fallback URL bounds; the dialog's fallback-only mode without the capability, opt-in defaults and preview, single in-flight submission, identifier reuse on retry and renewal after success, switching to fallback after `bug_reports_unavailable`, rejection of a forged issue link, and the Help menu action.

Integration checks outside unit tests: a live end-to-end posting check against GitHub requires the human's token and repository and is not part of automated tests.

## Acceptance criteria

- With `BUG_REPORT_GITHUB_TOKEN` unset, `/api/info` omits `bug-reports/1`, `POST /api/bug-reports` returns `503 bug_reports_unavailable` without any outbound request, and every client offers “Open on GitHub instead”, which opens a prefilled `megaschlong/sloppy-voice` new-issue page of at most 6000 characters.
- With a valid token configured, an authenticated Home account's valid report creates exactly one issue in the configured repository and the client shows its number and a link; repeating the same request identifier within 24 hours returns the same issue without creating another.
- The report form states “Reports are posted publicly to GitHub.”, shows client kind, version and platform before sending, and attaches diagnostics or the account address only when the corresponding checkbox (default off) is selected.
- No GitHub credential appears in any client bundle, API response, error message or application log; operator logs contain only the event, issue number, upstream status or fixed reason.
- A report containing `@user`, raw HTML, an unterminated code fence or backticks in diagnostics produces an issue that notifies nobody, renders no reporter HTML, and keeps the server metadata and diagnostics sections intact.
- The sixth report from one account within an hour and the twenty-first report on a server within a UTC day are refused with `429` before GitHub is contacted.
- Attached diagnostics are at most 32 768 bytes and contain no call identifiers, keys, tokens, SDP, candidates, TURN credentials or network addresses.
- The Electron shell opens only GitHub issue pages through the system browser; the Qt client opens them through the desktop's URL handler; iOS, which keeps navigation inside the app, offers the link for copying.
